@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
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-DaE_memX.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-CAUezTBY.css">
21
+ <script type="module" crossorigin src="/assets/index-DPAuXATr.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-Cl0WXtxF.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -2162,9 +2162,9 @@
2162
2162
  }
2163
2163
  },
2164
2164
  "node_modules/baseline-browser-mapping": {
2165
- "version": "2.10.41",
2166
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
2167
- "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
2165
+ "version": "2.10.42",
2166
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
2167
+ "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
2168
2168
  "dev": true,
2169
2169
  "license": "Apache-2.0",
2170
2170
  "bin": {
@@ -2580,9 +2580,9 @@
2580
2580
  "license": "MIT"
2581
2581
  },
2582
2582
  "node_modules/electron-to-chromium": {
2583
- "version": "1.5.385",
2584
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
2585
- "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==",
2583
+ "version": "1.5.387",
2584
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
2585
+ "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
2586
2586
  "dev": true,
2587
2587
  "license": "ISC"
2588
2588
  },
@@ -84,7 +84,7 @@ function Shell() {
84
84
  onOpenRoby={() => setRobyOpen(true)}
85
85
  onOpenAddProject={openAdd}
86
86
  />
87
- <main className="m-2 ml-0 flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
87
+ <main className="m-2 flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
88
88
  <TopBar onToggleTheme={toggle} isDark={theme === "dark"} pathname={location.pathname} />
89
89
  <div className="flex-1 overflow-y-auto">
90
90
  <Routes>
@@ -0,0 +1,123 @@
1
+ import { useState } from "react";
2
+ import useSWR from "swr";
3
+ import { Plus } from "lucide-react";
4
+ import { cn } from "../../lib/cn";
5
+ import { Field, Input } from "../ui";
6
+ import { UiSelect } from "../UiSelect";
7
+ import { Org } from "../../lib/api/organization";
8
+ import { t } from "../../i18n";
9
+ import type { AgentAutonomy } from "../../types/daemon";
10
+ import { AreaDialog, RoleDialog } from "../structure/StructureDialogs";
11
+
12
+ // ── Emoji ────────────────────────────────────────────────────────────────────
13
+ export function EmojiInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
14
+ return (
15
+ <Input
16
+ value={value}
17
+ onChange={(e) => onChange([...e.target.value].slice(-2).join(""))}
18
+ className="text-center text-lg"
19
+ placeholder="🤖"
20
+ aria-label={t("agents_form.emoji")}
21
+ />
22
+ );
23
+ }
24
+
25
+ // ── Autonomy (permission mode) segmented control ─────────────────────────────
26
+ const AUTONOMY_OPTIONS: { value: AgentAutonomy; labelKey: "auto_total" | "auto_automatico" | "auto_permiso" }[] = [
27
+ { value: "total", labelKey: "auto_total" },
28
+ { value: "automatico", labelKey: "auto_automatico" },
29
+ { value: "permiso", labelKey: "auto_permiso" },
30
+ ];
31
+
32
+ export function AutonomyPicker({ value, onChange }: { value: string; onChange: (v: AgentAutonomy) => void }) {
33
+ return (
34
+ <div className="inline-flex w-full rounded-lg border border-border p-0.5">
35
+ {AUTONOMY_OPTIONS.map((opt) => (
36
+ <button
37
+ key={opt.value}
38
+ type="button"
39
+ onClick={() => onChange(opt.value)}
40
+ className={cn(
41
+ "flex-1 rounded-md px-2 py-1 text-[12px] font-medium capitalize transition-colors",
42
+ value === opt.value ? "bg-primary/15 text-foreground" : "text-muted-foreground hover:text-foreground",
43
+ )}
44
+ >
45
+ {t(`agents_form.${opt.labelKey}`)}
46
+ </button>
47
+ ))}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ // ── Area + Role selects (fed by the project org structure) ──────────────────
53
+ // Quick-create area/role via floating dialogs (Panda's "org quick actions"),
54
+ // keeping the picker simple. On create we refetch and auto-select the new item.
55
+ export function AreaRoleFields({
56
+ pid, area, role, onArea, onRole,
57
+ }: {
58
+ pid: string;
59
+ area: string;
60
+ role: string;
61
+ onArea: (v: string) => void;
62
+ onRole: (v: string) => void;
63
+ }) {
64
+ const org = useSWR(`/projects/${pid}/organization`, () => Org.get(pid));
65
+ const [areaDialog, setAreaDialog] = useState(false);
66
+ const [roleDialog, setRoleDialog] = useState(false);
67
+
68
+ const areas = org.data?.areas ?? [];
69
+ const roles = org.data?.roles ?? [];
70
+ // Roles offered: those in the selected area, plus general (no-area) roles.
71
+ const roleChoices = roles.filter((r) => (area ? r.area === area || r.area === null : true));
72
+
73
+ const areaOptions = [{ value: "", label: t("structure.no_area") }, ...areas.map((a) => ({ value: a.slug, label: a.name }))];
74
+ const roleOptions = [{ value: "", label: t("agents_form.no_role") }, ...roleChoices.map((r) => ({ value: r.slug, label: r.name }))];
75
+
76
+ return (
77
+ <div className="space-y-3">
78
+ <div className="grid grid-cols-2 gap-3">
79
+ <Field label={t("agents_form.area")}>
80
+ <div className="flex items-center gap-1">
81
+ <UiSelect value={area} onChange={(v) => { onArea(v); }} options={areaOptions} placeholder={t("structure.no_area")} className="flex-1" />
82
+ <QuickAdd label={t("structure.new_area")} onClick={() => setAreaDialog(true)} />
83
+ </div>
84
+ </Field>
85
+ <Field label={t("agents_form.role")}>
86
+ <div className="flex items-center gap-1">
87
+ <UiSelect value={role} onChange={onRole} options={roleOptions} placeholder={t("agents_form.no_role")} className="flex-1" />
88
+ <QuickAdd label={t("structure.new_role")} onClick={() => setRoleDialog(true)} />
89
+ </div>
90
+ </Field>
91
+ </div>
92
+
93
+ <AreaDialog
94
+ open={areaDialog}
95
+ onClose={() => setAreaDialog(false)}
96
+ pid={pid}
97
+ onSaved={() => void org.mutate()}
98
+ />
99
+ <RoleDialog
100
+ open={roleDialog}
101
+ onClose={() => setRoleDialog(false)}
102
+ pid={pid}
103
+ areas={areas}
104
+ presetArea={area || null}
105
+ onSaved={() => void org.mutate()}
106
+ />
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function QuickAdd({ label, onClick }: { label: string; onClick: () => void }) {
112
+ return (
113
+ <button
114
+ type="button"
115
+ onClick={onClick}
116
+ title={label}
117
+ aria-label={label}
118
+ className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
119
+ >
120
+ <Plus className="size-4" />
121
+ </button>
122
+ );
123
+ }
@@ -12,7 +12,6 @@ import {
12
12
  Monitor,
13
13
  Plus,
14
14
  Send,
15
- Sparkles,
16
15
  Timer,
17
16
  User,
18
17
  } from "lucide-react";
@@ -23,14 +22,15 @@ import { t } from "../../i18n";
23
22
  import type { AgentEntry, ConversationListEntry, ThreadListEntry } from "../../types/daemon";
24
23
 
25
24
  // Channel taxonomy — same channels the daemon writes ("web", "voice",
26
- // "desktop", "telegram", …) folded into 8 sidebar groups. Each group has an
27
- // icon + a fixed display order so the sidebar is stable across reloads.
25
+ // "desktop", "telegram", …) folded into sidebar groups. Each group has an
26
+ // icon + a fixed display order so the sidebar is stable across reloads. Web is
27
+ // pinned first (it's where chats started from this UI land), then the remote
28
+ // channels, with the catch-all "other" last.
28
29
  export type ChannelGroupKey =
29
- | "live"
30
+ | "web"
30
31
  | "telegram"
31
- | "voice"
32
32
  | "desktop"
33
- | "web"
33
+ | "voice"
34
34
  | "a2a"
35
35
  | "schedule"
36
36
  | "other";
@@ -42,14 +42,13 @@ interface GroupMeta {
42
42
  }
43
43
 
44
44
  const GROUP_META: Record<ChannelGroupKey, GroupMeta> = {
45
- live: { label: "Live", icon: Sparkles, order: 0 },
45
+ web: { label: "Web", icon: MessageSquare, order: 0 },
46
46
  telegram: { label: "Telegram", icon: Send, order: 1 },
47
- voice: { label: "Voice", icon: Mic, order: 2 },
48
- desktop: { label: "Desktop", icon: Monitor, order: 3 },
49
- web: { label: "Web", icon: MessageSquare, order: 4 },
50
- a2a: { label: "Agent ↔ Agent", icon: Bot, order: 5 },
51
- schedule: { label: "Schedule", icon: Timer, order: 6 },
52
- other: { label: "Other", icon: FolderOpen, order: 7 },
47
+ desktop: { label: "Desktop", icon: Monitor, order: 2 },
48
+ voice: { label: "Voice", icon: Mic, order: 3 },
49
+ a2a: { label: "Agent ↔ Agent", icon: Bot, order: 4 },
50
+ schedule: { label: "Schedule", icon: Timer, order: 5 },
51
+ other: { label: "Other", icon: FolderOpen, order: 6 },
53
52
  };
54
53
 
55
54
  function channelGroup(channel?: string): ChannelGroupKey {
@@ -78,6 +77,14 @@ export function chatKeyToString(k: ChatKey): string {
78
77
  return `thread:${k.channel}:${k.threadId}`;
79
78
  }
80
79
 
80
+ /** Display metadata carried alongside a selection so the right-pane header can
81
+ * show "Created {date} · {channel} · {agent}" without a second fetch. */
82
+ export interface ChatSelectionMeta {
83
+ channel?: string;
84
+ createdAt?: string;
85
+ title?: string;
86
+ }
87
+
81
88
  interface Props {
82
89
  pid: string;
83
90
  agents: AgentEntry[];
@@ -85,8 +92,10 @@ interface Props {
85
92
  superAgentSlug: string;
86
93
  superAgentLabel: string;
87
94
  selected: ChatKey;
88
- onSelect: (key: ChatKey) => void;
89
- onNewChat: () => void;
95
+ onSelect: (key: ChatKey, meta?: ChatSelectionMeta) => void;
96
+ /** Start a fresh in-memory session with the chosen agent (super-agent or a
97
+ * project agent). It materialises in the Web group on the first message. */
98
+ onNewChat: (agentSlug: string) => void;
90
99
  }
91
100
 
92
101
  // Per-agent SWR fetcher. Lives in a child so adding/removing agents doesn't
@@ -125,6 +134,7 @@ export function ChatList({
125
134
  const [agentFilter, setAgentFilter] = useState("");
126
135
  const [collapsed, setCollapsed] = useState<Partial<Record<ChannelGroupKey, boolean>>>({});
127
136
  const [byAgent, setByAgent] = useState<Record<string, ConversationListEntry[]>>({});
137
+ const [pickerOpen, setPickerOpen] = useState(false);
128
138
 
129
139
  // Super-agent channel threads (telegram, web quick-chat, desktop …) come from
130
140
  // the global message ledger, which is daemon-level and NOT project-scoped.
@@ -208,20 +218,16 @@ export function ChatList({
208
218
  .sort((a, b) => GROUP_META[a.key].order - GROUP_META[b.key].order);
209
219
  }, [filteredConvs, filteredThreads]);
210
220
 
211
- // "Live" agents shown at the top: super-agent + project agents matching the
212
- // current agent filter (so "filter by foo" hides the others everywhere).
213
- const liveAgents = useMemo(() => {
214
- if (agentFilter === superAgentSlug) {
215
- return [{ slug: superAgentSlug, label: superAgentLabel }];
216
- }
217
- const out: { slug: string; label: string }[] = [];
218
- if (!agentFilter) out.push({ slug: superAgentSlug, label: superAgentLabel });
219
- for (const a of agents) {
220
- if (agentFilter && a.slug !== agentFilter) continue;
221
- out.push({ slug: a.slug, label: a.slug });
222
- }
223
- return out;
224
- }, [agents, agentFilter, superAgentSlug, superAgentLabel]);
221
+ // Agents offered by the "+ New" picker: the super-agent (always available,
222
+ // listed first) followed by this project's own agents. Not affected by the
223
+ // sidebar filter you can always start a fresh chat with any of them.
224
+ const newChatAgents = useMemo(
225
+ () => [
226
+ { slug: superAgentSlug, label: superAgentLabel },
227
+ ...agents.map((a) => ({ slug: a.slug, label: a.slug })),
228
+ ],
229
+ [agents, superAgentSlug, superAgentLabel],
230
+ );
225
231
 
226
232
  const agentOptions = useMemo(
227
233
  () => [
@@ -232,7 +238,7 @@ export function ChatList({
232
238
  [agents, superAgentSlug, superAgentLabel],
233
239
  );
234
240
 
235
- const totalCount = allConvs.length + (threadsQ.data?.length || 0) + liveAgents.length;
241
+ const totalCount = allConvs.length + (threadsQ.data?.length || 0);
236
242
  const anyLoaded =
237
243
  Object.keys(byAgent).length > 0 || agents.length === 0 || !!threadsQ.data;
238
244
 
@@ -251,13 +257,48 @@ export function ChatList({
251
257
  {t("project.chat.list.count", { n: totalCount })}
252
258
  </p>
253
259
  </div>
254
- <button
255
- type="button"
256
- onClick={onNewChat}
257
- className="inline-flex items-center gap-1 rounded-md border border-border bg-accent/60 px-2 py-1 text-[11px] font-medium hover:bg-accent"
258
- >
259
- <Plus className="size-3" /> {t("project.chat.list.new")}
260
- </button>
260
+ <div className="relative">
261
+ <button
262
+ type="button"
263
+ onClick={() => setPickerOpen((o) => !o)}
264
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-accent/60 px-2 py-1 text-[11px] font-medium hover:bg-accent"
265
+ >
266
+ <Plus className="size-3" /> {t("project.chat.list.new")}
267
+ </button>
268
+ {pickerOpen && (
269
+ <>
270
+ {/* Click-away layer. */}
271
+ <button
272
+ type="button"
273
+ aria-hidden
274
+ tabIndex={-1}
275
+ className="fixed inset-0 z-10 cursor-default"
276
+ onClick={() => setPickerOpen(false)}
277
+ />
278
+ <div className="absolute right-0 top-full z-20 mt-1 w-56 rounded-md border border-border bg-card p-1 shadow-lg">
279
+ <p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-fg">
280
+ {t("project.chat.list.pick_agent")}
281
+ </p>
282
+ <div className="max-h-64 overflow-y-auto">
283
+ {newChatAgents.map((a) => (
284
+ <button
285
+ key={`new-${a.slug}`}
286
+ type="button"
287
+ onClick={() => {
288
+ setPickerOpen(false);
289
+ onNewChat(a.slug);
290
+ }}
291
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50"
292
+ >
293
+ <Bot className="size-3 shrink-0 text-muted-fg" />
294
+ <span className="truncate">{a.label}</span>
295
+ </button>
296
+ ))}
297
+ </div>
298
+ </div>
299
+ </>
300
+ )}
301
+ </div>
261
302
  </header>
262
303
 
263
304
  <div className="space-y-2 border-b border-border p-2">
@@ -276,32 +317,6 @@ export function ChatList({
276
317
  </div>
277
318
  )}
278
319
 
279
- {/* Live group — synthetic, always first. */}
280
- {liveAgents.length > 0 && (
281
- <ChannelGroup
282
- keyName="live"
283
- count={liveAgents.length}
284
- collapsed={!!collapsed.live}
285
- onToggle={() => setCollapsed((p) => ({ ...p, live: !p.live }))}
286
- >
287
- {liveAgents.map((a) => {
288
- const isRoby = a.slug === superAgentSlug;
289
- const active =
290
- selected.kind === "live" && selected.agentSlug === a.slug;
291
- return (
292
- <ChatListItem
293
- key={`live-${a.slug}`}
294
- title={isRoby ? a.label : t("project.chat.list.live_with", { slug: a.slug })}
295
- subtitle={t("project.chat.list.live_subtitle")}
296
- badge={isRoby ? "super" : a.slug}
297
- selected={active}
298
- onClick={() => onSelect({ kind: "live", agentSlug: a.slug })}
299
- />
300
- );
301
- })}
302
- </ChannelGroup>
303
- )}
304
-
305
320
  {/* Stored conversations + super-agent channel threads, grouped by channel. */}
306
321
  {groups.map((g) => (
307
322
  <ChannelGroup
@@ -327,7 +342,10 @@ export function ChatList({
327
342
  timeAgo={th.last_ts}
328
343
  selected={active}
329
344
  onClick={() =>
330
- onSelect({ kind: "thread", channel: th.channel, threadId: th.id })
345
+ onSelect(
346
+ { kind: "thread", channel: th.channel, threadId: th.id },
347
+ { channel: th.channel, createdAt: th.started_at, title: th.title },
348
+ )
331
349
  }
332
350
  />
333
351
  );
@@ -348,7 +366,10 @@ export function ChatList({
348
366
  timeAgo={c.started_at}
349
367
  selected={active}
350
368
  onClick={() =>
351
- onSelect({ kind: "conv", agentSlug: c.agent_slug, convId: c.id })
369
+ onSelect(
370
+ { kind: "conv", agentSlug: c.agent_slug, convId: c.id },
371
+ { channel: c.channel, createdAt: c.started_at, title: c.title },
372
+ )
352
373
  }
353
374
  />
354
375
  );
@@ -0,0 +1,51 @@
1
+ import { useState } from "react";
2
+ import { Dialog, Button } from "../ui";
3
+ import { t } from "../../i18n";
4
+
5
+ // Reusable confirm dialog for destructive/executing actions (project rule:
6
+ // never native confirm()). Shows a loading state while the action runs.
7
+ export function ConfirmDialog({
8
+ open,
9
+ onClose,
10
+ onConfirm,
11
+ title,
12
+ description,
13
+ confirmLabel,
14
+ destructive = true,
15
+ }: {
16
+ open: boolean;
17
+ onClose: () => void;
18
+ onConfirm: () => Promise<void> | void;
19
+ title: string;
20
+ description?: string;
21
+ confirmLabel?: string;
22
+ destructive?: boolean;
23
+ }) {
24
+ const [busy, setBusy] = useState(false);
25
+ const run = async () => {
26
+ setBusy(true);
27
+ try {
28
+ await onConfirm();
29
+ onClose();
30
+ } finally {
31
+ setBusy(false);
32
+ }
33
+ };
34
+ return (
35
+ <Dialog
36
+ open={open}
37
+ onClose={onClose}
38
+ title={title}
39
+ footer={
40
+ <>
41
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
42
+ <Button variant={destructive ? "destructive" : "primary"} onClick={() => void run()} loading={busy}>
43
+ {confirmLabel ?? t("common.confirm")}
44
+ </Button>
45
+ </>
46
+ }
47
+ >
48
+ <p className="text-sm text-muted-foreground">{description}</p>
49
+ </Dialog>
50
+ );
51
+ }
@@ -65,7 +65,7 @@ export function TabNav({ sections, active, onChange, collapsed = false }: TabNav
65
65
  <nav
66
66
  className={cn(
67
67
  "hidden md:flex shrink-0 flex-col gap-1 py-3 transition-all",
68
- collapsed ? "w-12 items-center px-1" : "w-52 px-2",
68
+ collapsed ? "w-12 items-center px-1" : "w-44 px-2",
69
69
  )}
70
70
  >
71
71
  {sections.map((section, si) => (
@@ -0,0 +1,138 @@
1
+ import { useState } from "react";
2
+ import useSWR from "swr";
3
+ import { RefreshCw, FilePlus2, FolderOpen } from "lucide-react";
4
+ import { ProjectFiles, type FileScope } from "../../lib/api/projectFiles";
5
+ import type { FileNode, FileContent } from "../../types/daemon";
6
+ import { Spinner, Button, Empty } from "../ui";
7
+ import { useToast } from "../Toast";
8
+ import { t } from "../../i18n";
9
+ import { FileTree } from "./FileTree";
10
+ import { FileViewer } from "./FileViewer";
11
+ import { NewFileDialog } from "./NewFileDialog";
12
+
13
+ // Shared file browser used by both /files (scope=project) and /docs
14
+ // (scope=docs). One component, two roots — the docs surface is just the same
15
+ // browser with `editable` on and a "new document" affordance.
16
+ export function FileBrowser({
17
+ pid,
18
+ scope,
19
+ editable = false,
20
+ emptyHint,
21
+ }: {
22
+ pid: string;
23
+ scope: FileScope;
24
+ /** Allow editing text/markdown + creating/deleting files (docs surface). */
25
+ editable?: boolean;
26
+ emptyHint?: string;
27
+ }) {
28
+ const toast = useToast();
29
+ const [selected, setSelected] = useState<string | null>(null);
30
+ const [newOpen, setNewOpen] = useState(false);
31
+
32
+ const treeKey = `/projects/${pid}/fs/tree?scope=${scope}`;
33
+ const tree = useSWR(treeKey, () => ProjectFiles.tree(pid, scope));
34
+
35
+ const fileKey = selected ? `/projects/${pid}/fs/file?scope=${scope}&path=${selected}` : null;
36
+ const file = useSWR<FileContent | null>(fileKey, () => (selected ? ProjectFiles.read(pid, selected, scope) : null));
37
+
38
+ const onSelect = (node: FileNode) => setSelected(node.path);
39
+
40
+ const onSave = editable
41
+ ? async (content: string) => {
42
+ if (!selected) return;
43
+ await ProjectFiles.write(pid, selected, content, scope);
44
+ toast.success(t("files.saved"));
45
+ void file.mutate();
46
+ }
47
+ : undefined;
48
+
49
+ const onDelete = editable
50
+ ? async (node: FileNode) => {
51
+ await ProjectFiles.remove(pid, node.path, scope);
52
+ if (selected === node.path) setSelected(null);
53
+ toast.success(t("files.deleted"));
54
+ void tree.mutate();
55
+ }
56
+ : undefined;
57
+
58
+ const onCreated = (path: string) => {
59
+ setNewOpen(false);
60
+ setSelected(path);
61
+ void tree.mutate();
62
+ };
63
+
64
+ const nodes = tree.data?.tree ?? [];
65
+ const isEmpty = !tree.isLoading && nodes.length === 0;
66
+
67
+ return (
68
+ <div className="flex h-full min-h-0 overflow-hidden rounded-xl border border-border bg-card">
69
+ {/* Sidebar: tree */}
70
+ <div className="flex w-64 shrink-0 flex-col border-r border-border">
71
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
72
+ <FolderOpen className="size-4 text-muted-foreground" />
73
+ <span className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
74
+ {scope === "docs" ? t("files.docs_label") : t("files.files_label")}
75
+ </span>
76
+ {editable && (
77
+ <button
78
+ type="button"
79
+ data-testid="docs-new"
80
+ onClick={() => setNewOpen(true)}
81
+ className="text-muted-foreground hover:text-foreground"
82
+ aria-label={t("files.new_doc")}
83
+ title={t("files.new_doc")}
84
+ >
85
+ <FilePlus2 className="size-4" />
86
+ </button>
87
+ )}
88
+ <button
89
+ type="button"
90
+ onClick={() => void tree.mutate()}
91
+ className="text-muted-foreground hover:text-foreground"
92
+ aria-label={t("common.refresh")}
93
+ >
94
+ <RefreshCw className={tree.isValidating ? "size-3.5 animate-spin" : "size-3.5"} />
95
+ </button>
96
+ </div>
97
+ <div className="min-h-0 flex-1 overflow-y-auto p-1.5">
98
+ {tree.isLoading ? (
99
+ <div className="flex justify-center py-6"><Spinner size={14} /></div>
100
+ ) : isEmpty ? (
101
+ <div className="p-3">
102
+ <Empty>
103
+ <div className="space-y-2">
104
+ <p>{emptyHint ?? t("files.empty")}</p>
105
+ {editable && (
106
+ <Button size="sm" variant="secondary" onClick={() => setNewOpen(true)}>
107
+ <FilePlus2 className="size-3.5" />{t("files.new_doc")}
108
+ </Button>
109
+ )}
110
+ </div>
111
+ </Empty>
112
+ </div>
113
+ ) : (
114
+ <FileTree nodes={nodes} selectedPath={selected} onSelect={onSelect} onDelete={onDelete} />
115
+ )}
116
+ {tree.data?.truncated && (
117
+ <p className="px-2 py-1 text-[10px] text-muted-foreground/60">{t("files.truncated")}</p>
118
+ )}
119
+ </div>
120
+ </div>
121
+
122
+ {/* Main: viewer */}
123
+ <div className="flex min-w-0 flex-1 flex-col">
124
+ <FileViewer file={file.data ?? null} loading={!!fileKey && file.isLoading} onSave={onSave} />
125
+ </div>
126
+
127
+ {editable && (
128
+ <NewFileDialog
129
+ open={newOpen}
130
+ onClose={() => setNewOpen(false)}
131
+ pid={pid}
132
+ scope={scope}
133
+ onCreated={onCreated}
134
+ />
135
+ )}
136
+ </div>
137
+ );
138
+ }