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.released": "released",
"issue.comment_added": "commented on",
"issue.attachment_added": "attached file to",
"issue.attachment_removed": "removed attachment from",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"agent.created": "created",
@@ -105,23 +107,24 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
return (
<div
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",
className,
)}
onClick={link ? () => navigate(link) : undefined}
>
<div className="flex items-center gap-1.5 min-w-0">
<Identity
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
size="sm"
/>
<span className="text-muted-foreground shrink-0">{verb}</span>
{name && <span className="truncate">{name}</span>}
<div className="flex gap-3">
<p className="flex-1 min-w-0">
<Identity
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
size="xs"
className="align-baseline"
/>
<span className="text-muted-foreground ml-1">{verb} </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>
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(event.createdAt)}
</span>
</div>
);
}

View File

@@ -716,26 +716,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</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>
);
}

View File

@@ -6,7 +6,7 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "./StatusBadge";
import { Identity } from "./Identity";
import { formatCents, formatDate } from "../lib/utils";
import { formatDate } from "../lib/utils";
import { Separator } from "@/components/ui/separator";
interface AgentPropertiesProps {
@@ -62,24 +62,6 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
<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">
{(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) && (
<PropertyRow label="Session">

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
<select
value={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) => (
<option key={item.value} value={item.value}>

View File

@@ -8,14 +8,14 @@ function Avatar({
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
size?: "default" | "xs" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
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
)}
{...props}
@@ -44,7 +44,7 @@ function AvatarFallback({
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
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
)}
{...props}

View File

@@ -177,6 +177,7 @@ export function AgentDetail() {
const [configSaving, setConfigSaving] = useState(false);
const saveConfigActionRef = useRef<(() => void) | null>(null);
const cancelConfigActionRef = useRef<(() => void) | null>(null);
const { isMobile } = useSidebar();
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.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 reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
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({
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 (!agent) return null;
const isPendingApproval = agent.status === "pending_approval";
const showConfigActionBar = activeTab === "configuration" && configDirty;
return (
<div className="space-y-6">
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
@@ -347,6 +353,18 @@ export function AgentDetail() {
</Button>
)}
<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 */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
@@ -398,33 +416,61 @@ export function AgentDetail() {
</p>
)}
{/* Floating Save/Cancel — sticky so it's always reachable when scrolled */}
<div
className={cn(
"sticky top-6 z-10 float-right transition-opacity duration-150",
activeTab === "configuration" && configDirty
? "opacity-100"
: "opacity-0 pointer-events-none"
)}
>
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
{configSaving ? "Saving..." : "Save"}
</Button>
{/* Floating Save/Cancel (desktop) */}
{!isMobile && (
<div
className={cn(
"sticky top-6 z-10 float-right transition-opacity duration-150",
showConfigActionBar
? "opacity-100"
: "opacity-0 pointer-events-none"
)}
>
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
<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>
</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}>
<PageTabBar
@@ -472,27 +518,6 @@ export function AgentDetail() {
: <span className="text-muted-foreground">Never</span>
}
</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>
@@ -591,7 +616,7 @@ export function AgentDetail() {
{/* COSTS TAB */}
<TabsContent value="costs" className="mt-4">
<CostsTab agent={agent} runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} />
<CostsTab runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} />
</TabsContent>
{/* KEYS TAB */}
@@ -1633,19 +1658,12 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
/* ---- Costs Tab ---- */
function CostsTab({
agent,
runtimeState,
runs,
}: {
agent: Agent;
runtimeState?: AgentRuntimeState;
runs: HeartbeatRun[];
}) {
const budgetPct =
agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
const runsWithCost = runs
.filter((r) => {
const u = r.usageJson as Record<string, unknown> | null;
@@ -1680,27 +1698,6 @@ function CostsTab({
</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 */}
{runsWithCost.length > 0 && (
<div>

View File

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

View File

@@ -175,7 +175,7 @@ export function Dashboard() {
{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
icon={Bot}
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) => (
<div
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}`)}
>
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="truncate flex-1">{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" className="shrink-0 hidden sm:flex" />
: <span className="text-xs text-muted-foreground font-mono shrink-0 hidden sm:inline">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(issue.updatedAt)}
</span>
<div className="flex items-start gap-2">
<div className="flex items-center gap-2 shrink-0 mt-0.5">
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
</div>
<p className="min-w-0 flex-1">
<span>{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline"><Identity name={name} size="sm" className="ml-2 inline-flex" /></span>
: null;
})()}
<span className="text-xs text-muted-foreground ml-2">
{timeAgo(issue.updatedAt)}
</span>
</p>
</div>
</div>
))}
</div>

View File

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