@agentprojectcontext/apx 1.57.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/core/apc/paths.js +7 -0
  3. package/src/core/stores/organization.js +152 -0
  4. package/src/core/stores/project-files.js +199 -0
  5. package/src/core/stores/tasks.js +36 -3
  6. package/src/host/daemon/api/agents.js +22 -2
  7. package/src/host/daemon/api/files-project.js +99 -0
  8. package/src/host/daemon/api/organization.js +88 -0
  9. package/src/host/daemon/api/shared.js +7 -0
  10. package/src/host/daemon/api/tasks.js +14 -0
  11. package/src/host/daemon/api.js +4 -0
  12. package/src/interfaces/cli/commands/org.js +77 -0
  13. package/src/interfaces/cli/index.js +48 -0
  14. package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +1 -0
  15. package/src/interfaces/web/dist/assets/index-DPAuXATr.js +705 -0
  16. package/src/interfaces/web/dist/assets/index-DPAuXATr.js.map +1 -0
  17. package/src/interfaces/web/dist/index.html +2 -2
  18. package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
  19. package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
  20. package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
  21. package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
  22. package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
  23. package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
  24. package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
  25. package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
  26. package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
  27. package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
  28. package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
  29. package/src/interfaces/web/src/i18n/en.ts +104 -0
  30. package/src/interfaces/web/src/i18n/es.ts +104 -0
  31. package/src/interfaces/web/src/lib/api/organization.ts +18 -0
  32. package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
  33. package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
  34. package/src/interfaces/web/src/lib/api.ts +2 -0
  35. package/src/interfaces/web/src/lib/slug.ts +11 -0
  36. package/src/interfaces/web/src/screens/ProjectScreen.tsx +21 -2
  37. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
  38. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
  39. package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
  40. package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
  41. package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
  42. package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
  43. package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
  44. package/src/interfaces/web/src/types/daemon.ts +63 -0
  45. package/src/interfaces/web/dist/assets/index-CEI8DfVg.css +0 -1
  46. package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js +0 -651
  47. package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js.map +0 -1
