Skip to content

Commit 5a09447

Browse files
committed
ui: AgentCard + workspace layout + agent-policy-store
Final pieces of the Phase B re-skin on the shadcn base: agentos/src/components/AgentCard.tsx (new) Polished card with avatar tile (harness logo or initial), status dot, LIB/1-shot badges, monospace harness label, source row with kind glyph + external-link icon, hairline divider, stats footer. bg-background solid. agentos/src/App.tsx Search + grouped Hosted/Library sections; uses <AgentCard>; overflow-y-auto / overflow-x-hidden scroll container; aside has overflow-hidden so cards don't bleed into the search bar. agentos/src/components/WorkspaceTab.tsx Replaced react-resizable-panels with plain flex layout (aside w-64 + main flex-1). The bad `Separator as PanelResizeHandle` import is gone. agentos/src/components/composite/EmptyState.tsx Minor copy + spacing tweaks. examples/agent-policy-store.ts Brought over from feat/agentos-observability-stack so examples/computeragent-server.ts has the policy-store import it needs. .gitignore — adds local scratch paths.
1 parent f17b984 commit 5a09447

6 files changed

Lines changed: 405 additions & 78 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,10 @@ test.html
3030
# Python bytecode caches from scripts/ test runners.
3131
__pycache__/
3232
*.pyc
33+
34+
# Private operational handoff docs — full keys, access creds, infra map for
35+
# sharing with teammates. NEVER commit these to a public repo.
36+
private.md
37+
PRIVATE.md
38+
*.private.md
39+
docs.md

agentos/src/App.tsx

