@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.
- package/package.json +1 -1
- package/src/core/apc/paths.js +7 -0
- package/src/core/stores/conversations.js +10 -0
- package/src/core/stores/messages.js +37 -6
- package/src/core/stores/organization.js +152 -0
- package/src/core/stores/project-files.js +199 -0
- package/src/core/stores/tasks.js +36 -3
- package/src/host/daemon/api/agents.js +22 -2
- package/src/host/daemon/api/conversations.js +20 -2
- package/src/host/daemon/api/files-project.js +99 -0
- package/src/host/daemon/api/organization.js +88 -0
- package/src/host/daemon/api/shared.js +7 -0
- package/src/host/daemon/api/tasks.js +14 -0
- package/src/host/daemon/api.js +4 -0
- package/src/interfaces/cli/commands/org.js +77 -0
- package/src/interfaces/cli/index.js +48 -0
- package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +1 -0
- package/src/interfaces/web/dist/assets/index-DPAuXATr.js +705 -0
- package/src/interfaces/web/dist/assets/index-DPAuXATr.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/App.tsx +1 -1
- package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
- package/src/interfaces/web/src/components/chat/ChatList.tsx +86 -65
- package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
- package/src/interfaces/web/src/components/common/TabNav.tsx +1 -1
- package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
- package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
- package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
- package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
- package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
- package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
- package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
- package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
- package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
- package/src/interfaces/web/src/hooks/useChat.ts +39 -7
- package/src/interfaces/web/src/i18n/en.ts +113 -3
- package/src/interfaces/web/src/i18n/es.ts +113 -3
- package/src/interfaces/web/src/lib/api/conversations.ts +6 -0
- package/src/interfaces/web/src/lib/api/organization.ts +18 -0
- package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
- package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
- package/src/interfaces/web/src/lib/api.ts +2 -0
- package/src/interfaces/web/src/lib/slug.ts +11 -0
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +22 -3
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +1 -1
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
- package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +136 -36
- package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
- package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
- package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
- package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
- package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
- package/src/interfaces/web/src/types/daemon.ts +68 -0
- package/src/interfaces/web/dist/assets/index-CAUezTBY.css +0 -1
- package/src/interfaces/web/dist/assets/index-DaE_memX.js +0 -651
- 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
|
|
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.
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
const [selected, setSelected] = useState<ChatKey>(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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 === "
|
|
105
|
-
?
|
|
106
|
-
:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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={
|
|
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
|
-
|
|
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={
|
|
217
|
+
onClick={newSession}
|
|
157
218
|
>
|
|
158
|
-
<
|
|
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
|
|
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="
|
|
15
|
-
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
}
|