@@ -0,0 +1,146 @@
1
+ import { Fragment, type ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ // A small, dependency-free markdown renderer. We deliberately avoid
5
+ // react-markdown/marked (no installers per project rules) and never use
6
+ // dangerouslySetInnerHTML — every node is a real React element, so untrusted
7
+ // document content can't inject markup. Coverage is the common subset:
8
+ // headings, fenced/inline code, bold/italic/links, blockquotes, hr, and
9
+ // ordered/unordered lists. Anything else renders as plain paragraphs.
10
+
11
+ // ── Inline: bold, italic, code, links ──────────────────────────────────────
12
+ function renderInline(text: string, keyBase: string): ReactNode[] {
13
+ const out: ReactNode[] = [];
14
+ // One regex, alternation ordered so `**` beats `*`. Groups capture the inner.
15
+ const re = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)]+)\))/g;
16
+ let last = 0;
17
+ let m: RegExpExecArray | null;
18
+ let i = 0;
19
+ while ((m = re.exec(text))) {
20
+ if (m.index > last) out.push(<Fragment key={`${keyBase}-t${i}`}>{text.slice(last, m.index)}</Fragment>);
21
+ if (m[2] !== undefined) out.push(<strong key={`${keyBase}-b${i}`}>{m[2]}</strong>);
22
+ else if (m[4] !== undefined) out.push(<em key={`${keyBase}-i${i}`}>{m[4]}</em>);
23
+ else if (m[6] !== undefined)
24
+ out.push(<code key={`${keyBase}-c${i}`} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">{m[6]}</code>);
25
+ else if (m[8] !== undefined)
26
+ out.push(
27
+ <a key={`${keyBase}-l${i}`} href={m[9]} target="_blank" rel="noreferrer" className="text-sky-500 underline underline-offset-2 hover:text-sky-400">
28
+ {m[8]}
29
+ </a>,
30
+ );
31
+ last = re.lastIndex;
32
+ i += 1;
33
+ }
34
+ if (last < text.length) out.push(<Fragment key={`${keyBase}-tend`}>{text.slice(last)}</Fragment>);
35
+ return out;
36
+ }
37
+
38
+ // ── Block-level ─────────────────────────────────────────────────────────────
39
+ export function MarkdownPreview({ content, className }: { content: string; className?: string }) {
40
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
41
+ const blocks: ReactNode[] = [];
42
+ let i = 0;
43
+ let key = 0;
44
+
45
+ const flushList = (items: string[], ordered: boolean) => {
46
+ const Tag = ordered ? "ol" : "ul";
47
+ blocks.push(
48
+ <Tag key={`k${key++}`} className={cn("my-2 space-y-1 pl-5", ordered ? "list-decimal" : "list-disc")}>
49
+ {items.map((it, idx) => (
50
+ <li key={idx}>{renderInline(it, `li${key}-${idx}`)}</li>
51
+ ))}
52
+ </Tag>,
53
+ );
54
+ };
55
+
56
+ while (i < lines.length) {
57
+ const line = lines[i];
58
+
59
+ // Fenced code block.
60
+ if (/^```/.test(line.trim())) {
61
+ const buf: string[] = [];
62
+ i += 1;
63
+ while (i < lines.length && !/^```/.test(lines[i].trim())) buf.push(lines[i++]);
64
+ i += 1; // closing fence
65
+ blocks.push(
66
+ <pre key={`k${key++}`} className="my-2 overflow-x-auto rounded-lg bg-muted/60 p-3 font-mono text-[12px] leading-[1.6]">
67
+ <code>{buf.join("\n")}</code>
68
+ </pre>,
69
+ );
70
+ continue;
71
+ }
72
+
73
+ // Blank line.
74
+ if (line.trim() === "") { i += 1; continue; }
75
+
76
+ // Headings.
77
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
78
+ if (h) {
79
+ const level = h[1].length;
80
+ const sizes = ["text-2xl", "text-xl", "text-lg", "text-base", "text-sm", "text-sm"];
81
+ blocks.push(
82
+ <div key={`k${key++}`} className={cn("mt-3 mb-1 font-semibold text-foreground", sizes[level - 1])}>
83
+ {renderInline(h[2], `h${key}`)}
84
+ </div>,
85
+ );
86
+ i += 1;
87
+ continue;
88
+ }
89
+
90
+ // Horizontal rule.
91
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
92
+ blocks.push(<hr key={`k${key++}`} className="my-3 border-border" />);
93
+ i += 1;
94
+ continue;
95
+ }
96
+
97
+ // Blockquote.
98
+ if (/^>\s?/.test(line)) {
99
+ const buf: string[] = [];
100
+ while (i < lines.length && /^>\s?/.test(lines[i])) buf.push(lines[i++].replace(/^>\s?/, ""));
101
+ blocks.push(
102
+ <blockquote key={`k${key++}`} className="my-2 border-l-2 border-border pl-3 text-muted-foreground">
103
+ {renderInline(buf.join(" "), `q${key}`)}
104
+ </blockquote>,
105
+ );
106
+ continue;
107
+ }
108
+
109
+ // Unordered list.
110
+ if (/^\s*[-*+]\s+/.test(line)) {
111
+ const items: string[] = [];
112
+ while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*[-*+]\s+/, ""));
113
+ flushList(items, false);
114
+ continue;
115
+ }
116
+
117
+ // Ordered list.
118
+ if (/^\s*\d+\.\s+/.test(line)) {
119
+ const items: string[] = [];
120
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*\d+\.\s+/, ""));
121
+ flushList(items, true);
122
+ continue;
123
+ }
124
+
125
+ // Paragraph (gather consecutive non-blank, non-structural lines).
126
+ const para: string[] = [];
127
+ while (
128
+ i < lines.length &&
129
+ lines[i].trim() !== "" &&
130
+ !/^(#{1,6})\s/.test(lines[i]) &&
131
+ !/^```/.test(lines[i].trim()) &&
132
+ !/^>\s?/.test(lines[i]) &&
133
+ !/^\s*[-*+]\s+/.test(lines[i]) &&
134
+ !/^\s*\d+\.\s+/.test(lines[i])
135
+ ) {
136
+ para.push(lines[i++]);
137
+ }
138
+ blocks.push(
139
+ <p key={`k${key++}`} className="my-2 leading-relaxed">
140
+ {renderInline(para.join(" "), `p${key}`)}
141
+ </p>,
142
+ );
143
+ }
144
+
145
+ return <div className={cn("text-sm text-foreground/90", className)}>{blocks}</div>;
146
+ }
@@ -0,0 +1,66 @@
1
+ import { useState } from "react";
2
+ import { Dialog, Button, Field, Input } from "../ui";
3
+ import { useToast } from "../Toast";
4
+ import { ProjectFiles, type FileScope } from "../../lib/api/projectFiles";
5
+ import { t } from "../../i18n";
6
+
7
+ // Create a new document. The user types a path (folders allowed, like
8
+ // Appsi's work/<case>/… layout); we default a .md extension when none is given.
9
+ export function NewFileDialog({
10
+ open, onClose, pid, scope, onCreated,
11
+ }: {
12
+ open: boolean;
13
+ onClose: () => void;
14
+ pid: string;
15
+ scope: FileScope;
16
+ onCreated: (path: string) => void;
17
+ }) {
18
+ const toast = useToast();
19
+ const [path, setPath] = useState("");
20
+ const [saving, setSaving] = useState(false);
21
+
22
+ const create = async () => {
23
+ let rel = path.trim().replace(/^\/+/, "");
24
+ if (!rel) return;
25
+ if (!/\.[a-z0-9]+$/i.test(rel)) rel += ".md";
26
+ setSaving(true);
27
+ try {
28
+ await ProjectFiles.write(pid, rel, "", scope);
29
+ toast.success(t("files.created"));
30
+ setPath("");
31
+ onCreated(rel);
32
+ } catch (e) {
33
+ toast.error(e instanceof Error ? e.message : String(e));
34
+ } finally {
35
+ setSaving(false);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <Dialog
41
+ open={open}
42
+ onClose={onClose}
43
+ title={t("files.new_doc")}
44
+ description={t("files.new_doc_hint")}
45
+ footer={
46
+ <>
47
+ <Button variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
48
+ <Button variant="primary" data-testid="new-file-create" onClick={() => void create()} loading={saving} disabled={!path.trim()}>
49
+ {t("files.create")}
50
+ </Button>
51
+ </>
52
+ }
53
+ >
54
+ <Field label={t("files.path_label")} hint={t("files.path_example")}>
55
+ <Input
56
+ autoFocus
57
+ data-testid="new-file-path"
58
+ value={path}
59
+ onChange={(e) => setPath(e.target.value)}
60
+ onKeyDown={(e) => { if (e.key === "Enter") void create(); }}
61
+ placeholder="cases/onboarding/spec.md"
62
+ />
63
+ </Field>
64
+ </Dialog>
65
+ );
66
+ }
@@ -0,0 +1,172 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Dialog, Button, Field, Input, Textarea } from "../ui";
3
+ import { UiSelect } from "../UiSelect";
4
+ import { useToast } from "../Toast";
5
+ import { Org } from "../../lib/api/organization";
6
+ import { slugify } from "../../lib/slug";
7
+ import { t } from "../../i18n";
8
+ import type { OrgArea, OrgRole } from "../../types/daemon";
9
+
10
+ // Create or edit an area. `editing` present → edit mode (slug is immutable).
11
+ export function AreaDialog({
12
+ open, onClose, pid, editing, onSaved,
13
+ }: {
14
+ open: boolean;
15
+ onClose: () => void;
16
+ pid: string;
17
+ editing?: OrgArea | null;
18
+ onSaved: () => void;
19
+ }) {
20
+ const toast = useToast();
21
+ const [name, setName] = useState("");
22
+ const [slug, setSlug] = useState("");
23
+ const [goal, setGoal] = useState("");
24
+ const [busy, setBusy] = useState(false);
25
+
26
+ useEffect(() => {
27
+ if (!open) return;
28
+ setName(editing?.name ?? "");
29
+ setSlug(editing?.slug ?? "");
30
+ setGoal(editing?.goal ?? "");
31
+ }, [open, editing]);
32
+
33
+ const save = async () => {
34
+ if (!name.trim()) return;
35
+ setBusy(true);
36
+ try {
37
+ if (editing) await Org.updateArea(pid, editing.slug, { name, goal });
38
+ else await Org.createArea(pid, { name, slug: slug || slugify(name), goal });
39
+ toast.success(t("common.saved"));
40
+ onSaved();
41
+ onClose();
42
+ } catch (e) {
43
+ toast.error(e instanceof Error ? e.message : String(e));
44
+ } finally {
45
+ setBusy(false);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <Dialog
51
+ open={open}
52
+ onClose={onClose}
53
+ title={editing ? t("structure.edit_area") : t("structure.new_area")}
54
+ footer={
55
+ <>
56
+ <Button variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
57
+ <Button variant="primary" data-testid="area-create" onClick={() => void save()} loading={busy} disabled={!name.trim()}>
58
+ {editing ? t("common.save") : t("structure.create_area")}
59
+ </Button>
60
+ </>
61
+ }
62
+ >
63
+ <div className="space-y-3">
64
+ <Field label={t("structure.name")}>
65
+ <Input
66
+ autoFocus
67
+ data-testid="area-name"
68
+ value={name}
69
+ onChange={(e) => { setName(e.target.value); if (!editing) setSlug(slugify(e.target.value)); }}
70
+ placeholder="Engineering"
71
+ />
72
+ </Field>
73
+ {!editing && (
74
+ <Field label={t("structure.slug")}>
75
+ <Input value={slug} onChange={(e) => setSlug(slugify(e.target.value))} className="font-mono" placeholder="engineering" />
76
+ </Field>
77
+ )}
78
+ <Field label={t("structure.goal")} hint={t("structure.goal_hint")}>
79
+ <Textarea value={goal} onChange={(e) => setGoal(e.target.value)} rows={2} />
80
+ </Field>
81
+ </div>
82
+ </Dialog>
83
+ );
84
+ }
85
+
86
+ // Create or edit a role. `presetArea` pre-selects an area (quick-create from an
87
+ // area card).
88
+ export function RoleDialog({
89
+ open, onClose, pid, areas, editing, presetArea, onSaved,
90
+ }: {
91
+ open: boolean;
92
+ onClose: () => void;
93
+ pid: string;
94
+ areas: OrgArea[];
95
+ editing?: OrgRole | null;
96
+ presetArea?: string | null;
97
+ onSaved: () => void;
98
+ }) {
99
+ const toast = useToast();
100
+ const [name, setName] = useState("");
101
+ const [slug, setSlug] = useState("");
102
+ const [area, setArea] = useState("");
103
+ const [description, setDescription] = useState("");
104
+ const [busy, setBusy] = useState(false);
105
+
106
+ useEffect(() => {
107
+ if (!open) return;
108
+ setName(editing?.name ?? "");
109
+ setSlug(editing?.slug ?? "");
110
+ setArea(editing?.area ?? presetArea ?? "");
111
+ setDescription(editing?.description ?? "");
112
+ }, [open, editing, presetArea]);
113
+
114
+ const save = async () => {
115
+ if (!name.trim()) return;
116
+ setBusy(true);
117
+ try {
118
+ if (editing) await Org.updateRole(pid, editing.slug, { name, area: area || null, description });
119
+ else await Org.createRole(pid, { name, slug: slug || slugify(name), area: area || null, description });
120
+ toast.success(t("common.saved"));
121
+ onSaved();
122
+ onClose();
123
+ } catch (e) {
124
+ toast.error(e instanceof Error ? e.message : String(e));
125
+ } finally {
126
+ setBusy(false);
127
+ }
128
+ };
129
+
130
+ const areaOptions = [
131
+ { value: "", label: t("structure.no_area") },
132
+ ...areas.map((a) => ({ value: a.slug, label: a.name })),
133
+ ];
134
+
135
+ return (
136
+ <Dialog
137
+ open={open}
138
+ onClose={onClose}
139
+ title={editing ? t("structure.edit_role") : t("structure.new_role")}
140
+ footer={
141
+ <>
142
+ <Button variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
143
+ <Button variant="primary" onClick={() => void save()} loading={busy} disabled={!name.trim()}>
144
+ {editing ? t("common.save") : t("structure.create_role")}
145
+ </Button>
146
+ </>
147
+ }
148
+ >
149
+ <div className="space-y-3">
150
+ <Field label={t("structure.name")}>
151
+ <Input
152
+ autoFocus
153
+ value={name}
154
+ onChange={(e) => { setName(e.target.value); if (!editing) setSlug(slugify(e.target.value)); }}
155
+ placeholder="Tech Lead"
156
+ />
157
+ </Field>
158
+ {!editing && (
159
+ <Field label={t("structure.slug")}>
160
+ <Input value={slug} onChange={(e) => setSlug(slugify(e.target.value))} className="font-mono" placeholder="tech-lead" />
161
+ </Field>
162
+ )}
163
+ <Field label={t("structure.area")}>
164
+ <UiSelect value={area} onChange={setArea} options={areaOptions} placeholder={t("structure.no_area")} />
165
+ </Field>
166
+ <Field label={t("structure.description")}>
167
+ <Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} />
168
+ </Field>
169
+ </div>
170
+ </Dialog>
171
+ );
172
+ }
@@ -0,0 +1,142 @@
1
+ import { useState, useEffect } from "react";
2
+ import useSWR from "swr";
3
+ import { X, Check, Trash2, RotateCcw, ExternalLink, Save } from "lucide-react";
4
+ import { useNavigate } from "react-router-dom";
5
+ import { Tasks } from "../../lib/api";
6
+ import { Button, Spinner, Textarea } from "../ui";
7
+ import { UiSelect } from "../UiSelect";
8
+ import { useToast } from "../Toast";
9
+ import { StatusBadge, effectiveStatus, TASK_STATUS_ORDER, statusLabel } from "./taskStatus";
10
+ import { t } from "../../i18n";
11
+ import type { TaskStatus } from "../../types/daemon";
12
+
13
+ function Row({ label, children }: { label: string; children: React.ReactNode }) {
14
+ return (
15
+ <div className="flex items-baseline justify-between gap-3 text-xs">
16
+ <span className="text-muted-foreground">{label}</span>
17
+ <span className="text-right font-mono text-foreground/90">{children}</span>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ // Right-hand task inspector: prompt/body, workflow status, who created it, the
23
+ // linked thread, timestamps, and lifecycle actions. Mirrors Panda's detail
24
+ // panel but wired to APX's task store.
25
+ export function TaskDetailPanel({
26
+ pid, taskId, onClose, onChanged,
27
+ }: {
28
+ pid: string;
29
+ taskId: string;
30
+ onClose: () => void;
31
+ onChanged: () => void;
32
+ }) {
33
+ const toast = useToast();
34
+ const navigate = useNavigate();
35
+ const { data: task, isLoading, mutate } = useSWR(`/projects/${pid}/tasks/${taskId}`, () => Tasks.get(pid, taskId));
36
+ const [body, setBody] = useState("");
37
+ const [busy, setBusy] = useState(false);
38
+
39
+ useEffect(() => { setBody(task?.body ?? ""); }, [task?.id, task?.body]);
40
+
41
+ const refresh = () => { void mutate(); onChanged(); };
42
+ const act = async (fn: () => Promise<unknown>) => {
43
+ setBusy(true);
44
+ try { await fn(); refresh(); }
45
+ catch (e) { toast.error(e instanceof Error ? e.message : String(e)); }
46
+ finally { setBusy(false); }
47
+ };
48
+
49
+ if (isLoading) return <div className="flex w-80 items-center justify-center border-l border-border"><Spinner /></div>;
50
+ if (!task) return null;
51
+
52
+ const eff = effectiveStatus(task);
53
+ const isOpen = task.state === "open";
54
+ const bodyDirty = body !== (task.body ?? "");
55
+
56
+ return (
57
+ <div className="flex w-80 shrink-0 flex-col border-l border-border bg-card/40" data-testid="task-detail">
58
+ <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
59
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("tasks.detail_title")}</span>
60
+ <button type="button" onClick={onClose} aria-label={t("common.close")} className="text-muted-foreground hover:text-foreground">
61
+ <X className="size-4" />
62
+ </button>
63
+ </div>
64
+
65
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
66
+ <div>
67
+ <div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">{t("tasks.field_title")}</div>
68
+ <div className="text-sm font-semibold">{task.title}</div>
69
+ </div>
70
+
71
+ <div className="flex items-center gap-2">
72
+ <StatusBadge status={eff} />
73
+ <span className="font-mono text-[10px] text-muted-foreground">{task.id}</span>
74
+ </div>
75
+
76
+ {/* Prompt / body — editable */}
77
+ <div>
78
+ <div className="mb-1 flex items-center justify-between">
79
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t("tasks.field_prompt")}</span>
80
+ {bodyDirty && (
81
+ <button type="button" onClick={() => act(async () => { await Tasks.patch(pid, task.id, { body }); toast.success(t("common.saved")); })} className="flex items-center gap-1 text-[10px] text-emerald-500 hover:text-emerald-400">
82
+ <Save className="size-3" />{t("files.save")}
83
+ </button>
84
+ )}
85
+ </div>
86
+ <Textarea rows={4} value={body} onChange={(e) => setBody(e.target.value)} placeholder={t("tasks.prompt_ph")} className="text-xs" />
87
+ </div>
88
+
89
+ {/* Workflow status control (open tasks) */}
90
+ {isOpen && (
91
+ <div>
92
+ <div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">{t("tasks.field_status")}</div>
93
+ <UiSelect
94
+ value={task.status ?? "pending"}
95
+ onChange={(v) => act(() => Tasks.status(pid, task.id, v as TaskStatus))}
96
+ options={TASK_STATUS_ORDER.map((s) => ({ value: s, label: statusLabel(s) }))}
97
+ />
98
+ </div>
99
+ )}
100
+
101
+ <div className="space-y-1.5 rounded-lg border border-border bg-background/40 p-2.5">
102
+ {task.agent && <Row label={t("tasks.field_agent")}>@{task.agent}</Row>}
103
+ {task.created_by && <Row label={t("tasks.field_creator")}>{task.created_by}</Row>}
104
+ {task.source && <Row label={t("tasks.field_source")}>{task.source}</Row>}
105
+ {task.due && <Row label={t("project.tasks.due")}>{task.due}</Row>}
106
+ <Row label={t("tasks.field_created")}>{new Date(task.created_at).toLocaleString()}</Row>
107
+ <Row label={t("tasks.field_updated")}>{new Date(task.updated_at).toLocaleString()}</Row>
108
+ {task.done_at && <Row label={t("tasks.field_done")}>{new Date(task.done_at).toLocaleString()}</Row>}
109
+ </div>
110
+
111
+ {/* Thread link */}
112
+ {task.thread && (
113
+ <button
114
+ type="button"
115
+ onClick={() => navigate(`/p/${pid}/chat?thread=${task.thread}`)}
116
+ className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-sky-500/30 bg-sky-500/5 px-3 py-2 text-xs text-sky-500 hover:bg-sky-500/10"
117
+ >
118
+ <ExternalLink className="size-3.5" />{t("tasks.view_thread")}
119
+ </button>
120
+ )}
121
+ </div>
122
+
123
+ {/* Actions */}
124
+ <div className="flex shrink-0 gap-2 border-t border-border px-4 py-3">
125
+ {isOpen ? (
126
+ <>
127
+ <Button size="sm" variant="primary" className="flex-1" loading={busy} onClick={() => act(() => Tasks.done(pid, task.id))}>
128
+ <Check size={13} />{t("tasks.mark_done")}
129
+ </Button>
130
+ <Button size="sm" variant="destructive" loading={busy} onClick={() => act(() => Tasks.drop(pid, task.id))} aria-label={t("project.tasks.aria_drop")}>
131
+ <Trash2 size={13} />
132
+ </Button>
133
+ </>
134
+ ) : (
135
+ <Button size="sm" variant="secondary" className="flex-1" loading={busy} onClick={() => act(() => Tasks.reopen(pid, task.id))}>
136
+ <RotateCcw size={13} />{t("project.tasks.reopen")}
137
+ </Button>
138
+ )}
139
+ </div>
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,57 @@
1
+ import { Loader2, CheckCircle2, XCircle, Clock, CircleDot, HelpCircle } from "lucide-react";
2
+ import { cn } from "../../lib/cn";
3
+ import { t } from "../../i18n";
4
+ import type { TaskEntry, TaskStatus } from "../../types/daemon";
5
+
6
+ type Meta = { labelKey: string; color: string; dot: string; Icon: typeof Clock; spin?: boolean };
7
+
8
+ // Single source of truth for how each workflow status looks. Reused by the
9
+ // task list, detail panel and the floor/overview.
10
+ const STATUS_META: Record<TaskStatus, Meta> = {
11
+ pending: { labelKey: "tasks.status_pending", color: "text-amber-500", dot: "bg-amber-400", Icon: CircleDot },
12
+ running: { labelKey: "tasks.status_running", color: "text-sky-500", dot: "bg-sky-400", Icon: Loader2, spin: true },
13
+ in_review: { labelKey: "tasks.status_in_review", color: "text-violet-500", dot: "bg-violet-400", Icon: Clock },
14
+ blocked: { labelKey: "tasks.status_blocked", color: "text-slate-400", dot: "bg-slate-400", Icon: HelpCircle },
15
+ };
16
+
17
+ export const TASK_STATUS_ORDER: TaskStatus[] = ["pending", "running", "in_review", "blocked"];
18
+
19
+ // Effective status for display: closed tasks render as done/dropped regardless
20
+ // of their last open sub-status.
21
+ export function effectiveStatus(task: TaskEntry): TaskStatus | "done" | "dropped" {
22
+ if (task.state === "done") return "done";
23
+ if (task.state === "dropped") return "dropped";
24
+ return task.status ?? "pending";
25
+ }
26
+
27
+ export function statusLabel(status: TaskStatus): string {
28
+ return t(STATUS_META[status].labelKey as never);
29
+ }
30
+
31
+ export function StatusIcon({ status, className }: { status: TaskStatus | "done" | "dropped"; className?: string }) {
32
+ if (status === "done") return <CheckCircle2 className={cn("size-4 text-emerald-500", className)} />;
33
+ if (status === "dropped") return <XCircle className={cn("size-4 text-muted-foreground", className)} />;
34
+ const m = STATUS_META[status];
35
+ return <m.Icon className={cn("size-4", m.color, m.spin && "animate-spin", className)} />;
36
+ }
37
+
38
+ export function StatusBadge({ status }: { status: TaskStatus | "done" | "dropped" }) {
39
+ const label =
40
+ status === "done" ? t("tasks.done_label")
41
+ : status === "dropped" ? t("tasks.dropped_label")
42
+ : statusLabel(status);
43
+ const color =
44
+ status === "done" ? "text-emerald-500 border-emerald-500/30"
45
+ : status === "dropped" ? "text-muted-foreground border-border"
46
+ : `${STATUS_META[status].color} border-current/30`;
47
+ return (
48
+ <span className={cn("inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-medium capitalize", color)}>
49
+ <StatusIcon status={status} className="size-3" />
50
+ {label}
51
+ </span>
52
+ );
53
+ }
54
+
55
+ export function StatusDot({ status }: { status: TaskStatus }) {
56
+ return <span className={cn("size-2 rounded-full", STATUS_META[status].dot)} />;
57
+ }