UI: mobile responsive layout, streamline agent budget display, and xs avatar size

Make agents list force list view on mobile with condensed trailing
info. Add mobile bottom bar for config save/cancel and live run
indicator on agent detail. Make MetricCard, PageTabBar, Dashboard
tasks, and ActivityRow responsive for small screens. Add xs avatar
size for inline text flow. Remove redundant budget displays from
agent overview, properties panel, costs tab, and config form.
Add attachment activity verb labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 11:29:13 -06:00
parent a22af8f72f
commit 39f8d38528
11 changed files with 217 additions and 258 deletions

View File

@@ -11,6 +11,8 @@ const ACTION_VERBS: Record<string, string> = {
"issue.checked_out": "checked out", "issue.checked_out": "checked out",
"issue.released": "released", "issue.released": "released",
"issue.comment_added": "commented on", "issue.comment_added": "commented on",
"issue.attachment_added": "attached file to",
"issue.attachment_removed": "removed attachment from",
"issue.commented": "commented on", "issue.commented": "commented on",
"issue.deleted": "deleted", "issue.deleted": "deleted",
"agent.created": "created", "agent.created": "created",
@@ -105,23 +107,24 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
return ( return (
<div <div
className={cn( className={cn(
"px-4 py-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-0.5 text-sm", "px-4 py-2 text-sm",
link && "cursor-pointer hover:bg-accent/50 transition-colors", link && "cursor-pointer hover:bg-accent/50 transition-colors",
className, className,
)} )}
onClick={link ? () => navigate(link) : undefined} onClick={link ? () => navigate(link) : undefined}
> >
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex gap-3">
<p className="flex-1 min-w-0">
<Identity <Identity
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")} name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
size="sm" size="xs"
className="align-baseline"
/> />
<span className="text-muted-foreground shrink-0">{verb}</span> <span className="text-muted-foreground ml-1">{verb} </span>
{name && <span className="truncate">{name}</span>} {name && <span className="font-medium">{name}</span>}
</p>
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
</div> </div>
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(event.createdAt)}
</span>
</div> </div>
); );
} }

View File

