@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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/skills/index.js +9 -0
  3. package/src/core/agent/skills/inspector.js +7 -2
  4. package/src/core/agent/skills/policy.js +128 -0
  5. package/src/core/agent/super-agent.js +8 -1
  6. package/src/core/agent/tools/handlers/list-skills.js +6 -3
  7. package/src/core/agent/tools/handlers/load-skill.js +9 -2
  8. package/src/core/apc/paths.js +7 -0
  9. package/src/core/stores/organization.js +152 -0
  10. package/src/core/stores/project-files.js +199 -0
  11. package/src/core/stores/tasks.js +36 -3
  12. package/src/host/daemon/api/agents.js +22 -2
  13. package/src/host/daemon/api/files-project.js +99 -0
  14. package/src/host/daemon/api/organization.js +88 -0
  15. package/src/host/daemon/api/shared.js +7 -0
  16. package/src/host/daemon/api/skills.js +301 -17
  17. package/src/host/daemon/api/tasks.js +14 -0
  18. package/src/host/daemon/api.js +4 -0
  19. package/src/interfaces/cli/commands/org.js +77 -0
  20. package/src/interfaces/cli/commands/skills.js +3 -2
  21. package/src/interfaces/cli/index.js +48 -0
  22. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js +731 -0
  23. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +1 -0
  24. package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +1 -0
  25. package/src/interfaces/web/dist/index.html +2 -2
  26. package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
  27. package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
  28. package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
  29. package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
  30. package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
  31. package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
  32. package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
  33. package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
  34. package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
  35. package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
  36. package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
  37. package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
  38. package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
  39. package/src/interfaces/web/src/i18n/en.ts +171 -0
  40. package/src/interfaces/web/src/i18n/es.ts +171 -0
  41. package/src/interfaces/web/src/lib/api/organization.ts +18 -0
  42. package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
  43. package/src/interfaces/web/src/lib/api/skills.ts +79 -8
  44. package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
  45. package/src/interfaces/web/src/lib/api.ts +2 -0
  46. package/src/interfaces/web/src/lib/slug.ts +11 -0
  47. package/src/interfaces/web/src/screens/ProjectScreen.tsx +25 -2
  48. package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
  49. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
  50. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
  51. package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
  52. package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
  53. package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
  54. package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
  55. package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
  56. package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
  57. package/src/interfaces/web/src/types/daemon.ts +63 -0
  58. package/src/interfaces/web/dist/assets/index-CEI8DfVg.css +0 -1
  59. package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js +0 -651
  60. 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(""); setModel(""); setLanguage("");
297
- setDescription(""); setSkills(""); setTools(""); setIsMaster(false); setParent("");
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-2 gap-3">
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.agents.role_label")}><Input value={role} onChange={(e) => setRole(e.target.value)} placeholder={t("project.agents.role_ph")} /></Field>
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 tasks = useSWR(`/projects/${pid}/tasks?state=open`, () => Tasks.list(pid));
14
+ const navigate = useNavigate();
15
+ const tasks = useSWR(`/projects/${pid}/tasks?state=open`, () => Tasks.list(pid), { refreshInterval: 20_000 });
16
+ const summary = useSWR(`/projects/${pid}/tasks-summary`, () => Tasks.summary(pid), { refreshInterval: 20_000 });
9
17
  const routines = useSWR(`/projects/${pid}/routines`, () => Routines.list(pid));
10
18
  const agents = useSWR(`/projects/${pid}/agents`, () => Agents.list(pid));
11
19
  const mcps = useSWR(`/projects/${pid}/mcps`, () => Mcps.list(pid));
12
20
  const artifacts = useSWR(`/projects/${pid}/artifacts`, () => Artifacts.list(pid));
