@agentprojectcontext/apx 1.57.0 → 1.59.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/agent/skills/index.js +9 -0
- package/src/core/agent/skills/inspector.js +7 -2
- package/src/core/agent/skills/policy.js +128 -0
- package/src/core/agent/super-agent.js +8 -1
- package/src/core/agent/tools/handlers/list-skills.js +6 -3
- package/src/core/agent/tools/handlers/load-skill.js +9 -2
- package/src/core/apc/paths.js +7 -0
- 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/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/skills.js +301 -17
- 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/commands/skills.js +3 -2
- package/src/interfaces/cli/index.js +48 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js +731 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
- package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
- 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/settings/SkillsManager.tsx +465 -0
- package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -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/i18n/en.ts +171 -0
- package/src/interfaces/web/src/i18n/es.ts +171 -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/skills.ts +79 -8
- 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 +25 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
- 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/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/SkillsTab.tsx +13 -0
- 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 +63 -0
- package/src/interfaces/web/dist/assets/index-CEI8DfVg.css +0 -1
- package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js +0 -651
- package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js.map +0 -1
|
@@ -8,8 +8,11 @@ import { Section } from "../../components/Section";
|
|
|
8
8
|
import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch, Textarea } from "../../components/ui";
|
|
9
9
|
import { UiSelect } from "../../components/UiSelect";
|
|
10
10
|
import { useToast } from "../../components/Toast";
|
|
11
|
+
import { EmojiInput, AutonomyPicker, AreaRoleFields } from "../../components/agents/AgentFormFields";
|
|
11
12
|
import { cn } from "../../lib/cn";
|
|
12
13
|
import { t } from "../../i18n";
|
|
14
|
+
import type { AgentAutonomy } from "../../types/daemon";
|
|
15
|
+
import { typeOptions } from "./AgentDetailScreen";
|
|
13
16
|
|
|
14
17
|
const LANGS = ["", "es", "en", "pt", "fr", "it", "de"];
|
|
15
18
|
const csv = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
@@ -224,7 +227,7 @@ function AgentCard({
|
|
|
224
227
|
>
|
|
225
228
|
<div className="flex items-center gap-2">
|
|
226
229
|
<div className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br", gradient)}>
|
|
227
|
-
<Icon className="size-4 text-white" />
|
|
230
|
+
{agent.emoji ? <span className="text-base leading-none">{agent.emoji}</span> : <Icon className="size-4 text-white" />}
|
|
228
231
|
</div>
|
|
229
232
|
<span className="truncate text-sm font-semibold">{agent.slug}</span>
|
|
230
233
|
</div>
|
|
@@ -250,7 +253,7 @@ function ListView({ agents, onOpen, onChat }: { agents: AgentEntry[]; onOpen: (s
|
|
|
250
253
|
return (
|
|
251
254
|
<div key={a.slug} data-testid={`agent-card-${a.slug}`} className="flex cursor-pointer items-center gap-4 rounded-xl border border-border bg-muted/30 p-3 hover:border-muted-fg/50" onClick={() => onOpen(a.slug)}>
|
|
252
255
|
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br", gradient)}>
|
|
253
|
-
<Icon className="size-4 text-white" />
|
|
256
|
+
{a.emoji ? <span className="text-lg leading-none">{a.emoji}</span> : <Icon className="size-4 text-white" />}
|
|
254
257
|
</div>
|
|
255
258
|
<div className="min-w-0 flex-1">
|
|
256
259
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -282,7 +285,11 @@ function CreateAgentDialog({
|
|
|
282
285
|
}: { open: boolean; onClose: () => void; onCreated: () => void; pid: string; agents: AgentEntry[] }) {
|
|
283
286
|
const toast = useToast();
|
|
284
287
|
const [slug, setSlug] = useState("");
|
|
288
|
+
const [emoji, setEmoji] = useState("");
|
|
289
|
+
const [type, setType] = useState("");
|
|
285
290
|
const [role, setRole] = useState("");
|
|
291
|
+
const [area, setArea] = useState("");
|
|
292
|
+
const [autonomy, setAutonomy] = useState<AgentAutonomy | "">("");
|
|
286
293
|
const [model, setModel] = useState("");
|
|
287
294
|
const [language, setLanguage] = useState("");
|
|
288
295
|
const [description, setDescription] = useState("");
|
|
@@ -293,8 +300,9 @@ function CreateAgentDialog({
|
|
|
293
300
|
const [busy, setBusy] = useState(false);
|
|
294
301
|
|
|
295
302
|
const reset = () => {
|
|
296
|
-
setSlug(""); setRole("");
|
|
297
|
-
|
|
303
|
+
setSlug(""); setEmoji(""); setType(""); setRole(""); setArea(""); setAutonomy("");
|
|
304
|
+
setModel(""); setLanguage(""); setDescription(""); setSkills(""); setTools("");
|
|
305
|
+
setIsMaster(false); setParent("");
|
|
298
306
|
};
|
|
299
307
|
|
|
300
308
|
const submit = async () => {
|
|
@@ -303,13 +311,17 @@ function CreateAgentDialog({
|
|
|
303
311
|
try {
|
|
304
312
|
await Agents.create(pid, {
|
|
305
313
|
slug,
|
|
314
|
+
emoji: emoji || undefined,
|
|
315
|
+
type: type || undefined,
|
|
306
316
|
role: role || undefined,
|
|
317
|
+
area: area || undefined,
|
|
318
|
+
autonomy: autonomy || undefined,
|
|
307
319
|
model: model || undefined,
|
|
308
320
|
language: language || undefined,
|
|
309
321
|
description: description || undefined,
|
|
310
322
|
skills: csv(skills),
|
|
311
323
|
tools: csv(tools),
|
|
312
|
-
is_master: isMaster,
|
|
324
|
+
is_master: isMaster || type === "orchestrator",
|
|
313
325
|
parent: parent || undefined,
|
|
314
326
|
});
|
|
315
327
|
toast.success(t("project.agents.create_success", { slug }));
|
|
@@ -334,10 +346,15 @@ function CreateAgentDialog({
|
|
|
334
346
|
}
|
|
335
347
|
>
|
|
336
348
|
<div className="space-y-3">
|
|
337
|
-
<div className="grid grid-cols-
|
|
349
|
+
<div className="grid grid-cols-[80px_1fr_1fr] gap-3">
|
|
350
|
+
<Field label={t("agents_form.emoji")}><EmojiInput value={emoji} onChange={setEmoji} /></Field>
|
|
338
351
|
<Field label={t("project.agents.slug_label")}><Input autoFocus data-testid="agent-slug" value={slug} onChange={(e) => setSlug(e.target.value)} placeholder={t("project.agents.slug_ph")} /></Field>
|
|
339
|
-
<Field label={t("project.
|
|
352
|
+
<Field label={t("project.agent_detail.type_label")}><UiSelect value={type} onChange={setType} options={typeOptions()} /></Field>
|
|
340
353
|
</div>
|
|
354
|
+
<AreaRoleFields pid={pid} area={area} role={role} onArea={setArea} onRole={setRole} />
|
|
355
|
+
<Field label={t("agents_form.autonomy")} hint={t("agents_form.autonomy_hint")}>
|
|
356
|
+
<AutonomyPicker value={autonomy} onChange={setAutonomy} />
|
|
357
|
+
</Field>
|
|
341
358
|
<div className="grid grid-cols-2 gap-3">
|
|
342
359
|
<Field label={t("project.agents.model_label")} hint={t("project.agents.model_hint")}><Input value={model} onChange={(e) => setModel(e.target.value)} /></Field>
|
|
343
360
|
<Field label={t("project.agents.lang_label")}><UiSelect value={language} onChange={setLanguage} options={LANGS.map((l) => ({ value: l, label: l || "—" }))} /></Field>
|
|
@@ -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,13 @@
|
|
|
1
|
+
import { useProject } from "../../hooks/useProjects";
|
|
2
|
+
import { Loading } from "../../components/ui";
|
|
3
|
+
import { SkillsManager } from "../../components/settings/SkillsManager";
|
|
4
|
+
|
|
5
|
+
// Per-project skills view: the Claude-Desktop-style manager locked to THIS
|
|
6
|
+
// project's scope so you can enable/disable and add skills while working in it.
|
|
7
|
+
// The base project (pid "0" = super-agent admin) manages the "default" scope.
|
|
8
|
+
export function SkillsTab({ pid }: { pid: string }) {
|
|
9
|
+
const { project } = useProject(pid);
|
|
10
|
+
const scope = pid === "0" ? "default" : project?.path;
|
|
11
|
+
if (!scope) return <Loading />;
|
|
12
|
+
return <SkillsManager scope={scope} />;
|
|
13
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { useSearchParams } from "react-router-dom";
|
|
3
|
+
import { Plus, ChevronDown, ChevronUp, Check, Trash2, RotateCcw } from "lucide-react";
|
|
3
4
|
import { Tasks } from "../../lib/api";
|
|
4
5
|
import { Section } from "../../components/Section";
|
|
5
6
|
import { PagedList, usePagedQuery } from "../../components/Pager";
|
|
6
|
-
import { Badge, Button, Empty, Field, Input, Loading } from "../../components/ui";
|
|
7
|
+
import { Badge, Button, Empty, Field, Input, Loading, Textarea } from "../../components/ui";
|
|
7
8
|
import { useToast } from "../../components/Toast";
|
|
9
|
+
import { StatusIcon, StatusBadge, effectiveStatus } from "../../components/tasks/taskStatus";
|
|
10
|
+
import { TaskDetailPanel } from "../../components/tasks/TaskDetailPanel";
|
|
8
11
|
import { t } from "../../i18n";
|
|
9
12
|
|
|
10
13
|
export function TasksTab({ pid }: { pid: string }) {
|
|
11
14
|
const [state, setState] = useState<"open" | "done" | "dropped">("open");
|
|
15
|
+
const [params, setParams] = useSearchParams();
|
|
16
|
+
const selected = params.get("task");
|
|
12
17
|
const toast = useToast();
|
|
13
18
|
// dedupingInterval:0 so switching the state filter always revalidates the
|
|
14
19
|
// target page instead of showing the stale cached one from a prior switch.
|
|
@@ -19,14 +24,22 @@ export function TasksTab({ pid }: { pid: string }) {
|
|
|
19
24
|
swr: { dedupingInterval: 0, revalidateOnFocus: true },
|
|
20
25
|
});
|
|
21
26
|
const [draft, setDraft] = useState("");
|
|
27
|
+
const [body, setBody] = useState("");
|
|
28
|
+
const [showBody, setShowBody] = useState(false);
|
|
22
29
|
const [busy, setBusy] = useState(false);
|
|
23
30
|
|
|
31
|
+
const select = (id: string | null) => {
|
|
32
|
+
const next = new URLSearchParams(params);
|
|
33
|
+
if (id) next.set("task", id); else next.delete("task");
|
|
34
|
+
setParams(next, { replace: true });
|
|
35
|
+
};
|
|
36
|
+
|
|
24
37
|
const add = async () => {
|
|
25
38
|
if (!draft.trim()) return;
|
|
26
39
|
setBusy(true);
|
|
27
40
|
try {
|
|
28
|
-
await Tasks.add(pid, { title: draft.trim() });
|
|
29
|
-
setDraft("");
|
|
41
|
+
await Tasks.add(pid, { title: draft.trim(), body: body.trim() || null, source: "web" });
|
|
42
|
+
setDraft(""); setBody(""); setShowBody(false);
|
|
30
43
|
toast.success(t("project.tasks.created"));
|
|
31
44
|
paged.mutate();
|
|
32
45
|
} catch (e: any) {
|
|
@@ -49,72 +62,98 @@ export function TasksTab({ pid }: { pid: string }) {
|
|
|
49
62
|
<div className="flex gap-1">
|
|
50
63
|
{(["open", "done", "dropped"] as const).map((s) => (
|
|
51
64
|
<Button key={s} size="sm" data-testid={`task-filter-${s}`} variant={state === s ? "primary" : "ghost"} onClick={() => setState(s)}>
|
|
52
|
-
{s}
|
|
65
|
+
{t(`tasks.state_${s}` as never)}
|
|
53
66
|
</Button>
|
|
54
67
|
))}
|
|
55
68
|
</div>
|
|
56
69
|
}
|
|
57
70
|
>
|
|
58
|
-
<div className="mb-4
|
|
59
|
-
<
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
+
<div className="mb-4 shrink-0 space-y-2">
|
|
72
|
+
<div className="flex items-end gap-2">
|
|
73
|
+
<Field label={t("project.tasks.add_label")}>
|
|
74
|
+
<Input
|
|
75
|
+
data-testid="task-input"
|
|
76
|
+
placeholder={t("project.tasks.add_placeholder")}
|
|
77
|
+
value={draft}
|
|
78
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
79
|
+
onKeyDown={(e) => { if (e.key === "Enter" && !showBody) add(); }}
|
|
80
|
+
/>
|
|
81
|
+
</Field>
|
|
82
|
+
<Button variant="ghost" size="sm" onClick={() => setShowBody((v) => !v)} aria-label={t("tasks.toggle_prompt")}>
|
|
83
|
+
{showBody ? <ChevronUp size={14} /> : <ChevronDown size={14} />} {t("tasks.field_prompt")}
|
|
84
|
+
</Button>
|
|
85
|
+
<Button variant="primary" data-testid="task-add" onClick={add} loading={busy}>
|
|
86
|
+
<Plus size={14} /> {t("project.tasks.add")}
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
{showBody && (
|
|
90
|
+
<Textarea rows={3} value={body} onChange={(e) => setBody(e.target.value)} placeholder={t("tasks.prompt_ph")} className="text-xs" />
|
|
91
|
+
)}
|
|
71
92
|
</div>
|
|
72
93
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
<div className="flex min-h-0 flex-1 gap-4">
|
|
95
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
96
|
+
{paged.isLoading && <Loading />}
|
|
97
|
+
{!paged.isLoading && paged.total === 0 && (
|
|
98
|
+
<Empty>
|
|
99
|
+
{state === "open" ? t("project.tasks.empty_open") : t("project.tasks.empty", { state })}
|
|
100
|
+
{" "}<code>apx task add "…"</code>
|
|
101
|
+
</Empty>
|
|
102
|
+
)}
|
|
82
103
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
104
|
+
<PagedList paged={paged} fullHeight>
|
|
105
|
+
<ul className="space-y-2 text-sm" data-testid="task-list">
|
|
106
|
+
{paged.items.map((task) => {
|
|
107
|
+
const eff = effectiveStatus(task);
|
|
108
|
+
return (
|
|
109
|
+
<li
|
|
110
|
+
key={task.id}
|
|
111
|
+
data-testid={`task-${task.id}`}
|
|
112
|
+
onClick={() => select(task.id)}
|
|
113
|
+
className={`flex cursor-pointer items-start gap-3 rounded-md border px-3 py-2 hover:border-muted-fg/50 ${selected === task.id ? "border-primary/50 bg-primary/5" : "border-border bg-muted/30"}`}
|
|
114
|
+
>
|
|
115
|
+
<StatusIcon status={eff} className="mt-0.5 shrink-0" />
|
|
116
|
+
<div className="min-w-0 flex-1">
|
|
117
|
+
<div className="truncate font-medium">{task.title}</div>
|
|
118
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-fg">
|
|
119
|
+
{task.state === "open" && <StatusBadge status={eff} />}
|
|
120
|
+
{task.tags?.map((tag) => <Badge key={tag}>#{tag}</Badge>)}
|
|
121
|
+
{task.agent && <Badge tone="info">@{task.agent}</Badge>}
|
|
122
|
+
{task.due && <span>{t("project.tasks.due")} {task.due}</span>}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="flex shrink-0 gap-1" onClick={(e) => e.stopPropagation()}>
|
|
126
|
+
{task.state === "open" ? (
|
|
127
|
+
<>
|
|
128
|
+
<Button size="sm" variant="secondary" aria-label={t("project.tasks.aria_done")} data-testid={`task-done-${task.id}`} onClick={() => mark(() => Tasks.done(pid, task.id), t("project.tasks.done"))}>
|
|
129
|
+
<Check size={13} />
|
|
130
|
+
</Button>
|
|
131
|
+
<Button size="sm" variant="destructive" aria-label={t("project.tasks.aria_drop")} data-testid={`task-drop-${task.id}`} onClick={() => mark(() => Tasks.drop(pid, task.id), t("project.tasks.drop"))}>
|
|
132
|
+
<Trash2 size={13} />
|
|
133
|
+
</Button>
|
|
134
|
+
</>
|
|
135
|
+
) : (
|
|
136
|
+
<Button size="sm" variant="ghost" aria-label={t("project.tasks.aria_reopen")} data-testid={`task-reopen-${task.id}`} onClick={() => mark(() => Tasks.reopen(pid, task.id), t("project.tasks.reopen"))}>
|
|
137
|
+
<RotateCcw size={13} />
|
|
138
|
+
</Button>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ul>
|
|
145
|
+
</PagedList>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{selected && (
|
|
149
|
+
<TaskDetailPanel
|
|
150
|
+
pid={pid}
|
|
151
|
+
taskId={selected}
|
|
152
|
+
onClose={() => select(null)}
|
|
153
|
+
onChanged={() => paged.mutate()}
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
118
157
|
</Section>
|
|
119
158
|
);
|
|
120
159
|
}
|