@agentprojectcontext/apx 1.67.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/core/config/redact.js +44 -0
  3. package/src/host/daemon/api/agents.js +37 -1
  4. package/src/host/daemon/api/config.js +17 -5
  5. package/src/host/daemon/api/sessions.js +9 -0
  6. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js +803 -0
  7. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js.map +1 -0
  8. package/src/interfaces/web/dist/assets/index-xQYf6_ab.css +1 -0
  9. package/src/interfaces/web/dist/index.html +2 -2
  10. package/src/interfaces/web/package-lock.json +3 -3
  11. package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +46 -31
  12. package/src/interfaces/web/src/components/config/project-config-sections.ts +9 -11
  13. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +162 -0
  14. package/src/interfaces/web/src/i18n/en.ts +10 -0
  15. package/src/interfaces/web/src/i18n/es.ts +10 -0
  16. package/src/interfaces/web/src/lib/api/agents.ts +2 -1
  17. package/src/interfaces/web/src/lib/api/sessions.ts +2 -1
  18. package/src/interfaces/web/src/screens/ProjectScreen.tsx +50 -60
  19. package/src/interfaces/web/src/screens/base/SessionsTab.tsx +11 -5
  20. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +169 -47
  21. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +108 -34
  22. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +72 -16
  23. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +110 -25
  24. package/src/interfaces/web/src/screens/project/MemoriesTab.tsx +7 -128
  25. package/src/interfaces/web/src/screens/project/Overview.tsx +93 -3
  26. package/src/interfaces/web/src/types/daemon.ts +10 -0
  27. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +0 -803
  28. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +0 -1
  29. package/src/interfaces/web/dist/assets/index-BPGECxzm.css +0 -1
@@ -1,11 +1,12 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
3
  import useSWR from "swr";
4
- import { Bot, Crown, Eye, GitBranch, List, Plus, Send, Sparkles, Upload, Wrench } from "lucide-react";
4
+ import { Activity, Bot, Crown, Eye, GitBranch, Heart, List, MessagesSquare, Plus, Send, Sparkles, Upload, Wrench, Zap } from "lucide-react";
5
5
  import { Agents } from "../../lib/api";
6
- import type { AgentEntry } from "../../types/daemon";
6
+ import type { AgentEntry, AgentStats } from "../../types/daemon";
7
7
  import { Section } from "../../components/Section";
8
8
  import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch, Textarea } from "../../components/ui";
9
+ import { Tip } from "../../components/ui/tip";
9
10
  import { UiSelect } from "../../components/UiSelect";
10
11
  import { useToast } from "../../components/Toast";
11
12
  import { EmojiInput, AutonomyPicker, AreaRoleFields } from "../../components/agents/AgentFormFields";
@@ -23,6 +24,44 @@ function agentVisual(a: AgentEntry) {
23
24
  : { gradient: "from-slate-600 to-gray-600", Icon: Bot };
24
25
  }
25
26
 
