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

@@ -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",