@agentprojectcontext/apx 1.56.2 → 1.58.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 (58) hide show
  1. package/package.json +1 -1
  2. package/src/core/apc/paths.js +7 -0
  3. package/src/core/stores/conversations.js +10 -0
  4. package/src/core/stores/messages.js +37 -6
  5. package/src/core/stores/organization.js +152 -0
  6. package/src/core/stores/project-files.js +199 -0
  7. package/src/core/stores/tasks.js +36 -3
  8. package/src/host/daemon/api/agents.js +22 -2
  9. package/src/host/daemon/api/conversations.js +20 -2
  10. package/src/host/daemon/api/files-project.js +99 -0
  11. package/src/host/daemon/api/organization.js +88 -0
  12. package/src/host/daemon/api/shared.js +7 -0
  13. package/src/host/daemon/api/tasks.js +14 -0
  14. package/src/host/daemon/api.js +4 -0
  15. package/src/interfaces/cli/commands/org.js +77 -0
  16. package/src/interfaces/cli/index.js +48 -0
  17. package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +1 -0
  18. package/src/interfaces/web/dist/assets/index-DPAuXATr.js +705 -0
  19. package/src/interfaces/web/dist/assets/index-DPAuXATr.js.map +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/package-lock.json +6 -6
  22. package/src/interfaces/web/src/App.tsx +1 -1
  23. package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
  24. package/src/interfaces/web/src/components/chat/ChatList.tsx +86 -65
  25. package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
  26. package/src/interfaces/web/src/components/common/TabNav.tsx +1 -1
  27. package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
  28. package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
  29. package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
  30. package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
  31. package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
  32. package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
  33. package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
  34. package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
  35. package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
  36. package/src/interfaces/web/src/hooks/useChat.ts +39 -7
  37. package/src/interfaces/web/src/i18n/en.ts +113 -3
  38. package/src/interfaces/web/src/i18n/es.ts +113 -3
  39. package/src/interfaces/web/src/lib/api/conversations.ts +6 -0
  40. package/src/interfaces/web/src/lib/api/organization.ts +18 -0
  41. package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
  42. package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
  43. package/src/interfaces/web/src/lib/api.ts +2 -0
  44. package/src/interfaces/web/src/lib/slug.ts +11 -0
  45. package/src/interfaces/web/src/screens/ProjectScreen.tsx +22 -3
  46. package/src/interfaces/web/src/screens/SettingsScreen.tsx +1 -1
  47. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
  48. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
  49. package/src/interfaces/web/src/screens/project/ChatTab.tsx +136 -36
  50. package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
  51. package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
  52. package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
  53. package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
  54. package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
  55. package/src/interfaces/web/src/types/daemon.ts +68 -0
  56. package/src/interfaces/web/dist/assets/index-CAUezTBY.css +0 -1
  57. package/src/interfaces/web/dist/assets/index-DaE_memX.js +0 -651
  58. package/src/interfaces/web/dist/assets/index-DaE_memX.js.map +0 -1
@@ -1,14 +1,14 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
  import { useSearchParams } from "react-router-dom";
3
- import useSWR from "swr";
4
- import { Plus, Trash2 } from "lucide-react";
5
- import { Agents } from "../../lib/api";
3
+ import useSWR, { mutate } from "swr";
4
+ import { Plus, RotateCcw, Trash2 } from "lucide-react";
5
+ import { Agents, Conversations } from "../../lib/api";
6
6
  import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch } from "../../components/ui";
7
7
  import { Composer } from "../../components/chat/Composer";
8
8
  import { MessageList } from "../../components/chat/MessageList";
9
9
  import { ContextBar } from "../../components/chat/ContextBar";
10
10
  import { InlineAskPanel, pendingAskQuestions } from "../../components/chat/InlineAskPanel";
11
- import { ChatList, type ChatKey } from "../../components/chat/ChatList";
11
+ import { ChatList, type ChatKey, type ChatSelectionMeta } from "../../components/chat/ChatList";
12
12
  import { useChat } from "../../hooks/useChat";
13
13
  import { useToast } from "../../components/Toast";
14
14
  import { t } from "../../i18n";