27
+ // Icon + count summary (threads / records / tasks / heartbeats) mirroring the
28
+ // agent Explorer, with an i18n tooltip on each so the icons are self-describing.
29
+ const STAT_META: { key: keyof AgentStats; icon: typeof Bot; i18n: Parameters<typeof t>[0] }[] = [
30
+ { key: "threads", icon: MessagesSquare, i18n: "agents_ui.stat_threads" },
31
+ { key: "records", icon: Activity, i18n: "agents_ui.stat_records" },
32
+ { key: "tasks", icon: Zap, i18n: "agents_ui.stat_tasks" },
33
+ { key: "heartbeats", icon: Heart, i18n: "agents_ui.stat_heartbeats" },
34
+ ];
35
+
36
+ function AgentStatRow({ stats, className }: { stats?: AgentStats; className?: string }) {
37
+ if (!stats) return null;
38
+ return (
39
+ <div className={cn("flex items-center gap-3 text-[11px] text-muted-fg", className)}>
40
+ {STAT_META.map(({ key, icon: I, i18n }) => (
41
+ <Tip key={key} content={t(i18n)}>
42
+ <span className="inline-flex items-center gap-1 tabular-nums">
43
+ <I size={12} /> {stats[key]}
44
+ </span>
45
+ </Tip>
46
+ ))}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ // Group agents by their area (category). Named areas sort alphabetically;
52
+ // uncategorized agents fall to the end.
53
+ function groupByArea(agents: AgentEntry[]): { area: string | null; agents: AgentEntry[] }[] {
54
+ const map = new Map<string | null, AgentEntry[]>();
55
+ for (const a of agents) {
56
+ const k = a.area || null;
57
+ if (!map.has(k)) map.set(k, []);
58
+ map.get(k)!.push(a);
59
+ }
60
+ return [...map.entries()]
61
+ .sort(([a], [b]) => (a === null ? 1 : b === null ? -1 : a.localeCompare(b)))
62
+ .map(([area, agents]) => ({ area, agents }));
63
+ }
64
+
26
65
  // Build parent→children map with panda's single-orchestrator fallback: if there
27
66
  // is exactly one master and an agent has no explicit parent, treat it as a child.
28
67
  function buildTree(agents: AgentEntry[]) {
@@ -50,7 +89,7 @@ function buildTree(agents: AgentEntry[]) {
50
89
  export function AgentsTab({ pid }: { pid: string }) {
51
90
  const navigate = useNavigate();
52
91
  const toast = useToast();
53
- const list = useSWR(`/projects/${pid}/agents`, () => Agents.list(pid));
92
+ const list = useSWR(`/projects/${pid}/agents?stats=1`, () => Agents.list(pid, { stats: true }));
54
93
  const [view, setView] = useState<"hierarchy" | "list">("hierarchy");
55
94
  const [creating, setCreating] = useState(false);
56
95
 
@@ -174,26 +213,41 @@ function HierarchyView({
174
213
  <div className="space-y-8">
175
214
  {roots.map((root) => {
176
215
  const kids = childrenByParent.get(root.slug) || [];
216
+ const groups = groupByArea(kids);
217
+ const categorized = groups.some((g) => g.area);
177
218
  return (
178
219
  <div key={root.slug} className="flex flex-col items-center">
179
220
  <AgentCard agent={root} onOpen={onOpen} onChat={onChat} wide />
180
221
  {kids.length > 0 && (
181
222
  <>
182
223
  <div className="h-5 w-px bg-border" />
183
- <div className="flex flex-wrap items-start justify-center gap-4 border-t border-border pt-5">
184
- {kids.map((k) => (
185
- <div key={k.slug} className="flex flex-col items-center">
186
- <AgentCard agent={k} onOpen={onOpen} onChat={onChat} />
187
- {(childrenByParent.get(k.slug) || []).length > 0 && (
188
- <>
189
- <div className="h-4 w-px bg-border" />
190
- <div className="flex flex-wrap justify-center gap-3 border-t border-border pt-4">
191
- {(childrenByParent.get(k.slug) || []).map((g) => (
192
- <AgentCard key={g.slug} agent={g} onOpen={onOpen} onChat={onChat} compact />
193
- ))}
194
- </div>
195
- </>
224
+ {/* When children carry a category (area), lay them out grouped
225
+ under a heading per category; otherwise keep the flat row. */}
226
+ <div className="flex flex-col gap-6 border-t border-border pt-5">
227
+ {groups.map((g) => (
228
+ <div key={g.area ?? "__none"} className="flex flex-col items-center gap-3">
229
+ {categorized && (
230
+ <span className="rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-fg">
231
+ {g.area || t("agents_ui.uncategorized")} · {g.agents.length}
232
+ </span>
196
233
  )}
234
+ <div className="flex flex-wrap items-start justify-center gap-4">
235
+ {g.agents.map((k) => (
236
+ <div key={k.slug} className="flex flex-col items-center">
237
+ <AgentCard agent={k} onOpen={onOpen} onChat={onChat} />
238
+ {(childrenByParent.get(k.slug) || []).length > 0 && (
239
+ <>
240
+ <div className="h-4 w-px bg-border" />
241
+ <div className="flex flex-wrap justify-center gap-3 border-t border-border pt-4">
242
+ {(childrenByParent.get(k.slug) || []).map((gc) => (
243
+ <AgentCard key={gc.slug} agent={gc} onOpen={onOpen} onChat={onChat} compact />
244
+ ))}
245
+ </div>
246
+ </>
247
+ )}
248
+ </div>
249
+ ))}
250
+ </div>
197
251
  </div>
198
252
  ))}
199
253
  </div>
@@ -236,6 +290,7 @@ function AgentCard({
236
290
  {agent.role && <Badge>{agent.role}</Badge>}
237
291
  {agent.model && !compact && <Badge tone="info">{agent.model}</Badge>}
238
292
  </div>
293
+ <AgentStatRow stats={agent.stats} className="mt-2" />
239
294
  <div className="mt-2 flex items-center gap-3 border-t border-border pt-2 text-xs text-muted-fg" onClick={(e) => e.stopPropagation()}>
240
295
  <button onClick={() => onOpen(agent.slug)} className="flex items-center gap-1 hover:text-foreground"><Eye size={12} /> {t("project.agents.view")}</button>
241
296
  <button onClick={() => onChat(agent.slug)} className="flex items-center gap-1 text-emerald-500 hover:text-emerald-400"><Send size={12} /> {t("project.agents.chat")}</button>
@@ -269,6 +324,7 @@ function ListView({ agents, onOpen, onChat }: { agents: AgentEntry[]; onOpen: (s
269
324
  {a.tools?.map((tl) => <span key={tl} className="inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] text-muted-fg"><Wrench size={9} /> {tl}</span>)}
270
325
  </div>
271
326
  </div>
327
+ <AgentStatRow stats={a.stats} className="hidden shrink-0 sm:flex" />
272
328
  <div className="flex shrink-0 items-center gap-3 text-xs text-muted-fg" onClick={(e) => e.stopPropagation()}>
273
329
  <button onClick={() => onOpen(a.slug)} className="flex items-center gap-1 hover:text-foreground"><Eye size={12} /> {t("project.agents.view")}</button>
274
330
  <button onClick={() => onChat(a.slug)} className="flex items-center gap-1 text-emerald-500 hover:text-emerald-400"><Send size={12} /> {t("project.agents.chat")}</button>
@@ -1,16 +1,17 @@
1
- import { useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
3
  import useSWR from "swr";
4
4
  import { RefreshCw, Trash2 } from "lucide-react";
5
5
  import { Projects } from "../../lib/api";
6
6
  import { Section } from "../../components/Section";
7
- import { Button, Dialog, Empty, Loading } from "../../components/ui";
7
+ import { Button, Dialog, Empty, Loading, Textarea } from "../../components/ui";
8
8
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
9
9
  import { ConfigTabsEditor } from "../../components/config/ConfigTabsEditor";
10
- import { apcProjectSections, projectOverrideSections } from "../../components/config/project-config-sections";
10
+ import { apcProjectSections, projectSettingsSections, projectEnginesSections } from "../../components/config/project-config-sections";
11
+ import { TelegramTab } from "./TelegramTab";
11
12
  import { useToast } from "../../components/Toast";
12
13
  import { useProject } from "../../hooks/useProjects";
13
- import { flattenObject } from "../../lib/config-values";
14
+ import { flattenObject, parseConfigJson } from "../../lib/config-values";
14
15
  import { isSecretMarker } from "../../lib/secrets";
15
16
  import { t } from "../../i18n";
16
17
 
@@ -36,54 +37,91 @@ export function ConfigTab({ pid }: { pid: string }) {
36
37
  cfg.mutate();
37
38
  };
38
39
 
40
+ const saveOverrideFields = async (set: Record<string, unknown>, unset: string[]) => {
41
+ await Projects.config.set(pid, set);
42
+ if (unset.length) await Projects.config.unset(pid, unset);
43
+ toast.success(t("project.config.save_fields_success"));
44
+ cfg.mutate();
45
+ };
46
+
39
47
  return (
40
48
  <div className="space-y-6">
41
49
  <Section title={t("project.config.section_title")} description={t("project.config.section_desc")}>
42
- <Tabs defaultValue="override" className="space-y-4">
43
- <TabsList>
44
- <TabsTrigger value="override">Override</TabsTrigger>
45
- <TabsTrigger value="project">APC project</TabsTrigger>
46
- <TabsTrigger value="effective">Effective</TabsTrigger>
50
+ {/* Organized by concept: Settings · Engines · Telegram · Project · JSON. */}
51
+ <Tabs defaultValue="settings" className="space-y-4">
52
+ <TabsList className="flex flex-wrap">
53
+ <TabsTrigger value="settings">{t("project.config.tab_settings")}</TabsTrigger>
54
+ <TabsTrigger value="engines">{t("settings_ui.cfg_engines_label")}</TabsTrigger>
55
+ {!isBase && <TabsTrigger value="telegram">{t("project.nav.telegram")}</TabsTrigger>}
56
+ <TabsTrigger value="project">{t("project.config.tab_project")}</TabsTrigger>
57
+ <TabsTrigger value="json">JSON</TabsTrigger>
47
58
  </TabsList>
48
59
 
49
- <TabsContent value="override">
60
+ <TabsContent value="settings">
50
61
  <ConfigTabsEditor
51
- sections={projectOverrideSections()}
62
+ sections={projectSettingsSections()}
52
63
  source={cfg.data.project_only}
53
64
  placeholderSource={cfg.data.effective}
54
65
  jsonTitle={cfg.data.project_config_path}
55
- jsonDescription=".apc/config.json. Overrides del proyecto."
56
- onSaveFields={async (set, unset) => {
57
- await Projects.config.set(pid, set);
58
- if (unset.length) await Projects.config.unset(pid, unset);
59
- toast.success(t("project.config.save_fields_success"));
60
- cfg.mutate();
61
- }}
66
+ onSaveFields={saveOverrideFields}
62
67
  onSaveJson={saveOverrideJson}
68
+ hideJson
63
69
  />
64
70
  </TabsContent>
65
71
 
72
+ <TabsContent value="engines">
73
+ <ConfigTabsEditor
74
+ sections={projectEnginesSections()}
75
+ source={cfg.data.project_only}
76
+ placeholderSource={cfg.data.effective}
77
+ jsonTitle={cfg.data.project_config_path}
78
+ onSaveFields={saveOverrideFields}
79
+ onSaveJson={saveOverrideJson}
80
+ hideJson
81
+ />
82
+ </TabsContent>
83
+
84
+ {!isBase && (
85
+ <TabsContent value="telegram">
86
+ <TelegramTab pid={pid} />
87
+ </TabsContent>
88
+ )}
89
+
66
90
  <TabsContent value="project">
67
91
  <ConfigTabsEditor
68
92
  sections={apcProjectSections()}
69
93
  source={cfg.data.apc_project || {}}
70
94
  jsonTitle={cfg.data.project_json_path}
71
- jsonDescription=".apc/project.json. Metadata APC portable."
72
95
  onSaveFields={async (set, unset) => {
73
96
  await Projects.apcProject.set(pid, cleanSet(set), unset);
74
97
  toast.success(t("project.config.save_meta_success"));
75
98
  cfg.mutate();
76
99
  }}
77
100
  onSaveJson={saveProjectJson}
101
+ hideJson
78
102
  />
79
103
  </TabsContent>
80
104
 
81
- <TabsContent value="effective">
82
- <div className="space-y-2">
83
- <p className="text-xs text-muted-fg">{t("project.config.effective_read")}</p>
84
- <pre className="max-h-96 overflow-auto rounded-lg border border-border bg-muted/40 p-3 text-xs">
85
- {JSON.stringify(cfg.data.effective, null, 2)}
86
- </pre>
105
+ <TabsContent value="json">
106
+ <div className="space-y-6">
107
+ <JsonEditor
108
+ title={cfg.data.project_config_path}
109
+ description=".apc/config.json overrides del proyecto."
110
+ source={cfg.data.project_only}
111
+ onSave={saveOverrideJson}
112
+ />
113
+ <JsonEditor
114
+ title={cfg.data.project_json_path}
115
+ description=".apc/project.json — metadata APC portable."
116
+ source={cfg.data.apc_project || {}}
117
+ onSave={saveProjectJson}
118
+ />
119
+ <div className="space-y-2">
120
+ <p className="text-xs text-muted-fg">{t("project.config.effective_read")}</p>
121
+ <pre className="max-h-96 overflow-auto rounded-lg border border-border bg-muted/40 p-3 text-xs">
122
+ {JSON.stringify(cfg.data.effective, null, 2)}
123
+ </pre>
124
+ </div>
87
125
  </div>
88
126
  </TabsContent>
89
127
  </Tabs>
@@ -216,6 +254,53 @@ function DangerZone({
216
254
  );
217
255
  }
218
256
 
257
+ // Raw JSON editor for one config file (redacted secrets echo back untouched —
258
+ // the daemon restores real values on save).
259
+ function JsonEditor({
260
+ title,
261
+ description,
262
+ source,
263
+ onSave,
264
+ }: {
265
+ title: string;
266
+ description?: string;
267
+ source: Record<string, unknown>;
268
+ onSave: (next: Record<string, unknown>) => Promise<void>;
269
+ }) {
270
+ const [raw, setRaw] = useState("");
271
+ const [error, setError] = useState("");
272
+ const [busy, setBusy] = useState(false);
273
+
274
+ useEffect(() => {
275
+ setRaw(JSON.stringify(source || {}, null, 2));
276
+ setError("");
277
+ }, [source]);
278
+
279
+ const save = async () => {
280
+ setError("");
281
+ setBusy(true);
282
+ try {
283
+ await onSave(parseConfigJson(raw));
284
+ } catch (e) {
285
+ setError((e as Error).message);
286
+ } finally {
287
+ setBusy(false);
288
+ }
289
+ };
290
+
291
+ return (
292
+ <div className="space-y-2">
293
+ <div>
294
+ <h3 className="text-sm font-medium">{title}</h3>
295
+ {description && <p className="text-xs text-muted-fg">{description}</p>}
296
+ </div>
297
+ <Textarea rows={14} className="font-mono text-xs" value={raw} onChange={(e) => setRaw(e.target.value)} />
298
+ {error && <p className="text-xs text-destructive">{error}</p>}
299
+ <Button variant="primary" loading={busy} onClick={save}>{t("settings_ui.save_json")}</Button>
300
+ </div>
301
+ );
302
+ }
303
+
219
304
  function cleanSet(set: Record<string, unknown>) {
220
305
  const out: Record<string, unknown> = {};
221
306
  for (const [key, value] of Object.entries(flattenObject(set))) {
@@ -1,134 +1,13 @@
1
- import { useEffect, useState } from "react";
2
- import useSWR from "swr";
3
- import { Bot, Brain, ChevronDown, ChevronRight, Crown, Save } from "lucide-react";
4
- import { Agents, Projects } from "../../lib/api";
5
- import type { AgentEntry } from "../../types/daemon";
6
- import { Section } from "../../components/Section";
7
- import { Button, Empty, Loading, Textarea } from "../../components/ui";
8
- import { useToast } from "../../components/Toast";
9
- import { t } from "../../i18n";
10
-
11
- // Editable markdown memory block with dirty-tracking + save.
12
- function MemoryEditor({
13
- load,
14
- save,
15
- rows = 10,
16
- placeholder,
17
- }: {
18
- load: () => Promise<string>;
19
- save: (body: string) => Promise<void>;
20
- rows?: number;
21
- placeholder?: string;
22
- }) {
23
- const toast = useToast();
24
- const [original, setOriginal] = useState<string | null>(null);
25
- const [value, setValue] = useState("");
26
- const [busy, setBusy] = useState(false);
27
-
28
- useEffect(() => {
29
- let live = true;
30
- load().then((b) => { if (live) { setOriginal(b); setValue(b); } }).catch(() => { if (live) { setOriginal(""); setValue(""); } });
31
- return () => { live = false; };
32
- }, [load]);
33
-
34
- if (original === null) return <Loading />;
35
- const dirty = value !== original;
36
-
37
- const onSave = async () => {
38
- setBusy(true);
39
- try {
40
- await save(value);
41
- setOriginal(value);
42
- toast.success(t("project.memories.saved"));
43
- } catch (e) { toast.error((e as Error).message); }
44
- finally { setBusy(false); }
45
- };
46
-
47
- return (
48
- <div className="space-y-2">
49
- <Textarea
50
- rows={rows}
51
- className="font-mono text-xs"
52
- value={value}
53
- onChange={(e) => setValue(e.target.value)}
54
- placeholder={placeholder}
55
- />
56
- <div className="flex items-center justify-between">
57
- <span className="text-[11px] text-muted-fg">{value.length} {t("project.memories.chars")}</span>
58
- <Button size="sm" variant="primary" loading={busy} disabled={!dirty} onClick={onSave}>
59
- <Save size={12} /> {t("project.memories.save_btn")}
60
- </Button>
61
- </div>
62
- </div>
63
- );
64
- }
65
-
66
- function AgentMemoryRow({ pid, agent }: { pid: string; agent: AgentEntry }) {
67
- const [open, setOpen] = useState(false);
68
- const Icon = agent.is_master ? Crown : Bot;
69
- return (
70
- <li className="rounded-xl border border-border bg-muted/30">
71
- <button
72
- type="button"
73
- onClick={() => setOpen((v) => !v)}
74
- className="flex w-full items-center gap-3 px-3 py-2 text-left"
75
- >
76
- {open ? <ChevronDown size={14} className="text-muted-fg" /> : <ChevronRight size={14} className="text-muted-fg" />}
77
- <Icon size={14} className={agent.is_master ? "text-violet-400" : "text-muted-fg"} />
78
- <span className="text-sm font-medium">{agent.slug}</span>
79
- {agent.role && <span className="text-xs text-muted-fg">· {agent.role}</span>}
80
- </button>
81
- {open && (
82
- <div className="border-t border-border p-3">
83
- <MemoryEditor
84
- rows={8}
85
- load={() => Agents.memory.get(pid, agent.slug).then((r) => r.body)}
86
- save={(body) => Agents.memory.put(pid, agent.slug, body).then(() => {})}
87
- />
88
- </div>
89
- )}
90
- </li>
91
- );
92
- }
1
+ import { MemoryBrowser } from "../../components/memory/MemoryBrowser";
93
2
 
3
+ // Durable memory surface. Same docs-style two-pane browser as /docs: a sidebar
4
+ // listing the project ("General") memory plus every agent's memory, and a shared
5
+ // markdown editor (edit / split-preview / save) on the right. Project memory is
6
+ // .apc/memory.md; agent memory is ~/.apx/projects/<id>/agents/<slug>/memory.md.
94
7
  export function MemoriesTab({ pid }: { pid: string }) {
95
- const agents = useSWR(`/projects/${pid}/agents`, () => Agents.list(pid));
96
-
97
8
  return (
98
- <div className="space-y-6">
99
- <Section
100
- title={t("project.memories.project_title")}
101
- description={t("project.memories.project_desc")}
102
- >
103
- <div className="flex items-start gap-3">
104
- <div className="mt-1 flex size-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-600 to-indigo-600">
105
- <Brain className="size-4 text-white" />
106
- </div>
107
- <div className="min-w-0 flex-1">
108
- <MemoryEditor
109
- rows={12}
110
- load={() => Projects.memory.get(pid).then((r) => r.body)}
111
- save={(body) => Projects.memory.put(pid, body).then(() => {})}
112
- placeholder={t("project.memories.project_ph")}
113
- />
114
- </div>
115
- </div>
116
- </Section>
117
-
118
- <Section
119
- title={t("project.memories.agents_title")}
120
- description={t("project.memories.agents_desc")}
121
- >
122
- {agents.isLoading && <Loading />}
123
- {!agents.isLoading && (agents.data?.length ?? 0) === 0 && (
124
- <Empty>{t("project.memories.no_agents")}</Empty>
125
- )}
126
- <ul className="space-y-2">
127
- {(agents.data || []).map((a) => (
128
- <AgentMemoryRow key={a.slug} pid={pid} agent={a} />
129
- ))}
130
- </ul>
131
- </Section>
9
+ <div className="h-full">
10
+ <MemoryBrowser pid={pid} />
132
11
  </div>
133
12
  );
134
13
  }
@@ -1,9 +1,11 @@
1
+ import { useMemo } from "react";
1
2
  import useSWR from "swr";
2
3
  import { NavLink, useNavigate } from "react-router-dom";
3
- import { Bot, FileCode2, Heart, MessagesSquare, Puzzle, Zap, Crown, Activity } from "lucide-react";
4
+ import { Bot, Briefcase, FileCode2, Heart, MessagesSquare, Puzzle, Zap, Crown, Activity } from "lucide-react";
4
5
  import { Agents, Artifacts, Mcps, Routines, Tasks } from "../../lib/api";
5
6
  import { Section } from "../../components/Section";
6
7
  import { StatusIcon, StatusBadge, effectiveStatus, statusLabel, TASK_STATUS_ORDER } from "../../components/tasks/taskStatus";
8
+ import { BrainGraph, type BrainNode, type BrainEdge } from "./AgentBrainGraph";
7
9
  import { cn } from "../../lib/cn";
8
10
  import { t } from "../../i18n";
9
11
  import type { AgentEntry } from "../../types/daemon";
@@ -22,6 +24,10 @@ export function Overview({ pid }: { pid: string }) {
22
24
  const agentList = agents.data ?? [];
23
25
  const orchestrators = agentList.filter((a) => a.is_master || a.type === "orchestrator");
24
26
  const specialists = agentList.filter((a) => !(a.is_master || a.type === "orchestrator"));
27
+ // When agents carry an area, the project reads as a company: group the roster
28
+ // by area instead of the flat orchestrator/specialist split.
29
+ const areaGroups = groupByArea(agentList);
30
+ const hasAreas = areaGroups.some((g) => g.area);
25
31
  const activeRoutines = (routines.data ?? []).filter((r) => r.enabled).length;
26
32
  const openTasks = [...(tasks.data ?? [])].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")).slice(0, 6);
27
33
 
@@ -35,8 +41,9 @@ export function Overview({ pid }: { pid: string }) {
35
41
  <Card title={t("project.overview.artifacts")} value={artifacts.data?.length ?? "…"} href={`/p/${pid}/artifacts`} icon={FileCode2} />
36
42
  </div>
37
43
 
38
- {/* Task workflow strip */}
39
- {summary.data && summary.data.open > 0 && (
44
+ {/* Task workflow strip — always shown once the summary loads, so every
45
+ status reads at least 0 (parity with the base dashboard). */}
46
+ {summary.data && (
40
47
  <div className="flex flex-wrap gap-2">
41
48
  {TASK_STATUS_ORDER.map((s) => (
42
49
  <NavLink
@@ -52,11 +59,32 @@ export function Overview({ pid }: { pid: string }) {
52
59
  </div>
53
60
  )}
54
61
 
62
+ {/* Team brain — the whole agent map: orchestrators at the core, their
63
+ specialists clustered around them as satellites, all connected. */}
64
+ {agentList.length > 0 && (
65
+ <Section title={t("project.overview.brain_title")} description={t("project.overview.brain_desc")} className="!p-4">
66
+ <TeamBrain pid={pid} agents={agentList} navigate={navigate} />
67
+ </Section>
68
+ )}
69
+
55
70
  <div className="grid gap-4 lg:grid-cols-2">
56
71
  {/* Agent roster */}
57
72
  <Section title={t("project.overview.roster")} className="!p-4">
58
73
  {agentList.length === 0 ? (
59
74
  <p className="text-sm text-muted-fg">{t("project.overview.no_agents")}</p>
75
+ ) : hasAreas ? (
76
+ <div className="space-y-3">
77
+ {areaGroups.map((g) => (
78
+ <RosterRow
79
+ key={g.area ?? "__none"}
80
+ label={g.area || t("agents_ui.uncategorized")}
81
+ icon={Briefcase}
82
+ agents={g.agents}
83
+ pid={pid}
84
+ navigate={navigate}
85
+ />
86
+ ))}
87
+ </div>
60
88
  ) : (
61
89
  <div className="space-y-3">
62
90
  {orchestrators.length > 0 && (
@@ -105,6 +133,67 @@ export function Overview({ pid }: { pid: string }) {
105
133
  );
106
134
  }
107
135
 
136
+ // Group agents by area (category); named areas first (alphabetical), the
137
+ // uncategorized bucket last.
138
+ function groupByArea(agents: AgentEntry[]): { area: string | null; agents: AgentEntry[] }[] {
139
+ const map = new Map<string | null, AgentEntry[]>();
140
+ for (const a of agents) {
141
+ const k = a.area || null;
142
+ if (!map.has(k)) map.set(k, []);
143
+ map.get(k)!.push(a);
144
+ }
145
+ return [...map.entries()]
146
+ .sort(([a], [b]) => (a === null ? 1 : b === null ? -1 : a.localeCompare(b)))
147
+ .map(([area, agents]) => ({ area, agents }));
148
+ }
149
+
150
+ // Whole-project agent map. The core is the team; orchestrators hang off it as
151
+ // hubs, and each specialist connects to its parent orchestrator (or the core if
152
+ // unparented) — so teams read as satellite clusters. Click a node to open it.
153
+ function TeamBrain({
154
+ pid, agents, navigate,
155
+ }: {
156
+ pid: string; agents: AgentEntry[]; navigate: (to: string) => void;
157
+ }) {
158
+ const { nodes, edges } = useMemo(() => {
159
+ const nodes: BrainNode[] = [];
160
+ const edges: BrainEdge[] = [];
161
+ const ROOT = "__root";
162
+ nodes.push({ id: ROOT, label: t("project.overview.brain_core"), kind: "agent", role: "core", emoji: "🧠" });
163
+
164
+ const slugs = new Set(agents.map((a) => a.slug));
165
+ const hasKids = (slug: string) => agents.some((x) => x.parent === slug);
166
+
167
+ for (const a of agents) {
168
+ const isOrch = !!a.is_master || a.type === "orchestrator";
169
+ nodes.push({
170
+ id: a.slug,
171
+ label: a.slug,
172
+ slug: a.slug,
173
+ kind: isOrch ? "agent" : "agentlink",
174
+ role: isOrch || hasKids(a.slug) ? "hub" : "leaf",
175
+ emoji: a.emoji || undefined,
176
+ relation: a.role || (isOrch ? t("project.agents.orchestrator") : t("project.overview.specialists")),
177
+ detail: a.description || undefined,
178
+ });
179
+ }
180
+ for (const a of agents) {
181
+ const parent = a.parent && slugs.has(a.parent) ? a.parent : ROOT;
182
+ edges.push({ source: parent, target: a.slug });
183
+ }
184
+ return { nodes, edges };
185
+ }, [agents]);
186
+
187
+ return (
188
+ <BrainGraph
189
+ nodes={nodes}
190
+ edges={edges}
191
+ height={520}
192
+ onNodeClick={(n) => { if (n.slug) navigate(`/p/${pid}/agents/${n.slug}`); }}
193
+ />
194
+ );
195
+ }
196
+
108
197
  function RosterRow({
109
198
  label, icon: Icon, agents, pid, navigate,
110
199
  }: {
@@ -127,6 +216,7 @@ function RosterRow({
127
216
  >
128
217
  <span className="text-sm leading-none">{a.emoji || "🤖"}</span>
129
218
  <span className="truncate">{a.slug}</span>
219
+ {a.role && <span className="truncate text-[10px] text-muted-fg">· {a.role}</span>}
130
220
  </button>
131
221
  ))}
132
222
  </div>
@@ -35,6 +35,16 @@ export interface AgentEntry {
35
35
  autonomy?: AgentAutonomy | null;
36
36
  skills: string[];
37
37
  tools: string[];
38
+ // Optional per-agent activity summary; only present when the list is
39
+ // requested with `?stats=1` (see AgentsTab).
40
+ stats?: AgentStats;
41
+ }
42
+
43
+ export interface AgentStats {
44
+ threads: number;
45
+ records: number;
46
+ tasks: number;
47
+ heartbeats: number;
38
48
  }
39
49
 
40
50
  export interface AgentDetail extends AgentEntry {