21
+
22
+ const agentList = agents.data ?? [];
23
+ const orchestrators = agentList.filter((a) => a.is_master || a.type === "orchestrator");
24
+ const specialists = agentList.filter((a) => !(a.is_master || a.type === "orchestrator"));
25
+ const activeRoutines = (routines.data ?? []).filter((r) => r.enabled).length;
26
+ const openTasks = [...(tasks.data ?? [])].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")).slice(0, 6);
27
+
13
28
  return (
14
- <div className="grid grid-cols-2 gap-4 md:grid-cols-3">
15
- <Card title={t("project.overview.tasks_open")} value={tasks.data?.length ?? "…"} href={`/p/${pid}/tasks`} icon={Zap} />
16
- <Card title={t("project.overview.routines")} value={routines.data?.length ?? "…"} href={`/p/${pid}/routines`} icon={Heart} />
17
- <Card title={t("project.overview.agents")} value={agents.data?.length ?? "…"} href={`/p/${pid}/agents`} icon={Bot} />
18
- <Card title={t("project.overview.mcps")} value={mcps.data?.length ?? "…"} href={`/p/${pid}/mcps`} icon={Puzzle} />
19
- <Card title={t("project.overview.artifacts")} value={artifacts.data?.length ?? "…"} href={`/p/${pid}/artifacts`} icon={FileCode2} />
20
- <Card title={t("project.overview.chat")} value={t("project.overview.chat_value")} href={`/p/${pid}/chat`} icon={MessagesSquare} />
29
+ <div className="space-y-6">
30
+ {/* Stat cards */}
31
+ <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
32
+ <Card title={t("project.overview.agents")} value={agentList.length} href={`/p/${pid}/agents`} icon={Bot} />
33
+ <Card title={t("project.overview.tasks_open")} value={summary.data?.open ?? tasks.data?.length ?? "…"} href={`/p/${pid}/tasks`} icon={Zap} />
34
+ <Card title={t("project.overview.routines_active")} value={activeRoutines} href={`/p/${pid}/routines`} icon={Heart} />
35
+ <Card title={t("project.overview.artifacts")} value={artifacts.data?.length ?? "…"} href={`/p/${pid}/artifacts`} icon={FileCode2} />
36
+ </div>
37
+
38
+ {/* Task workflow strip */}
39
+ {summary.data && summary.data.open > 0 && (
40
+ <div className="flex flex-wrap gap-2">
41
+ {TASK_STATUS_ORDER.map((s) => (
42
+ <NavLink
43
+ key={s}
44
+ to={`/p/${pid}/tasks`}
45
+ className="flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs hover:bg-accent/40"
46
+ >
47
+ <StatusIcon status={s} className="size-3.5" />
48
+ <span className="capitalize text-muted-foreground">{statusLabel(s)}</span>
49
+ <span className="font-semibold">{summary.data!.status?.[s] ?? 0}</span>
50
+ </NavLink>
51
+ ))}
52
+ </div>
53
+ )}
54
+
55
+ <div className="grid gap-4 lg:grid-cols-2">
56
+ {/* Agent roster */}
57
+ <Section title={t("project.overview.roster")} className="!p-4">
58
+ {agentList.length === 0 ? (
59
+ <p className="text-sm text-muted-fg">{t("project.overview.no_agents")}</p>
60
+ ) : (
61
+ <div className="space-y-3">
62
+ {orchestrators.length > 0 && (
63
+ <RosterRow label={t("project.overview.orchestrators")} icon={Crown} agents={orchestrators} pid={pid} navigate={navigate} />
64
+ )}
65
+ {specialists.length > 0 && (
66
+ <RosterRow label={t("project.overview.specialists")} icon={Bot} agents={specialists} pid={pid} navigate={navigate} />
67
+ )}
68
+ </div>
69
+ )}
70
+ </Section>
71
+
72
+ {/* Recent / in-flight tasks */}
73
+ <Section title={t("project.overview.recent_tasks")} className="!p-4"
74
+ action={<NavLink to={`/p/${pid}/tasks`} className="text-xs text-sky-500 hover:text-sky-400">{t("common.view_all")}</NavLink>}
75
+ >
76
+ {openTasks.length === 0 ? (
77
+ <p className="flex items-center gap-2 text-sm text-muted-fg"><Activity className="size-4" />{t("project.overview.no_activity")}</p>
78
+ ) : (
79
+ <ul className="space-y-1.5">
80
+ {openTasks.map((task) => (
81
+ <li key={task.id}>
82
+ <button
83
+ type="button"
84
+ onClick={() => navigate(`/p/${pid}/tasks?task=${task.id}`)}
85
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-accent/40"
86
+ >
87
+ <StatusIcon status={effectiveStatus(task)} className="size-3.5 shrink-0" />
88
+ <span className="min-w-0 flex-1 truncate text-sm">{task.title}</span>
89
+ <StatusBadge status={effectiveStatus(task)} />
90
+ </button>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ )}
95
+ </Section>
96
+ </div>
97
+
98
+ {/* Quick links */}
99
+ <div className="grid grid-cols-2 gap-4 md:grid-cols-3">
100
+ <Card title={t("project.overview.chat")} value={t("project.overview.chat_value")} href={`/p/${pid}/chat`} icon={MessagesSquare} />
101
+ <Card title={t("project.overview.mcps")} value={mcps.data?.length ?? "…"} href={`/p/${pid}/mcps`} icon={Puzzle} />
102
+ <Card title={t("project.overview.routines")} value={routines.data?.length ?? "…"} href={`/p/${pid}/routines`} icon={Heart} />
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function RosterRow({
109
+ label, icon: Icon, agents, pid, navigate,
110
+ }: {
111
+ label: string; icon: typeof Bot; agents: AgentEntry[]; pid: string; navigate: (to: string) => void;
112
+ }) {
113
+ return (
114
+ <div>
115
+ <div className="mb-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
116
+ <Icon className="size-3.5" />{label} ({agents.length})
117
+ </div>
118
+ <div className="flex flex-wrap gap-1.5">
119
+ {agents.map((a) => (
120
+ <button
121
+ key={a.slug}
122
+ type="button"
123
+ onClick={() => navigate(`/p/${pid}/agents/${a.slug}`)}
124
+ className={cn(
125
+ "inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-xs hover:border-muted-fg/50",
126
+ )}
127
+ >
128
+ <span className="text-sm leading-none">{a.emoji || "🤖"}</span>
129
+ <span className="truncate">{a.slug}</span>
130
+ </button>
131
+ ))}
132
+ </div>
21
133
  </div>
22
134
  );
23
135
  }
@@ -0,0 +1,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 { Check, Plus, RotateCcw, Trash2 } from "lucide-react";
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 flex shrink-0 items-end gap-2">
59
- <Field label={t("project.tasks.add_label")}>
60
- <Input
61
- data-testid="task-input"
62
- placeholder={t("project.tasks.add_placeholder")}
63
- value={draft}
64
- onChange={(e) => setDraft(e.target.value)}
65
- onKeyDown={(e) => { if (e.key === "Enter") add(); }}
66
- />
67
- </Field>
68
- <Button variant="primary" data-testid="task-add" onClick={add} loading={busy}>
69
- <Plus size={14} /> {t("project.tasks.add")}
70
- </Button>
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
- {paged.isLoading && <Loading />}
74
- {!paged.isLoading && paged.total === 0 && (
75
- <Empty>
76
- {state === "open"
77
- ? t("project.tasks.empty_open")
78
- : t("project.tasks.empty", { state })}
79
- {" "}<code>apx task add "…"</code>
80
- </Empty>
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
- <PagedList paged={paged} fullHeight>
84
- <ul className="space-y-2 text-sm" data-testid="task-list">
85
- {paged.items.map((task) => (
86
- <li key={task.id} data-testid={`task-${task.id}`} className="flex items-start gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
87
- <span className="mt-0.5 font-mono text-[10px] text-muted-fg">{task.id}</span>
88
- <div className="flex-1">
89
- <div className="font-medium">{task.title}</div>
90
- <div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-fg">
91
- {task.tags?.map((tag) => <Badge key={tag}>#{tag}</Badge>)}
92
- {task.agent && <Badge tone="info">@{task.agent}</Badge>}
93
- {task.source && <span>{t("project.tasks.via")} {task.source}</span>}
94
- {task.due && <span>{t("project.tasks.due")} {task.due}</span>}
95
- </div>
96
- </div>
97
- <div className="flex gap-1">
98
- {state === "open" && (
99
- <>
100
- <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"))}>
101
- <Check size={13} />
102
- </Button>
103
- <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"))}>
104
- <Trash2 size={13} />
105
- </Button>
106
- </>
107
- )}
108
- {state !== "open" && (
109
- <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"))}>
110
- <RotateCcw size={13} />
111
- </Button>
112
- )}
113
- </div>
114
- </li>
115
- ))}
116
- </ul>
117
- </PagedList>
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
  }