@@ -22,24 +22,52 @@ const ROBY_SLUG = "__super_agent__";
22
22
 
23
23
  export function ChatTab({ pid }: { pid: string }) {
24
24
  const toast = useToast();
25
- const [params] = useSearchParams();
25
+ const [params, setSearchParams] = useSearchParams();
26
26
  const agents = useSWR(`/projects/${pid}/agents`, () => Agents.list(pid));
27
27
  const [creating, setCreating] = useState(false);
28
28
  const [model, setModel] = useState("");
29
29
  const [dismissedAskKey, setDismissedAskKey] = useState<string | null>(null);
30
- const { msgs, send: sendChat, stop, clear, load, loadThread, streaming, conversationId } =
30
+ const { msgs, send: sendChat, stop, clear, load, loadThread, streaming } =
31
31
  useChat(pid, (m) => toast.error(m));
32
32
  const persona = usePersonaName();
33
33
 
34
34
  // Selection state — drives both the sidebar highlight and the right-pane
35
- // header. Defaults to a live session with the super-agent so the chat works
36
- // even on a brand-new project with zero agents and zero conversations.
37
- const initialFromUrl = params.get("agent");
38
- const [selected, setSelected] = useState<ChatKey>(
39
- initialFromUrl
40
- ? { kind: "live", agentSlug: initialFromUrl }
41
- : { kind: "live", agentSlug: ROBY_SLUG },
42
- );
35
+ // header. Restored from the URL query on mount (so a chat is deep-linkable),
36
+ // defaulting to a live session with the super-agent so the chat works even on
37
+ // a brand-new project with zero agents and zero conversations.
38
+ const [selected, setSelected] = useState<ChatKey>(() => {
39
+ const agent = params.get("agent");
40
+ const conv = params.get("conv");
41
+ const channel = params.get("channel");
42
+ const thread = params.get("thread");
43
+ if (channel && thread) return { kind: "thread", channel, threadId: thread };
44
+ if (agent && conv) return { kind: "conv", agentSlug: agent, convId: conv };
45
+ if (agent) return { kind: "live", agentSlug: agent };
46
+ return { kind: "live", agentSlug: ROBY_SLUG };
47
+ });
48
+ // Display metadata for the current selection (channel/created date/title),
49
+ // carried from the sidebar so the header can show it without a second fetch.
50
+ const [selectedMeta, setSelectedMeta] = useState<ChatSelectionMeta | undefined>(undefined);
51
+ const [confirmDelete, setConfirmDelete] = useState(false);
52
+ const [deleting, setDeleting] = useState(false);
53
+
54
+ // Select a chat and mirror its id into the URL query so the current chat is
55
+ // shareable/deep-linkable. `replace` keeps navigation history clean.
56
+ const selectChat = (key: ChatKey, meta?: ChatSelectionMeta) => {
57
+ setSelected(key);
58
+ setSelectedMeta(meta);
59
+ const next = new URLSearchParams();
60
+ if (key.kind === "conv") {
61
+ next.set("agent", key.agentSlug);
62
+ next.set("conv", key.convId);
63
+ } else if (key.kind === "thread") {
64
+ next.set("channel", key.channel);
65
+ next.set("thread", key.threadId);
66
+ } else {
67
+ next.set("agent", key.agentSlug);
68
+ }
69
+ setSearchParams(next, { replace: true });
70
+ };
43
71
 
44
72
  const agentList = agents.data || [];
45
73
  const isRoby = (slug: string | null | undefined) => slug === ROBY_SLUG;
@@ -95,27 +123,61 @@ export function ChatTab({ pid }: { pid: string }) {
95
123
  catch { /* ignore */ }
96
124
  };
97
125
 
98
- const onNewChat = () => {
99
- setSelected({ kind: "live", agentSlug: ROBY_SLUG });
126
+ // "+ New" from the sidebar: start a fresh in-memory session with the picked
127
+ // agent (super-agent or a project agent). It materialises in the Web group
128
+ // once the first message is sent.
129
+ const onNewChat = (agentSlug: string) => {
130
+ selectChat({ kind: "live", agentSlug });
131
+ clear();
132
+ };
133
+
134
+ // "New session" header button: reset the pane but stay with the current
135
+ // agent (Roby for channel threads / the super-agent, else the project agent).
136
+ const newSession = () => {
137
+ const agentSlug = activeIsRoby ? ROBY_SLUG : activeAgent?.slug ?? selected.agentSlug;
138
+ selectChat({ kind: "live", agentSlug });
100
139
  clear();
101
140
  };
102
141
 
142
+ // "Delete" header button: permanently remove the persisted conversation
143
+ // (agent `.md` file) or channel thread (ledger day-file), then reset the pane
144
+ // and revalidate the sidebar list so the entry disappears.
145
+ const doDelete = async () => {
146
+ setDeleting(true);
147
+ try {
148
+ if (selected.kind === "conv") {
149
+ await Conversations.remove(pid, selected.agentSlug, selected.convId);
150
+ void mutate(`/projects/${pid}/agents/${selected.agentSlug}/conversations`);
151
+ } else if (selected.kind === "thread") {
152
+ await Conversations.removeThread(pid, selected.channel, selected.threadId);
153
+ void mutate(`/projects/${pid}/super-agent/threads`);
154
+ }
155
+ toast.success(t("project.chat.deleted"));
156
+ setConfirmDelete(false);
157
+ newSession();
158
+ } catch (e) {
159
+ toast.error((e as Error)?.message || t("shared_ui.err_chat_failed"));
160
+ } finally {
161
+ setDeleting(false);
162
+ }
163
+ };
164
+
165
+ // Header shows "Created {date} · {channel} · {agent}" (or "New chat · …" for a
166
+ // fresh session with no persisted date yet), per the sidebar redesign.
167
+ const agentLabel = activeIsRoby ? persona : activeAgent?.slug ?? selected.agentSlug;
168
+ const channelLabel =
169
+ selected.kind === "thread" ? selected.channel : selectedMeta?.channel || "web";
170
+ const createdIso =
171
+ selected.kind === "thread" ? selected.threadId : selectedMeta?.createdAt;
172
+
103
173
  const headerTitle =
104
- selected.kind === "thread"
105
- ? `${selected.channel} · ${selected.threadId}`
106
- : activeIsRoby
107
- ? t("project.chat.superagent_title", { persona })
108
- : selected.kind === "conv"
109
- ? selected.convId
110
- : t("project.chat.title");
111
- const headerSubtitle =
112
- selected.kind === "thread"
113
- ? t("project.chat.thread_subtitle", { channel: selected.channel, persona })
114
- : activeIsRoby
115
- ? t("project.chat.superagent_subtitle", { persona })
116
- : selected.kind === "conv"
117
- ? t("project.chat.loaded_subtitle", { slug: selected.agentSlug })
118
- : t("project.chat.subtitle");
174
+ selected.kind === "live"
175
+ ? t("project.chat.live_title", { agent: agentLabel })
176
+ : selectedMeta?.title ||
177
+ (selected.kind === "thread" ? selected.threadId : selected.convId);
178
+ const headerSubtitle = createdIso
179
+ ? t("project.chat.meta_created", { date: formatDate(createdIso), channel: channelLabel })
180
+ : t("project.chat.meta_new", { channel: channelLabel });
119
181
 
120
182
  if (agents.isLoading) return <Loading />;
121
183
 
@@ -127,7 +189,7 @@ export function ChatTab({ pid }: { pid: string }) {
127
189
  superAgentSlug={ROBY_SLUG}
128
190
  superAgentLabel={t("agents_ui.super_agent_label", { persona })}
129
191
  selected={selected}
130
- onSelect={setSelected}
192
+ onSelect={selectChat}
131
193
  onNewChat={onNewChat}
132
194
  />
133
195
 
@@ -141,9 +203,8 @@ export function ChatTab({ pid }: { pid: string }) {
141
203
  {activeIsRoby ? (
142
204
  <Badge tone="success">{t("agents_ui.super_agent_badge")}</Badge>
143
205
  ) : (
144
- activeAgent?.model && <Badge tone="info">{activeAgent.model}</Badge>
206
+ <Badge tone="info">{agentLabel}</Badge>
145
207
  )}
146
- {selected.kind === "conv" && <Badge tone="info">{conversationId || "…"}</Badge>}
147
208
  {!agentList.length && !activeIsRoby && (
148
209
  <Button variant="primary" size="sm" onClick={() => setCreating(true)}>
149
210
  <Plus size={14} /> {t("project.chat.create_agent")}
@@ -153,10 +214,20 @@ export function ChatTab({ pid }: { pid: string }) {
153
214
  variant="ghost"
154
215
  size="sm"
155
216
  disabled={streaming || msgs.length === 0}
156
- onClick={onNewChat}
217
+ onClick={newSession}
157
218
  >
158
- <Trash2 size={13} /> {t("project.chat.clear")}
219
+ <RotateCcw size={13} /> {t("project.chat.new_session")}
159
220
  </Button>
221
+ {(selected.kind === "conv" || selected.kind === "thread") && (
222
+ <Button
223
+ variant="destructive"
224
+ size="sm"
225
+ disabled={streaming}
226
+ onClick={() => setConfirmDelete(true)}
227
+ >
228
+ <Trash2 size={13} /> {t("project.chat.delete")}
229
+ </Button>
230
+ )}
160
231
  </div>
161
232
  </header>
162
233
 
@@ -198,10 +269,39 @@ export function ChatTab({ pid }: { pid: string }) {
198
269
  onClose={() => setCreating(false)}
199
270
  onCreated={() => { setCreating(false); agents.mutate(); }}
200
271
  />
272
+
273
+ <Dialog
274
+ open={confirmDelete}
275
+ onClose={() => setConfirmDelete(false)}
276
+ title={t("project.chat.delete_confirm_title")}
277
+ description={t("project.chat.delete_confirm_desc")}
278
+ size="sm"
279
+ footer={
280
+ <>
281
+ <Button variant="ghost" onClick={() => setConfirmDelete(false)} disabled={deleting}>
282
+ {t("common.cancel")}
283
+ </Button>
284
+ <Button variant="destructive" onClick={doDelete} loading={deleting}>
285
+ <Trash2 size={14} /> {t("project.chat.delete")}
286
+ </Button>
287
+ </>
288
+ }
289
+ >
290
+ <p className="text-sm text-muted-fg">{headerTitle}</p>
291
+ </Dialog>
201
292
  </div>
202
293
  );
203
294
  }
204
295
 
296
+ // Localised short date for the header "Created {date}" line. Falls back to the
297
+ // raw string for anything Date can't parse.
298
+ function formatDate(iso?: string): string {
299
+ if (!iso) return "";
300
+ const d = new Date(iso);
301
+ if (Number.isNaN(d.getTime())) return iso;
302
+ return d.toLocaleDateString();
303
+ }
304
+
205
305
  function CreateAgentDialog({
206
306
  open,
207
307
  onClose,
@@ -0,0 +1,13 @@
1
+ import { FileBrowser } from "../../components/files/FileBrowser";
2
+ import { t } from "../../i18n";
3
+
4
+ // Project documentation / specs. Rooted at the configured docs folder
5
+ // (config docs.root, default "docs") — folders per case, like Appsi's work/.
6
+ // Editable: create, edit (markdown split-preview) and delete docs.
7
+ export function DocsTab({ pid }: { pid: string }) {
8
+ return (
9
+ <div className="h-full">
10
+ <FileBrowser pid={pid} scope="docs" editable emptyHint={t("files.docs_empty")} />
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,12 @@
1
+ import { FileBrowser } from "../../components/files/FileBrowser";
2
+
3
+ // Read-only browser over the whole project tree (the same viewer the docs
4
+ // surface uses, scoped to the repo root). Type-aware: markdown renders,
5
+ // code shows with line numbers, images preview inline.
6
+ export function FilesTab({ pid }: { pid: string }) {
7
+ return (
8
+ <div className="h-full">
9
+ <FileBrowser pid={pid} scope="project" />
10
+ </div>
11
+ );
12
+ }
@@ -1,23 +1,135 @@
1
1
  import useSWR from "swr";
2
- import { NavLink } from "react-router-dom";
3
- import { Bot, FileCode2, Heart, MessagesSquare, Puzzle, Zap } from "lucide-react";
2
+ import { NavLink, useNavigate } from "react-router-dom";
3
+ import { Bot, FileCode2, Heart, MessagesSquare, Puzzle, Zap, Crown, Activity } from "lucide-react";
4
4
  import { Agents, Artifacts, Mcps, Routines, Tasks } from "../../lib/api";
5
+ import { Section } from "../../components/Section";
6
+ import { StatusIcon, StatusBadge, effectiveStatus, statusLabel, TASK_STATUS_ORDER } from "../../components/tasks/taskStatus";
7
+ import { cn } from "../../lib/cn";
5
8
  import { t } from "../../i18n";
9
+ import type { AgentEntry } from "../../types/daemon";
6
10
 
11
+ // Floor / mission control: a live per-project summary — what's here (agents,
12
+ // automation), what's in flight (task workflow), and what just happened.
7
13
  export function Overview({ pid }: { pid: string }) {
8
- const tasks = useSWR(`/projects/${pid}/tasks?state=open`, () => Tasks.list(pid));
14
+ const navigate = useNavigate();
15
+ const tasks = useSWR(`/projects/${pid}/tasks?state=open`, () => Tasks.list(pid), { refreshInterval: 20_000 });
16
+ const summary = useSWR(`/projects/${pid}/tasks-summary`, () => Tasks.summary(pid), { refreshInterval: 20_000 });
9
17
  const routines = useSWR(`/projects/${pid}/routines`, () => Routines.list(pid));
10
18
  const agents = useSWR(`/projects/${pid}/agents`, () => Agents.list(pid));
11
19
  const mcps = useSWR(`/projects/${pid}/mcps`, () => Mcps.list(pid));
12
20
  const artifacts = useSWR(`/projects/${pid}/artifacts`, () => Artifacts.list(pid));
21
+
22
+ const agentList = agents.data ?? [];
23
+ const orchestrators = agentList.filter((a) => a.is_master || a.type === "orchestrator");
24
+ const specialists = agentList.filter((a) => !(a.is_master || a.type === "orchestrator"));
25
+ const activeRoutines = (routines.data ?? []).filter((r) => r.enabled).length;
26
+ const openTasks = [...(tasks.data ?? [])].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")).slice(0, 6);
27
+
13
28
  return (
14
- <div className="grid grid-cols-2 gap-4 md:grid-cols-3">
15
- <Card title={t("project.overview.tasks_open")} value={tasks.data?.length ?? "…"} href={`/p/${pid}/tasks`} icon={Zap} />
16
- <Card title={t("project.overview.routines")} value={routines.data?.length ?? "…"} href={`/p/${pid}/routines`} icon={Heart} />
17
- <Card title={t("project.overview.agents")} value={agents.data?.length ?? "…"} href={`/p/${pid}/agents`} icon={Bot} />
18
- <Card title={t("project.overview.mcps")} value={mcps.data?.length ?? "…"} href={`/p/${pid}/mcps`} icon={Puzzle} />
19
- <Card title={t("project.overview.artifacts")} value={artifacts.data?.length ?? "…"} href={`/p/${pid}/artifacts`} icon={FileCode2} />
20
- <Card title={t("project.overview.chat")} value={t("project.overview.chat_value")} href={`/p/${pid}/chat`} icon={MessagesSquare} />
29
+ <div className="space-y-6">
30
+ {/* Stat cards */}
31
+ <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
32
+ <Card title={t("project.overview.agents")} value={agentList.length} href={`/p/${pid}/agents`} icon={Bot} />
33
+ <Card title={t("project.overview.tasks_open")} value={summary.data?.open ?? tasks.data?.length ?? "…"} href={`/p/${pid}/tasks`} icon={Zap} />
34
+ <Card title={t("project.overview.routines_active")} value={activeRoutines} href={`/p/${pid}/routines`} icon={Heart} />
35
+ <Card title={t("project.overview.artifacts")} value={artifacts.data?.length ?? "…"} href={`/p/${pid}/artifacts`} icon={FileCode2} />
36
+ </div>
37
+
38
+ {/* Task workflow strip */}
39
+ {summary.data && summary.data.open > 0 && (
40
+ <div className="flex flex-wrap gap-2">
41
+ {TASK_STATUS_ORDER.map((s) => (
42
+ <NavLink
43
+ key={s}
44
+ to={`/p/${pid}/tasks`}
45
+ className="flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs hover:bg-accent/40"
46
+ >
47
+ <StatusIcon status={s} className="size-3.5" />
48
+ <span className="capitalize text-muted-foreground">{statusLabel(s)}</span>
49
+ <span className="font-semibold">{summary.data!.status?.[s] ?? 0}</span>
50
+ </NavLink>
51
+ ))}
52
+ </div>
53
+ )}
54
+
55
+ <div className="grid gap-4 lg:grid-cols-2">
56
+ {/* Agent roster */}
57
+ <Section title={t("project.overview.roster")} className="!p-4">
58
+ {agentList.length === 0 ? (
59
+ <p className="text-sm text-muted-fg">{t("project.overview.no_agents")}</p>
60
+ ) : (
61
+ <div className="space-y-3">
62
+ {orchestrators.length > 0 && (
63
+ <RosterRow label={t("project.overview.orchestrators")} icon={Crown} agents={orchestrators} pid={pid} navigate={navigate} />
64
+ )}
65
+ {specialists.length > 0 && (
66
+ <RosterRow label={t("project.overview.specialists")} icon={Bot} agents={specialists} pid={pid} navigate={navigate} />
67
+ )}
68
+ </div>
69
+ )}
70
+ </Section>
71
+
72
+ {/* Recent / in-flight tasks */}
73
+ <Section title={t("project.overview.recent_tasks")} className="!p-4"
74
+ action={<NavLink to={`/p/${pid}/tasks`} className="text-xs text-sky-500 hover:text-sky-400">{t("common.view_all")}</NavLink>}
75
+ >
76
+ {openTasks.length === 0 ? (
77
+ <p className="flex items-center gap-2 text-sm text-muted-fg"><Activity className="size-4" />{t("project.overview.no_activity")}</p>
78
+ ) : (
79
+ <ul className="space-y-1.5">
80
+ {openTasks.map((task) => (
81
+ <li key={task.id}>
82
+ <button
83
+ type="button"
84
+ onClick={() => navigate(`/p/${pid}/tasks?task=${task.id}`)}
85
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-accent/40"
86
+ >
87
+ <StatusIcon status={effectiveStatus(task)} className="size-3.5 shrink-0" />
88
+ <span className="min-w-0 flex-1 truncate text-sm">{task.title}</span>
89
+ <StatusBadge status={effectiveStatus(task)} />
90
+ </button>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ )}
95
+ </Section>
96
+ </div>
97
+
98
+ {/* Quick links */}
99
+ <div className="grid grid-cols-2 gap-4 md:grid-cols-3">
100
+ <Card title={t("project.overview.chat")} value={t("project.overview.chat_value")} href={`/p/${pid}/chat`} icon={MessagesSquare} />
101
+ <Card title={t("project.overview.mcps")} value={mcps.data?.length ?? "…"} href={`/p/${pid}/mcps`} icon={Puzzle} />
102
+ <Card title={t("project.overview.routines")} value={routines.data?.length ?? "…"} href={`/p/${pid}/routines`} icon={Heart} />
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function RosterRow({
109
+ label, icon: Icon, agents, pid, navigate,
110
+ }: {
111
+ label: string; icon: typeof Bot; agents: AgentEntry[]; pid: string; navigate: (to: string) => void;
112
+ }) {
113
+ return (
114
+ <div>
115
+ <div className="mb-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
116
+ <Icon className="size-3.5" />{label} ({agents.length})
117
+ </div>
118
+ <div className="flex flex-wrap gap-1.5">
119
+ {agents.map((a) => (
120
+ <button
121
+ key={a.slug}
122
+ type="button"
123
+ onClick={() => navigate(`/p/${pid}/agents/${a.slug}`)}
124
+ className={cn(
125
+ "inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-xs hover:border-muted-fg/50",
126
+ )}
127
+ >
128
+ <span className="text-sm leading-none">{a.emoji || "🤖"}</span>
129
+ <span className="truncate">{a.slug}</span>
130
+ </button>
131
+ ))}
132
+ </div>
21
133
  </div>
22
134
  );
23
135
  }
@@ -0,0 +1,147 @@
1
+ import { useState } from "react";
2
+ import useSWR from "swr";
3
+ import { FolderKanban, Briefcase, Plus, Pencil, Trash2, Info } from "lucide-react";
4
+ import { Section } from "../../components/Section";
5
+ import { Button, Empty, Loading } from "../../components/ui";
6
+ import { ConfirmDialog } from "../../components/common/ConfirmDialog";
7
+ import { AreaDialog, RoleDialog } from "../../components/structure/StructureDialogs";
8
+ import { Org } from "../../lib/api/organization";
9
+ import { useToast } from "../../components/Toast";
10
+ import { t } from "../../i18n";
11
+ import type { OrgArea, OrgRole } from "../../types/daemon";
12
+
13
+ export function StructureTab({ pid }: { pid: string }) {
14
+ const toast = useToast();
15
+ const org = useSWR(`/projects/${pid}/organization`, () => Org.get(pid));
16
+
17
+ const [areaDialog, setAreaDialog] = useState<{ editing?: OrgArea | null } | null>(null);
18
+ const [roleDialog, setRoleDialog] = useState<{ editing?: OrgRole | null; presetArea?: string | null } | null>(null);
19
+ const [confirm, setConfirm] = useState<{ kind: "area" | "role"; slug: string; name: string } | null>(null);
20
+
21
+ const refresh = () => void org.mutate();
22
+
23
+ const areas = org.data?.areas ?? [];
24
+ const roles = org.data?.roles ?? [];
25
+ const rolesByArea = (slug: string | null) => roles.filter((r) => r.area === slug);
26
+
27
+ const doDelete = async () => {
28
+ if (!confirm) return;
29
+ if (confirm.kind === "area") await Org.removeArea(pid, confirm.slug);
30
+ else await Org.removeRole(pid, confirm.slug);
31
+ toast.success(t("common.deleted"));
32
+ refresh();
33
+ };
34
+
35
+ return (
36
+ <div className="space-y-6">
37
+ <Section
38
+ title={t("structure.title")}
39
+ description={t("structure.subtitle")}
40
+ action={
41
+ <div className="flex gap-2">
42
+ <Button size="sm" variant="secondary" data-testid="structure-new-area" onClick={() => setAreaDialog({})}>
43
+ <Plus className="size-3.5" />{t("structure.new_area")}
44
+ </Button>
45
+ <Button size="sm" variant="primary" onClick={() => setRoleDialog({})}>
46
+ <Plus className="size-3.5" />{t("structure.new_role")}
47
+ </Button>
48
+ </div>
49
+ }
50
+ >
51
+ <div className="mb-4 flex items-start gap-2 rounded-lg border border-sky-500/20 bg-sky-500/5 px-3 py-2 text-[13px] text-muted-foreground">
52
+ <Info className="mt-0.5 size-4 shrink-0 text-sky-500" />
53
+ <span>{t("structure.info")}</span>
54
+ </div>
55
+
56
+ {org.isLoading ? (
57
+ <Loading />
58
+ ) : areas.length === 0 && roles.length === 0 ? (
59
+ <Empty>{t("structure.empty")}</Empty>
60
+ ) : (
61
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
62
+ {areas.map((area) => (
63
+ <div key={area.slug} className="group rounded-lg border border-border bg-card/50 p-3">
64
+ <div className="flex items-start gap-2">
65
+ <FolderKanban className="mt-0.5 size-4 shrink-0 text-emerald-500" />
66
+ <div className="min-w-0 flex-1">
67
+ <div className="flex items-center gap-2">
68
+ <span className="truncate text-sm font-semibold">{area.name}</span>
69
+ <span className="font-mono text-[10px] text-muted-foreground">{area.slug}</span>
70
+ </div>
71
+ {area.goal && <p className="mt-0.5 text-xs text-muted-foreground">{area.goal}</p>}
72
+ </div>
73
+ <div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
74
+ <button type="button" onClick={() => setAreaDialog({ editing: area })} className="text-muted-foreground hover:text-foreground" aria-label={t("common.edit")}>
75
+ <Pencil className="size-3.5" />
76
+ </button>
77
+ <button type="button" onClick={() => setConfirm({ kind: "area", slug: area.slug, name: area.name })} className="text-muted-foreground hover:text-red-500" aria-label={t("common.delete")}>
78
+ <Trash2 className="size-3.5" />
79
+ </button>
80
+ </div>
81
+ </div>
82
+
83
+ {/* Roles nested inside the area (Panda pattern). */}
84
+ <div className="mt-3 border-t border-border/60 pt-2">
85
+ <div className="mb-1.5 flex items-center justify-between">
86
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
87
+ {t("structure.roles")} ({rolesByArea(area.slug).length})
88
+ </span>
89
+ <button type="button" onClick={() => setRoleDialog({ presetArea: area.slug })} className="text-[11px] text-sky-500 hover:text-sky-400">
90
+ + {t("structure.add_role")}
91
+ </button>
92
+ </div>
93
+ <div className="flex flex-wrap gap-1.5">
94
+ {rolesByArea(area.slug).map((role) => (
95
+ <RoleChip key={role.slug} role={role} onEdit={() => setRoleDialog({ editing: role })} onDelete={() => setConfirm({ kind: "role", slug: role.slug, name: role.name })} />
96
+ ))}
97
+ {rolesByArea(area.slug).length === 0 && <span className="text-[11px] text-muted-foreground/60">{t("structure.no_roles")}</span>}
98
+ </div>
99
+ </div>
100
+ </div>
101
+ ))}
102
+
103
+ {/* Unassigned roles (no area). */}
104
+ {rolesByArea(null).length > 0 && (
105
+ <div className="rounded-lg border border-dashed border-border bg-card/30 p-3">
106
+ <div className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
107
+ <Briefcase className="size-3.5" />{t("structure.general_roles")}
108
+ </div>
109
+ <div className="flex flex-wrap gap-1.5">
110
+ {rolesByArea(null).map((role) => (
111
+ <RoleChip key={role.slug} role={role} onEdit={() => setRoleDialog({ editing: role })} onDelete={() => setConfirm({ kind: "role", slug: role.slug, name: role.name })} />
112
+ ))}
113
+ </div>
114
+ </div>
115
+ )}
116
+ </div>
117
+ )}
118
+ </Section>
119
+
120
+ <AreaDialog open={!!areaDialog} onClose={() => setAreaDialog(null)} pid={pid} editing={areaDialog?.editing} onSaved={refresh} />
121
+ <RoleDialog open={!!roleDialog} onClose={() => setRoleDialog(null)} pid={pid} areas={areas} editing={roleDialog?.editing} presetArea={roleDialog?.presetArea} onSaved={refresh} />
122
+ <ConfirmDialog
123
+ open={!!confirm}
124
+ onClose={() => setConfirm(null)}
125
+ onConfirm={doDelete}
126
+ title={confirm?.kind === "area" ? t("structure.delete_area") : t("structure.delete_role")}
127
+ description={confirm?.kind === "area" ? t("structure.delete_area_desc", { name: confirm?.name ?? "" }) : t("structure.delete_role_desc", { name: confirm?.name ?? "" })}
128
+ confirmLabel={t("common.delete")}
129
+ />
130
+ </div>
131
+ );
132
+ }
133
+
134
+ function RoleChip({ role, onEdit, onDelete }: { role: OrgRole; onEdit: () => void; onDelete: () => void }) {
135
+ return (
136
+ <span className="group/chip inline-flex items-center gap-1 rounded-md border border-border bg-background px-1.5 py-0.5 text-[11px]">
137
+ <Briefcase className="size-3 text-muted-foreground" />
138
+ <span>{role.name}</span>
139
+ <button type="button" onClick={onEdit} className="opacity-0 transition-opacity group-hover/chip:opacity-100 text-muted-foreground hover:text-foreground" aria-label={t("common.edit")}>
140
+ <Pencil className="size-2.5" />
141
+ </button>
142
+ <button type="button" onClick={onDelete} className="opacity-0 transition-opacity group-hover/chip:opacity-100 text-muted-foreground hover:text-red-500" aria-label={t("common.delete")}>
143
+ <Trash2 className="size-2.5" />
144
+ </button>
145
+ </span>
146
+ );
147
+ }