@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
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-DJ-ocXOR.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-CEI8DfVg.css">
21
+ <script type="module" crossorigin src="/assets/index-DPAuXATr.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-Cl0WXtxF.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -0,0 +1,123 @@
1
+ import { useState } from "react";
2
+ import useSWR from "swr";
3
+ import { Plus } from "lucide-react";
4
+ import { cn } from "../../lib/cn";
5
+ import { Field, Input } from "../ui";
6
+ import { UiSelect } from "../UiSelect";
7
+ import { Org } from "../../lib/api/organization";
8
+ import { t } from "../../i18n";
9
+ import type { AgentAutonomy } from "../../types/daemon";
10
+ import { AreaDialog, RoleDialog } from "../structure/StructureDialogs";
11
+
12
+ // ── Emoji ────────────────────────────────────────────────────────────────────
13
+ export function EmojiInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
14
+ return (
15
+ <Input
16
+ value={value}
17
+ onChange={(e) => onChange([...e.target.value].slice(-2).join(""))}
18
+ className="text-center text-lg"
19
+ placeholder="🤖"
20
+ aria-label={t("agents_form.emoji")}
21
+ />
22
+ );
23
+ }
24
+
25
+ // ── Autonomy (permission mode) segmented control ─────────────────────────────
26
+ const AUTONOMY_OPTIONS: { value: AgentAutonomy; labelKey: "auto_total" | "auto_automatico" | "auto_permiso" }[] = [
27
+ { value: "total", labelKey: "auto_total" },
28
+ { value: "automatico", labelKey: "auto_automatico" },
29
+ { value: "permiso", labelKey: "auto_permiso" },
30
+ ];
31
+
32
+ export function AutonomyPicker({ value, onChange }: { value: string; onChange: (v: AgentAutonomy) => void }) {
33
+ return (
34
+ <div className="inline-flex w-full rounded-lg border border-border p-0.5">
35
+ {AUTONOMY_OPTIONS.map((opt) => (
36
+ <button
37
+ key={opt.value}
38
+ type="button"
39
+ onClick={() => onChange(opt.value)}
40
+ className={cn(
41
+ "flex-1 rounded-md px-2 py-1 text-[12px] font-medium capitalize transition-colors",
42
+ value === opt.value ? "bg-primary/15 text-foreground" : "text-muted-foreground hover:text-foreground",
43
+ )}
44
+ >
45
+ {t(`agents_form.${opt.labelKey}`)}
46
+ </button>
47
+ ))}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ // ── Area + Role selects (fed by the project org structure) ──────────────────
53
+ // Quick-create area/role via floating dialogs (Panda's "org quick actions"),
54
+ // keeping the picker simple. On create we refetch and auto-select the new item.
55
+ export function AreaRoleFields({
56
+ pid, area, role, onArea, onRole,
57
+ }: {
58
+ pid: string;
59
+ area: string;
60
+ role: string;
61
+ onArea: (v: string) => void;
62
+ onRole: (v: string) => void;
63
+ }) {
64
+ const org = useSWR(`/projects/${pid}/organization`, () => Org.get(pid));
65
+ const [areaDialog, setAreaDialog] = useState(false);
66
+ const [roleDialog, setRoleDialog] = useState(false);
67
+
68
+ const areas = org.data?.areas ?? [];
69
+ const roles = org.data?.roles ?? [];
70
+ // Roles offered: those in the selected area, plus general (no-area) roles.
71
+ const roleChoices = roles.filter((r) => (area ? r.area === area || r.area === null : true));
72
+
73
+ const areaOptions = [{ value: "", label: t("structure.no_area") }, ...areas.map((a) => ({ value: a.slug, label: a.name }))];
74
+ const roleOptions = [{ value: "", label: t("agents_form.no_role") }, ...roleChoices.map((r) => ({ value: r.slug, label: r.name }))];
75
+
76
+ return (
77
+ <div className="space-y-3">
78
+ <div className="grid grid-cols-2 gap-3">
79
+ <Field label={t("agents_form.area")}>
80
+ <div className="flex items-center gap-1">
81
+ <UiSelect value={area} onChange={(v) => { onArea(v); }} options={areaOptions} placeholder={t("structure.no_area")} className="flex-1" />
82
+ <QuickAdd label={t("structure.new_area")} onClick={() => setAreaDialog(true)} />
83
+ </div>
84
+ </Field>
85
+ <Field label={t("agents_form.role")}>
86
+ <div className="flex items-center gap-1">
87
+ <UiSelect value={role} onChange={onRole} options={roleOptions} placeholder={t("agents_form.no_role")} className="flex-1" />
88
+ <QuickAdd label={t("structure.new_role")} onClick={() => setRoleDialog(true)} />
89
+ </div>
90
+ </Field>
91
+ </div>
92
+
93
+ <AreaDialog
94
+ open={areaDialog}
95
+ onClose={() => setAreaDialog(false)}
96
+ pid={pid}
97
+ onSaved={() => void org.mutate()}
98
+ />
99
+ <RoleDialog
100
+ open={roleDialog}
101
+ onClose={() => setRoleDialog(false)}
102
+ pid={pid}
103
+ areas={areas}
104
+ presetArea={area || null}
105
+ onSaved={() => void org.mutate()}
106
+ />
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function QuickAdd({ label, onClick }: { label: string; onClick: () => void }) {
112
+ return (
113
+ <button
114
+ type="button"
115
+ onClick={onClick}
116
+ title={label}
117
+ aria-label={label}
118
+ className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
119
+ >
120
+ <Plus className="size-4" />
121
+ </button>
122
+ );
123
+ }
@@ -0,0 +1,51 @@
1
+ import { useState } from "react";
2
+ import { Dialog, Button } from "../ui";
3
+ import { t } from "../../i18n";
4
+
5
+ // Reusable confirm dialog for destructive/executing actions (project rule:
6
+ // never native confirm()). Shows a loading state while the action runs.
7
+ export function ConfirmDialog({
8
+ open,
9
+ onClose,
10
+ onConfirm,
11
+ title,
12
+ description,
13
+ confirmLabel,
14
+ destructive = true,
15
+ }: {
16
+ open: boolean;
17
+ onClose: () => void;
18
+ onConfirm: () => Promise<void> | void;
19
+ title: string;
20
+ description?: string;
21
+ confirmLabel?: string;
22
+ destructive?: boolean;
23
+ }) {
24
+ const [busy, setBusy] = useState(false);
25
+ const run = async () => {
26
+ setBusy(true);
27
+ try {
28
+ await onConfirm();
29
+ onClose();
30
+ } finally {
31
+ setBusy(false);
32
+ }
33
+ };
34
+ return (
35
+ <Dialog
36
+ open={open}
37
+ onClose={onClose}
38
+ title={title}
39
+ footer={
40
+ <>
41
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
42
+ <Button variant={destructive ? "destructive" : "primary"} onClick={() => void run()} loading={busy}>
43
+ {confirmLabel ?? t("common.confirm")}
44
+ </Button>
45
+ </>
46
+ }
47
+ >
48
+ <p className="text-sm text-muted-foreground">{description}</p>
49
+ </Dialog>
50
+ );
51
+ }
@@ -0,0 +1,138 @@
1
+ import { useState } from "react";
2
+ import useSWR from "swr";
3
+ import { RefreshCw, FilePlus2, FolderOpen } from "lucide-react";
4
+ import { ProjectFiles, type FileScope } from "../../lib/api/projectFiles";
5
+ import type { FileNode, FileContent } from "../../types/daemon";
6
+ import { Spinner, Button, Empty } from "../ui";
7
+ import { useToast } from "../Toast";
8
+ import { t } from "../../i18n";
9
+ import { FileTree } from "./FileTree";
10
+ import { FileViewer } from "./FileViewer";
11
+ import { NewFileDialog } from "./NewFileDialog";
12
+
13
+ // Shared file browser used by both /files (scope=project) and /docs
14
+ // (scope=docs). One component, two roots — the docs surface is just the same
15
+ // browser with `editable` on and a "new document" affordance.
16
+ export function FileBrowser({
17
+ pid,
18
+ scope,
19
+ editable = false,
20
+ emptyHint,
21
+ }: {
22
+ pid: string;
23
+ scope: FileScope;
24
+ /** Allow editing text/markdown + creating/deleting files (docs surface). */
25
+ editable?: boolean;
26
+ emptyHint?: string;
27
+ }) {
28
+ const toast = useToast();
29
+ const [selected, setSelected] = useState<string | null>(null);
30
+ const [newOpen, setNewOpen] = useState(false);
31
+
32
+ const treeKey = `/projects/${pid}/fs/tree?scope=${scope}`;
33
+ const tree = useSWR(treeKey, () => ProjectFiles.tree(pid, scope));
34
+
35
+ const fileKey = selected ? `/projects/${pid}/fs/file?scope=${scope}&path=${selected}` : null;
36
+ const file = useSWR<FileContent | null>(fileKey, () => (selected ? ProjectFiles.read(pid, selected, scope) : null));
37
+
38
+ const onSelect = (node: FileNode) => setSelected(node.path);
39
+
40
+ const onSave = editable
41
+ ? async (content: string) => {
42
+ if (!selected) return;
43
+ await ProjectFiles.write(pid, selected, content, scope);
44
+ toast.success(t("files.saved"));
45
+ void file.mutate();
46
+ }
47
+ : undefined;
48
+
49
+ const onDelete = editable
50
+ ? async (node: FileNode) => {
51
+ await ProjectFiles.remove(pid, node.path, scope);
52
+ if (selected === node.path) setSelected(null);
53
+ toast.success(t("files.deleted"));
54
+ void tree.mutate();
55
+ }
56
+ : undefined;
57
+
58
+ const onCreated = (path: string) => {
59
+ setNewOpen(false);
60
+ setSelected(path);
61
+ void tree.mutate();
62
+ };
63
+
64
+ const nodes = tree.data?.tree ?? [];
65
+ const isEmpty = !tree.isLoading && nodes.length === 0;
66
+
67
+ return (
68
+ <div className="flex h-full min-h-0 overflow-hidden rounded-xl border border-border bg-card">
69
+ {/* Sidebar: tree */}
70
+ <div className="flex w-64 shrink-0 flex-col border-r border-border">
71
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
72
+ <FolderOpen className="size-4 text-muted-foreground" />
73
+ <span className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
74
+ {scope === "docs" ? t("files.docs_label") : t("files.files_label")}
75
+ </span>
76
+ {editable && (
77
+ <button
78
+ type="button"
79
+ data-testid="docs-new"
80
+ onClick={() => setNewOpen(true)}
81
+ className="text-muted-foreground hover:text-foreground"
82
+ aria-label={t("files.new_doc")}
83
+ title={t("files.new_doc")}
84
+ >
85
+ <FilePlus2 className="size-4" />
86
+ </button>
87
+ )}
88
+ <button
89
+ type="button"
90
+ onClick={() => void tree.mutate()}
91
+ className="text-muted-foreground hover:text-foreground"
92
+ aria-label={t("common.refresh")}
93
+ >
94
+ <RefreshCw className={tree.isValidating ? "size-3.5 animate-spin" : "size-3.5"} />
95
+ </button>
96
+ </div>
97
+ <div className="min-h-0 flex-1 overflow-y-auto p-1.5">
98
+ {tree.isLoading ? (
99
+ <div className="flex justify-center py-6"><Spinner size={14} /></div>
100
+ ) : isEmpty ? (
101
+ <div className="p-3">
102
+ <Empty>
103
+ <div className="space-y-2">
104
+ <p>{emptyHint ?? t("files.empty")}</p>
105
+ {editable && (
106
+ <Button size="sm" variant="secondary" onClick={() => setNewOpen(true)}>
107
+ <FilePlus2 className="size-3.5" />{t("files.new_doc")}
108
+ </Button>
109
+ )}
110
+ </div>
111
+ </Empty>
112
+ </div>
113
+ ) : (
114
+ <FileTree nodes={nodes} selectedPath={selected} onSelect={onSelect} onDelete={onDelete} />
115
+ )}
116
+ {tree.data?.truncated && (
117
+ <p className="px-2 py-1 text-[10px] text-muted-foreground/60">{t("files.truncated")}</p>
118
+ )}
119
+ </div>
120
+ </div>
121
+
122
+ {/* Main: viewer */}
123
+ <div className="flex min-w-0 flex-1 flex-col">
124
+ <FileViewer file={file.data ?? null} loading={!!fileKey && file.isLoading} onSave={onSave} />
125
+ </div>
126
+
127
+ {editable && (
128
+ <NewFileDialog
129
+ open={newOpen}
130
+ onClose={() => setNewOpen(false)}
131
+ pid={pid}
132
+ scope={scope}
133
+ onCreated={onCreated}
134
+ />
135
+ )}
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,133 @@
1
+ import { useState, useEffect } from "react";
2
+ import {
3
+ ChevronRight, ChevronDown, Folder, FolderOpen,
4
+ FileText, FileCode, Image as ImageIcon, File, Trash2,
5
+ } from "lucide-react";
6
+ import { cn } from "../../lib/cn";
7
+ import type { FileNode, FileKind } from "../../types/daemon";
8
+
9
+ function kindIcon(kind?: FileKind) {
10
+ switch (kind) {
11
+ case "markdown": return { Icon: FileText, color: "text-sky-500" };
12
+ case "text": return { Icon: FileCode, color: "text-amber-500" };
13
+ case "image": return { Icon: ImageIcon, color: "text-pink-500" };
14
+ default: return { Icon: File, color: "text-muted-foreground" };
15
+ }
16
+ }
17
+
18
+ // Ancestor dir paths of a file path, so the tree can auto-expand to a selection.
19
+ function ancestors(path: string): string[] {
20
+ const parts = path.split("/");
21
+ const out: string[] = [];
22
+ for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
23
+ return out;
24
+ }
25
+
26
+ function Row({
27
+ node, depth, selectedPath, expanded, toggle, onSelect, onDelete,
28
+ }: {
29
+ node: FileNode;
30
+ depth: number;
31
+ selectedPath: string | null;
32
+ expanded: Set<string>;
33
+ toggle: (p: string) => void;
34
+ onSelect: (node: FileNode) => void;
35
+ onDelete?: (node: FileNode) => void;
36
+ }) {
37
+ const isDir = node.type === "dir";
38
+ const open = expanded.has(node.path);
39
+ const selected = selectedPath === node.path;
40
+ const { Icon, color } = isDir
41
+ ? { Icon: open ? FolderOpen : Folder, color: "text-muted-foreground" }
42
+ : kindIcon(node.kind);
43
+
44
+ return (
45
+ <div>
46
+ <div
47
+ className={cn(
48
+ "group flex items-center gap-1 rounded px-1.5 py-1 text-[13px] cursor-pointer",
49
+ selected ? "bg-primary/15 text-foreground" : "hover:bg-accent/40 text-foreground/80",
50
+ )}
51
+ style={{ paddingLeft: depth * 12 + 6 }}
52
+ onClick={() => (isDir ? toggle(node.path) : onSelect(node))}
53
+ >
54
+ {isDir ? (
55
+ open ? <ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
56
+ : <ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
57
+ ) : (
58
+ <span className="w-3.5 shrink-0" />
59
+ )}
60
+ <Icon className={cn("size-3.5 shrink-0", color)} />
61
+ <span className="min-w-0 flex-1 truncate">{node.name}</span>
62
+ {onDelete && !isDir && (
63
+ <button
64
+ type="button"
65
+ onClick={(e) => { e.stopPropagation(); onDelete(node); }}
66
+ className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-500"
67
+ aria-label={`delete ${node.name}`}
68
+ >
69
+ <Trash2 className="size-3.5" />
70
+ </button>
71
+ )}
72
+ </div>
73
+ {isDir && open && node.children?.map((child) => (
74
+ <Row
75
+ key={child.path}
76
+ node={child}
77
+ depth={depth + 1}
78
+ selectedPath={selectedPath}
79
+ expanded={expanded}
80
+ toggle={toggle}
81
+ onSelect={onSelect}
82
+ onDelete={onDelete}
83
+ />
84
+ ))}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ export function FileTree({
90
+ nodes, selectedPath, onSelect, onDelete, className,
91
+ }: {
92
+ nodes: FileNode[];
93
+ selectedPath: string | null;
94
+ onSelect: (node: FileNode) => void;
95
+ onDelete?: (node: FileNode) => void;
96
+ className?: string;
97
+ }) {
98
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
99
+
100
+ // Auto-expand ancestors of the current selection so it's always visible.
101
+ useEffect(() => {
102
+ if (!selectedPath) return;
103
+ setExpanded((prev) => {
104
+ const next = new Set(prev);
105
+ for (const a of ancestors(selectedPath)) next.add(a);
106
+ return next;
107
+ });
108
+ }, [selectedPath]);
109
+
110
+ const toggle = (p: string) =>
111
+ setExpanded((prev) => {
112
+ const next = new Set(prev);
113
+ next.has(p) ? next.delete(p) : next.add(p);
114
+ return next;
115
+ });
116
+
117
+ return (
118
+ <div className={cn("select-none", className)}>
119
+ {nodes.map((node) => (
120
+ <Row
121
+ key={node.path}
122
+ node={node}
123
+ depth={0}
124
+ selectedPath={selectedPath}
125
+ expanded={expanded}
126
+ toggle={toggle}
127
+ onSelect={onSelect}
128
+ onDelete={onDelete}
129
+ />
130
+ ))}
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,167 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Save, RotateCcw, Pencil, Eye, Columns2, FileQuestion, Download } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Spinner } from "../ui";
5
+ import { t } from "../../i18n";
6
+ import type { FileContent } from "../../types/daemon";
7
+ import { MarkdownPreview } from "./MarkdownPreview";
8
+ import { MarkdownEditor } from "./MarkdownEditor";
9
+
10
+ function ToolbarButton({
11
+ onClick, active, disabled, children,
12
+ }: { onClick: () => void; active?: boolean; disabled?: boolean; children: React.ReactNode }) {
13
+ return (
14
+ <button
15
+ type="button"
16
+ onClick={onClick}
17
+ disabled={disabled}
18
+ className={cn(
19
+ "inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] font-medium transition-colors disabled:opacity-40",
20
+ active ? "bg-primary/15 text-foreground" : "text-muted-foreground hover:bg-accent hover:text-foreground",
21
+ )}
22
+ >
23
+ {children}
24
+ </button>
25
+ );
26
+ }
27
+
28
+ function CodeView({ content }: { content: string }) {
29
+ return (
30
+ <div className="min-h-0 flex-1 overflow-auto">
31
+ <table className="w-full border-collapse font-mono text-[12px] leading-[1.6]">
32
+ <tbody>
33
+ {content.split("\n").map((line, i) => (
34
+ <tr key={i} className="hover:bg-accent/20">
35
+ <td className="w-12 select-none border-r border-border/30 px-3 text-right align-top text-[10px] text-muted-foreground/40" aria-hidden="true">
36
+ {i + 1}
37
+ </td>
38
+ <td className="whitespace-pre px-4 align-top text-foreground/90">{line || " "}</td>
39
+ </tr>
40
+ ))}
41
+ </tbody>
42
+ </table>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ // Type-aware file view/editor. Markdown gets a preview + optional split editor;
48
+ // text/code get a line-numbered view + optional textarea; images render inline;
49
+ // binaries show metadata only. `onSave` (docs, editable text) turns on editing.
50
+ export function FileViewer({
51
+ file, loading, onSave,
52
+ }: {
53
+ file: FileContent | null;
54
+ loading?: boolean;
55
+ onSave?: (content: string) => Promise<void> | void;
56
+ }) {
57
+ const editable = typeof onSave === "function";
58
+ const [draft, setDraft] = useState("");
59
+ const [editing, setEditing] = useState(false);
60
+ const [showPreview, setShowPreview] = useState(true);
61
+ const [saving, setSaving] = useState(false);
62
+
63
+ // Reset local state whenever a different file loads.
64
+ useEffect(() => {
65
+ setDraft(file?.content ?? "");
66
+ setEditing(false);
67
+ setShowPreview(true);
68
+ }, [file?.path, file?.content]);
69
+
70
+ if (loading) {
71
+ return <div className="flex flex-1 items-center justify-center"><Spinner size={16} /></div>;
72
+ }
73
+ if (!file) {
74
+ return (
75
+ <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
76
+ {t("files.select_prompt")}
77
+ </div>
78
+ );
79
+ }
80
+
81
+ const isMarkdown = file.kind === "markdown";
82
+ const isText = file.kind === "text" || file.kind === "markdown";
83
+ const dirty = editing && draft !== (file.content ?? "");
84
+
85
+ const save = async () => {
86
+ if (!onSave || !dirty) return;
87
+ setSaving(true);
88
+ try {
89
+ await onSave(draft);
90
+ setEditing(false);
91
+ } finally {
92
+ setSaving(false);
93
+ }
94
+ };
95
+
96
+ return (
97
+ <div className="flex h-full min-h-0 flex-col bg-card/40" data-testid="file-viewer">
98
+ {/* Header / toolbar */}
99
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
100
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
101
+ {file.path}
102
+ {dirty && <span className="ml-1 text-amber-400">•</span>}
103
+ </span>
104
+
105
+ {isMarkdown && editing && (
106
+ <ToolbarButton onClick={() => setShowPreview((v) => !v)} active={showPreview}>
107
+ <Columns2 className="size-3" />{t("files.preview")}
108
+ </ToolbarButton>
109
+ )}
110
+
111
+ {editable && isText && (
112
+ editing ? (
113
+ <>
114
+ <ToolbarButton onClick={() => { setDraft(file.content ?? ""); setEditing(false); }} disabled={saving}>
115
+ <RotateCcw className="size-3" />{t("files.discard")}
116
+ </ToolbarButton>
117
+ <ToolbarButton onClick={() => void save()} disabled={!dirty || saving} active={dirty}>
118
+ {saving ? <Spinner size={10} /> : <Save className="size-3" />}{t("files.save")}
119
+ </ToolbarButton>
120
+ </>
121
+ ) : (
122
+ <ToolbarButton onClick={() => setEditing(true)}>
123
+ <Pencil className="size-3" />{t("files.edit")}
124
+ </ToolbarButton>
125
+ )
126
+ )}
127
+ </div>
128
+
129
+ {/* Body */}
130
+ {isMarkdown ? (
131
+ editing ? (
132
+ <MarkdownEditor value={draft} onChange={setDraft} showPreview={showPreview} onSave={() => void save()} />
133
+ ) : (
134
+ <div className="min-h-0 flex-1 overflow-y-auto p-4"><MarkdownPreview content={file.content ?? ""} /></div>
135
+ )
136
+ ) : file.kind === "text" ? (
137
+ editing ? (
138
+ <textarea
139
+ value={draft}
140
+ onChange={(e) => setDraft(e.target.value)}
141
+ onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); void save(); } }}
142
+ spellCheck={false}
143
+ className="min-h-0 flex-1 resize-none bg-transparent p-4 font-mono text-[12px] leading-[1.6] text-foreground/90 outline-none"
144
+ />
145
+ ) : (
146
+ <CodeView content={file.content ?? ""} />
147
+ )
148
+ ) : file.kind === "image" && file.encoding === "base64" ? (
149
+ <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
150
+ <img
151
+ src={`data:${file.mime};base64,${file.content}`}
152
+ alt={file.name}
153
+ className="max-h-full max-w-full rounded object-contain"
154
+ />
155
+ </div>
156
+ ) : (
157
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
158
+ <FileQuestion className="size-8 opacity-50" />
159
+ <span>{file.too_large ? t("files.too_large") : t("files.no_preview")}</span>
160
+ <span className="flex items-center gap-1 text-xs opacity-70">
161
+ <Download className="size-3" />{(file.size / 1024).toFixed(1)} KB
162
+ </span>
163
+ </div>
164
+ )}
165
+ </div>
166
+ );
167
+ }
@@ -0,0 +1,48 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { MarkdownPreview } from "./MarkdownPreview";
3
+
4
+ // A plain-textarea markdown editor with an optional live side-by-side preview.
5
+ // Deliberately not a heavyweight editor (Monaco/CodeMirror) — a styled textarea
6
+ // is fast, dependency-free, and enough for docs/specs. The parent owns the
7
+ // toolbar (save / preview toggle) and passes `showPreview`.
8
+ export function MarkdownEditor({
9
+ value,
10
+ onChange,
11
+ showPreview = false,
12
+ placeholder,
13
+ onSave,
14
+ className,
15
+ }: {
16
+ value: string;
17
+ onChange: (v: string) => void;
18
+ showPreview?: boolean;
19
+ placeholder?: string;
20
+ onSave?: () => void;
21
+ className?: string;
22
+ }) {
23
+ return (
24
+ <div className={cn("flex min-h-0 flex-1", className)}>
25
+ <textarea
26
+ value={value}
27
+ onChange={(e) => onChange(e.target.value)}
28
+ onKeyDown={(e) => {
29
+ if (onSave && (e.metaKey || e.ctrlKey) && e.key === "s") {
30
+ e.preventDefault();
31
+ onSave();
32
+ }
33
+ }}
34
+ placeholder={placeholder}
35
+ spellCheck={false}
36
+ className={cn(
37
+ "min-h-0 resize-none bg-transparent p-4 font-mono text-[13px] leading-[1.7] text-foreground/90 outline-none",
38
+ showPreview ? "w-1/2 border-r border-border" : "w-full",
39
+ )}
40
+ />
41
+ {showPreview && (
42
+ <div className="min-h-0 w-1/2 overflow-y-auto p-4">
43
+ <MarkdownPreview content={value} />
44
+ </div>
45
+ )}
46
+ </div>
47
+ );
48
+ }