diff --git a/docs/design-docs/task-history.md b/docs/design-docs/task-history.md new file mode 100644 index 000000000..96baa1c49 --- /dev/null +++ b/docs/design-docs/task-history.md @@ -0,0 +1,243 @@ +# Task Discussion and Revision History + +A task's description is its specification, and until now it was the only place +anything about a task could live. Refinement happened in chat and evaporated; +an agent rewriting a description destroyed the version it replaced. Two records +fix both halves, with deliberately different semantics. + +**`task_comments`** is the append-only conversation *around* the spec — what was +investigated, what was decided, what a worker found. Comments are never edited +or deleted; a record that can be rewritten is not a record. + +**`task_revisions`** is the immutable version history *of* the spec. Every +material change appends a whole snapshot in the same transaction as the change +itself, so any past version reads back directly and restoring one appends a new +version rather than rewinding. + +Related: [task-comments.md](task-comments.md) (the discussion design this +implements), [wiki.md](wiki.md) (the version/history/restore model this +mirrors), [task-dependencies.md](task-dependencies.md), +[execution-plan.md](execution-plan.md). + +## Schema + +```sql +CREATE TABLE task_comments ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + author_type TEXT NOT NULL, -- user | agent | worker | system + author_id TEXT, + body TEXT NOT NULL, + worker_id TEXT, + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE TABLE task_revisions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + snapshot TEXT NOT NULL, -- JSON TaskRevisionSnapshot + author_type TEXT NOT NULL, + author_id TEXT, + source TEXT NOT NULL, + edit_summary TEXT, + restored_from INTEGER, + created_at TEXT NOT NULL, + UNIQUE (task_id, revision) +); + +ALTER TABLE tasks ADD COLUMN revision INTEGER NOT NULL DEFAULT 0; +``` + +`seq` on comments breaks ties inside a millisecond, so chronological ordering +and cursor pagination stay stable. `UNIQUE (task_id, revision)` is what makes +numbering race-safe: a concurrent writer that computed the same next number +fails its insert rather than overwriting. + +`tasks.revision` serves two jobs — the current version number, and the +optimistic-concurrency token a caller sends back as `expected_revision`. + +## The versioned snapshot contract + +`TaskRevisionSnapshot` captures every field whose change needs historical +reconstruction: + +`title`, `description`, `status`, `priority`, `assigned_agent_id`, `subtasks`, +`metadata`, `goal_id`, `worker_type`, `project_id`, `repo_id`, +`worktree_mode`, `worktree_id`, `required_skills`, `depends_on`. + +Excluded by explicit decision, because they describe a *run* rather than the +spec: + +- `worker_id` — which worker is currently bound. Binding and unbinding a worker + happens many times per task and says nothing about what the task asks for. +- `approved_at` / `approved_by` / `completed_at` — derived from status + transitions, which are versioned. The author of the status revision already + records who did it. +- `updated_at` — a clock, not a decision. +- `id`, `task_number`, `owner_agent_id`, `created_by`, `source_memory_id` — + identity, fixed at creation. + +Dependency edges are snapshotted as task *numbers*, not internal ids, and +sorted, so two snapshots of the same edge set compare equal. + +## Revision semantics + +- Creating a task commits **revision 1** with the initial snapshot in the same + transaction as the task row. A task cannot exist without the version it + started from. +- Every material edit appends **exactly one** revision, whatever the number of + fields it touched. +- A no-op creates **nothing**: the store captures the snapshot before and after + the write and compares them. Identical means no revision row and no + `task_revised` event. +- Historical revisions are never updated or deleted by ordinary operations. +- **Restore N** performs a concurrency-checked material update and appends a + new latest revision whose snapshot matches N, with `restored_from = N`. + Revision N is untouched, numbering never rewinds, and no version in between + is erased. + +### Optimistic concurrency + +A caller supplies `expected_revision`. When it no longer matches, the write +fails with a structured conflict carrying both the expected and the current +revision, so the caller can refresh and retry without another round trip. Over +HTTP that is a `409` with: + +```json +{ "error": "...", "expected_revision": 4, "current_revision": 6 } +``` + +Omitting `expected_revision` means last-write-wins, which is right for a status +toggle and wrong for a description rewrite. The Portal and the tools supply it +on the paths that matter. + +### Partial updates and clearing + +`UpdateTaskInput` distinguishes "leave alone" from "set to null" through +`Patch = Option>`: absent leaves the stored value, `Some(None)` +clears it. Over HTTP, omitting a key leaves it and sending `null` clears it. +Restore depends on this — a revision that had no project must be restorable +onto a task that has one. + +`metadata` normally deep-merges. Restore sets `replace_metadata`, so a key +removed in the restored revision stays gone. + +## One transactional mutation path + +Every caller reaches storage through `TaskStore::apply_update` (or +`create_with_dependencies` for creation). The task row, its revision, and its +dependency edges commit together or not at all; a failure can never leave a +changed task without its revision, or a revision for a change that rolled back. + +Migrated onto it: REST mutations, CLI mutations, Portal mutations, the +`task_update` and `task_create` tools, worker-permitted subtask/metadata +updates, status and approval transitions, assignment, and restore. Workers keep +their existing restriction — subtasks and metadata only, on the task they are +bound to — because restore and history are built *on* the update path, not +around it. For the same reason a restore cannot bypass status-transition rules, +dependency validation, or execution-plan validation. + +`TaskStore::delete` removes comments, revisions, and dependency edges in the +same transaction rather than relying on a cascade that only fires with +`PRAGMA foreign_keys`. + +## Author and source taxonomy + +`author_type` is who: `user`, `agent`, `worker`, `system`. +`source` is which surface: `api`, `cli`, `portal`, `tool`, `worker`, +`restore`, `migration`, `system`. + +Both are recorded per revision, and `author_type` per comment. The pair is what +makes history legible — "agent orion, via tool" reads differently from "user +jamie, via portal", and a `restore` source is never mistaken for an ordinary +edit that happened to reproduce an old version. + +## Migration + +`backfill_baseline_revisions` gives every task with `revision = 0` a baseline +revision 1 snapshotting it exactly as it stands, authored as `system` / +`migration`. It runs at startup, before the API serves. + +It is idempotent twice over: the query selects only unversioned tasks, and +`UNIQUE (task_id, revision)` makes a retry after a partial run a no-op rather +than a duplicate. Verified against empty, fresh, and populated databases. + +It does **not** reconstruct history that predates the feature, and the summary +it records says so. Descriptions overwritten before revisions existed are gone; +no synthetic history is inferred to cover that up. + +## Surfaces + +### API + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/tasks/{n}/comments` | Thread, oldest first, cursor-paginated | +| `POST` | `/tasks/{n}/comments` | Append a comment | +| `GET` | `/tasks/{n}/revisions` | History summaries, newest first | +| `GET` | `/tasks/{n}/revisions/{r}` | One revision with its snapshot | +| `GET` | `/tasks/{n}/revisions/diff?from&to` | Field-aware diff; `to` defaults to current | +| `POST` | `/tasks/{n}/revisions/{r}/restore` | Restore, `expected_revision` required | + +Every write endpoint accepts `author_type`, `author_id`, `source`, and +`edit_summary`; updates and restores also take `expected_revision`. Task reads +carry `revision`. Errors return a JSON body rather than a bare status. + +### CLI + +``` +spacebot task comment # append to the thread +spacebot task comments # read the thread +spacebot task history # revision list +spacebot task revision # one revision, whole +spacebot task diff [to] # what changed +spacebot task restore --summary "…" # restore, reads current revision first +spacebot task update --summary "…" --expect +``` + +All CLI writes record `source: cli`. + +### Tools + +- `add_task_comment` — branch/cortex on any task; worker restricted to the task + it is bound to. +- `task_history` — `list`, `get`, `diff`, `restore` in one tool, because that is + one train of thought. `restore` requires an `edit_summary` and sends the + revision it just read as `expected_revision`. +- `task_update` — gains `edit_summary` and `expected_revision`, and returns the + task's revision so the next edit can be written against it. A no-op says so in + its message rather than claiming an update. + +### Events + +`task_commented` and `task_revised` are emitted **after commit**, and +`task_revised` only when a revision was actually written — a no-op update is +silent. Both carry the agent id so existing per-agent SSE routing applies, and +they ride the same stream and reconnect path as `task_updated`. + +### Portal + +The task detail pane gains a Discussion section (thread plus composer, with +system comments visually quiet and worker comments expanding their run output) +and a History section: revision list with author, source, summary and time; a +snapshot view; a field-aware diff against current; and restore behind a +confirmation that requires a reason. The restore carries the revision the user +was looking at, so a task that moved on mid-decision produces a conflict +message naming the new revision rather than a silent overwrite. + +## Retention + +Comments and revisions live in the instance database alongside tasks and the +wiki, and are covered by the same backup. Neither is pruned; both are deleted +only when their task is. + +## Out of scope + +- Recovering task history from before this feature. +- Editing or deleting comments. If it is ever added it needs explicit audit + semantics, not a silent overwrite. +- Autonomy activity logging into comments — that is task #16, which can build on + these APIs but is not a dependency. diff --git a/interface/bun.lock b/interface/bun.lock index ae78b619a..9db572b37 100644 --- a/interface/bun.lock +++ b/interface/bun.lock @@ -22,15 +22,15 @@ "@lobehub/icons": "^4.6.0", "@phosphor-icons/react": "^2.1.10", "@react-sigma/core": "^5.0.6", - "@spacedrive/ai": "^0.2.3", + "@spacedrive/ai": "^0.2.5", "@spacedrive/explorer": "^0.2.3", "@spacedrive/forms": "^0.2.3", - "@spacedrive/primitives": "^0.2.3", + "@spacedrive/primitives": "^0.2.4", "@spacedrive/tokens": "^0.2.3", "@tanstack/react-query": "^5.62.0", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router": "^1.159.5", - "@tanstack/react-virtual": "^3.13.18", + "@tanstack/react-virtual": "^3.13.26", "@tanstack/router-devtools": "^1.159.5", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", @@ -592,13 +592,13 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@spacedrive/ai": ["@spacedrive/ai@0.2.3", "", { "dependencies": { "@phosphor-icons/react": "^2.1.0", "@spacedrive/primitives": "^0.2.0", "clsx": "^2.1.0", "framer-motion": "^11.0.0", "react-loader-spinner": "^8.0.2", "react-markdown": "^9.0.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0" }, "optionalDependencies": { "@dnd-kit/core": "^6.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.0.0", "@react-sigma/core": "^4.0.0", "graphology": "^0.25.0", "sigma": "^3.0.0" }, "peerDependencies": { "@tanstack/react-query": "^5.0.0", "@tanstack/react-virtual": "^3.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/mHRJq5T5+xa17m9uzofH1yoO4s+ZvJLp7iLnXwv1mXLX0KVvgafBG4zc2LpsKm5SyOz6QyCzatIfbA8ub6z/g=="], + "@spacedrive/ai": ["@spacedrive/ai@0.2.5", "", { "dependencies": { "@phosphor-icons/react": "^2.1.0", "@spacedrive/primitives": "^0.2.0", "clsx": "^2.1.0", "framer-motion": "^11.0.0", "react-loader-spinner": "^8.0.2", "react-markdown": "^9.0.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0" }, "optionalDependencies": { "@dnd-kit/core": "^6.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.0.0", "@react-sigma/core": "^4.0.0", "graphology": "^0.25.0", "sigma": "^3.0.0" }, "peerDependencies": { "@tanstack/react-query": "^5.0.0", "@tanstack/react-virtual": "^3.13.26", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-KHasMj9zBEAPsPo3C+/m/5psWJySOF6/8o+VLOcg7feNlP6epgJlRHuFTxPbwv+1svatKkYfFQ8bxVKBYsDUvg=="], "@spacedrive/explorer": ["@spacedrive/explorer@0.2.3", "", { "dependencies": { "@phosphor-icons/react": "^2.1.0", "@spacedrive/primitives": "^0.2.0", "clsx": "^2.1.0" }, "peerDependencies": { "@tanstack/react-virtual": "^3.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-I0D2OonJZFZ3DdKdmePGX9kT/RF64fev3A8pp3utvpLevYp0tR/ka6ngXE66huV588KcFpAcr3BVSKsYF7RXUQ=="], "@spacedrive/forms": ["@spacedrive/forms@0.2.3", "", { "dependencies": { "@spacedrive/primitives": "^0.2.0", "clsx": "^2.1.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "react-hook-form": "^7.0.0", "zod": "^3.0.0" } }, "sha512-DqqHrHBgolUGK1EdLc4rQd4H3uu90TgW+Zz9xIdH3lez27x/fv/Pb60ibw3EPOKf5dW973e0fADUvluo4yJvag=="], - "@spacedrive/primitives": ["@spacedrive/primitives@0.2.3", "", { "dependencies": { "@headlessui/react": "^1.7.0", "@phosphor-icons/react": "^2.1.0", "@radix-ui/react-checkbox": "^1.1.0", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-context-menu": "^2.2.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-select": "^2.1.0", "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.0", "@react-spring/web": "^9.7.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "framer-motion": "^11.0.0", "react-hook-form": "^7.50.0", "react-loading-icons": "^1.1.0", "react-resizable-layout": "^0.7.0", "sonner": "^1.4.0", "zod": "^3.22.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "tailwindcss": "^4.1.0" } }, "sha512-2ak+qriCS81QLfPsLxrA69A57eJoyARVWd3pj/0gZmHpIeH6fTgQvao9Da7/o8azjMZ5o2gCC8tb3b5CoEHIbw=="], + "@spacedrive/primitives": ["@spacedrive/primitives@0.2.4", "", { "dependencies": { "@headlessui/react": "^1.7.0", "@phosphor-icons/react": "^2.1.0", "@radix-ui/react-checkbox": "^1.1.0", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-context-menu": "^2.2.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-select": "^2.1.0", "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.0", "@react-spring/web": "^9.7.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "framer-motion": "^11.0.0", "react-hook-form": "^7.50.0", "react-loading-icons": "^1.1.0", "react-resizable-layout": "^0.7.0", "sonner": "^1.4.0", "zod": "^3.22.0" }, "peerDependencies": { "@tanstack/react-virtual": "^3.13.26", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "tailwindcss": "^4.1.0" } }, "sha512-M6bnxN2RR9SCv4zrt2a9n974JJ39PQcm0DpZnzMM33KxiXjWiwFPSDdteGAPbXUX5Xvch8tWyoLTPkNZAKGF3g=="], "@spacedrive/tokens": ["@spacedrive/tokens@0.2.3", "", {}, "sha512-AozHOoDjyx08R7hn6mvXAxGNMobro7XjsPML59fAP6e+E/ZRiOSuDCh9s6xgUgsH+iiOBkv52Em9QAR4LT47iQ=="], @@ -656,7 +656,7 @@ "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.26", "", { "dependencies": { "@tanstack/virtual-core": "3.16.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ=="], "@tanstack/router-core": ["@tanstack/router-core@1.168.9", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g=="], @@ -666,7 +666,7 @@ "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.16.0", "", {}, "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A=="], "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], @@ -1752,6 +1752,8 @@ "@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + "@headlessui/react/@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="], + "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], "@lobehub/ui/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], @@ -1798,6 +1800,10 @@ "@spacedrive/ai/react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="], + "@spacedrive/explorer/@spacedrive/primitives": ["@spacedrive/primitives@0.2.3", "", { "dependencies": { "@headlessui/react": "^1.7.0", "@phosphor-icons/react": "^2.1.0", "@radix-ui/react-checkbox": "^1.1.0", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-context-menu": "^2.2.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-select": "^2.1.0", "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.0", "@react-spring/web": "^9.7.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "framer-motion": "^11.0.0", "react-hook-form": "^7.50.0", "react-loading-icons": "^1.1.0", "react-resizable-layout": "^0.7.0", "sonner": "^1.4.0", "zod": "^3.22.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "tailwindcss": "^4.1.0" } }, "sha512-2ak+qriCS81QLfPsLxrA69A57eJoyARVWd3pj/0gZmHpIeH6fTgQvao9Da7/o8azjMZ5o2gCC8tb3b5CoEHIbw=="], + + "@spacedrive/forms/@spacedrive/primitives": ["@spacedrive/primitives@0.2.3", "", { "dependencies": { "@headlessui/react": "^1.7.0", "@phosphor-icons/react": "^2.1.0", "@radix-ui/react-checkbox": "^1.1.0", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-context-menu": "^2.2.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-select": "^2.1.0", "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.0", "@react-spring/web": "^9.7.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "framer-motion": "^11.0.0", "react-hook-form": "^7.50.0", "react-loading-icons": "^1.1.0", "react-resizable-layout": "^0.7.0", "sonner": "^1.4.0", "zod": "^3.22.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "tailwindcss": "^4.1.0" } }, "sha512-2ak+qriCS81QLfPsLxrA69A57eJoyARVWd3pj/0gZmHpIeH6fTgQvao9Da7/o8azjMZ5o2gCC8tb3b5CoEHIbw=="], + "@spacedrive/primitives/framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], "@spacedrive/primitives/sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], @@ -1850,10 +1856,24 @@ "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], + "@headlessui/react/@tanstack/react-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], + "@spacedrive/ai/framer-motion/motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], "@spacedrive/ai/framer-motion/motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + "@spacedrive/explorer/@spacedrive/primitives/framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + + "@spacedrive/explorer/@spacedrive/primitives/sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], + + "@spacedrive/explorer/@spacedrive/primitives/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@spacedrive/forms/@spacedrive/primitives/framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + + "@spacedrive/forms/@spacedrive/primitives/sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], + + "@spacedrive/forms/@spacedrive/primitives/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@spacedrive/primitives/framer-motion/motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], "@spacedrive/primitives/framer-motion/motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], @@ -1863,5 +1883,13 @@ "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "@spacedrive/explorer/@spacedrive/primitives/framer-motion/motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "@spacedrive/explorer/@spacedrive/primitives/framer-motion/motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + + "@spacedrive/forms/@spacedrive/primitives/framer-motion/motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "@spacedrive/forms/@spacedrive/primitives/framer-motion/motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], } } diff --git a/interface/package.json b/interface/package.json index d87a9bf3c..73d89b2fc 100644 --- a/interface/package.json +++ b/interface/package.json @@ -26,15 +26,15 @@ "@lobehub/icons": "^4.6.0", "@phosphor-icons/react": "^2.1.10", "@react-sigma/core": "^5.0.6", - "@spacedrive/ai": "^0.2.3", + "@spacedrive/ai": "^0.2.5", "@spacedrive/explorer": "^0.2.3", "@spacedrive/forms": "^0.2.3", - "@spacedrive/primitives": "^0.2.3", + "@spacedrive/primitives": "^0.2.4", "@spacedrive/tokens": "^0.2.3", "@tanstack/react-query": "^5.62.0", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router": "^1.159.5", - "@tanstack/react-virtual": "^3.13.18", + "@tanstack/react-virtual": "^3.13.26", "@tanstack/router-devtools": "^1.159.5", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 532dea0eb..0edd17848 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1149,6 +1149,8 @@ export interface TaskItem { worktree_id?: string | null; required_skills: string[]; depends_on: TaskDependencyEdge[]; + /** Latest revision number, and the token to send back as expected_revision. */ + revision: number; created_by: string; approved_at?: string; approved_by?: string; @@ -1157,6 +1159,169 @@ export interface TaskItem { completed_at?: string; } +export type TaskAuthorKind = "user" | "agent" | "worker" | "system"; + +export type TaskMutationSource = + | "api" + | "cli" + | "portal" + | "tool" + | "worker" + | "restore" + | "migration" + | "system"; + +export interface TaskComment { + seq: number; + id: string; + task_id: string; + author_type: TaskAuthorKind; + author_id?: string; + body: string; + worker_id?: string; + metadata: Record; + created_at: string; +} + +export interface TaskCommentListResponse { + comments: TaskComment[]; + total: number; + next_cursor?: number | null; +} + +export interface TaskCommentResponse { + comment: TaskComment; +} + +export interface CreateTaskCommentRequest { + author_type?: TaskAuthorKind; + author_id?: string; + body: string; + worker_id?: string; + metadata?: Record; +} + +export interface TaskRevisionDependency { + task: number; + kind: TaskDependencyKind; +} + +export interface TaskRevisionSnapshot { + title: string; + description?: string | null; + status: TaskStatus; + priority: TaskPriority; + assigned_agent_id?: string | null; + subtasks: TaskSubtask[]; + metadata: Record; + goal_id?: string | null; + worker_type?: TaskWorkerType | null; + project_id?: string | null; + repo_id?: string | null; + worktree_mode?: TaskWorktreeMode | null; + worktree_id?: string | null; + required_skills: string[]; + depends_on: TaskRevisionDependency[]; +} + +export interface TaskRevisionSummary { + id: string; + task_id: string; + revision: number; + author_type: TaskAuthorKind; + author_id?: string | null; + source: TaskMutationSource; + edit_summary?: string | null; + restored_from?: number | null; + created_at: string; +} + +export type TaskRevision = TaskRevisionSummary & { + snapshot: TaskRevisionSnapshot; +}; + +export interface TaskHistoryResponse { + revisions: TaskRevisionSummary[]; + current: number; +} + +export interface TaskRevisionResponse { + revision: TaskRevision; +} + +export interface TaskFieldChange { + field: string; + before: unknown; + after: unknown; +} + +export interface TaskRevisionDiff { + task_number: number; + from: number; + to: number; + changes: TaskFieldChange[]; +} + +/** + * A task write that the server rejected. + * + * A 409 carries the revision the caller sent alongside the one actually + * stored, so the UI can say what happened rather than only that it failed. + */ +export class TaskRequestError extends Error { + readonly status: number; + readonly expectedRevision?: number; + readonly currentRevision?: number; + + constructor( + status: number, + message: string, + expectedRevision?: number, + currentRevision?: number, + ) { + super(message); + this.name = "TaskRequestError"; + this.status = status; + this.expectedRevision = expectedRevision; + this.currentRevision = currentRevision; + } + + /** True when the task moved on between the read and the write. */ + get isConflict(): boolean { + return this.status === 409; + } +} + +async function taskRequest( + path: string, + init?: Omit & { body?: unknown }, +): Promise { + const { body, ...rest } = init ?? {}; + const response = await fetch(`${getApiBase()}${path}`, { + ...rest, + headers: + body === undefined + ? rest.headers + : { "Content-Type": "application/json", ...rest.headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!response.ok) { + const detail = (await response.json().catch(() => null)) as { + error?: string; + expected_revision?: number; + current_revision?: number; + } | null; + throw new TaskRequestError( + response.status, + detail?.error ?? `API error: ${response.status}`, + detail?.expected_revision, + detail?.current_revision, + ); + } + return response.json() as Promise; +} + export interface TaskListResponse { tasks: TaskItem[]; } @@ -1185,15 +1350,21 @@ export interface CreateTaskRequest { export interface UpdateTaskRequest { title?: string; - description?: string; + /** null clears the description; omit to leave it unchanged. */ + description?: string | null; status?: TaskStatus; priority?: TaskPriority; - assigned_agent_id?: string; + /** null unassigns; omit to leave the assignment unchanged. */ + assigned_agent_id?: string | null; subtasks?: TaskSubtask[]; metadata?: Record; complete_subtask?: number; worker_id?: string; approved_by?: string; + /** Revision this edit was written against; a stale value gets a 409. */ + expected_revision?: number; + edit_summary?: string; + source?: TaskMutationSource; } // -- Goal Types -- @@ -2647,24 +2818,57 @@ export const api = { }, getTask: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}`), - createTask: async (request: CreateTaskRequest): Promise => { - const response = await fetch(`${getApiBase()}/tasks`, { + createTask: (request: CreateTaskRequest): Promise => + taskRequest("/tasks", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(request), - }); - if (!response.ok) throw new Error(`API error: ${response.status}`); - return response.json() as Promise; - }, - updateTask: async (taskNumber: number, request: UpdateTaskRequest): Promise => { - const response = await fetch(`${getApiBase()}/tasks/${taskNumber}`, { + body: { source: "portal", ...request }, + }), + updateTask: (taskNumber: number, request: UpdateTaskRequest): Promise => + taskRequest(`/tasks/${taskNumber}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(request), - }); - if (!response.ok) throw new Error(`API error: ${response.status}`); - return response.json() as Promise; + body: { source: "portal", ...request }, + }), + listTaskComments: (taskNumber: number, params?: { after?: number; limit?: number }) => { + const search = new URLSearchParams(); + if (params?.after !== undefined) search.set("after", String(params.after)); + if (params?.limit) search.set("limit", String(params.limit)); + const query = search.toString(); + return taskRequest( + query ? `/tasks/${taskNumber}/comments?${query}` : `/tasks/${taskNumber}/comments`, + ); }, + createTaskComment: ( + taskNumber: number, + request: CreateTaskCommentRequest, + ): Promise => + taskRequest(`/tasks/${taskNumber}/comments`, { + method: "POST", + body: request, + }), + listTaskRevisions: (taskNumber: number, limit = 50): Promise => + taskRequest(`/tasks/${taskNumber}/revisions?limit=${limit}`), + getTaskRevision: (taskNumber: number, revision: number): Promise => + taskRequest(`/tasks/${taskNumber}/revisions/${revision}`), + diffTaskRevisions: ( + taskNumber: number, + from: number, + to?: number, + ): Promise => { + const search = new URLSearchParams({ from: String(from) }); + if (to !== undefined) search.set("to", String(to)); + return taskRequest( + `/tasks/${taskNumber}/revisions/diff?${search.toString()}`, + ); + }, + restoreTaskRevision: ( + taskNumber: number, + revision: number, + request: { expected_revision: number; edit_summary?: string }, + ): Promise => + taskRequest(`/tasks/${taskNumber}/revisions/${revision}/restore`, { + method: "POST", + body: { source: "portal", ...request }, + }), deleteTask: async (taskNumber: number): Promise => { const response = await fetch(`${getApiBase()}/tasks/${taskNumber}`, { method: "DELETE", @@ -2672,33 +2876,21 @@ export const api = { if (!response.ok) throw new Error(`API error: ${response.status}`); return response.json() as Promise; }, - approveTask: async (taskNumber: number, approvedBy?: string): Promise => { - const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/approve`, { + approveTask: (taskNumber: number, approvedBy?: string): Promise => + taskRequest(`/tasks/${taskNumber}/approve`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ approved_by: approvedBy }), - }); - if (!response.ok) throw new Error(`API error: ${response.status}`); - return response.json() as Promise; - }, - executeTask: async (taskNumber: number): Promise => { - const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/execute`, { + body: { approved_by: approvedBy, source: "portal" }, + }), + executeTask: (taskNumber: number): Promise => + taskRequest(`/tasks/${taskNumber}/execute`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - if (!response.ok) throw new Error(`API error: ${response.status}`); - return response.json() as Promise; - }, - assignTask: async (taskNumber: number, assignedAgentId: string): Promise => { - const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/assign`, { + body: { source: "portal" }, + }), + assignTask: (taskNumber: number, assignedAgentId: string): Promise => + taskRequest(`/tasks/${taskNumber}/assign`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ assigned_agent_id: assignedAgentId }), - }); - if (!response.ok) throw new Error(`API error: ${response.status}`); - return response.json() as Promise; - }, + body: { assigned_agent_id: assignedAgentId, source: "portal" }, + }), // Goals API listGoals: (params?: { status?: GoalStatus; limit?: number }) => { diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index fcf732e12..3b3baac6f 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -431,6 +431,40 @@ export interface paths { patch?: never; trace?: never; }; + "/agents/processes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List branch and worker runs for an agent. */ + get: operations["list_processes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agents/processes/detail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one branch or worker run with its transcript. */ + get: operations["process_detail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/agents/profile": { parameters: { query?: never; @@ -1202,6 +1236,22 @@ export interface paths { patch?: never; trace?: never; }; + "/chronicle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_chronicle_history"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/conversation-defaults": { parameters: { query?: never; @@ -2509,6 +2559,24 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** `GET /tasks/{number}/comments` — list a task's comments, oldest first. */ + get: operations["list_task_comments"]; + put?: never; + /** `POST /tasks/{number}/comments` — append a comment to a task. */ + post: operations["create_task_comment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{number}/execute": { parameters: { query?: never; @@ -2529,6 +2597,82 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** `GET /tasks/{number}/revisions` — list revision summaries, newest first. */ + get: operations["list_task_revisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{number}/revisions/diff": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * `GET /tasks/{number}/revisions/diff` — compare two points in a task's + * history. `to` defaults to the current revision. + */ + get: operations["diff_task_revisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{number}/revisions/{revision}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** `GET /tasks/{number}/revisions/{revision}` — read one historical revision. */ + get: operations["get_task_revision"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{number}/revisions/{revision}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * `POST /tasks/{number}/revisions/{revision}/restore` — put a task back to a + * historical revision by appending a new one. + * @description Nothing is rewound: revision `revision` and everything after it stay exactly + * as they were, and the restore lands as the new latest version. + */ + post: operations["restore_task_revision"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tools": { parameters: { query?: never; @@ -2838,6 +2982,15 @@ export interface components { tuning?: null | components["schemas"]["TuningUpdate"]; warmup?: null | components["schemas"]["WarmupUpdate"]; }; + AgentDailyBrief: { + agent_id: string; + /** Format: date-time */ + created_at: string; + day: string; + /** Format: int64 */ + event_count: number; + summary: string; + }; /** @description Summary of an agent's configuration, exposed via the API. */ AgentInfo: { context_window: number; @@ -2893,10 +3046,10 @@ export interface components { AgentsResponse: { agents: components["schemas"]["AgentInfo"][]; }; - ApproveRequest: { + ApproveRequest: components["schemas"]["MutationAttribution"] & { approved_by?: string | null; }; - AssignRequest: { + AssignRequest: components["schemas"]["MutationAttribution"] & { assigned_agent_id: string; }; /** @description Association between memories. */ @@ -3142,6 +3295,25 @@ export interface components { seq: number; title: string; }; + ChronicleHistoryItem: { + agent_id: string; + channel_id: string; + /** Format: date-time */ + covers_from: string; + /** Format: date-time */ + covers_to: string; + /** Format: date-time */ + created_at: string; + id: string; + /** Format: int64 */ + message_count: number; + summary: string; + title: string; + }; + ChronicleHistoryResponse: { + checkpoints: components["schemas"]["ChronicleHistoryItem"][]; + daily_briefs: components["schemas"]["AgentDailyBrief"][]; + }; /** @description Session chronicle tuning. Only consulted when `mode` is "chronicle". */ ChronicleSection: { context_token_budget: number; @@ -3156,6 +3328,8 @@ export interface components { max_recent: number; /** Format: int64 */ recent_window_hours: number; + rollup_batch: number; + rollup_threshold: number; }; ChronicleUpdate: { context_token_budget?: number | null; @@ -3170,6 +3344,8 @@ export interface components { max_recent?: number | null; /** Format: int64 */ recent_window_hours?: number | null; + rollup_batch?: number | null; + rollup_threshold?: number | null; }; /** * @description What happens when a worker explicitly calls "close" on the browser. @@ -3316,12 +3492,8 @@ export interface components { total: number; }; CortexSection: { - /** Format: int64 */ - branch_timeout_secs: number; /** Format: int32 */ circuit_breaker_threshold: number; - /** Format: int32 */ - detached_worker_timeout_retry_limit: number; /** Format: float */ maintenance_decay_rate: number; /** Format: int64 */ @@ -3332,19 +3504,14 @@ export interface components { maintenance_min_age_days: number; /** Format: float */ maintenance_prune_threshold: number; - supervisor_kill_budget_per_tick: number; /** Format: int64 */ tick_interval_secs: number; /** Format: int64 */ - worker_timeout_secs: number; + worker_wall_clock_timeout_secs: number; }; CortexUpdate: { - /** Format: int64 */ - branch_timeout_secs?: number | null; /** Format: int32 */ circuit_breaker_threshold?: number | null; - /** Format: int32 */ - detached_worker_timeout_retry_limit?: number | null; /** Format: float */ maintenance_decay_rate?: number | null; /** Format: int64 */ @@ -3355,11 +3522,10 @@ export interface components { maintenance_min_age_days?: number | null; /** Format: float */ maintenance_prune_threshold?: number | null; - supervisor_kill_budget_per_tick?: number | null; /** Format: int64 */ tick_interval_secs?: number | null; /** Format: int64 */ - worker_timeout_secs?: number | null; + worker_wall_clock_timeout_secs?: number | null; }; CreateAgentRequest: { agent_id: string; @@ -3490,7 +3656,16 @@ export interface components { path: string; remote_url?: string | null; }; - CreateTaskRequest: { + CreateTaskCommentRequest: { + author_id?: string | null; + /** @description Defaults to `user` — the interface is the human's comment surface. */ + author_type?: string | null; + body: string; + metadata?: unknown; + /** @description Worker run this comment reports on, when applicable. */ + worker_id?: string | null; + }; + CreateTaskRequest: components["schemas"]["MutationAttribution"] & { /** @description Agent assigned to execute. Defaults to `owner_agent_id`. */ assigned_agent_id?: string | null; created_by?: string | null; @@ -4026,6 +4201,19 @@ export interface components { ModelsResponse: { models: components["schemas"]["ModelInfo"][]; }; + /** + * @description Who is performing a mutation and why, carried by every write endpoint and + * recorded on the revision it produces. + */ + MutationAttribution: { + author_id?: string | null; + /** @description `user` (default), `agent`, `worker`, or `system`. */ + author_type?: string | null; + /** @description One line on why the edit was made. */ + edit_summary?: string | null; + /** @description Which surface this call came from: `api` (default), `cli`, `portal`. */ + source?: string | null; + }; MutationResponse: { message: string; success: boolean; @@ -4212,6 +4400,37 @@ export interface components { name: string; tags?: string[]; }; + ProcessListResponse: { + processes: components["schemas"]["ProcessResponse"][]; + /** Format: int64 */ + total: number; + }; + ProcessResponse: { + channel_id?: string | null; + channel_name?: string | null; + completed_at?: string | null; + directory?: string | null; + has_transcript: boolean; + id: string; + input: string; + interactive: boolean; + kind: string; + /** Format: int64 */ + max_turns?: number | null; + model?: string | null; + /** Format: int32 */ + opencode_port?: number | null; + opencode_session_id?: string | null; + output?: string | null; + process_type: string; + profile?: string | null; + project_id?: string | null; + started_at: string; + status: string; + /** Format: int64 */ + tool_calls: number; + transcript?: components["schemas"]["TranscriptStep"][] | null; + }; ProcessTokens: { /** Format: int64 */ cache_read: number; @@ -4452,6 +4671,14 @@ export interface components { RestartResponse: { status: string; }; + RestoreRevisionRequest: components["schemas"]["MutationAttribution"] & { + /** + * Format: int64 + * @description The task's revision as the caller last read it. Required so a restore + * never silently discards an edit made while the user was deciding. + */ + expected_revision: number; + }; RestoreVersionRequest: { author_id?: string; author_type?: string; @@ -4646,6 +4873,12 @@ export interface components { * unconditionally — a contract, unlike advisory `suggested_skills`. */ required_skills: string[]; + /** + * Format: int64 + * @description Number of the task's latest revision, and the token a caller passes back + * as `expected_revision` to prove it is editing the version it read. + */ + revision: number; source_memory_id?: string | null; status: components["schemas"]["TaskStatus"]; subtasks: components["schemas"]["TaskSubtask"][]; @@ -4663,6 +4896,43 @@ export interface components { message: string; success: boolean; }; + /** + * @description Who performed a task mutation or wrote a comment. + * @enum {string} + */ + TaskAuthorKind: "user" | "agent" | "worker" | "system"; + TaskComment: { + author_id?: string | null; + author_type: components["schemas"]["TaskAuthorKind"]; + body: string; + created_at: string; + id: string; + metadata: unknown; + /** + * Format: int64 + * @description Monotonic sequence number. Stable pagination cursor. + */ + seq: number; + task_id: string; + /** @description Worker run this comment reports on, when it reports on one. */ + worker_id?: string | null; + }; + TaskCommentListResponse: { + comments: components["schemas"]["TaskComment"][]; + /** + * Format: int64 + * @description Cursor for the next page, absent when this page is the last one. + */ + next_cursor?: number | null; + /** + * Format: int64 + * @description Total comments on the task, independent of this page. + */ + total: number; + }; + TaskCommentResponse: { + comment: components["schemas"]["TaskComment"]; + }; /** * @description A dependency edge as seen from the dependent task, with enough context to * render and to compute blockedness without another query. @@ -4686,14 +4956,117 @@ export interface components { /** Format: int64 */ task: number; }; + /** + * @description A task handler failure rendered as JSON. + * + * A stale write needs more than a status code: the response carries the + * revision the caller expected alongside the one actually stored, so a client + * can refresh and retry without a second round trip. + */ + TaskErrorBody: { + /** Format: int64 */ + current_revision?: number | null; + error: string; + /** Format: int64 */ + expected_revision?: number | null; + }; + /** @description One field that differs between two snapshots. */ + TaskFieldChange: { + after: unknown; + before: unknown; + field: string; + }; + TaskHistoryResponse: { + /** + * Format: int64 + * @description The task's current revision number. + */ + current: number; + revisions: components["schemas"]["TaskRevisionSummary"][]; + }; TaskListResponse: { tasks: components["schemas"]["Task"][]; }; + /** + * @description Which surface a task mutation arrived through. Recorded per revision so + * history reads as a sequence of decisions with their origin intact. + * @enum {string} + */ + TaskMutationSource: "api" | "cli" | "portal" | "tool" | "worker" | "restore" | "migration" | "system"; /** @enum {string} */ TaskPriority: "critical" | "high" | "medium" | "low"; TaskResponse: { task: components["schemas"]["Task"]; }; + /** @description A revision with the full material snapshot it recorded. */ + TaskRevision: components["schemas"]["TaskRevisionSummary"] & { + snapshot: components["schemas"]["TaskRevisionSnapshot"]; + }; + /** + * @description A dependency edge as stored in a snapshot: the referenced task number, not + * its internal id, so a snapshot stays readable after the store is rebuilt. + */ + TaskRevisionDependency: { + kind: components["schemas"]["TaskDependencyKind"]; + /** Format: int64 */ + task: number; + }; + /** @description A diff between two points in a task's history. */ + TaskRevisionDiff: { + changes: components["schemas"]["TaskFieldChange"][]; + /** Format: int64 */ + from: number; + /** Format: int64 */ + task_number: number; + /** Format: int64 */ + to: number; + }; + TaskRevisionResponse: { + revision: components["schemas"]["TaskRevision"]; + }; + /** + * @description Every field whose change needs historical reconstruction. + * + * Deliberately excluded, because they describe a run rather than the spec: + * `worker_id` (the currently bound worker), `approved_at`/`completed_at` + * (derived from status transitions), `updated_at`, and the identity fields + * that never change — `id`, `task_number`, `owner_agent_id`, `created_by`, + * `source_memory_id`. + */ + TaskRevisionSnapshot: { + assigned_agent_id?: string | null; + depends_on: components["schemas"]["TaskRevisionDependency"][]; + description?: string | null; + goal_id?: string | null; + metadata: unknown; + priority: components["schemas"]["TaskPriority"]; + project_id?: string | null; + repo_id?: string | null; + required_skills: string[]; + status: components["schemas"]["TaskStatus"]; + subtasks: components["schemas"]["TaskSubtask"][]; + title: string; + worker_type?: null | components["schemas"]["TaskWorkerType"]; + worktree_id?: string | null; + worktree_mode?: null | components["schemas"]["TaskWorktreeMode"]; + }; + /** @description A revision without its snapshot — what a history list renders. */ + TaskRevisionSummary: { + author_id?: string | null; + author_type: components["schemas"]["TaskAuthorKind"]; + created_at: string; + edit_summary?: string | null; + id: string; + /** + * Format: int64 + * @description Set when this revision was produced by restoring an earlier one. + */ + restored_from?: number | null; + /** Format: int64 */ + revision: number; + source: components["schemas"]["TaskMutationSource"]; + task_id: string; + }; /** @enum {string} */ TaskStatus: "pending_approval" | "backlog" | "ready" | "in_progress" | "done" | "failed"; TaskSubtask: { @@ -4986,15 +5359,23 @@ export interface components { release_url?: string | null; update_available: boolean; }; - UpdateTaskRequest: { + UpdateTaskRequest: components["schemas"]["MutationAttribution"] & { approved_by?: string | null; + /** @description Send `null` to unassign. Omit to leave unchanged. */ assigned_agent_id?: string | null; complete_subtask?: number | null; /** @description Full replacement of dependency edges; omit to leave unchanged. */ depends_on?: components["schemas"]["TaskDependencyRequest"][] | null; + /** @description Send `null` to clear. Omit to leave unchanged. */ description?: string | null; - metadata?: unknown; - priority?: string | null; + /** + * Format: int64 + * @description The task's revision as the caller last read it. When supplied and stale, + * the update fails with 409 instead of overwriting the newer version. + */ + expected_revision?: number | null; + metadata?: unknown; + priority?: string | null; project_id?: string | null; repo_id?: string | null; required_skills?: string[] | null; @@ -6542,6 +6923,82 @@ export interface operations { }; }; }; + list_processes: { + parameters: { + query: { + agent_id: string; + limit?: number; + offset?: number; + status?: string | null; + kind?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProcessListResponse"]; + }; + }; + /** @description Agent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + process_detail: { + parameters: { + query: { + agent_id: string; + kind: string; + process_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProcessResponse"]; + }; + }; + /** @description Agent or process not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; get_agent_profile: { parameters: { query: { @@ -8364,6 +8821,35 @@ export interface operations { }; }; }; + get_chronicle_history: { + parameters: { + query?: { + /** @description Maximum number of chronicle checkpoints to return. */ + limit?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChronicleHistoryResponse"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; conversation_defaults: { parameters: { query: { @@ -11399,6 +11885,103 @@ export interface operations { }; }; }; + list_task_comments: { + parameters: { + query?: { + /** @description Resume after this comment `seq`. Comments are returned oldest-first. */ + after?: number | null; + limit?: number; + }; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskCommentListResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + }; + }; + create_task_comment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTaskCommentRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskCommentResponse"]; + }; + }; + /** @description Invalid comment */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + }; + }; execute_task: { parameters: { query?: never; @@ -11446,6 +12029,199 @@ export interface operations { }; }; }; + list_task_revisions: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskHistoryResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + }; + }; + diff_task_revisions: { + parameters: { + query: { + /** @description Revision to diff from. */ + from: number; + /** @description Revision to diff to. Defaults to the task's current revision. */ + to?: number | null; + }; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskRevisionDiff"]; + }; + }; + /** @description Task or revision not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + }; + }; + get_task_revision: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + /** @description Revision number */ + revision: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskRevisionResponse"]; + }; + }; + /** @description Task or revision not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + }; + }; + restore_task_revision: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + /** @description Revision to restore */ + revision: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RestoreRevisionRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskResponse"]; + }; + }; + /** @description Restore rejected by task rules */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task or revision not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task changed since the caller read it */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskErrorBody"]; + }; + }; + }; + }; list_tools: { parameters: { query?: never; diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 6f17c68c1..cd9e48661 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -370,12 +370,36 @@ export type TaskSubtask = components["schemas"]["TaskSubtask"]; export type TaskListResponse = components["schemas"]["TaskListResponse"]; export type TaskResponse = components["schemas"]["TaskResponse"]; export type TaskActionResponse = components["schemas"]["TaskActionResponse"]; +export type TaskErrorBody = components["schemas"]["TaskErrorBody"]; + +// Discussion +export type TaskAuthorKind = components["schemas"]["TaskAuthorKind"]; +export type TaskComment = components["schemas"]["TaskComment"]; +export type TaskCommentListResponse = + components["schemas"]["TaskCommentListResponse"]; +export type TaskCommentResponse = components["schemas"]["TaskCommentResponse"]; + +// Revision history +export type TaskMutationSource = components["schemas"]["TaskMutationSource"]; +export type TaskRevision = components["schemas"]["TaskRevision"]; +export type TaskRevisionSummary = components["schemas"]["TaskRevisionSummary"]; +export type TaskRevisionSnapshot = + components["schemas"]["TaskRevisionSnapshot"]; +export type TaskRevisionDiff = components["schemas"]["TaskRevisionDiff"]; +export type TaskFieldChange = components["schemas"]["TaskFieldChange"]; +export type TaskHistoryResponse = components["schemas"]["TaskHistoryResponse"]; +export type TaskRevisionResponse = + components["schemas"]["TaskRevisionResponse"]; // Requests export type CreateTaskRequest = components["schemas"]["CreateTaskRequest"]; export type UpdateTaskRequest = components["schemas"]["UpdateTaskRequest"]; export type ApproveRequest = components["schemas"]["ApproveRequest"]; export type AssignRequest = components["schemas"]["AssignRequest"]; +export type CreateTaskCommentRequest = + components["schemas"]["CreateTaskCommentRequest"]; +export type RestoreRevisionRequest = + components["schemas"]["RestoreRevisionRequest"]; // ============================================================================= // Messaging Types diff --git a/interface/src/components/CortexChatPanel.tsx b/interface/src/components/CortexChatPanel.tsx index 49b6bf56d..0680a38e7 100644 --- a/interface/src/components/CortexChatPanel.tsx +++ b/interface/src/components/CortexChatPanel.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from "react"; +import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import {useCortexChat, type ToolActivity} from "@/hooks/useCortexChat"; import {Markdown} from "@/components/Markdown"; import {ToolCall, type ToolCallPair} from "@/components/ToolCall"; @@ -14,6 +14,7 @@ import { PopoverContent, PopoverTrigger, } from "@spacedrive/primitives"; +import {ChatMessageList} from "@spacedrive/ai"; import {Plus, X, Clock, Trash} from "@phosphor-icons/react"; interface CortexChatPanelProps { @@ -382,7 +383,6 @@ export function CortexChatPanel({ } = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt}); const [input, setInput] = useState(""); const [threadListOpen, setThreadListOpen] = useState(false); - const messagesEndRef = useRef(null); const initialPromptSentRef = useRef(false); // Auto-send initial prompt once the fresh thread is ready @@ -399,9 +399,21 @@ export function CortexChatPanel({ } }, [initialPrompt, threadId, isStreaming, messages.length, sendMessage]); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({behavior: "smooth"}); - }, [messages.length, isStreaming, toolActivity.length]); + type ChatRow = + | {kind: "message"; id: string; message: (typeof messages)[number]} + | {kind: "streaming"} + | {kind: "error"; message: string}; + + const rows: ChatRow[] = useMemo(() => { + const list: ChatRow[] = messages.map((m) => ({ + kind: "message", + id: m.id, + message: m, + })); + if (isStreaming) list.push({kind: "streaming"}); + if (error) list.push({kind: "error", message: error}); + return list; + }, [messages, isStreaming, error]); const handleSubmit = () => { const trimmed = input.trim(); @@ -469,52 +481,67 @@ export function CortexChatPanel({ )} {/* Messages */} -
-
- {messages.map((message) => ( -
- {message.role === "user" ? ( -
-
-

{message.content}

+
+ + messages={rows} + getMessageKey={(index) => { + const row = rows[index]!; + if (row.kind === "message") return row.id; + return `__${row.kind}__`; + }} + estimateMessageSize={() => 80} + renderMessage={(row) => { + if (row.kind === "streaming") { + return ( +
+ + {!toolActivity.some((t) => t.status === "running") && ( + + )} +
+ ); + } + if (row.kind === "error") { + return ( +
+
+ {row.message}
- ) : ( -
- {message.tool_calls && message.tool_calls.length > 0 && ( -
- {message.tool_calls.map((call) => ( - - ))} -
- )} - {message.content && ( -
- {message.content} + ); + } + const message = row.message; + return ( +
+ {message.role === "user" ? ( +
+
+

{message.content}

- )} -
- )} -
- ))} - - {/* Streaming state */} - {isStreaming && ( -
- - {!toolActivity.some((t) => t.status === "running") && ( - - )} -
- )} - - {error && ( -
- {error} -
- )} -
-
+
+ ) : ( +
+ {message.tool_calls && message.tool_calls.length > 0 && ( +
+ {message.tool_calls.map((call) => ( + + ))} +
+ )} + {message.content && ( +
+ {message.content} +
+ )} +
+ )} +
+ ); + }} + />
{messages.length === 0 && !isStreaming && ( diff --git a/interface/src/components/TaskComments.tsx b/interface/src/components/TaskComments.tsx new file mode 100644 index 000000000..e3d1543cd --- /dev/null +++ b/interface/src/components/TaskComments.tsx @@ -0,0 +1,253 @@ +import {useCallback, useEffect, useRef, useState} from "react"; +import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import { + faRobot, + faUser, + faGear, + faCircleInfo, + faChevronDown, + faChevronRight, +} from "@fortawesome/free-solid-svg-icons"; +import {Badge, Button} from "@spacedrive/primitives"; +import {api, type TaskComment, type TaskAuthorKind} from "@/api/client"; +import {useLiveContext} from "@/hooks/useLiveContext"; + +const PAGE_SIZE = 50; + +/** Mirrors MAX_COMMENT_BODY_BYTES and MIN_COMMENT_BODY_CHARS on the server. */ +const MAX_BODY_BYTES = 4000; +const MIN_BODY_CHARS = 4; + +const AUTHOR_ICON: Record = { + user: faUser, + agent: faRobot, + worker: faGear, + system: faCircleInfo, +}; + +const AUTHOR_VARIANT: Record = { + user: "info", + agent: "success", + worker: "default", + system: "default", +}; + +function formatTimestamp(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); +} + +/** + * A worker-authored comment links back to the run that produced it. The body + * stays the worker's summary; the full output is fetched only when asked for. + */ +function WorkerOutput({agentId, workerId}: {agentId: string; workerId: string}) { + const [expanded, setExpanded] = useState(false); + + const {data, isLoading, error} = useQuery({ + queryKey: ["worker-detail", agentId, workerId], + queryFn: () => api.workerDetail(agentId, workerId), + enabled: expanded, + staleTime: 60_000, + }); + + return ( +
+ + + {expanded && ( +
+ {isLoading ? ( + Loading worker output… + ) : error ? ( + + Worker run is no longer available. + + ) : ( +
+							{data?.result?.trim() || "This worker recorded no output."}
+						
+ )} +
+ )} +
+ ); +} + +function CommentRow({ + comment, + agentId, + resolveAgentName, +}: { + comment: TaskComment; + agentId?: string; + resolveAgentName?: (agentId: string) => string; +}) { + const author = + comment.author_type === "agent" && comment.author_id + ? (resolveAgentName?.(comment.author_id) ?? comment.author_id) + : comment.author_type === "worker" + ? "Worker" + : comment.author_type === "system" + ? "Spacebot" + : (comment.author_id ?? "You"); + + return ( +
  • +
    + + + {author} + + + {formatTimestamp(comment.created_at)} + +
    +

    + {comment.body} +

    + {comment.worker_id && agentId && ( + + )} +
  • + ); +} + +/** + * Chronological discussion thread for a task, with a composer. + * + * Comments are append-only: there is no edit or delete affordance because the + * store has no such operation. `agentId` enables the worker-output links; it is + * the agent the task is assigned to, which is the pool a worker run lives in. + */ +export function TaskComments({ + taskNumber, + agentId, + resolveAgentName, +}: { + taskNumber: number; + agentId?: string; + resolveAgentName?: (agentId: string) => string; +}) { + const queryClient = useQueryClient(); + const {taskCommentVersion} = useLiveContext(); + const queryKey = ["task-comments", taskNumber]; + + // A comment from an agent or worker arrives over SSE. + const previousVersion = useRef(taskCommentVersion); + useEffect(() => { + if (taskCommentVersion !== previousVersion.current) { + previousVersion.current = taskCommentVersion; + void queryClient.invalidateQueries({queryKey}); + } + }, [taskCommentVersion, queryClient, taskNumber]); + + const {data, isLoading, error} = useQuery({ + queryKey, + queryFn: () => api.listTaskComments(taskNumber, {limit: PAGE_SIZE}), + }); + + const [draft, setDraft] = useState(""); + + const createMutation = useMutation({ + mutationFn: (body: string) => api.createTaskComment(taskNumber, {body}), + onSuccess: () => { + setDraft(""); + void queryClient.invalidateQueries({queryKey}); + }, + }); + + const trimmed = draft.trim(); + const canSubmit = + trimmed.length >= MIN_BODY_CHARS && + new TextEncoder().encode(trimmed).length <= MAX_BODY_BYTES; + + const handleSubmit = useCallback(() => { + if (!canSubmit) return; + createMutation.mutate(trimmed); + }, [canSubmit, trimmed, createMutation]); + + const comments = data?.comments ?? []; + const total = data?.total ?? 0; + const hasMore = data?.next_cursor !== undefined && data?.next_cursor !== null; + + return ( +
    +

    + Discussion{total > 0 ? ` (${total})` : ""} +

    + + {isLoading ? ( +

    Loading discussion…

    + ) : error ? ( +

    Failed to load the discussion.

    + ) : comments.length === 0 ? ( +

    + No comments yet. Thoughts, corrections, and agent findings land here. +

    + ) : ( + <> +
      + {comments.map((comment) => ( + + ))} +
    + {hasMore && ( +

    + Showing the first {comments.length} of {total}. +

    + )} + + )} + +
    +