@@ -716,26 +716,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div> </div>
)} )}
{/* ---- Runtime (edit only) ---- */}
{!isCreate && (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Runtime</div>
<div className="px-4 pb-3 space-y-3">
<Field label="Monthly budget (cents)" hint={help.budgetMonthlyCents}>
<DraftNumberInput
value={eff(
"runtime",
"budgetMonthlyCents",
props.agent.budgetMonthlyCents,
)}
onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)}
immediate
className={inputClass}
/>
</Field>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -6,7 +6,7 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { formatCents, formatDate } from "../lib/utils"; import { formatDate } from "../lib/utils";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
interface AgentPropertiesProps { interface AgentPropertiesProps {
@@ -62,24 +62,6 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
<Separator /> <Separator />
<div className="space-y-1">
<PropertyRow label="Budget">
<span className="text-sm">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</PropertyRow>
<PropertyRow label="Utilization">
<span className="text-sm">
{agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0}
%
</span>
</PropertyRow>
</div>
<Separator />
<div className="space-y-1"> <div className="space-y-1">
{(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) && ( {(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) && (
<PropertyRow label="Session"> <PropertyRow label="Session">

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
type IdentitySize = "sm" | "default" | "lg"; type IdentitySize = "xs" | "sm" | "default" | "lg";
export interface IdentityProps { export interface IdentityProps {
name: string; name: string;
@@ -18,6 +18,7 @@ function deriveInitials(name: string): string {
} }
const textSize: Record<IdentitySize, string> = { const textSize: Record<IdentitySize, string> = {
xs: "text-sm",
sm: "text-xs", sm: "text-xs",
default: "text-sm", default: "text-sm",
lg: "text-sm", lg: "text-sm",
@@ -27,8 +28,8 @@ export function Identity({ name, avatarUrl, initials, size = "default", classNam
const displayInitials = initials ?? deriveInitials(name); const displayInitials = initials ?? deriveInitials(name);
return ( return (
<span className={cn("inline-flex items-center gap-1.5", size === "lg" && "gap-2", className)}> <span className={cn("inline-flex gap-1.5", size === "xs" ? "items-baseline gap-1" : "items-center", size === "lg" && "gap-2", className)}>
<Avatar size={size}> <Avatar size={size} className={size === "xs" ? "relative top-[2px]" : undefined}>
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />} {avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback>{displayInitials}</AvatarFallback> <AvatarFallback>{displayInitials}</AvatarFallback>
</Avatar> </Avatar>

View File

@@ -13,27 +13,27 @@ interface MetricCardProps {
export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) { export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) {
return ( return (
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-3 sm:p-4">
<div className="flex gap-3"> <div className="flex gap-2 sm:gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p <p
className={`text-2xl font-bold${onClick ? " cursor-pointer" : ""}`} className={`text-lg sm:text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
onClick={onClick} onClick={onClick}
> >
{value} {value}
</p> </p>
<p <p
className={`text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`} className={`text-xs sm:text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
onClick={onClick} onClick={onClick}
> >
{label} {label}
</p> </p>
{description && ( {description && (
<div className="text-xs text-muted-foreground mt-1">{description}</div> <div className="text-[11px] sm:text-xs text-muted-foreground mt-1 hidden sm:block">{description}</div>
)} )}
</div> </div>
<div className="bg-muted p-2 rounded-md h-fit shrink-0"> <div className="bg-muted p-1.5 sm:p-2 rounded-md h-fit shrink-0">
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -21,7 +21,7 @@ export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
<select <select
value={value} value={value}
onChange={(e) => onValueChange(e.target.value)} onChange={(e) => onValueChange(e.target.value)}
className="h-8 rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring" className="h-9 rounded-md border border-border bg-background px-2 py-1 text-base focus:outline-none focus:ring-1 focus:ring-ring"
> >
{items.map((item) => ( {items.map((item) => (
<option key={item.value} value={item.value}> <option key={item.value} value={item.value}>

View File

@@ -8,14 +8,14 @@ function Avatar({
size = "default", size = "default",
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & { }: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg" size?: "default" | "xs" | "sm" | "lg"
}) { }) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6", "group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6 data-[size=xs]:size-5",
className className
)} )}
{...props} {...props}
@@ -44,7 +44,7 @@ function AvatarFallback({
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs", "bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs group-data-[size=xs]/avatar:text-[10px]",
className className
)} )}
{...props} {...props}

View File

@@ -177,6 +177,7 @@ export function AgentDetail() {
const [configSaving, setConfigSaving] = useState(false); const [configSaving, setConfigSaving] = useState(false);
const saveConfigActionRef = useRef<(() => void) | null>(null); const saveConfigActionRef = useRef<(() => void) | null>(null);
const cancelConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null);
const { isMobile } = useSidebar();
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
@@ -213,6 +214,10 @@ export function AgentDetail() {
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId); const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated"); const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated");
const mobileLiveRun = useMemo(
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
[heartbeats],
);
const agentAction = useMutation({ const agentAction = useMutation({
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => {
@@ -295,9 +300,10 @@ export function AgentDetail() {
if (error) return <p className="text-sm text-destructive">{error.message}</p>; if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!agent) return null; if (!agent) return null;
const isPendingApproval = agent.status === "pending_approval"; const isPendingApproval = agent.status === "pending_approval";
const showConfigActionBar = activeTab === "configuration" && configDirty;
return ( return (
<div className="space-y-6"> <div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
@@ -347,6 +353,18 @@ export function AgentDetail() {
</Button> </Button>
)} )}
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span> <span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
{mobileLiveRun && (
<button
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors"
onClick={() => navigate(`/agents/${agent.id}/runs/${mobileLiveRun.id}`)}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">Live</span>
</button>
)}
{/* Overflow menu */} {/* Overflow menu */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}> <Popover open={moreOpen} onOpenChange={setMoreOpen}>
@@ -398,11 +416,12 @@ export function AgentDetail() {
</p> </p>
)} )}
{/* Floating Save/Cancel — sticky so it's always reachable when scrolled */} {/* Floating Save/Cancel (desktop) */}
{!isMobile && (
<div <div
className={cn( className={cn(
"sticky top-6 z-10 float-right transition-opacity duration-150", "sticky top-6 z-10 float-right transition-opacity duration-150",
activeTab === "configuration" && configDirty showConfigActionBar
? "opacity-100" ? "opacity-100"
: "opacity-0 pointer-events-none" : "opacity-0 pointer-events-none"
)} )}
@@ -425,6 +444,33 @@ export function AgentDetail() {
</Button> </Button>
</div> </div>
</div> </div>
)}
{/* Mobile bottom Save/Cancel bar */}
{isMobile && showConfigActionBar && (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-background/95 backdrop-blur-sm">
<div
className="flex items-center justify-end gap-2 px-3 py-2"
style={{ paddingBottom: "max(env(safe-area-inset-bottom), 0.5rem)" }}
>
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
{configSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<PageTabBar <PageTabBar
@@ -472,27 +518,6 @@ export function AgentDetail() {
: <span className="text-muted-foreground">Never</span> : <span className="text-muted-foreground">Never</span>
} }
</SummaryRow> </SummaryRow>
<SummaryRow label="Budget">
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full",
(() => {
const pct = agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
return pct > 90 ? "bg-red-400" : pct > 70 ? "bg-yellow-400" : "bg-green-400";
})(),
)}
style={{ width: `${Math.min(100, agent.budgetMonthlyCents > 0 ? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100) : 0)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
</SummaryRow>
</div> </div>
</div> </div>
@@ -591,7 +616,7 @@ export function AgentDetail() {
{/* COSTS TAB */} {/* COSTS TAB */}
<TabsContent value="costs" className="mt-4"> <TabsContent value="costs" className="mt-4">
<CostsTab agent={agent} runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} /> <CostsTab runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} />
</TabsContent> </TabsContent>
{/* KEYS TAB */} {/* KEYS TAB */}
@@ -1633,19 +1658,12 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
/* ---- Costs Tab ---- */ /* ---- Costs Tab ---- */
function CostsTab({ function CostsTab({
agent,
runtimeState, runtimeState,
runs, runs,
}: { }: {
agent: Agent;
runtimeState?: AgentRuntimeState; runtimeState?: AgentRuntimeState;
runs: HeartbeatRun[]; runs: HeartbeatRun[];
}) { }) {
const budgetPct =
agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
const runsWithCost = runs const runsWithCost = runs
.filter((r) => { .filter((r) => {
const u = r.usageJson as Record<string, unknown> | null; const u = r.usageJson as Record<string, unknown> | null;
@@ -1680,27 +1698,6 @@ function CostsTab({
</div> </div>
)} )}
{/* Monthly budget */}
<div className="border border-border rounded-lg p-4">
<h3 className="text-sm font-medium mb-3">Monthly Budget</h3>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Utilization</span>
<span>
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
budgetPct > 90 ? "bg-red-400" : budgetPct > 70 ? "bg-yellow-400" : "bg-green-400"
)}
style={{ width: `${Math.min(100, budgetPct)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">{budgetPct}% utilized</p>
</div>
{/* Per-run cost table */} {/* Per-run cost table */}
{runsWithCost.length > 0 && ( {runsWithCost.length > 0 && (
<div> <div>

View File

@@ -6,11 +6,12 @@ import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { formatCents, relativeTime, cn } from "../lib/utils"; import { relativeTime, cn } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar"; import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -61,9 +62,12 @@ export function Agents() {
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { isMobile } = useSidebar();
const pathSegment = location.pathname.split("/").pop() ?? "all"; const pathSegment = location.pathname.split("/").pop() ?? "all";
const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all"; const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all";
const [view, setView] = useState<"list" | "org">("org"); const [view, setView] = useState<"list" | "org">("org");
const forceListView = isMobile;
const effectiveView: "list" | "org" = forceListView ? "list" : view;
const [showTerminated, setShowTerminated] = useState(false); const [showTerminated, setShowTerminated] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false);
@@ -76,7 +80,7 @@ export function Agents() {
const { data: orgTree } = useQuery({ const { data: orgTree } = useQuery({
queryKey: queryKeys.org(selectedCompanyId!), queryKey: queryKeys.org(selectedCompanyId!),
queryFn: () => agentsApi.org(selectedCompanyId!), queryFn: () => agentsApi.org(selectedCompanyId!),
enabled: !!selectedCompanyId && view === "org", enabled: !!selectedCompanyId && effectiveView === "org",
}); });
const { data: runs } = useQuery({ const { data: runs } = useQuery({
@@ -161,11 +165,12 @@ export function Agents() {
)} )}
</div> </div>
{/* View toggle */} {/* View toggle */}
{!forceListView && (
<div className="flex items-center border border-border"> <div className="flex items-center border border-border">
<button <button
className={cn( className={cn(
"p-1.5 transition-colors", "p-1.5 transition-colors",
view === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50" effectiveView === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)} )}
onClick={() => setView("list")} onClick={() => setView("list")}
> >
@@ -174,13 +179,14 @@ export function Agents() {
<button <button
className={cn( className={cn(
"p-1.5 transition-colors", "p-1.5 transition-colors",
view === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50" effectiveView === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)} )}
onClick={() => setView("org")} onClick={() => setView("org")}
> >
<GitBranch className="h-3.5 w-3.5" /> <GitBranch className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
)}
<Button size="sm" onClick={openNewAgent}> <Button size="sm" onClick={openNewAgent}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> <Plus className="h-3.5 w-3.5 mr-1.5" />
New Agent New Agent
@@ -205,14 +211,9 @@ export function Agents() {
)} )}
{/* List view */} {/* List view */}
{view === "list" && filtered.length > 0 && ( {effectiveView === "list" && filtered.length > 0 && (
<div className="border border-border"> <div className="border border-border">
{filtered.map((agent) => { {filtered.map((agent) => {
const budgetPct =
agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
return ( return (
<EntityRow <EntityRow
key={agent.id} key={agent.id}
@@ -240,6 +241,18 @@ export function Agents() {
} }
trailing={ trailing={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="sm:hidden">
{liveRunByAgent.has(agent.id) ? (
<LiveRunIndicator
agentId={agent.id}
runId={liveRunByAgent.get(agent.id)!.runId}
navigate={navigate}
/>
) : (
<StatusBadge status={agent.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && ( {liveRunByAgent.has(agent.id) && (
<LiveRunIndicator <LiveRunIndicator
agentId={agent.id} agentId={agent.id}
@@ -253,27 +266,11 @@ export function Agents() {
<span className="text-xs text-muted-foreground w-16 text-right"> <span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span> </span>
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
budgetPct > 90
? "bg-red-400"
: budgetPct > 70
? "bg-yellow-400"
: "bg-green-400"
}`}
style={{ width: `${Math.min(100, budgetPct)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-24 text-right">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
<span className="w-20 flex justify-end"> <span className="w-20 flex justify-end">
<StatusBadge status={agent.status} /> <StatusBadge status={agent.status} />
</span> </span>
</div> </div>
</div>
} }
/> />
); );
@@ -281,14 +278,14 @@ export function Agents() {
</div> </div>
)} )}
{view === "list" && agents && agents.length > 0 && filtered.length === 0 && ( {effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No agents match the selected filter. No agents match the selected filter.
</p> </p>
)} )}
{/* Org chart view */} {/* Org chart view */}
{view === "org" && filteredOrg.length > 0 && ( {effectiveView === "org" && filteredOrg.length > 0 && (
<div className="border border-border py-1"> <div className="border border-border py-1">
{filteredOrg.map((node) => ( {filteredOrg.map((node) => (
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} /> <OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
@@ -296,13 +293,13 @@ export function Agents() {
</div> </div>
)} )}
{view === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && ( {effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No agents match the selected filter. No agents match the selected filter.
</p> </p>
)} )}
{view === "org" && orgTree && orgTree.length === 0 && ( {effectiveView === "org" && orgTree && orgTree.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No organizational hierarchy defined. No organizational hierarchy defined.
</p> </p>
@@ -339,11 +336,6 @@ function OrgTreeNode({
? "bg-red-400" ? "bg-red-400"
: "bg-neutral-400"; : "bg-neutral-400";
const budgetPct =
agent && agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
return ( return (
<div style={{ paddingLeft: depth * 24 }}> <div style={{ paddingLeft: depth * 24 }}>
<button <button
@@ -361,6 +353,18 @@ function OrgTreeNode({
</span> </span>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<span className="sm:hidden">
{liveRunByAgent.has(node.id) ? (
<LiveRunIndicator
agentId={node.id}
runId={liveRunByAgent.get(node.id)!.runId}
navigate={navigate}
/>
) : (
<StatusBadge status={node.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(node.id) && ( {liveRunByAgent.has(node.id) && (
<LiveRunIndicator <LiveRunIndicator
agentId={node.id} agentId={node.id}
@@ -376,29 +380,13 @@ function OrgTreeNode({
<span className="text-xs text-muted-foreground w-16 text-right"> <span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span> </span>
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
budgetPct > 90
? "bg-red-400"
: budgetPct > 70
? "bg-yellow-400"
: "bg-green-400"
}`}
style={{ width: `${Math.min(100, budgetPct)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-24 text-right">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
</> </>
)} )}
<span className="w-20 flex justify-end"> <span className="w-20 flex justify-end">
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</span> </span>
</div> </div>
</div>
</button> </button>
{node.reports && node.reports.length > 0 && ( {node.reports && node.reports.length > 0 && (
<div className="border-l border-border/50 ml-4"> <div className="border-l border-border/50 ml-4">

View File

@@ -175,7 +175,7 @@ export function Dashboard() {
{data && ( {data && (
<> <>
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-2 xl:grid-cols-4 gap-2 sm:gap-4">
<MetricCard <MetricCard
icon={Bot} icon={Bot}
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error} value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
@@ -263,21 +263,27 @@ export function Dashboard() {
{recentIssues.slice(0, 10).map((issue) => ( {recentIssues.slice(0, 10).map((issue) => (
<div <div
key={issue.id} key={issue.id}
className="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors" className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate(`/issues/${issue.id}`)} onClick={() => navigate(`/issues/${issue.id}`)}
> >
<div className="flex items-start gap-2">
<div className="flex items-center gap-2 shrink-0 mt-0.5">
<PriorityIcon priority={issue.priority} /> <PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} /> <StatusIcon status={issue.status} />
<span className="truncate flex-1">{issue.title}</span> </div>
<p className="min-w-0 flex-1">
<span>{issue.title}</span>
{issue.assigneeAgentId && (() => { {issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId); const name = agentName(issue.assigneeAgentId);
return name return name
? <Identity name={name} size="sm" className="shrink-0 hidden sm:flex" /> ? <span className="hidden sm:inline"><Identity name={name} size="sm" className="ml-2 inline-flex" /></span>
: <span className="text-xs text-muted-foreground font-mono shrink-0 hidden sm:inline">{issue.assigneeAgentId.slice(0, 8)}</span>; : null;
})()} })()}
<span className="text-xs text-muted-foreground shrink-0"> <span className="text-xs text-muted-foreground ml-2">
{timeAgo(issue.updatedAt)} {timeAgo(issue.updatedAt)}
</span> </span>
</p>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -31,6 +31,8 @@ const ACTION_LABELS: Record<string, string> = {
"issue.checked_out": "checked out the issue", "issue.checked_out": "checked out the issue",
"issue.released": "released the issue", "issue.released": "released the issue",
"issue.comment_added": "added a comment", "issue.comment_added": "added a comment",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.deleted": "deleted the issue", "issue.deleted": "deleted the issue",
"agent.created": "created an agent", "agent.created": "created an agent",
"agent.updated": "updated the agent", "agent.updated": "updated the agent",