Lines changed: 110 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useMemo, useState } from "react";
22
import {
33
Home as HomeIcon,
44
Activity,
@@ -7,6 +7,8 @@ import {
77
ChevronDown,
88
Folder,
99
FolderOpen,
10+
Search,
11+
X,
1012
} from "lucide-react";
1113
import { api, type Agent } from "./api.ts";
1214
import { LogsTab } from "./components/LogsTab.tsx";
@@ -16,11 +18,14 @@ import { HomePage } from "./components/HomePage.tsx";
1618
import { PolicyTab } from "./components/PolicyTab.tsx";
1719
import { PoliciesPage } from "./components/PoliciesPage.tsx";
1820
import { ObservabilityTab } from "./components/observability/ObservabilityTab.tsx";
21+
import { AgentCard } from "./components/AgentCard.tsx";
22+
import { RegisterAgentForm } from "./components/RegisterAgentForm.tsx";
1923
import { Tabs, TabsList, TabsTrigger } from "./components/ui/tabs.tsx";
2024
import { Badge } from "./components/ui/badge.tsx";
2125
import { ScrollArea } from "./components/ui/scroll-area.tsx";
2226
import { Skeleton } from "./components/ui/skeleton.tsx";
2327
import { Separator } from "./components/ui/separator.tsx";
28+
import { Input } from "./components/ui/input.tsx";
2429
import { StatusDot } from "./components/composite/StatusDot.tsx";
2530
import { PageHeader } from "./components/composite/PageHeader.tsx";
2631
import { cn } from "./lib/cn.ts";
@@ -55,6 +60,18 @@ function typeLogo(harness: string): string | null {
5560
return null;
5661
}
5762

63+
function SectionLabel({ name, count }: { name: string; count: number }) {
64+
return (
65+
<div className="flex items-center gap-2 px-1">
66+
<span className="text-[9.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70">
67+
{name}
68+
</span>
69+
<span className="flex-1 h-px bg-border/60" />
70+
<span className="text-[9.5px] font-mono text-muted-foreground/50">{count}</span>
71+
</div>
72+
);
73+
}
74+
5875
function TypeBadge({ agent, className = "" }: { agent: Agent; className?: string }) {
5976
const logo = typeLogo(agent.harness);
6077
return (
@@ -77,6 +94,26 @@ export default function App() {
7794
const [err, setErr] = useState<string | null>(null);
7895
const [launchMessage, setLaunchMessage] = useState<string | null>(null);
7996
const [agentsOpen, setAgentsOpen] = useState(true);
97+
const [search, setSearch] = useState("");
98+
99+
const filteredAgents = useMemo(() => {
100+
if (!search.trim()) return agents;
101+
const q = search.trim().toLowerCase();
102+
return agents.filter((a) =>
103+
a.name.toLowerCase().includes(q) ||
104+
(a.label ?? "").toLowerCase().includes(q) ||
105+
(a.sourceUrl ?? "").toLowerCase().includes(q) ||
106+
a.harness.toLowerCase().includes(q),
107+
);
108+
}, [agents, search]);
109+
110+
// Group by origin so Hosted (server-config'd) and Library (registry,
111+
// dashboard- or SDK-registered) are visually separated.
112+
const grouped = useMemo(() => {
113+
const hosted = filteredAgents.filter((a) => (a.origin ?? "in-memory") === "in-memory");
114+
const library = filteredAgents.filter((a) => a.origin === "registry");
115+
return { hosted, library };
116+
}, [filteredAgents]);
80117

81118
useEffect(() => {
82119
api.agents().then(setAgents).catch((e) => setErr(String(e)));
@@ -100,7 +137,7 @@ export default function App() {
100137
return (
101138
<div className="flex h-full bg-background text-foreground">
102139
{/* Left rail */}
103-
<aside className="w-72 shrink-0 border-r border-border bg-card flex flex-col">
140+
<aside className="w-64 md:w-72 lg:w-80 shrink-0 border-r border-border bg-card flex flex-col overflow-hidden">
104141
<div className="px-5 py-4 border-b border-border">
105142
<div className="flex items-center gap-2.5">
106143
<img src="/logos/agentos.png" alt="ComputerAgent" className="h-8 w-8 rounded-md object-contain" />
@@ -146,42 +183,80 @@ export default function App() {
146183
</div>
147184

148185
{agentsOpen && (
149-
<ScrollArea className="flex-1 mt-1 mr-2">
150-
<div className="pl-3 ml-4 border-l border-border space-y-0.5">
151-
{err && <div className="m-2 text-xs text-destructive">{err}</div>}
152-
{agents.length === 0 && !err && (
153-
<div className="m-2 space-y-1.5">
154-
<Skeleton className="h-12 w-full" />
155-
<Skeleton className="h-12 w-full" />
156-
<Skeleton className="h-12 w-full" />
157-
</div>
158-
)}
159-
{agents.map((a) => (
160-
<button
161-
key={a.name}
162-
onClick={() => openAgent(a.name)}
163-
className={cn(
164-
"w-full text-left rounded-md px-3 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
165-
view === "dashboard" && selected === a.name
166-
? "bg-muted ring-1 ring-primary/40"
167-
: "hover:bg-muted/60",
168-
)}
169-
>
170-
<div className="flex items-center gap-2">
171-
<StatusDot status={a.activeSandboxes > 0 ? "live" : "idle"} />
172-
<span className="font-medium text-sm truncate flex-1">{agentNameFromSource(a.sourceUrl ?? "")}</span>
173-
<TypeBadge agent={a} />
186+
<>
187+
{/* Search — sticky at top of the rail, opaque so cards never
188+
appear "behind" it as they scroll. */}
189+
<div className="px-3 pt-2 pb-2 bg-card border-b border-border/60 relative z-10">
190+
<div className="relative">
191+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/60 pointer-events-none" />
192+
<Input
193+
value={search}
194+
onChange={(e) => setSearch(e.target.value)}
195+
placeholder="Search agents…"
196+
className="pl-8 pr-7 h-8 text-xs bg-background"
197+
/>
198+
{search && (
199+
<button
200+
onClick={() => setSearch("")}
201+
className="absolute right-1.5 top-1/2 -translate-y-1/2 h-5 w-5 grid place-items-center rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted"
202+
title="Clear"
203+
>
204+
<X className="h-3 w-3" />
205+
</button>
206+
)}
207+
</div>
208+
</div>
209+
210+
<div className="flex-1 overflow-y-auto overflow-x-hidden mt-2 min-w-0">
211+
<div className="px-3 pb-3 space-y-3 min-w-0">
212+
{err && <div className="text-xs text-destructive">{err}</div>}
213+
{agents.length === 0 && !err && (
214+
<div className="space-y-2">
215+
<Skeleton className="h-20 w-full" />
216+
<Skeleton className="h-20 w-full" />
217+
<Skeleton className="h-20 w-full" />
218+
</div>
219+
)}
220+
{agents.length > 0 && filteredAgents.length === 0 && (
221+
<div className="text-center text-xs text-muted-foreground py-6">
222+
No agents match <span className="font-mono">"{search}"</span>
223+
</div>
224+
)}
225+
226+
{grouped.hosted.length > 0 && (
227+
<div className="space-y-2 min-w-0">
228+
<SectionLabel name="Hosted" count={grouped.hosted.length} />
229+
{grouped.hosted.map((a) => (
230+
<AgentCard
231+
key={a.name}
232+
agent={a}
233+
selected={view === "dashboard" && selected === a.name}
234+
onClick={() => openAgent(a.name)}
235+
/>
236+
))}
174237
</div>
175-
<div className="mt-1 text-[11px] text-muted-foreground truncate">{a.sourceUrl ?? ""}</div>
176-
<div className="mt-1 flex gap-3 text-[10px] text-muted-foreground/70">
177-
<span>{a.sessionCount} sessions</span>
178-
<span>{a.logCount} logs</span>
179-
<span>{timeAgo(a.lastActivity)}</span>
238+
)}
239+
240+
{grouped.library.length > 0 && (
241+
<div className="space-y-2 min-w-0">
242+
<SectionLabel name="Library" count={grouped.library.length} />
243+
{grouped.library.map((a) => (
244+
<AgentCard
245+
key={a.name}
246+
agent={a}
247+
selected={view === "dashboard" && selected === a.name}
248+
onClick={() => openAgent(a.name)}
249+
/>
250+
))}
180251
</div>
181-
</button>
182-
))}
252+
)}
253+
254+
<div className="pt-2">
255+
<RegisterAgentForm onRegistered={() => api.agents().then(setAgents).catch(() => {})} />
256+
</div>
257+
</div>
183258
</div>
184-
</ScrollArea>
259+
</>
185260
)}
186261

187262
<Separator />
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink } from "lucide-react";
2+
import { type Agent, displaySource } from "../api.ts";
3+
import { Badge } from "./ui/badge.tsx";
4+
import { cn } from "../lib/cn.ts";
5+
6+
function typeLogo(harness: string): string | null {
7+
if (harness === "gitagent") return "/logos/gitagent.png";
8+
if (harness === "claude-agent-sdk") return "/logos/claude.svg";
9+
if (harness === "deepagents") return "/logos/langchain.svg";
10+
return null;
11+
}
12+
13+
const NAME_OVERRIDES: Record<string, string> = {
14+
"general-agent": "General Agent",
15+
"agentos-builder": "AgentOS Builder",
16+
"gap-promoter": "GAP Promoter",
17+
"framework-translator-agent": "Framework Translator",
18+
};
19+
function agentNameFromSource(source: string, fallback: string): string {
20+
const slug = source.split("/").pop() ?? "";
21+
if (NAME_OVERRIDES[slug]) return NAME_OVERRIDES[slug];
22+
if (slug) return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
23+
return fallback
24+
.replace(/[-_]+/g, " ")
25+
.replace(/\b\w/g, (c) => c.toUpperCase());
26+
}
27+
28+
function timeAgo(iso: string | null): string {
29+
if (!iso) return "never";
30+
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
31+
if (s < 60) return `${s}s`;
32+
if (s < 3600) return `${Math.floor(s / 60)}m`;
33+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
34+
return `${Math.floor(s / 86400)}d`;
35+
}
36+
37+
/**
38+
* Single agent card for the rail.
39+
*
40+
* Two zones with a hairline divider between them:
41+
* Top: avatar (harness logo on white) + name + status pill + LIB/1-shot chip
42+
* source line: owner/repo with kind glyph + clickable external icon
43+
* Bottom: stat row — sessions / logs / lastActivity, each with icon
44+
*
45+
* Click anywhere on the card opens the agent's workspace (chat). The
46+
* external-link icon is the only nested clickable; stopPropagation
47+
* prevents card-click bubbling.
48+
*/
49+
export function AgentCard({
50+
agent: a,
51+
selected,
52+
onClick,
53+
}: {
54+
agent: Agent;
55+
selected: boolean;
56+
onClick: () => void;
57+
}) {
58+
const sourceUrl = a.sourceUrl ?? "";
59+
const displayName = agentNameFromSource(sourceUrl, a.name);
60+
const harnessLogo = typeLogo(a.harness);
61+
const initial = (displayName || a.name).charAt(0).toUpperCase();
62+
const isLive = a.activeSandboxes > 0;
63+
const sd = displaySource(a.source);
64+
65+
return (
66+
<button
67+
onClick={onClick}
68+
className={cn(
69+
"group w-full text-left rounded-xl border bg-background transition overflow-hidden shadow-sm",
70+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
71+
selected
72+
? "border-primary/50 ring-1 ring-primary/30 shadow-md shadow-primary/10"
73+
: "border-border/60 hover:border-border hover:bg-muted/30 hover:shadow-md",
74+
)}
75+
>
76+
{/* HEADER */}
77+
<div className="px-3 pt-3 pb-2.5">
78+
<div className="flex items-start gap-2.5 min-w-0">
79+
{/* Avatar */}
80+
<div className="relative shrink-0">
81+
{harnessLogo ? (
82+
<span className="h-9 w-9 grid place-items-center rounded-lg bg-white ring-1 ring-border shadow-sm">
83+
<img src={harnessLogo} alt="" className="h-4 w-4 object-contain" />
84+
</span>
85+
) : (
86+
<span className="h-9 w-9 grid place-items-center rounded-lg bg-gradient-to-br from-primary/30 to-primary/10 text-primary text-sm font-bold ring-1 ring-primary/20">
87+
{initial}
88+
</span>
89+
)}
90+
<span
91+
title={isLive ? `${a.activeSandboxes} live` : "idle"}
92+
className={cn(
93+
"absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-card",
94+
isLive ? "bg-emerald-400" : "bg-muted-foreground/40",
95+
)}
96+
/>
97+
</div>
98+
99+
{/* Name + badges */}
100+
<div className="min-w-0 flex-1">
101+
<div className="flex items-center gap-1.5 min-w-0">
102+
<span className="font-semibold text-[13.5px] text-foreground truncate" title={displayName}>
103+
{displayName}
104+
</span>
105+
{a.origin === "registry" && (
106+
<Badge variant="secondary" className="shrink-0 h-4 text-[9px] uppercase tracking-wider px-1.5 font-medium" title={`Registered via SDK${a.registeredBy ? ` by ${a.registeredBy}` : ""}`}>
107+
lib
108+
</Badge>
109+
)}
110+
{!a.sandboxCapable && (
111+
<Badge variant="outline" className="shrink-0 h-4 text-[9px] uppercase tracking-wider px-1.5 border-amber-500/40 text-amber-500" title="one-shot — no memory across turns">
112+
1-shot
113+
</Badge>
114+
)}
115+
</div>
116+
<div className="mt-0.5 text-[10.5px] text-muted-foreground/80 font-mono truncate" title={a.harness}>
117+
{a.harness}
118+
</div>
119+
</div>
120+
</div>
121+
122+
{/* Source row */}
123+
{sourceUrl && (
124+
<div className="mt-2.5 flex items-center gap-1.5 min-w-0 text-[11px] text-muted-foreground/90">
125+
<SourceGlyph kind={sd.kind} />
126+
<span className="truncate min-w-0 flex-1 font-mono" title={sd.primary}>
127+
{sd.primary}
128+
</span>
129+
{sd.href && (
130+
<a
131+
href={sd.href}
132+
target="_blank"
133+
rel="noopener noreferrer"
134+
onClick={(e) => e.stopPropagation()}
135+
title={`Open on ${sd.secondary}`}
136+
aria-label={`Open ${sd.primary} on ${sd.secondary}`}
137+
className="shrink-0 h-5 w-5 grid place-items-center rounded text-muted-foreground hover:text-primary hover:bg-muted transition-colors"
138+
>
139+
<ExternalLink className="h-3 w-3" />
140+
</a>
141+
)}
142+
</div>
143+
)}
144+
</div>
145+
146+
{/* Hairline */}
147+
<div className="h-px bg-border/60 mx-3" />
148+
149+
{/* FOOTER */}
150+
<div className="px-3 py-1.5 flex items-center gap-2.5 text-[10.5px] text-muted-foreground">
151+
<span className="inline-flex items-center gap-1" title={`${a.sessionCount} session${a.sessionCount === 1 ? "" : "s"}`}>
152+
<MessageSquare className="h-3 w-3" /> <span className="font-mono">{a.sessionCount}</span>
153+
</span>
154+
<span className="inline-flex items-center gap-1" title={`${a.logCount} log${a.logCount === 1 ? "" : "s"}`}>
155+
<Activity className="h-3 w-3" /> <span className="font-mono">{a.logCount}</span>
156+
</span>
157+
<span className="ml-auto inline-flex items-center gap-1" title={a.lastActivity ?? a.lastSeen ?? "no activity"}>
158+
<Clock className="h-3 w-3" /> {timeAgo(a.lastActivity ?? a.lastSeen ?? null)}
159+
</span>
160+
</div>
161+
</button>
162+
);
163+
}
164+
165+
function SourceGlyph({ kind }: { kind: "git" | "local" | "inline" | "unknown" }) {
166+
if (kind === "git") return <GitBranch className="h-3 w-3 shrink-0" />;
167+
if (kind === "local") return <FolderTree className="h-3 w-3 shrink-0" />;
168+
if (kind === "inline") return <Code2 className="h-3 w-3 shrink-0" />;
169+
return <span className="h-3 w-3 inline-block shrink-0" />;
170+
}

0 commit comments

Comments
 (0)