Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd0c903a2 | |||
| f6f1bf4a76 | |||
| f0dc1445fc | |||
| 96e4d28f7c |
+3
-1
@@ -374,7 +374,7 @@ graph TD
|
|||||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
|
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
|
||||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
|
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
|
||||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<tmpdir>/agenthub-drops-<uid>/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||||
@@ -2244,6 +2244,7 @@ interface UpdateStatusPayload {
|
|||||||
situation?: // categorical hint for the UI
|
situation?: // categorical hint for the UI
|
||||||
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
|
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
|
||||||
| "release_available" // fast-forward onto the release tag
|
| "release_available" // fast-forward onto the release tag
|
||||||
|
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
|
||||||
| "tracking_canonical"
|
| "tracking_canonical"
|
||||||
| "fork_or_diverged_tracking"
|
| "fork_or_diverged_tracking"
|
||||||
| "feature_branch"
|
| "feature_branch"
|
||||||
@@ -2309,6 +2310,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
|||||||
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
|
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
|
||||||
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
|
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
|
||||||
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
|
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
|
||||||
|
| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch <remote> --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep <tag>` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
|
||||||
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
|
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
|
||||||
|
|
||||||
### Why Self-Apply Stays Narrow
|
### Why Self-Apply Stays Narrow
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.6.0 (2026-10-09) — S3 one Linux user per account (experimental, async member tmux/snapshot), git on a workspace's extra folders (`?source=`, #70), project files in the prompt (#54), a long paste attached as a file (#68). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.7.1 (2026-10-10) — the UI build type-checks test files too (`tsc -b`); a test that skips required props breaks every update._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
@@ -60,7 +60,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
|||||||
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
||||||
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
|
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
|
||||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||||
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot) or the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
|
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
|
||||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||||
|
|
||||||
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
|
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Sidebar`
|
* - `./Sidebar`
|
||||||
* - `./UpdateNotifier`
|
* - `./UpdateNotifier`
|
||||||
|
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||||
* - `./Sagi/SagiMount`
|
* - `./Sagi/SagiMount`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
@@ -81,6 +82,7 @@ import { Menu } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||||
import { UpdateNotifier } from "./UpdateNotifier";
|
import { UpdateNotifier } from "./UpdateNotifier";
|
||||||
|
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||||
import { SagiMount } from "./Sagi/SagiMount";
|
import { SagiMount } from "./Sagi/SagiMount";
|
||||||
import { StatusBar } from "./StatusBar";
|
import { StatusBar } from "./StatusBar";
|
||||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||||
@@ -173,6 +175,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
{t("skipToContent")}
|
{t("skipToContent")}
|
||||||
</a>
|
</a>
|
||||||
<UpdateNotifier />
|
<UpdateNotifier />
|
||||||
|
<InterruptedSessionsPrompt />
|
||||||
<SagiMount />
|
<SagiMount />
|
||||||
<OnboardingWizard />
|
<OnboardingWizard />
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
|
|||||||
@@ -463,7 +463,10 @@ export function UpdateNotifier() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-xs text-status-danger" role="alert">
|
<p
|
||||||
|
className="text-xs text-status-danger whitespace-pre-wrap break-words max-h-48 overflow-auto"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -185,7 +185,10 @@ export function ConversationView({
|
|||||||
firstLineRef.current = result.first_line;
|
firstLineRef.current = result.first_line;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
// A live pane's new session has no row until its first hook: nothing
|
||||||
|
// to show yet, and the poll below picks it up once it exists.
|
||||||
|
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
||||||
|
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||||
* and auto-linked URLs.
|
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||||
|
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||||
|
* same links for plain text.
|
||||||
*
|
*
|
||||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||||
@@ -244,6 +246,71 @@ function parseBlocks(src: string): Block[] {
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens a file a message names; provided by a view that can show one. */
|
||||||
|
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
|
||||||
|
|
||||||
|
const LINK_CLASS =
|
||||||
|
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
||||||
|
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||||
|
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
||||||
|
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||||
|
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||||
|
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||||
|
|
||||||
|
function UrlLink({ url }: { url: string }) {
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path as a button that opens it, or as it was when nothing can. */
|
||||||
|
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
||||||
|
const open = React.useContext(OpenFileContext);
|
||||||
|
if (!open) return <>{children}</>;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={path}
|
||||||
|
onClick={() => open(path)}
|
||||||
|
className={`${LINK_CLASS} text-left`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
|
||||||
|
export function linkify(text: string): React.ReactNode[] {
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
let buf = "";
|
||||||
|
for (let i = 0; i < text.length;) {
|
||||||
|
const rest = text.slice(i);
|
||||||
|
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
||||||
|
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
|
||||||
|
const hit = url || path;
|
||||||
|
if (!hit) {
|
||||||
|
buf += text[i++];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (buf) out.push(buf);
|
||||||
|
buf = "";
|
||||||
|
out.push(
|
||||||
|
url ? (
|
||||||
|
<UrlLink key={i} url={url} />
|
||||||
|
) : (
|
||||||
|
<PathLink key={i} path={hit}>
|
||||||
|
{hit}
|
||||||
|
</PathLink>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
i += hit.length;
|
||||||
|
}
|
||||||
|
if (buf) out.push(buf);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
|
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
|
||||||
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||||
const out: React.ReactNode[] = [];
|
const out: React.ReactNode[] = [];
|
||||||
@@ -267,11 +334,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
// Inline code: `...`
|
// Inline code: `...`
|
||||||
const codeM = rest.match(/^`([^`\n]+)`/);
|
const codeM = rest.match(/^`([^`\n]+)`/);
|
||||||
if (codeM) {
|
if (codeM) {
|
||||||
push(
|
const code = (
|
||||||
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
|
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
|
||||||
{codeM[1]}
|
{codeM[1]}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
push(
|
||||||
|
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||||
|
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||||
|
) : (
|
||||||
|
code
|
||||||
|
)
|
||||||
|
);
|
||||||
i += codeM[0].length;
|
i += codeM[0].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -330,22 +404,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-link
|
// Auto-link
|
||||||
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
|
const urlM = rest.match(URL_RE);
|
||||||
if (urlM) {
|
if (urlM) {
|
||||||
push(
|
push(<UrlLink url={urlM[0]} />);
|
||||||
<a
|
|
||||||
href={urlM[0]}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
|
||||||
>
|
|
||||||
{urlM[0]}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
i += urlM[0].length;
|
i += urlM[0].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An absolute path at a word start
|
||||||
|
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
|
||||||
|
if (pathM) {
|
||||||
|
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
|
||||||
|
i += pathM[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
buf += text[i]!;
|
buf += text[i]!;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||||
* the Conversation tab on the Session detail page. An image the prompt box
|
* the Conversation tab on the Session detail page. An image the prompt box
|
||||||
* attached reaches the transcript as its saved path; a user turn shows the
|
* attached reaches the transcript as its saved path; a user turn shows the
|
||||||
* image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
|
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
||||||
|
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
||||||
|
* a System row; URLs and absolute paths in a turn are links (a path opens
|
||||||
|
* through `onOpenFile`). Each assistant turn that changed
|
||||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||||
* when the caller can (`onOpenFile`). A thinking block that carries text is
|
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
||||||
|
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is
|
||||||
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||||
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||||
* chat (`live`) ends, until its turn is over, with what the model is doing
|
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||||
@@ -124,7 +128,8 @@ const SENDER_STYLES: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||||
import { MarkdownContent } from "./MarkdownContent";
|
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||||
|
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
||||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||||
|
|
||||||
@@ -440,7 +445,26 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
|||||||
endTurn(key);
|
endTurn(key);
|
||||||
return items.push({ kind: "user", key, msg });
|
return items.push({ kind: "user", key, msg });
|
||||||
}
|
}
|
||||||
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
if (sender !== "assistant") {
|
||||||
|
// Claude Code's line for the image a prompt attached: the image joins that prompt.
|
||||||
|
const paths = sender === "system" ? imageSourcePaths(msg) : null;
|
||||||
|
const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
|
||||||
|
if (paths && prompt?.kind === "user") {
|
||||||
|
prompt.msg = {
|
||||||
|
...prompt.msg,
|
||||||
|
content: [
|
||||||
|
...prompt.msg.content.map((c) =>
|
||||||
|
c.type === "text" && c.text
|
||||||
|
? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
{ type: "text", text: paths.join("\n") },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return items.push({ kind: "row", key, msg });
|
||||||
|
}
|
||||||
msg.content.forEach((block, b) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||||
@@ -499,7 +523,11 @@ function EditedFiles({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => dragAttachments(e, [...files.keys()].map(projectAttachment))}
|
||||||
|
className="flex cursor-grab items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary"
|
||||||
|
>
|
||||||
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
|
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
|
||||||
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
|
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
|
||||||
{counts(added, removed)}
|
{counts(added, removed)}
|
||||||
@@ -511,6 +539,8 @@ function EditedFiles({
|
|||||||
title={p}
|
title={p}
|
||||||
disabled={!onOpenFile}
|
disabled={!onOpenFile}
|
||||||
onClick={() => onOpenFile?.(p)}
|
onClick={() => onOpenFile?.(p)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => dragAttachments(e, [projectAttachment(p)])}
|
||||||
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
|
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||||
@@ -546,40 +576,44 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
<OpenFileContext.Provider value={onOpenFile}>
|
||||||
{items.map((item) => {
|
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||||
switch (item.kind) {
|
{items.map((item) => {
|
||||||
case "event":
|
switch (item.kind) {
|
||||||
return (
|
case "event":
|
||||||
<SessionEventRow
|
return (
|
||||||
key={item.key}
|
<SessionEventRow
|
||||||
title={item.msg.title}
|
key={item.key}
|
||||||
timestamp={item.msg.timestamp}
|
title={item.msg.title}
|
||||||
/>
|
timestamp={item.msg.timestamp}
|
||||||
);
|
/>
|
||||||
case "row":
|
);
|
||||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
case "row":
|
||||||
case "user":
|
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||||
return <UserBubble key={item.key} msg={item.msg} />;
|
case "user":
|
||||||
case "text":
|
return <UserBubble key={item.key} msg={item.msg} />;
|
||||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
case "text":
|
||||||
case "work":
|
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
case "work":
|
||||||
case "edits":
|
return (
|
||||||
// The card closes a finished turn; one still working has none yet.
|
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
||||||
if (pending && item === items[items.length - 1]) return null;
|
);
|
||||||
return (
|
case "edits":
|
||||||
<EditedFiles
|
// The card closes a finished turn; one still working has none yet.
|
||||||
key={item.key}
|
if (pending && item === items[items.length - 1]) return null;
|
||||||
blocks={item.blocks}
|
return (
|
||||||
toolResultMap={toolResultMap}
|
<EditedFiles
|
||||||
onOpenFile={onOpenFile}
|
key={item.key}
|
||||||
/>
|
blocks={item.blocks}
|
||||||
);
|
toolResultMap={toolResultMap}
|
||||||
}
|
onOpenFile={onOpenFile}
|
||||||
})}
|
/>
|
||||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
);
|
||||||
</div>
|
}
|
||||||
|
})}
|
||||||
|
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||||
|
</div>
|
||||||
|
</OpenFileContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,18 +680,37 @@ function RunningStep({ block, since }: { block: TranscriptContent | null; since:
|
|||||||
const meta = "text-[10px] font-mono text-fg-muted";
|
const meta = "text-[10px] font-mono text-fg-muted";
|
||||||
|
|
||||||
// What the prompt box sends for an attached image: its saved path under the
|
// What the prompt box sends for an attached image: its saved path under the
|
||||||
// drop dir (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
|
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
|
||||||
|
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||||
const DROP_IMAGE =
|
const DROP_IMAGE =
|
||||||
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||||
|
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
|
||||||
|
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
||||||
|
|
||||||
/** A user turn's text without its dropped-image paths, and those images' names. */
|
/** A user turn's text without its dropped-image paths (and Claude Code's
|
||||||
|
* `[Image #N]` placeholders for them), and those images' names. */
|
||||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||||
const images: string[] = [];
|
const images: string[] = [];
|
||||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||||
images.push(name);
|
images.push(name);
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
|
return {
|
||||||
|
text: images.length
|
||||||
|
? rest
|
||||||
|
.replace(/\[Image #\d+\]/g, "")
|
||||||
|
.replace(/[ \t]+$/gm, "")
|
||||||
|
.trim()
|
||||||
|
: text,
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The drop paths of a meta message made only of `[Image: source: <drop>]` lines. */
|
||||||
|
function imageSourcePaths(msg: TranscriptMessage): string[] | null {
|
||||||
|
const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : []));
|
||||||
|
const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? "");
|
||||||
|
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropImage({ name }: { name: string }) {
|
function DropImage({ name }: { name: string }) {
|
||||||
@@ -739,7 +792,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
|||||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||||
return plain ? (
|
return plain ? (
|
||||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{stripAnsi(text).trim()}
|
{linkify(stripAnsi(text).trim())}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
renderTextBlock(text, b)
|
renderTextBlock(text, b)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @file ConversationView.notYet.test.tsx
|
||||||
|
* @description A live pane's new session has no row until its first hook:
|
||||||
|
* its 404 shows an empty chat, not "Session not found"; a replay still shows it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ConversationView } from "../ConversationView";
|
||||||
|
|
||||||
|
const transcript = vi.hoisted(() =>
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConversationView before a session's first hook", () => {
|
||||||
|
it("shows no error for a live pane", async () => {
|
||||||
|
render(<ConversationView sessionId="new" fill live />);
|
||||||
|
await waitFor(() => expect(transcript).toHaveBeenCalled());
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(screen.queryByText("Session not found")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports it when not live", async () => {
|
||||||
|
render(<ConversationView sessionId="gone" fill />);
|
||||||
|
expect(await screen.findByText("Session not found")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
* @file MessageList.edits.test.tsx
|
* @file MessageList.edits.test.tsx
|
||||||
* @description Each assistant turn that changed files ends with Claude
|
* @description Each assistant turn that changed files ends with Claude
|
||||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||||
* removed summed over the turn, a failed call left out, and a row click handed
|
* removed summed over the turn, a failed call left out, a row click handed
|
||||||
* to the caller.
|
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||||
|
* attachments for a prompt box.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
@@ -67,4 +68,36 @@ describe("the Edited N files card", () => {
|
|||||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
user("fix it"),
|
||||||
|
call("1", "Edit", "/w/a.ts"),
|
||||||
|
result("1", { added: 1, removed: 0 }),
|
||||||
|
call("2", "Write", "/w/b.md"),
|
||||||
|
result("2", { added: 2, removed: 0 }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const card = screen.getByTestId("edited-files");
|
||||||
|
const drag = (el: Element) => {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
fireEvent.dragStart(el, {
|
||||||
|
dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||||
|
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||||
|
{ path: "@/w/b.md", name: "b.md" },
|
||||||
|
]);
|
||||||
|
expect(one["text/plain"]).toBe("/w/b.md");
|
||||||
|
const all = drag(within(card).getByText("Edited 2 files"));
|
||||||
|
expect(
|
||||||
|
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||||
|
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* @description An image the prompt box attached reaches the transcript as its
|
* @description An image the prompt box attached reaches the transcript as its
|
||||||
* saved drop path; the user's turn shows the image (served by
|
* saved drop path; the user's turn shows the image (served by
|
||||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||||
* other path as typed.
|
* other path as typed. Claude Code turns a pasted drop path into an image
|
||||||
|
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||||
|
* URLs and absolute paths in a turn are links.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
@@ -22,6 +24,11 @@ describe("dropped images in a user turn", () => {
|
|||||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||||
"0123456789abcdef.webp",
|
"0123456789abcdef.webp",
|
||||||
]);
|
]);
|
||||||
|
// The drop dir since the data-dir move.
|
||||||
|
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||||
|
text: "fix this",
|
||||||
|
images: [SHOT],
|
||||||
|
});
|
||||||
// Not a drop, or not an image: left as typed.
|
// Not a drop, or not an image: left as typed.
|
||||||
for (const text of [
|
for (const text of [
|
||||||
"/home/me/shot.png",
|
"/home/me/shot.png",
|
||||||
@@ -51,4 +58,56 @@ describe("dropped images in a user turn", () => {
|
|||||||
fireEvent.click(img);
|
fireEvent.click(img);
|
||||||
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
timestamp: "2026-10-09T09:43:00.000Z",
|
||||||
|
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "system",
|
||||||
|
timestamp: "2026-10-09T09:43:11.000Z",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as TranscriptMessage[];
|
||||||
|
render(<MessageList messages={messages} loading={false} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("System")).toBeNull();
|
||||||
|
const bubble = screen.getByTestId("chat-user");
|
||||||
|
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
|
||||||
|
expect(bubble).not.toHaveTextContent("[Image #1]");
|
||||||
|
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
|
||||||
|
`/api/run/drop/${SHOT}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes URLs and absolute paths clickable", () => {
|
||||||
|
const opened: string[] = [];
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
timestamp: "2026-10-09T09:43:00.000Z",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as TranscriptMessage[];
|
||||||
|
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
|
||||||
|
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
|
||||||
|
expect(link.getAttribute("target")).toBe("_blank");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
|
||||||
|
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* @file InterruptedSessionsPrompt.tsx
|
||||||
|
* @description The app-wide "resume interrupted sessions?" dialog. A session
|
||||||
|
* whose pane died under it — the machine shut down, tmux was killed, the CLI
|
||||||
|
* crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones
|
||||||
|
* to resume (each in the first free tab of its workspace, with its run's
|
||||||
|
* model and effort) or resumes none. Either answer dismisses the whole list,
|
||||||
|
* so it is asked once; a resume that failed stays listed. Refetched on the
|
||||||
|
* `runs_interrupted` broadcast, so a tmux that dies under an open dashboard
|
||||||
|
* asks too. Resuming is the explicit click here, never the dialog opening.
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api, type InterruptedRun } from "../../lib/api";
|
||||||
|
import { eventBus } from "../../lib/eventBus";
|
||||||
|
import { timeAgo } from "../../lib/format";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { startInFreeTabDetailed } from "./startSession";
|
||||||
|
|
||||||
|
const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd;
|
||||||
|
|
||||||
|
export function InterruptedSessionsPrompt() {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<InterruptedRun[]>([]);
|
||||||
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
// A refetch keeps the user's ticks; a session not seen before starts ticked.
|
||||||
|
const seen = useRef(new Set<string>());
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.run
|
||||||
|
.interrupted()
|
||||||
|
.then((r) => {
|
||||||
|
const fresh = r.items.filter((i) => !seen.current.has(i.session_id));
|
||||||
|
fresh.forEach((i) => seen.current.add(i.session_id));
|
||||||
|
setItems(r.items);
|
||||||
|
setPicked((p) => {
|
||||||
|
const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id));
|
||||||
|
fresh.forEach((i) => next.add(i.session_id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // a convenience: no prompt when it cannot be read
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
eventBus.subscribe((msg) => {
|
||||||
|
if (msg.type === "runs_interrupted") load();
|
||||||
|
}),
|
||||||
|
[load]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismiss = async (sessionIds: string[]) => {
|
||||||
|
if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeNone = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
await dismiss(items.map((i) => i.session_id));
|
||||||
|
setItems([]);
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumePicked = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
const failed = new Set<string>();
|
||||||
|
let first: { runId: string; laneId: number } | null = null;
|
||||||
|
for (const it of items.filter((i) => picked.has(i.session_id))) {
|
||||||
|
try {
|
||||||
|
const r = await startInFreeTabDetailed(it.lane_id, {
|
||||||
|
resumeSessionId: it.session_id,
|
||||||
|
...(it.model ? { model: it.model } : {}),
|
||||||
|
...(it.effort ? { effort: it.effort } : {}),
|
||||||
|
// Bypass is a per-run choice, never carried over.
|
||||||
|
...(it.permission_mode && it.permission_mode !== "bypassPermissions"
|
||||||
|
? { permissionMode: it.permission_mode }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
first ??= r;
|
||||||
|
} catch (err) {
|
||||||
|
failed.add(it.session_id);
|
||||||
|
toast.error(
|
||||||
|
t("interrupted.failed", {
|
||||||
|
name: label(it),
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id)));
|
||||||
|
setItems((cur) => cur.filter((i) => failed.has(i.session_id)));
|
||||||
|
setBusy(false);
|
||||||
|
if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (id: string, on: boolean) =>
|
||||||
|
setPicked((p) => {
|
||||||
|
const next = new Set(p);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = items.filter((i) => picked.has(i.session_id)).length;
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open={items.length > 0}
|
||||||
|
title={t("interrupted.title")}
|
||||||
|
message={t("interrupted.message")}
|
||||||
|
confirmLabel={t("interrupted.resume", { count })}
|
||||||
|
cancelLabel={t("interrupted.none")}
|
||||||
|
destructive={false}
|
||||||
|
busy={busy}
|
||||||
|
disabled={count === 0}
|
||||||
|
onConfirm={() => void resumePicked()}
|
||||||
|
onCancel={() => void resumeNone()}
|
||||||
|
>
|
||||||
|
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.session_id} className="rounded-md border border-border px-2 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={picked.has(it.session_id)}
|
||||||
|
onChange={(on) => toggle(it.session_id, on)}
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full"
|
||||||
|
labelClassName="min-w-0 flex-1"
|
||||||
|
label={
|
||||||
|
<span className="block min-w-0 text-left">
|
||||||
|
<span className="block truncate text-xs text-fg-primary">
|
||||||
|
{label(it)} · {it.tool}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-[11px] text-fg-muted">
|
||||||
|
{it.session_name || it.prompt_preview || it.session_id}
|
||||||
|
{it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -643,6 +643,8 @@ function ChangeRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(f.path)}
|
onClick={() => onOpen(f.path)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(ev) => dragProjectPath(ev, f.path)}
|
||||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ export function SessionReplay({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
|
<div
|
||||||
|
data-testid="session-replay"
|
||||||
|
data-prompt-drop
|
||||||
|
className="flex min-h-0 flex-1 flex-col gap-2"
|
||||||
|
>
|
||||||
{session && (
|
{session && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<SessionResumeButton session={session} />
|
<SessionResumeButton session={session} />
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
/**
|
||||||
|
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||||
|
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||||
|
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||||
|
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||||
|
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||||
|
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
import { dragProjectPath } from "../../lib/promptAttach";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
|
||||||
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||||
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
import { PathPickButton } from "../PathPickButton";
|
||||||
|
|
||||||
|
function fmtBytes(n: number | null): string {
|
||||||
|
if (!n) return "";
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||||
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||||
|
|
||||||
|
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
|
||||||
|
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
|
||||||
|
return <ContextBody key={props.lane.id} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextBody({
|
||||||
|
lane,
|
||||||
|
onSourcesChange,
|
||||||
|
sourcesVersion = 0,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
lane: Lane;
|
||||||
|
/** Called with the sources after a folder or file is added or removed. */
|
||||||
|
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||||
|
/** Bumped when the folder list changed elsewhere: the lists re-read. */
|
||||||
|
sourcesVersion?: number;
|
||||||
|
/** The Changes panel's narrow fold: no card, tighter rows. */
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const aiTools = useAiTools();
|
||||||
|
const prefs = useProjectTreePrefs();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||||
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||||
|
const [memoryTab, setMemoryTab] = useState(0);
|
||||||
|
const [folderOpen, setFolderOpen] = useState(false);
|
||||||
|
const [folderPath, setFolderPath] = useState("");
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [ctxDrag, setCtxDrag] = useState(false);
|
||||||
|
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||||
|
const sourceCtx = useContextMenu<LaneSource>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
api.lanes
|
||||||
|
.sources(lane.id)
|
||||||
|
.then((v) => live && setView(v))
|
||||||
|
.catch((err) => live && setError(message(err)));
|
||||||
|
api.lanes
|
||||||
|
.memory(lane.id)
|
||||||
|
.then((r) => live && setMemory(r.files))
|
||||||
|
.catch(() => live && setMemory([]));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [lane.id]);
|
||||||
|
|
||||||
|
// Suggestions are read when the folder form opens, not on every visit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderOpen) return;
|
||||||
|
let live = true;
|
||||||
|
api.projects
|
||||||
|
.list()
|
||||||
|
.then(({ projects }) => {
|
||||||
|
if (!live) return;
|
||||||
|
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||||||
|
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||||||
|
setSuggestions(
|
||||||
|
!p || !prefs.showUntracked
|
||||||
|
? []
|
||||||
|
: p.worktrees
|
||||||
|
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||||||
|
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||||||
|
.map((w) => w.path)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourcesVersion) return;
|
||||||
|
let live = true;
|
||||||
|
api.lanes
|
||||||
|
.sources(lane.id)
|
||||||
|
.then((v) => live && setView(v))
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||||
|
}, [sourcesVersion]);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const next = await fn();
|
||||||
|
setView(next);
|
||||||
|
onSourcesChange?.(next);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(message(err));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addFiles = async (picked: File[]) => {
|
||||||
|
for (const f of picked) {
|
||||||
|
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||||
|
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||||
|
const card = compact
|
||||||
|
? "border-b border-border/60 px-1 py-2 last:border-b-0"
|
||||||
|
: "border-b border-border px-4 py-4 last:border-b-0";
|
||||||
|
const heading = compact
|
||||||
|
? "text-xs font-medium text-fg-secondary"
|
||||||
|
: "text-sm font-medium text-fg-primary";
|
||||||
|
const iconBtn =
|
||||||
|
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<aside
|
||||||
|
data-testid="workspace-context"
|
||||||
|
aria-label={t("home.contextPanel")}
|
||||||
|
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||||
|
>
|
||||||
|
{view && !view.supported && (
|
||||||
|
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||||
|
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className={card}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={heading}>{t("home.instructions")}</h3>
|
||||||
|
{editing === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("home.edit")}
|
||||||
|
title={t("home.edit")}
|
||||||
|
onClick={() => setEditing(view?.instructions ?? "")}
|
||||||
|
disabled={!view}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing === null ? (
|
||||||
|
<p
|
||||||
|
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
|
||||||
|
>
|
||||||
|
{view?.instructions || t("home.instructionsEmpty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<textarea
|
||||||
|
data-testid="workspace-instructions"
|
||||||
|
aria-label={t("home.instructions")}
|
||||||
|
autoFocus
|
||||||
|
value={editing}
|
||||||
|
maxLength={view?.maxInstructions}
|
||||||
|
onChange={(e) => setEditing(e.target.value)}
|
||||||
|
rows={compact ? 4 : 6}
|
||||||
|
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
>
|
||||||
|
{t("home.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
className="btn-primary text-xs"
|
||||||
|
>
|
||||||
|
{t("home.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={card}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={heading}>{t("home.memory")}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMemoryTab(0);
|
||||||
|
setMemoryOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={!memory?.length}
|
||||||
|
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("home.viewMemory")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{memory?.length
|
||||||
|
? t("home.memoryCount", { count: memory.length })
|
||||||
|
: t("home.memoryEmpty")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
data-testid="workspace-context-files"
|
||||||
|
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||||
|
// Files dragged in from the OS are added like the + button's pick.
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
setCtxDrag(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
setCtxDrag(false);
|
||||||
|
if (!view || e.dataTransfer.files.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void addFiles([...e.dataTransfer.files]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={heading}>{t("home.context")}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("home.addFile")}
|
||||||
|
title={t("home.addFile")}
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
disabled={!view}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
data-testid="workspace-file-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const picked = [...(e.target.files ?? [])];
|
||||||
|
e.target.value = "";
|
||||||
|
void addFiles(picked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
|
||||||
|
>
|
||||||
|
{t("home.contextEmpty")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f.id}
|
||||||
|
className="group flex items-center gap-2 text-xs"
|
||||||
|
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||||
|
// Dragged onto a prompt box, it is attached as `@<path>`.
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => dragProjectPath(e, f.path)}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||||
|
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-fg-muted">
|
||||||
|
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${t("home.remove")} ${f.name}`}
|
||||||
|
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||||
|
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={card}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={heading}>{t("home.folder")}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("home.addFolder")}
|
||||||
|
title={t("home.addFolder")}
|
||||||
|
onClick={() => setFolderOpen((v) => !v)}
|
||||||
|
disabled={!view}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-2 space-y-1.5">
|
||||||
|
<li
|
||||||
|
className="flex items-start gap-2 text-xs"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
|
||||||
|
>
|
||||||
|
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||||||
|
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||||||
|
</span>
|
||||||
|
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{folders.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f.id}
|
||||||
|
className="group flex items-center gap-2 text-xs"
|
||||||
|
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
|
||||||
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||||
|
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||||
|
{f.name}
|
||||||
|
{f.missing && (
|
||||||
|
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${t("home.remove")} ${f.name}`}
|
||||||
|
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||||
|
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{folderOpen && (
|
||||||
|
<form
|
||||||
|
className="mt-3 space-y-2"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||||||
|
setFolderPath("");
|
||||||
|
setFolderOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
data-testid="workspace-folder-input"
|
||||||
|
aria-label={t("home.folderPath")}
|
||||||
|
placeholder={t("home.folderPath")}
|
||||||
|
autoFocus
|
||||||
|
value={folderPath}
|
||||||
|
onChange={(e) => setFolderPath(e.target.value)}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||||
|
/>
|
||||||
|
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||||
|
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||||
|
{t("home.add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||||
|
{t("home.suggested")}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{suggestions.map((p) => (
|
||||||
|
<li key={p}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFolderPath(p)}
|
||||||
|
title={p}
|
||||||
|
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{memoryOpen && memory && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
role="presentation"
|
||||||
|
onClick={() => setMemoryOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={memoryPanel}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("home.memory")}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
autoFocus
|
||||||
|
aria-label={t("home.close")}
|
||||||
|
onClick={() => setMemoryOpen(false)}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t("home.memory")}
|
||||||
|
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||||
|
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||||
|
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||||
|
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||||
|
>
|
||||||
|
{memory.map((f, i) => (
|
||||||
|
<button
|
||||||
|
key={f.path}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`workspace-memory-tab-${i}`}
|
||||||
|
aria-controls="workspace-memory-panel"
|
||||||
|
tabIndex={i === memoryTab ? 0 : -1}
|
||||||
|
title={f.path}
|
||||||
|
aria-selected={i === memoryTab}
|
||||||
|
onClick={() => setMemoryTab(i)}
|
||||||
|
// APG tabs: arrows move (and select), one tab stop.
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
const n = memory.length;
|
||||||
|
const to =
|
||||||
|
e.key === "ArrowRight"
|
||||||
|
? (i + 1) % n
|
||||||
|
: e.key === "ArrowLeft"
|
||||||
|
? (i - 1 + n) % n
|
||||||
|
: e.key === "Home"
|
||||||
|
? 0
|
||||||
|
: e.key === "End"
|
||||||
|
? n - 1
|
||||||
|
: null;
|
||||||
|
if (to === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setMemoryTab(to);
|
||||||
|
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||||
|
}}
|
||||||
|
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||||
|
i === memoryTab
|
||||||
|
? "border-accent/30 bg-accent/15 text-accent"
|
||||||
|
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{memory[memoryTab] && (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id="workspace-memory-panel"
|
||||||
|
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<MarkdownPreview text={memory[memoryTab].content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sourceCtx.menu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={sourceCtx.menu.x}
|
||||||
|
y={sourceCtx.menu.y}
|
||||||
|
onClose={sourceCtx.close}
|
||||||
|
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
void copyText(sourceCtx.menu!.item.path);
|
||||||
|
sourceCtx.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("menu.copyPath")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
onSelect={() => {
|
||||||
|
const id = sourceCtx.menu!.item.id;
|
||||||
|
sourceCtx.close();
|
||||||
|
void run(() => api.lanes.removeSource(lane.id, id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.remove")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,40 +16,30 @@
|
|||||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
|
||||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Checkbox } from "../Checkbox";
|
import { Checkbox } from "../Checkbox";
|
||||||
import { copyText } from "../../lib/clipboard";
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
FileText,
|
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Plus,
|
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type CodexApproval,
|
type CodexApproval,
|
||||||
type CodexSandbox,
|
type CodexSandbox,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
type LaneMemoryFile,
|
|
||||||
type LaneSource,
|
|
||||||
type LaneSourcesView,
|
type LaneSourcesView,
|
||||||
type ModelChoice,
|
type ModelChoice,
|
||||||
type PermissionMode,
|
type PermissionMode,
|
||||||
@@ -58,18 +48,16 @@ import {
|
|||||||
import type { Lane, Project, Session } from "../../lib/types";
|
import type { Lane, Project, Session } from "../../lib/types";
|
||||||
import { fmtCost, timeAgo } from "../../lib/format";
|
import { fmtCost, timeAgo } from "../../lib/format";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||||
import { ChatAddMenu } from "../run/ChatAddMenu";
|
import { ChatAddMenu } from "../run/ChatAddMenu";
|
||||||
import { addAttachments } from "../../lib/promptAttach";
|
import { addAttachments } from "../../lib/promptAttach";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
import { workspaceName } from "./WorkspaceRow";
|
import { workspaceName } from "./WorkspaceRow";
|
||||||
import { SessionMenu } from "./SessionMenu";
|
import { SessionMenu } from "./SessionMenu";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
|
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||||
import {
|
import {
|
||||||
liveRunFor,
|
liveRunFor,
|
||||||
SessionFold,
|
SessionFold,
|
||||||
@@ -87,16 +75,9 @@ export {
|
|||||||
startInFreeTabDetailed,
|
startInFreeTabDetailed,
|
||||||
} from "./startSession";
|
} from "./startSession";
|
||||||
import { startInFreeTab } from "./startSession";
|
import { startInFreeTab } from "./startSession";
|
||||||
import { PathPickButton } from "../PathPickButton";
|
import { WorkspaceContext } from "./WorkspaceContext";
|
||||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||||
|
|
||||||
function fmtBytes(n: number | null): string {
|
|
||||||
if (!n) return "";
|
|
||||||
if (n < 1024) return `${n} B`;
|
|
||||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
||||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recents' page size; Show more adds another. */
|
/** Recents' page size; Show more adds another. */
|
||||||
const RECENT_PAGE = 30;
|
const RECENT_PAGE = 30;
|
||||||
|
|
||||||
@@ -119,6 +100,7 @@ function HomeBody({
|
|||||||
onMenuAction,
|
onMenuAction,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
onSourcesChange,
|
onSourcesChange,
|
||||||
|
sourcesVersion = 0,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||||
@@ -127,11 +109,13 @@ function HomeBody({
|
|||||||
onRenamed?: () => void;
|
onRenamed?: () => void;
|
||||||
/** Called with the sources after a folder or file is added or removed. */
|
/** Called with the sources after a folder or file is added or removed. */
|
||||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||||
|
/** Bumped when the folder list changed elsewhere (the Changes panel's
|
||||||
|
* Close repository): the Folder list re-reads. */
|
||||||
|
sourcesVersion?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const prefs = useProjectTreePrefs();
|
|
||||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||||
const [tool, setTool] = useState(lane.tool);
|
const [tool, setTool] = useState(lane.tool);
|
||||||
const entry = aiTools.byId(tool);
|
const entry = aiTools.byId(tool);
|
||||||
@@ -205,27 +189,9 @@ function HomeBody({
|
|||||||
// A Recents row being renamed (its session id) and the draft.
|
// A Recents row being renamed (its session id) and the draft.
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [nameDraft, setNameDraft] = useState("");
|
const [nameDraft, setNameDraft] = useState("");
|
||||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
|
||||||
const [editing, setEditing] = useState<string | null>(null);
|
|
||||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
|
||||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
|
||||||
const [memoryTab, setMemoryTab] = useState(0);
|
|
||||||
const [folderOpen, setFolderOpen] = useState(false);
|
|
||||||
const [folderPath, setFolderPath] = useState("");
|
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
||||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [title, setTitle] = useState<string | null>(null);
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
|
||||||
const [ctxDrag, setCtxDrag] = useState(false);
|
|
||||||
const addFiles = async (picked: File[]) => {
|
|
||||||
for (const f of picked) {
|
|
||||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
|
||||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
|
||||||
const recentCtx = useContextMenu<Session>();
|
const recentCtx = useContextMenu<Session>();
|
||||||
const sourceCtx = useContextMenu<LaneSource>();
|
|
||||||
|
|
||||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||||
@@ -254,25 +220,6 @@ function HomeBody({
|
|||||||
`${runKey}:${version}`
|
`${runKey}:${version}`
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let live = true;
|
|
||||||
setView(null);
|
|
||||||
setMemory(null);
|
|
||||||
setEditing(null);
|
|
||||||
setError(null);
|
|
||||||
api.lanes
|
|
||||||
.sources(lane.id)
|
|
||||||
.then((v) => live && setView(v))
|
|
||||||
.catch((err) => live && setError(message(err)));
|
|
||||||
api.lanes
|
|
||||||
.memory(lane.id)
|
|
||||||
.then((r) => live && setMemory(r.files))
|
|
||||||
.catch(() => live && setMemory([]));
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
};
|
|
||||||
}, [lane.id]);
|
|
||||||
|
|
||||||
// The repository this workspace belongs to (Settings → Projects used to
|
// The repository this workspace belongs to (Settings → Projects used to
|
||||||
// show this): read once per visit, from the server's cached project list.
|
// show this): read once per visit, from the server's cached project list.
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
@@ -289,44 +236,6 @@ function HomeBody({
|
|||||||
};
|
};
|
||||||
}, [lane.id]);
|
}, [lane.id]);
|
||||||
|
|
||||||
// Suggestions are read when the folder form opens, not on every visit.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!folderOpen) return;
|
|
||||||
let live = true;
|
|
||||||
api.projects
|
|
||||||
.list()
|
|
||||||
.then(({ projects }) => {
|
|
||||||
if (!live) return;
|
|
||||||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
|
||||||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
|
||||||
setSuggestions(
|
|
||||||
!p || !prefs.showUntracked
|
|
||||||
? []
|
|
||||||
: p.worktrees
|
|
||||||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
|
||||||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
|
||||||
.map((w) => w.path)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
};
|
|
||||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
|
||||||
|
|
||||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const next = await fn();
|
|
||||||
setView(next);
|
|
||||||
onSourcesChange?.(next);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
setError(message(err));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||||
// at once — the same start the session view's tab bar `+` makes.
|
// at once — the same start the session view's tab bar `+` makes.
|
||||||
const openTerminal = async () => {
|
const openTerminal = async () => {
|
||||||
@@ -449,10 +358,7 @@ function HomeBody({
|
|||||||
? t("run:hint.sandboxWarning")
|
? t("run:hint.sandboxWarning")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
|
||||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
|
||||||
const name = workspaceName(lane);
|
const name = workspaceName(lane);
|
||||||
const card = "border-b border-border px-4 py-4 last:border-b-0";
|
|
||||||
const iconBtn =
|
const iconBtn =
|
||||||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||||
|
|
||||||
@@ -461,7 +367,7 @@ function HomeBody({
|
|||||||
data-testid="workspace-home"
|
data-testid="workspace-home"
|
||||||
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
|
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 basis-[28rem]">
|
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="text-xs text-fg-muted">
|
||||||
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
|
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -821,363 +727,11 @@ function HomeBody({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside
|
<WorkspaceContext
|
||||||
data-testid="workspace-context"
|
lane={lane}
|
||||||
aria-label={t("home.contextPanel")}
|
onSourcesChange={onSourcesChange}
|
||||||
className="card w-80 shrink-0 self-start p-0"
|
sourcesVersion={sourcesVersion}
|
||||||
>
|
/>
|
||||||
{view && !view.supported && (
|
|
||||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
|
||||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className={card}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
|
|
||||||
{editing === null && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("home.edit")}
|
|
||||||
title={t("home.edit")}
|
|
||||||
onClick={() => setEditing(view?.instructions ?? "")}
|
|
||||||
disabled={!view}
|
|
||||||
className={iconBtn}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{editing === null ? (
|
|
||||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
|
|
||||||
{view?.instructions || t("home.instructionsEmpty")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<textarea
|
|
||||||
data-testid="workspace-instructions"
|
|
||||||
aria-label={t("home.instructions")}
|
|
||||||
autoFocus
|
|
||||||
value={editing}
|
|
||||||
maxLength={view?.maxInstructions}
|
|
||||||
onChange={(e) => setEditing(e.target.value)}
|
|
||||||
rows={6}
|
|
||||||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditing(null)}
|
|
||||||
className="btn-ghost text-xs"
|
|
||||||
>
|
|
||||||
{t("home.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
|
||||||
setEditing(null);
|
|
||||||
}}
|
|
||||||
className="btn-primary text-xs"
|
|
||||||
>
|
|
||||||
{t("home.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={card}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMemoryTab(0);
|
|
||||||
setMemoryOpen(true);
|
|
||||||
}}
|
|
||||||
disabled={!memory?.length}
|
|
||||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{t("home.viewMemory")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-fg-muted">
|
|
||||||
{memory?.length
|
|
||||||
? t("home.memoryCount", { count: memory.length })
|
|
||||||
: t("home.memoryEmpty")}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
data-testid="workspace-context-files"
|
|
||||||
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
|
||||||
// Files dragged in from the OS are added like the + button's pick.
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "copy";
|
|
||||||
setCtxDrag(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={(e) => {
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
setCtxDrag(false);
|
|
||||||
if (!view || e.dataTransfer.files.length === 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
void addFiles([...e.dataTransfer.files]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("home.addFile")}
|
|
||||||
title={t("home.addFile")}
|
|
||||||
onClick={() => fileInput.current?.click()}
|
|
||||||
disabled={!view}
|
|
||||||
className={iconBtn}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInput}
|
|
||||||
data-testid="workspace-file-input"
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
hidden
|
|
||||||
onChange={(e) => {
|
|
||||||
const picked = [...(e.target.files ?? [])];
|
|
||||||
e.target.value = "";
|
|
||||||
void addFiles(picked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
|
|
||||||
{files.length === 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fileInput.current?.click()}
|
|
||||||
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
|
|
||||||
>
|
|
||||||
{t("home.contextEmpty")}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{files.map((f) => (
|
|
||||||
<li
|
|
||||||
key={f.id}
|
|
||||||
className="group flex items-center gap-2 text-xs"
|
|
||||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
|
||||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
|
||||||
{f.name}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-fg-muted">
|
|
||||||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`${t("home.remove")} ${f.name}`}
|
|
||||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
|
||||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={card}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("home.addFolder")}
|
|
||||||
title={t("home.addFolder")}
|
|
||||||
onClick={() => setFolderOpen((v) => !v)}
|
|
||||||
disabled={!view}
|
|
||||||
className={iconBtn}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ul className="mt-2 space-y-1.5">
|
|
||||||
<li className="flex items-start gap-2 text-xs">
|
|
||||||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
|
||||||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
|
||||||
</span>
|
|
||||||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{folders.map((f) => (
|
|
||||||
<li
|
|
||||||
key={f.id}
|
|
||||||
className="group flex items-center gap-2 text-xs"
|
|
||||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
|
||||||
>
|
|
||||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
|
||||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
|
||||||
{f.name}
|
|
||||||
{f.missing && (
|
|
||||||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`${t("home.remove")} ${f.name}`}
|
|
||||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
|
||||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{folderOpen && (
|
|
||||||
<form
|
|
||||||
className="mt-3 space-y-2"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
|
||||||
setFolderPath("");
|
|
||||||
setFolderOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
data-testid="workspace-folder-input"
|
|
||||||
aria-label={t("home.folderPath")}
|
|
||||||
placeholder={t("home.folderPath")}
|
|
||||||
autoFocus
|
|
||||||
value={folderPath}
|
|
||||||
onChange={(e) => setFolderPath(e.target.value)}
|
|
||||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
|
||||||
/>
|
|
||||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
|
||||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
|
||||||
{t("home.add")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{suggestions.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
|
||||||
{t("home.suggested")}
|
|
||||||
</p>
|
|
||||||
<ul className="mt-1 space-y-0.5">
|
|
||||||
{suggestions.map((p) => (
|
|
||||||
<li key={p}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFolderPath(p)}
|
|
||||||
title={p}
|
|
||||||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{memoryOpen && memory && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
|
||||||
role="presentation"
|
|
||||||
onClick={() => setMemoryOpen(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={memoryPanel}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("home.memory")}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
|
||||||
>
|
|
||||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
autoFocus
|
|
||||||
aria-label={t("home.close")}
|
|
||||||
onClick={() => setMemoryOpen(false)}
|
|
||||||
className={iconBtn}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
aria-label={t("home.memory")}
|
|
||||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
|
||||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
|
||||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
|
||||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
|
||||||
>
|
|
||||||
{memory.map((f, i) => (
|
|
||||||
<button
|
|
||||||
key={f.path}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
id={`workspace-memory-tab-${i}`}
|
|
||||||
aria-controls="workspace-memory-panel"
|
|
||||||
tabIndex={i === memoryTab ? 0 : -1}
|
|
||||||
title={f.path}
|
|
||||||
aria-selected={i === memoryTab}
|
|
||||||
onClick={() => setMemoryTab(i)}
|
|
||||||
// APG tabs: arrows move (and select), one tab stop.
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
const n = memory.length;
|
|
||||||
const to =
|
|
||||||
e.key === "ArrowRight"
|
|
||||||
? (i + 1) % n
|
|
||||||
: e.key === "ArrowLeft"
|
|
||||||
? (i - 1 + n) % n
|
|
||||||
: e.key === "Home"
|
|
||||||
? 0
|
|
||||||
: e.key === "End"
|
|
||||||
? n - 1
|
|
||||||
: null;
|
|
||||||
if (to === null) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setMemoryTab(to);
|
|
||||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
|
||||||
}}
|
|
||||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
|
||||||
i === memoryTab
|
|
||||||
? "border-accent/30 bg-accent/15 text-accent"
|
|
||||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{memory[memoryTab] && (
|
|
||||||
<div
|
|
||||||
role="tabpanel"
|
|
||||||
id="workspace-memory-panel"
|
|
||||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<MarkdownPreview text={memory[memoryTab].content} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recentCtx.menu && (
|
{recentCtx.menu && (
|
||||||
<SessionMenu
|
<SessionMenu
|
||||||
@@ -1195,34 +749,6 @@ function HomeBody({
|
|||||||
onArchive={() => void archiveSession(recentCtx.menu!.item)}
|
onArchive={() => void archiveSession(recentCtx.menu!.item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sourceCtx.menu && (
|
|
||||||
<ContextMenu
|
|
||||||
x={sourceCtx.menu.x}
|
|
||||||
y={sourceCtx.menu.y}
|
|
||||||
onClose={sourceCtx.close}
|
|
||||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void copyText(sourceCtx.menu!.item.path);
|
|
||||||
sourceCtx.close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("menu.copyPath")}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuSeparator />
|
|
||||||
<MenuItem
|
|
||||||
danger
|
|
||||||
onSelect={() => {
|
|
||||||
const id = sourceCtx.menu!.item.id;
|
|
||||||
sourceCtx.close();
|
|
||||||
void run(() => api.lanes.removeSource(lane.id, id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("home.remove")}
|
|
||||||
</MenuItem>
|
|
||||||
</ContextMenu>
|
|
||||||
)}
|
|
||||||
{menuAt && (
|
{menuAt && (
|
||||||
<WorkspaceRowMenu
|
<WorkspaceRowMenu
|
||||||
lane={lane}
|
lane={lane}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* @file InterruptedSessionsPrompt.test.tsx
|
||||||
|
* @description The "resume interrupted sessions?" dialog: it opens only when
|
||||||
|
* the server lists some, resumes only the ticked ones (never a bypass mode
|
||||||
|
* carried over), and either answer dismisses the list — but a resume that
|
||||||
|
* failed stays offered.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { InterruptedRun } from "../../../lib/api";
|
||||||
|
|
||||||
|
vi.mock("../startSession", () => ({
|
||||||
|
startInFreeTabDetailed: vi.fn(async (laneId: number) => ({
|
||||||
|
runId: `agenthub-lane-${laneId}-1`,
|
||||||
|
laneId,
|
||||||
|
held: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
run: {
|
||||||
|
interrupted: vi.fn(),
|
||||||
|
dismissInterrupted: vi.fn(async () => ({ dismissed: 0 })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import { startInFreeTabDetailed } from "../startSession";
|
||||||
|
import { InterruptedSessionsPrompt } from "../InterruptedSessionsPrompt";
|
||||||
|
|
||||||
|
const row = (session_id: string, lane_id: number, extra: Partial<InterruptedRun> = {}) =>
|
||||||
|
({
|
||||||
|
id: `agenthub-lane-${lane_id}`,
|
||||||
|
session_id,
|
||||||
|
lane_id,
|
||||||
|
tool: "claude",
|
||||||
|
cwd: "/r",
|
||||||
|
model: null,
|
||||||
|
permission_mode: null,
|
||||||
|
effort: null,
|
||||||
|
prompt_preview: `prompt ${session_id}`,
|
||||||
|
started_at: "2026-10-10T00:00:00Z",
|
||||||
|
ended_at: "2026-10-10T01:00:00Z",
|
||||||
|
lane_title: `ws-${lane_id}`,
|
||||||
|
lane_cwd: "/r",
|
||||||
|
session_name: null,
|
||||||
|
...extra,
|
||||||
|
}) as InterruptedRun;
|
||||||
|
|
||||||
|
const list = (items: InterruptedRun[]) =>
|
||||||
|
vi.mocked(api.run.interrupted).mockResolvedValue({ items });
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const renderIt = () =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<InterruptedSessionsPrompt />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("InterruptedSessionsPrompt", () => {
|
||||||
|
it("shows nothing when no session was interrupted", async () => {
|
||||||
|
list([]);
|
||||||
|
renderIt();
|
||||||
|
await waitFor(() => expect(api.run.interrupted).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes only the ticked sessions, then dismisses the list", async () => {
|
||||||
|
list([row("s-a", 1, { permission_mode: "bypassPermissions", model: "opus" }), row("s-b", 2)]);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderIt();
|
||||||
|
await screen.findByRole("dialog");
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /ws-2/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /resume 1/i }));
|
||||||
|
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a", "s-b"]));
|
||||||
|
expect(startInFreeTabDetailed).toHaveBeenCalledTimes(1);
|
||||||
|
// The model goes back; a bypass mode is never carried over.
|
||||||
|
expect(startInFreeTabDetailed).toHaveBeenCalledWith(1, {
|
||||||
|
resumeSessionId: "s-a",
|
||||||
|
model: "opus",
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes nothing on Don't resume, and keeps a session whose resume failed", async () => {
|
||||||
|
list([row("s-a", 1)]);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { unmount } = renderIt();
|
||||||
|
await user.click(await screen.findByText("Don't resume"));
|
||||||
|
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a"]));
|
||||||
|
expect(startInFreeTabDetailed).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
list([row("s-a", 1), row("s-b", 2)]);
|
||||||
|
vi.mocked(startInFreeTabDetailed).mockRejectedValueOnce(new Error("boom"));
|
||||||
|
renderIt();
|
||||||
|
await user.click(await screen.findByRole("button", { name: /resume 2/i }));
|
||||||
|
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-b"]));
|
||||||
|
expect(screen.getByRole("checkbox", { name: /ws-1/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
import { MAX_SLOTS } from "../../lib/paneLayout";
|
import { MAX_SLOTS, slotOfKey } from "../../lib/paneLayout";
|
||||||
|
|
||||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||||
export function paneRunId(laneId: number, slot: number): string {
|
export function paneRunId(laneId: number, slot: number): string {
|
||||||
@@ -58,7 +58,9 @@ export async function startInFreeTabDetailed(
|
|||||||
/** The first pane slot with no live run, or null when all ten are busy. */
|
/** The first pane slot with no live run, or null when all ten are busy. */
|
||||||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||||||
const busy = new Set(
|
const busy = new Set(
|
||||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
runs
|
||||||
|
.filter((r) => r.laneId === laneId && r.status === "running")
|
||||||
|
.map((r) => slotOfKey(r.slot ?? 0))
|
||||||
);
|
);
|
||||||
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
|
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,18 +6,24 @@
|
|||||||
* on the right — Claude's Uploads dialog). Content comes only through routes
|
* on the right — Claude's Uploads dialog). Content comes only through routes
|
||||||
* that already guard it: a file inside the workspace through
|
* that already guard it: a file inside the workspace through
|
||||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||||
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
|
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
||||||
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
|
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
||||||
|
* the session read anywhere else through `GET /api/sessions/:id/image`. Anything
|
||||||
|
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||||
|
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||||
|
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||||
|
* or the project's); a built-in skill has no file and shows by name only.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget } from "../../lib/api";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { dragSkill } from "../../lib/promptAttach";
|
||||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
|
|
||||||
/** Where a Chat pane's files can be read from. */
|
/** Where a Chat pane's files can be read from. */
|
||||||
@@ -31,9 +37,17 @@ export interface PreviewScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||||
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
// An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js).
|
||||||
|
const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i;
|
||||||
|
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
|
||||||
|
const DROP_RE =
|
||||||
|
/[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||||
|
/** A dropped text file shown at most this long. */
|
||||||
|
const DROP_TEXT_MAX = 200_000;
|
||||||
|
|
||||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
/** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */
|
||||||
|
const slashed = (p: string) => p.replace(/\\/g, "/");
|
||||||
|
export const baseName = (p: string) => p.split(/[\\/]/).pop() || p;
|
||||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||||
export const extOf = (p: string) => {
|
export const extOf = (p: string) => {
|
||||||
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
||||||
@@ -52,37 +66,54 @@ type Content =
|
|||||||
function useContent(path: string, scope: PreviewScope): Content {
|
function useContent(path: string, scope: PreviewScope): Content {
|
||||||
const [content, setContent] = useState<Content>({ kind: "loading" });
|
const [content, setContent] = useState<Content>({ kind: "loading" });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const drop = DROP_RE.exec(path);
|
|
||||||
if (drop) {
|
|
||||||
setContent(
|
|
||||||
IMAGE_RE.test(path)
|
|
||||||
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
|
|
||||||
: { kind: "none" }
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let live = true;
|
let live = true;
|
||||||
const show = (p: Promise<Content>) => {
|
const show = (p: Promise<Content>) => {
|
||||||
setContent({ kind: "loading" });
|
setContent({ kind: "loading" });
|
||||||
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
||||||
};
|
};
|
||||||
const base = scope.cwd.replace(/\/+$/, "");
|
const drop = DROP_RE.exec(path);
|
||||||
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
|
if (drop) {
|
||||||
// Claude Code's own files (memory): the config pages' guarded read.
|
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
|
||||||
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
|
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
|
||||||
|
else if (DROP_TEXT_RE.test(path))
|
||||||
|
show(
|
||||||
|
fetch(src).then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
const text = await r.text();
|
||||||
|
return {
|
||||||
|
kind: "text",
|
||||||
|
text: text.slice(0, DROP_TEXT_MAX),
|
||||||
|
truncated: text.length > DROP_TEXT_MAX,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
else setContent({ kind: "none" });
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const file = slashed(path);
|
||||||
|
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||||
|
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
||||||
|
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||||
|
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||||
show(
|
show(
|
||||||
api.ccConfig
|
api.ccConfig
|
||||||
.file(path)
|
.file(path)
|
||||||
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
||||||
);
|
);
|
||||||
|
} else if (IMAGE_RE.test(file) && scope.sessionId) {
|
||||||
|
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||||
|
// only because this session's own Read named it.
|
||||||
|
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
||||||
} else setContent({ kind: "none" });
|
} else setContent({ kind: "none" });
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rel = path.slice(base.length + 1);
|
const rel = file.slice(base.length + 1);
|
||||||
const target =
|
const target =
|
||||||
base === scope.laneCwd.replace(/\/+$/, "")
|
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||||
? scope.laneId
|
? scope.laneId
|
||||||
: gitTarget(scope.laneId, scope.sessionId);
|
: gitTarget(scope.laneId, scope.sessionId);
|
||||||
if (IMAGE_RE.test(path)) {
|
if (IMAGE_RE.test(path)) {
|
||||||
@@ -107,7 +138,11 @@ function useContent(path: string, scope: PreviewScope): Content {
|
|||||||
|
|
||||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const content = useContent(path, scope);
|
const loaded = useContent(path, scope);
|
||||||
|
// An image the route refused (gone, server mode) shows like no preview.
|
||||||
|
const [broken, setBroken] = useState<string | null>(null);
|
||||||
|
const content: Content =
|
||||||
|
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||||
if (content.kind === "loading") {
|
if (content.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
@@ -121,6 +156,7 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
|||||||
<img
|
<img
|
||||||
src={content.src}
|
src={content.src}
|
||||||
alt={baseName(path)}
|
alt={baseName(path)}
|
||||||
|
onError={() => setBroken(content.src)}
|
||||||
className="max-h-full max-w-full object-contain"
|
className="max-h-full max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,13 +240,19 @@ export function FilePreview({
|
|||||||
export function FileListModal({
|
export function FileListModal({
|
||||||
title,
|
title,
|
||||||
paths,
|
paths,
|
||||||
|
label = displayName,
|
||||||
scope,
|
scope,
|
||||||
onClose,
|
onClose,
|
||||||
|
onDragItem,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
paths: string[];
|
paths: string[];
|
||||||
|
/** A row's text; the file name by default. */
|
||||||
|
label?: (path: string) => string;
|
||||||
scope: PreviewScope;
|
scope: PreviewScope;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Rows drag into the prompt box; the dialog then closes, out of the way. */
|
||||||
|
onDragItem?: (e: DragEvent, path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [picked, setPicked] = useState(paths[0] ?? "");
|
const [picked, setPicked] = useState(paths[0] ?? "");
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -239,12 +281,18 @@ export function FileListModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPicked(p)}
|
onClick={() => setPicked(p)}
|
||||||
|
draggable={!!onDragItem}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
onDragItem?.(e, p);
|
||||||
|
// Not in dragstart itself: removing the source there cancels the drag.
|
||||||
|
if (onDragItem) window.setTimeout(onClose, 0);
|
||||||
|
}}
|
||||||
title={p}
|
title={p}
|
||||||
aria-current={p === picked}
|
aria-current={p === picked}
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||||
<span className="min-w-0 truncate">{displayName(p)}</span>
|
<span className="min-w-0 truncate">{label(p)}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -256,3 +304,58 @@ export function FileListModal({
|
|||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The session's skills in the list dialog, each one's SKILL.md previewed. */
|
||||||
|
export function SkillListModal({
|
||||||
|
title,
|
||||||
|
names,
|
||||||
|
scope,
|
||||||
|
onClose,
|
||||||
|
draggable = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
names: string[];
|
||||||
|
scope: PreviewScope;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Rows drag into the prompt box as `/name`. */
|
||||||
|
draggable?: boolean;
|
||||||
|
}) {
|
||||||
|
const [files, setFiles] = useState<Map<string, string> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
void Promise.all([
|
||||||
|
api.ccConfig.skills(undefined, scope.cwd).catch(() => ({ items: [] })),
|
||||||
|
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||||
|
]).then(([sk, pl]) => {
|
||||||
|
const found = new Map<string, string>();
|
||||||
|
const add = (name: string, file: string | undefined) => {
|
||||||
|
if (file && !found.has(name)) found.set(name, file);
|
||||||
|
};
|
||||||
|
for (const p of pl.plugins) {
|
||||||
|
const items = p.contributes?.items;
|
||||||
|
for (const it of [...(items?.skills ?? []), ...(items?.commands ?? [])])
|
||||||
|
add(`${p.name}:${it.name}`, it.file);
|
||||||
|
}
|
||||||
|
// A personal skill wins over a project one of the same name, as in Claude Code.
|
||||||
|
for (const s of sk.items) if (s.scope === "user") add(s.name, s.file);
|
||||||
|
for (const s of sk.items) add(s.name, s.file);
|
||||||
|
if (live) setFiles(found);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [scope.cwd]);
|
||||||
|
if (!files) return null;
|
||||||
|
// A built-in skill has no file: its name stands in and previews as nothing.
|
||||||
|
const byPath = new Map(names.map((n) => [files.get(n) ?? n, n]));
|
||||||
|
return (
|
||||||
|
<FileListModal
|
||||||
|
title={title}
|
||||||
|
paths={[...byPath.keys()]}
|
||||||
|
label={(p) => byPath.get(p) ?? displayName(p)}
|
||||||
|
scope={scope}
|
||||||
|
onClose={onClose}
|
||||||
|
onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* @file ChatOutline.tsx
|
* @file ChatOutline.tsx
|
||||||
* @description The right-hand panel of a Chat pane shown alone, laid out like
|
* @description The right-hand panel of a Chat pane, laid out like
|
||||||
* Claude Desktop's activity panel: the session's Progress (its
|
* Claude Desktop's activity panel: the session's Progress (its
|
||||||
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
|
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
|
||||||
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
||||||
* this session" — the files attached to its prompts, the files it read and the
|
* this session" — the files attached to its prompts, the files it read and the
|
||||||
* memory files it used (a click opens the list with a preview), its skills
|
* memory files it used and its skills (a click opens the list with a
|
||||||
* and MCP servers (a click opens the Claude config page). Read from
|
* preview — a skill's SKILL.md), and its MCP servers (a click opens the
|
||||||
|
* Claude config page). Every row but MCP drags into the prompt box: a file
|
||||||
|
* as an attachment, a group as all of its files, a lone skill as `/name`.
|
||||||
|
* Read from
|
||||||
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
|
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
|
||||||
* uses for the collapsed button's count.
|
* uses for the collapsed button's count.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type SessionOutline } from "../../lib/api";
|
import { api, type SessionOutline } from "../../lib/api";
|
||||||
import { displayName, extOf } from "./ChatFilePreview";
|
import { displayName, extOf } from "./ChatFilePreview";
|
||||||
|
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
|
||||||
|
|
||||||
const POLL_MS = 5000;
|
const POLL_MS = 5000;
|
||||||
const EMPTY: SessionOutline = {
|
const EMPTY: SessionOutline = {
|
||||||
@@ -65,7 +69,21 @@ export function useSessionOutline(sessionId: string): SessionOutline {
|
|||||||
return outline;
|
return outline;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OutlineList = "uploads" | "files" | "memory";
|
export type OutlineList = "uploads" | "files" | "memory" | "skills";
|
||||||
|
|
||||||
|
/** A listed file as a prompt attachment: an upload by its bare path, as the
|
||||||
|
* composer attaches one; anything else as an `@` mention. */
|
||||||
|
export function outlineAttachment(path: string, upload = false): Attachment {
|
||||||
|
return { path: upload ? path : `@${path}`, name: displayName(path) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragFiles =
|
||||||
|
(paths: string[], upload = false) =>
|
||||||
|
(e: DragEvent) =>
|
||||||
|
dragAttachments(
|
||||||
|
e,
|
||||||
|
paths.map((p) => outlineAttachment(p, upload))
|
||||||
|
);
|
||||||
|
|
||||||
function fileIcon(path: string) {
|
function fileIcon(path: string) {
|
||||||
const ext = extOf(path).toLowerCase();
|
const ext = extOf(path).toLowerCase();
|
||||||
@@ -136,12 +154,15 @@ function UsedRow({
|
|||||||
label,
|
label,
|
||||||
names,
|
names,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDragStart,
|
||||||
to,
|
to,
|
||||||
}: {
|
}: {
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
names: string[];
|
names: string[];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
/** The row drags into the prompt box. */
|
||||||
|
onDragStart?: (e: DragEvent) => void;
|
||||||
to?: string;
|
to?: string;
|
||||||
}) {
|
}) {
|
||||||
const shown = names.slice(0, 2).join(", ");
|
const shown = names.slice(0, 2).join(", ");
|
||||||
@@ -161,7 +182,14 @@ function UsedRow({
|
|||||||
{inner}
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" title={title} onClick={onClick} className={rowClass}>
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={!!onDragStart}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
className={rowClass}
|
||||||
|
>
|
||||||
{inner}
|
{inner}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -198,7 +226,9 @@ export function ChatOutline({
|
|||||||
<aside
|
<aside
|
||||||
data-testid="chat-outline"
|
data-testid="chat-outline"
|
||||||
aria-label={t("chat.outline.panel")}
|
aria-label={t("chat.outline.panel")}
|
||||||
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
|
// Over the chat in a narrow pane (the container is PaneChat's root),
|
||||||
|
// beside it once the pane has room, as wide as PaneChat's splitter says.
|
||||||
|
className="absolute inset-y-3 right-3 z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
|
||||||
>
|
>
|
||||||
{o.progress.length > 0 && (
|
{o.progress.length > 0 && (
|
||||||
<Section title={t("chat.outline.progress")} action={close}>
|
<Section title={t("chat.outline.progress")} action={close}>
|
||||||
@@ -241,6 +271,8 @@ export function ChatOutline({
|
|||||||
title={p}
|
title={p}
|
||||||
aria-label={`${extOf(p)}: ${stem(p)}`}
|
aria-label={`${extOf(p)}: ${stem(p)}`}
|
||||||
onClick={() => onOpenFile(p)}
|
onClick={() => onOpenFile(p)}
|
||||||
|
draggable
|
||||||
|
onDragStart={dragFiles([p])}
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||||
@@ -261,6 +293,7 @@ export function ChatOutline({
|
|||||||
label={t("chat.outline.uploads")}
|
label={t("chat.outline.uploads")}
|
||||||
names={o.uploads.map(displayName)}
|
names={o.uploads.map(displayName)}
|
||||||
onClick={() => onOpenList("uploads")}
|
onClick={() => onOpenList("uploads")}
|
||||||
|
onDragStart={dragFiles(o.uploads, true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{o.files.length > 0 && (
|
{o.files.length > 0 && (
|
||||||
@@ -269,6 +302,7 @@ export function ChatOutline({
|
|||||||
label={t("chat.outline.files")}
|
label={t("chat.outline.files")}
|
||||||
names={o.files.map(displayName)}
|
names={o.files.map(displayName)}
|
||||||
onClick={() => onOpenList("files")}
|
onClick={() => onOpenList("files")}
|
||||||
|
onDragStart={dragFiles(o.files)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{o.memory.length > 0 && (
|
{o.memory.length > 0 && (
|
||||||
@@ -277,6 +311,7 @@ export function ChatOutline({
|
|||||||
label={t("chat.outline.memory")}
|
label={t("chat.outline.memory")}
|
||||||
names={o.memory.map(stem)}
|
names={o.memory.map(stem)}
|
||||||
onClick={() => onOpenList("memory")}
|
onClick={() => onOpenList("memory")}
|
||||||
|
onDragStart={dragFiles(o.memory)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{o.skills.length > 0 && (
|
{o.skills.length > 0 && (
|
||||||
@@ -284,7 +319,9 @@ export function ChatOutline({
|
|||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
label={t("chat.skills")}
|
label={t("chat.skills")}
|
||||||
names={o.skills}
|
names={o.skills}
|
||||||
to="/settings/agents/claude-code?tab=skills"
|
onClick={() => onOpenList("skills")}
|
||||||
|
// One command per prompt: several are dragged one by one from the list.
|
||||||
|
onDragStart={o.skills.length === 1 ? (e) => dragSkill(e, o.skills[0]!) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{o.mcp.length > 0 && (
|
{o.mcp.length > 0 && (
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
* of another tab, a tab (or a pane header) joins that tab instead
|
* of another tab, a tab (or a pane header) joins that tab instead
|
||||||
* (`joinGroup`: side by side, then under the right pane, then under the left
|
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||||
* one, up to four panes). A right-click on a tab
|
* one, up to four panes). A right-click on a tab
|
||||||
* opens the same actions as a menu (`ContextMenu`).
|
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||||
|
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||||
|
* and `workspaceOf` names each tab's workspace before its own name.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ import {
|
|||||||
joinGroup,
|
joinGroup,
|
||||||
leaves,
|
leaves,
|
||||||
reorderGroups,
|
reorderGroups,
|
||||||
|
slotOfKey,
|
||||||
} from "../../lib/paneLayout";
|
} from "../../lib/paneLayout";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ export function runName(
|
|||||||
): string {
|
): string {
|
||||||
if (r.tool === "shell") return terminal;
|
if (r.tool === "shell") return terminal;
|
||||||
const prompt = r.promptPreview?.trim().split("\n")[0];
|
const prompt = r.promptPreview?.trim().split("\n")[0];
|
||||||
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
|
return session?.name || prompt || `${toolLabel} #${slotOfKey(r.slot ?? 0) + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunState = "awaiting" | "error" | "running";
|
export type RunState = "awaiting" | "error" | "running";
|
||||||
@@ -160,10 +163,13 @@ export function ConsoleTabs({
|
|||||||
actions,
|
actions,
|
||||||
sessions,
|
sessions,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
|
workspaceOf,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** The workspace's live runs, in slot order. */
|
/** The workspace's live runs, in slot order. */
|
||||||
tabs: RunHandle[];
|
tabs: RunHandle[];
|
||||||
|
/** Tabs from every workspace: the name of the workspace a run belongs to. */
|
||||||
|
workspaceOf?: (r: RunHandle) => string;
|
||||||
/** The tabs: groups of panes, the active one on screen. */
|
/** The tabs: groups of panes, the active one on screen. */
|
||||||
groups: PaneGroups;
|
groups: PaneGroups;
|
||||||
/** Reorders the tabs or takes a pane out into a tab of its own. */
|
/** Reorders the tabs or takes a pane out into a tab of its own. */
|
||||||
@@ -325,7 +331,10 @@ export function ConsoleTabs({
|
|||||||
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
|
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
|
||||||
const toolLabel = (r: RunHandle) =>
|
const toolLabel = (r: RunHandle) =>
|
||||||
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
|
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
|
||||||
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
const label = (r: RunHandle) => {
|
||||||
|
const name = runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||||
|
return workspaceOf ? `${workspaceOf(r)} · ${name}` : name;
|
||||||
|
};
|
||||||
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
|
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
|
||||||
// A combo's dot is its most urgent AI pane's; a shell has none.
|
// A combo's dot is its most urgent AI pane's; a shell has none.
|
||||||
const tabState = (tab: Tab): RunState | null => {
|
const tabState = (tab: Tab): RunState | null => {
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ export interface LaneConsolePaneProps {
|
|||||||
onRunExit?: () => void;
|
onRunExit?: () => void;
|
||||||
/** The terminal takes the keyboard when it appears (default). */
|
/** The terminal takes the keyboard when it appears (default). */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** The only pane shown: its Chat view gets the right panel. */
|
/** The pane spans the grid's full width or height: its Chat view's right
|
||||||
solo?: boolean;
|
* panel starts open (collapsed in a quarter-sized pane). */
|
||||||
|
panelOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||||
@@ -71,7 +72,7 @@ export function LaneConsolePane({
|
|||||||
onNewSession,
|
onNewSession,
|
||||||
onRunExit,
|
onRunExit,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
solo = false,
|
panelOpen = false,
|
||||||
}: LaneConsolePaneProps) {
|
}: LaneConsolePaneProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||||
@@ -201,7 +202,7 @@ export function LaneConsolePane({
|
|||||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||||
laneId={laneId}
|
laneId={laneId}
|
||||||
laneCwd={currentLane?.cwd || ""}
|
laneCwd={currentLane?.cwd || ""}
|
||||||
outline={solo}
|
panelOpen={panelOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,15 +13,23 @@
|
|||||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||||
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||||
* Shown alone (`outline`), it also has Claude Desktop's activity panel
|
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||||
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
|
* outputs, what the session used), open at first in a pane spanning the
|
||||||
* file button with the output count until clicked (remembered per browser);
|
* grid's full width or height (`panelOpen`) and collapsed into a file button
|
||||||
* a file clicked there opens its preview in the panel's place
|
* with the output count in a quarter-sized one; it sits beside the
|
||||||
|
* conversation when the pane is wide and over it when narrow (a CSS container
|
||||||
|
* query on the pane, not the window), dragged wider or narrower by its
|
||||||
|
* splitter there (a fraction of the pane, remembered per browser). A file
|
||||||
|
* clicked there opens its preview
|
||||||
|
* in the panel's place
|
||||||
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
||||||
* the workspace's folder —, a group
|
* the workspace's folder —, a group
|
||||||
* of files a list with a preview (`FileListModal`). A file in a turn's
|
* of files or the used skills a list with a preview (`FileListModal`,
|
||||||
|
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||||
* "Edited N files" card opens in the Workspace's Changes panel
|
* "Edited N files" card opens in the Workspace's Changes panel
|
||||||
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
|
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||||
|
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
|
||||||
|
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
|
||||||
* conversation to its end and keeps it there (`follow`), so the message and
|
* conversation to its end and keeps it there (`follow`), so the message and
|
||||||
* the step running after it come into sight on their own. While the session's
|
* the step running after it come into sight on their own. While the session's
|
||||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||||
@@ -29,16 +37,18 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||||
import { ConversationView } from "../conversation/ConversationView";
|
import { ConversationView } from "../conversation/ConversationView";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||||
import { ChatAddMenu } from "./ChatAddMenu";
|
import { ChatAddMenu } from "./ChatAddMenu";
|
||||||
import { addAttachments } from "../../lib/promptAttach";
|
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
||||||
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
|
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||||
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
|
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||||
|
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
|
import { Divider } from "./PaneGrid";
|
||||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
import { openInChanges } from "../../lib/openInChanges";
|
import { openInChanges } from "../../lib/openInChanges";
|
||||||
@@ -53,7 +63,12 @@ const MODES: [PermissionMode, string][] = [
|
|||||||
["bypassPermissions", "Bypass"],
|
["bypassPermissions", "Bypass"],
|
||||||
];
|
];
|
||||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||||
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||||
|
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||||
|
const storedSide = () => {
|
||||||
|
const f = Number(localStorage.getItem(SIDE_KEY));
|
||||||
|
return f ? clampSide(f) : null;
|
||||||
|
};
|
||||||
|
|
||||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||||
@@ -109,7 +124,7 @@ export function PaneChat({
|
|||||||
cwd,
|
cwd,
|
||||||
laneId = null,
|
laneId = null,
|
||||||
laneCwd = "",
|
laneCwd = "",
|
||||||
outline = false,
|
panelOpen = false,
|
||||||
}: {
|
}: {
|
||||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -117,27 +132,34 @@ export function PaneChat({
|
|||||||
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
||||||
laneId?: number | null;
|
laneId?: number | null;
|
||||||
laneCwd?: string;
|
laneCwd?: string;
|
||||||
/** The pane is shown alone: room for the right panel. */
|
/** The right panel starts open: the pane spans the grid's width or height. */
|
||||||
outline?: boolean;
|
panelOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const runId = run.id;
|
const runId = run.id;
|
||||||
const claude = run.tool === "claude";
|
const claude = run.tool === "claude";
|
||||||
const [prompt, setPrompt] = useState("");
|
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||||
|
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||||
|
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [follow, setFollow] = useState(0);
|
const [follow, setFollow] = useState(0);
|
||||||
const [model, setModel] = useState(run.model ?? "");
|
const [model, setModel] = useState(run.model ?? "");
|
||||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||||
const pickFiles = useRef<(() => void) | null>(null);
|
const pickFiles = useRef<(() => void) | null>(null);
|
||||||
// Collapsed until asked for, like Claude Desktop's activity panel.
|
const [outlineOpen, setOutlineOpen] = useState(panelOpen);
|
||||||
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
|
// A split or a close changes the pane's shape: its default comes back.
|
||||||
|
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
|
||||||
const openOutline = (open: boolean) => {
|
const openOutline = (open: boolean) => {
|
||||||
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
|
||||||
setOutlineOpen(open);
|
setOutlineOpen(open);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
};
|
};
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
// The side panel's width as a fraction of the pane: the dragged one, else
|
||||||
|
// a default per content (a preview wants more room than the panel).
|
||||||
|
const [dragged, setDragged] = useState(storedSide);
|
||||||
|
const side = dragged ?? (preview ? 0.45 : 0.3);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [list, setList] = useState<OutlineList | null>(null);
|
const [list, setList] = useState<OutlineList | null>(null);
|
||||||
const activity = useSessionOutline(sessionId);
|
const activity = useSessionOutline(sessionId);
|
||||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||||
@@ -186,7 +208,12 @@ export function PaneChat({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
data-prompt-drop
|
||||||
|
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||||
|
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||||
|
>
|
||||||
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
<ConversationView
|
<ConversationView
|
||||||
@@ -196,19 +223,19 @@ export function PaneChat({
|
|||||||
follow={follow}
|
follow={follow}
|
||||||
onOpenFile={(path) => {
|
onOpenFile={(path) => {
|
||||||
// The workspace's Changes panel when the file is in its folder;
|
// The workspace's Changes panel when the file is in its folder;
|
||||||
// else this view's own preview, when it has room for one.
|
// else this view's own preview.
|
||||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||||
if (outline) setPreview(path);
|
setPreview(path);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{outline && !outlineOpen && !preview && (
|
{!outlineOpen && !preview && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="chat-outline-show"
|
data-testid="chat-outline-show"
|
||||||
onClick={() => openOutline(true)}
|
onClick={() => openOutline(true)}
|
||||||
title={t("chat.outline.show")}
|
title={t("chat.outline.show")}
|
||||||
aria-label={t("chat.outline.show")}
|
aria-label={t("chat.outline.show")}
|
||||||
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
|
className="absolute right-3 top-2 flex h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
{activity.outputs.length}
|
{activity.outputs.length}
|
||||||
@@ -291,31 +318,57 @@ export function PaneChat({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{outline &&
|
{preview ? (
|
||||||
(preview ? (
|
<FilePreview
|
||||||
<FilePreview
|
path={preview}
|
||||||
path={preview}
|
scope={scope}
|
||||||
scope={scope}
|
onClose={() => setPreview(null)}
|
||||||
onClose={() => setPreview(null)}
|
// Beside the chat when the pane has room, over it when not.
|
||||||
// Beside the chat when there is room, over it when not.
|
className="absolute inset-0 z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||||
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
|
/>
|
||||||
|
) : (
|
||||||
|
outlineOpen && (
|
||||||
|
<ChatOutline
|
||||||
|
outline={activity}
|
||||||
|
onClose={() => openOutline(false)}
|
||||||
|
onOpenFile={setPreview}
|
||||||
|
onOpenList={setList}
|
||||||
/>
|
/>
|
||||||
) : (
|
)
|
||||||
outlineOpen && (
|
)}
|
||||||
<ChatOutline
|
{(preview || outlineOpen) && (
|
||||||
outline={activity}
|
// Only beside the chat: a panel over a narrow pane has no edge to drag.
|
||||||
onClose={() => openOutline(false)}
|
<div className="hidden [@container(min-width:40rem)]:contents">
|
||||||
onOpenFile={setPreview}
|
<Divider
|
||||||
onOpenList={setList}
|
gridRef={rootRef}
|
||||||
/>
|
dir="row"
|
||||||
)
|
at={1 - side}
|
||||||
))}
|
box={{ x: 0, y: 0, w: 1, h: 1 }}
|
||||||
{list && (
|
label={t("chat.outline.resize")}
|
||||||
|
onRatio={(r) => {
|
||||||
|
const f = clampSide(1 - r);
|
||||||
|
localStorage.setItem(SIDE_KEY, String(f));
|
||||||
|
setDragged(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list === "skills" && (
|
||||||
|
<SkillListModal
|
||||||
|
title={t("chat.outline.skills")}
|
||||||
|
names={activity.skills}
|
||||||
|
scope={scope}
|
||||||
|
onClose={() => setList(null)}
|
||||||
|
draggable={claude}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{list && list !== "skills" && (
|
||||||
<FileListModal
|
<FileListModal
|
||||||
title={t(`chat.outline.${list}`)}
|
title={t(`chat.outline.${list}`)}
|
||||||
paths={activity[list]}
|
paths={activity[list]}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onClose={() => setList(null)}
|
onClose={() => setList(null)}
|
||||||
|
onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
|||||||
import {
|
import {
|
||||||
EMPTY_GROUPS,
|
EMPTY_GROUPS,
|
||||||
type Edge,
|
type Edge,
|
||||||
|
type GroupsScope,
|
||||||
type PaneGroups,
|
type PaneGroups,
|
||||||
type PaneLayout,
|
type PaneLayout,
|
||||||
type Rect,
|
type Rect,
|
||||||
@@ -82,10 +83,10 @@ export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
|
|||||||
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
|
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
|
||||||
|
|
||||||
/** A workspace's tabs (groups of split panes), loaded and saved per
|
/** A workspace's tabs (groups of split panes), loaded and saved per
|
||||||
* workspace. An update that lands after a workspace switch never writes into
|
* workspace (or once for every workspace, `all`). An update that lands after
|
||||||
* the new one. */
|
* a workspace switch never writes into the new one. */
|
||||||
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
|
export function usePaneGroups(laneId: GroupsScope | null): [PaneGroups, GroupsUpdate] {
|
||||||
const load = (id: number | null) => ({
|
const load = (id: GroupsScope | null) => ({
|
||||||
laneId: id,
|
laneId: id,
|
||||||
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
|
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
||||||
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
||||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||||
* remembered for the + menu's Recent uploads), and a Files panel row
|
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||||
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
|
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||||
|
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
@@ -63,7 +64,9 @@ import { ImageLightbox } from "../ImageLightbox";
|
|||||||
import {
|
import {
|
||||||
ATTACH_MIME,
|
ATTACH_MIME,
|
||||||
addAttachments,
|
addAttachments,
|
||||||
droppedAttachment,
|
droppedAttachments,
|
||||||
|
droppedSkill,
|
||||||
|
SKILL_MIME,
|
||||||
rememberUploads,
|
rememberUploads,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
|
|
||||||
@@ -388,6 +391,45 @@ export function PromptEditor({
|
|||||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||||
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||||
|
|
||||||
|
// A drop lands anywhere on the screen around the composer — the nearest
|
||||||
|
// `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home),
|
||||||
|
// else a `bare` editor's parent, the box every caller draws around it and
|
||||||
|
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
||||||
|
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
||||||
|
// `/command`, a file is uploaded.
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
||||||
|
dropRef.current = (e) => {
|
||||||
|
const atts = droppedAttachments(e.dataTransfer);
|
||||||
|
const skill = droppedSkill(e.dataTransfer);
|
||||||
|
if (atts.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
onImagesChange((prev) => addAttachments(prev, atts));
|
||||||
|
} else if (skill) {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||||
|
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const zone =
|
||||||
|
rootRef.current?.closest<HTMLElement>("[data-prompt-drop]") ??
|
||||||
|
(bare ? rootRef.current?.parentElement : rootRef.current);
|
||||||
|
if (!zone) return;
|
||||||
|
const over = (e: DragEvent) => {
|
||||||
|
const types = e.dataTransfer?.types ?? [];
|
||||||
|
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer!.dropEffect = "copy";
|
||||||
|
};
|
||||||
|
const drop = (e: DragEvent) => dropRef.current(e);
|
||||||
|
zone.addEventListener("dragover", over);
|
||||||
|
zone.addEventListener("drop", drop);
|
||||||
|
return () => {
|
||||||
|
zone.removeEventListener("dragover", over);
|
||||||
|
zone.removeEventListener("drop", drop);
|
||||||
|
};
|
||||||
|
}, [bare]);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (kind && count > 0) {
|
if (kind && count > 0) {
|
||||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
@@ -431,7 +473,7 @@ export function PromptEditor({
|
|||||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div ref={rootRef} className="relative">
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||||
{images.map((img) => (
|
{images.map((img) => (
|
||||||
@@ -527,19 +569,6 @@ export function PromptEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => {
|
|
||||||
// A Files panel row is not a file: say a drop is welcome.
|
|
||||||
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
const att = droppedAttachment(e.dataTransfer);
|
|
||||||
if (att) {
|
|
||||||
e.preventDefault();
|
|
||||||
onImagesChange((prev) => addAttachments(prev, [att]));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
* @file ChatFilePreview.test.tsx
|
* @file ChatFilePreview.test.tsx
|
||||||
* @description Where a Chat pane's file preview reads from: a file under the
|
* @description Where a Chat pane's file preview reads from: a file under the
|
||||||
* pane's folder through the lane's git file read, a memory file under
|
* pane's folder through the lane's git file read, a memory file under
|
||||||
* `~/.claude` through the config pages' read, anything else by path only; and
|
* `~/.claude` through the config pages' read, a dropped text file through the
|
||||||
* the list dialog previewing the file picked in it.
|
* drop route, an image read outside the folder through the session's image
|
||||||
|
* route, a Windows path like a POSIX one, anything else by path only; the
|
||||||
|
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||||
|
* each skill's SKILL.md by name.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
import { FileListModal, FilePreview, SkillListModal, displayName } from "../ChatFilePreview";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
@@ -28,7 +31,30 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
gitRawUrl: () => "",
|
gitRawUrl: () => "",
|
||||||
},
|
},
|
||||||
|
sessions: { readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}` },
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
|
skills: vi.fn().mockResolvedValue({
|
||||||
|
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
||||||
|
}),
|
||||||
|
plugins: vi.fn().mockResolvedValue({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "agenthub",
|
||||||
|
contributes: {
|
||||||
|
items: {
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "file-headers",
|
||||||
|
description: null,
|
||||||
|
file: "/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
commands: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
file: vi
|
file: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||||
@@ -53,6 +79,37 @@ describe("FilePreview", () => {
|
|||||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads Windows paths the same way", async () => {
|
||||||
|
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||||
|
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||||
|
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||||
|
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||||
|
cleanup();
|
||||||
|
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||||
|
render(<FilePreview path={tool} scope={win} onClose={() => {}} />);
|
||||||
|
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||||
|
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(tool);
|
||||||
|
expect(screen.getByText("b")).toBeTruthy(); // the name, not the whole path
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a dropped text file through the drop route", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response("a long paste"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const drop = "C:\\Users\\k\\.claude\\agenthub\\drops\\0123456789abcdef-pasted-text.txt";
|
||||||
|
render(<FilePreview path={drop} scope={scope} onClose={() => {}} />);
|
||||||
|
expect(await screen.findByText("a long paste")).toBeTruthy();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/run/drop/0123456789abcdef-pasted-text.txt");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an image it read outside the folder through the session, its path if refused", () => {
|
||||||
|
render(<FilePreview path="/h/.cache/shot.png" scope={scope} onClose={() => {}} />);
|
||||||
|
const img = screen.getByAltText("shot.png") as HTMLImageElement;
|
||||||
|
expect(img.getAttribute("src")).toBe("/img/s1?path=/h/.cache/shot.png");
|
||||||
|
fireEvent.error(img);
|
||||||
|
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows only the path of a file it may not read", () => {
|
it("shows only the path of a file it may not read", () => {
|
||||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||||
@@ -76,3 +133,28 @@ describe("FileListModal", () => {
|
|||||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SkillListModal", () => {
|
||||||
|
it("lists the skills by name and previews each one's SKILL.md", async () => {
|
||||||
|
render(
|
||||||
|
<SkillListModal
|
||||||
|
title="Skills"
|
||||||
|
names={["agenthub:file-headers", "xlsx", "simplify"]}
|
||||||
|
scope={scope}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// A plugin's skill first, read through the config read.
|
||||||
|
expect(await screen.findByRole("button", { name: "agenthub:file-headers" })).toBeTruthy();
|
||||||
|
await screen.findByText("Launch notes");
|
||||||
|
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(
|
||||||
|
"/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md"
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "xlsx" }));
|
||||||
|
await screen.findByText("Launch notes");
|
||||||
|
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith("/h/.claude/skills/xlsx/SKILL.md");
|
||||||
|
// A built-in skill has no file.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "simplify" }));
|
||||||
|
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @description The Chat pane's activity panel renders a session's outline the
|
* @description The Chat pane's activity panel renders a session's outline the
|
||||||
* way Claude Desktop does: progress with done/current items, outputs by name
|
* way Claude Desktop does: progress with done/current items, outputs by name
|
||||||
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
||||||
* uploads, files read and memory open their list, skills link to the config.
|
* uploads, files read, memory and skills open their list.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -52,15 +52,47 @@ describe("ChatOutline", () => {
|
|||||||
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
||||||
fireEvent.click(screen.getByTitle("Memory: release"));
|
fireEvent.click(screen.getByTitle("Memory: release"));
|
||||||
expect(onOpenList).toHaveBeenCalledWith("memory");
|
expect(onOpenList).toHaveBeenCalledWith("memory");
|
||||||
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
|
fireEvent.click(screen.getByTitle("Skills: xlsx"));
|
||||||
"/settings/agents/claude-code?tab=skills"
|
expect(onOpenList).toHaveBeenCalledWith("skills");
|
||||||
);
|
|
||||||
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drags its rows into the prompt box", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ChatOutline
|
||||||
|
outline={outline}
|
||||||
|
onClose={() => {}}
|
||||||
|
onOpenFile={() => {}}
|
||||||
|
onOpenList={() => {}}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const dragged = (el: HTMLElement) => {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
fireEvent.dragStart(el, {
|
||||||
|
dataTransfer: { setData: (k: string, v: string) => (data[k] = v) },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
const attached = (el: HTMLElement) =>
|
||||||
|
JSON.parse(dragged(el)["application/x-agenthub-attach"] ?? "[]") as { path: string }[];
|
||||||
|
expect(attached(screen.getByRole("button", { name: "XLSX: Progress report" }))).toEqual([
|
||||||
|
{ path: "@/w/out/Progress report.xlsx", name: "Progress report.xlsx" },
|
||||||
|
]);
|
||||||
|
expect(attached(screen.getByTitle(/a\.ts, /)).map((a) => a.path)).toEqual([
|
||||||
|
"@/w/src/a.ts",
|
||||||
|
"@/w/src/b.ts",
|
||||||
|
"@/w/src/c.ts",
|
||||||
|
]);
|
||||||
|
// An upload goes as the composer attaches one: its bare path.
|
||||||
|
expect(attached(screen.getByTitle("Uploads: minutes.xlsx"))[0]!.path).toBe(outline.uploads[0]);
|
||||||
|
expect(dragged(screen.getByTitle("Skills: xlsx"))["application/x-agenthub-skill"]).toBe("xlsx");
|
||||||
|
});
|
||||||
|
|
||||||
it("hides progress and the used section when there is none", () => {
|
it("hides progress and the used section when there is none", () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
|
|||||||
@@ -122,7 +122,11 @@ function renderChat(run: Partial<typeof RUN> = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => vi.mocked(submitToRun).mockClear());
|
beforeEach(() => {
|
||||||
|
vi.mocked(submitToRun).mockClear();
|
||||||
|
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
||||||
|
localStorage.removeItem("agenthub.chat.draft.s1");
|
||||||
|
});
|
||||||
|
|
||||||
describe("PaneChat — the + menu", () => {
|
describe("PaneChat — the + menu", () => {
|
||||||
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
||||||
@@ -230,10 +234,9 @@ const byId = (id: string) => () => screen.getByTestId(id);
|
|||||||
|
|
||||||
describe("PaneChat — the activity panel", () => {
|
describe("PaneChat — the activity panel", () => {
|
||||||
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
||||||
localStorage.removeItem("agenthub.chat.outlineOpen");
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
@@ -248,7 +251,33 @@ describe("PaneChat — the activity panel", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||||
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
});
|
||||||
|
|
||||||
|
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
|
||||||
|
const pane = (panelOpen: boolean) => (
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const { rerender } = render(pane(true));
|
||||||
|
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||||
|
rerender(pane(false));
|
||||||
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
|
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes from its splitter and remembers the width", () => {
|
||||||
|
localStorage.removeItem("agenthub.chat.panelWidth");
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
|
||||||
|
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
||||||
|
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
||||||
|
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
||||||
|
expect(splitter.getAttribute("aria-valuenow")).toBe("65");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,7 +288,7 @@ describe("PaneChat — an edited file", () => {
|
|||||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByTestId("edited-in"));
|
fireEvent.click(screen.getByTestId("edited-in"));
|
||||||
@@ -272,3 +301,36 @@ describe("PaneChat — an edited file", () => {
|
|||||||
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PaneChat — the unsent prompt", () => {
|
||||||
|
it("keeps the half-typed prompt and its attachments when the chat unmounts (terminal view, reload)", async () => {
|
||||||
|
const first = renderChat();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
||||||
|
target: { value: "half typed" },
|
||||||
|
});
|
||||||
|
// A dropped attachment, as a Files row or an "Edited N files" row brings it.
|
||||||
|
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(drop, "dataTransfer", {
|
||||||
|
value: {
|
||||||
|
types: ["application/x-agenthub-attach"],
|
||||||
|
getData: (t: string) =>
|
||||||
|
t === "application/x-agenthub-attach"
|
||||||
|
? JSON.stringify({ path: "@src/a.ts", name: "a.ts" })
|
||||||
|
: "",
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
screen.getByPlaceholderText("Write a message…").dispatchEvent(drop);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("a.ts")).toBeInTheDocument();
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
renderChat();
|
||||||
|
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||||
|
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
||||||
|
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +28,13 @@ import {
|
|||||||
type PromptImage,
|
type PromptImage,
|
||||||
} from "../RunSetup";
|
} from "../RunSetup";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
import {
|
||||||
|
ATTACH_MIME,
|
||||||
|
dragAttachments,
|
||||||
|
dragProjectPath,
|
||||||
|
dragSkill,
|
||||||
|
recentUploads,
|
||||||
|
} from "../../../lib/promptAttach";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -348,6 +354,134 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes a drop anywhere on a bare editor's box; an extra folder's row comes absolute", () => {
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<PromptEditor
|
||||||
|
bare
|
||||||
|
slash={false}
|
||||||
|
value="read"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<div data-testid="toolbar" />
|
||||||
|
</div>
|
||||||
|
<section data-drag-root="/Users/tester/extra/">
|
||||||
|
<span data-testid="row" />
|
||||||
|
</section>
|
||||||
|
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
const dataTransfer = {
|
||||||
|
setData: (k: string, v: string) => (data[k] = v),
|
||||||
|
getData: (k: string) => data[k] ?? "",
|
||||||
|
get types() {
|
||||||
|
return Object.keys(data);
|
||||||
|
},
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
dragProjectPath(
|
||||||
|
{ dataTransfer, currentTarget: screen.getByTestId("row") } as unknown as React.DragEvent,
|
||||||
|
"notes.md"
|
||||||
|
);
|
||||||
|
expect(data["text/plain"]).toBe("/Users/tester/extra/notes.md");
|
||||||
|
const over = createEvent.dragOver(screen.getByTestId("toolbar"), { dataTransfer });
|
||||||
|
fireEvent(screen.getByTestId("toolbar"), over);
|
||||||
|
expect(over.defaultPrevented).toBe(true);
|
||||||
|
fireEvent.drop(screen.getByTestId("toolbar"), { dataTransfer });
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("read @/Users/tester/extra/notes.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a drop anywhere inside its data-prompt-drop screen, not only on the box", () => {
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<div data-prompt-drop>
|
||||||
|
<div data-testid="transcript" />
|
||||||
|
<PromptEditor
|
||||||
|
value="see"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash={false}
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages("see", images)}</output>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const dataTransfer = {
|
||||||
|
getData: (k: string) =>
|
||||||
|
k === ATTACH_MIME ? JSON.stringify([{ path: "@/w/a.ts", name: "a.ts" }]) : "",
|
||||||
|
types: [ATTACH_MIME],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
const over = createEvent.dragOver(screen.getByTestId("transcript"), { dataTransfer });
|
||||||
|
fireEvent(screen.getByTestId("transcript"), over);
|
||||||
|
expect(over.defaultPrevented).toBe(true);
|
||||||
|
fireEvent.drop(screen.getByTestId("transcript"), { dataTransfer });
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("see @/w/a.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes an activity panel's drag: a group of files as chips, a skill as the /command", () => {
|
||||||
|
function Harness() {
|
||||||
|
const [value, setValue] = useState("check it");
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptEditor
|
||||||
|
slash={false}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages(value, images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const drag = (start: (e: React.DragEvent) => void) => {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
const dataTransfer = {
|
||||||
|
setData: (k: string, v: string) => (data[k] = v),
|
||||||
|
getData: (k: string) => data[k] ?? "",
|
||||||
|
get types() {
|
||||||
|
return Object.keys(data);
|
||||||
|
},
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
start({ dataTransfer } as unknown as React.DragEvent);
|
||||||
|
fireEvent.drop(box(), { dataTransfer });
|
||||||
|
};
|
||||||
|
drag((e) =>
|
||||||
|
dragAttachments(e, [
|
||||||
|
{ path: "@/a/x.md", name: "x.md" },
|
||||||
|
{ path: "/d/drops/0123456789abcdef.png", name: "y.png" },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
drag((e) => dragSkill(e, "ponytail:ponytail-help"));
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe(
|
||||||
|
"/ponytail:ponytail-help check it @/a/x.md /d/drops/0123456789abcdef.png"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves a text paste to the textarea", () => {
|
it("leaves a text paste to the textarea", () => {
|
||||||
renderEditor("");
|
renderEditor("");
|
||||||
fireEvent.paste(box(), {
|
fireEvent.paste(box(), {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.tsx
|
* @file AppearanceSettings.tsx
|
||||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
|
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
|
||||||
|
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||||
|
* `#workspace-tabs`), the interface language, the
|
||||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||||
import { PetSettings } from "./PetSettings";
|
import { PetSettings } from "./PetSettings";
|
||||||
import { ChatUiSettings } from "./ChatUiSettings";
|
import { ChatUiSettings } from "./ChatUiSettings";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
writeTerminalFont,
|
writeTerminalFont,
|
||||||
type TerminalFont,
|
type TerminalFont,
|
||||||
} from "../../lib/terminalFont";
|
} from "../../lib/terminalFont";
|
||||||
|
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||||
|
|
||||||
export const THEME_LABELS: Record<string, string> = {
|
export const THEME_LABELS: Record<string, string> = {
|
||||||
darcula: "Darcula",
|
darcula: "Darcula",
|
||||||
@@ -79,6 +82,7 @@ export function AppearanceSettings() {
|
|||||||
const { t, i18n } = useTranslation("settings");
|
const { t, i18n } = useTranslation("settings");
|
||||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||||
|
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||||
const pickTermFont = (font: TerminalFont) => {
|
const pickTermFont = (font: TerminalFont) => {
|
||||||
writeTerminalFont(font);
|
writeTerminalFont(font);
|
||||||
setTermFont(font);
|
setTermFont(font);
|
||||||
@@ -101,6 +105,22 @@ export function AppearanceSettings() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ChatUiSettings />
|
<ChatUiSettings />
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
id="workspace-tabs"
|
||||||
|
title={t("workspaceTabs.title")}
|
||||||
|
description={t("workspaceTabs.description")}
|
||||||
|
>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("workspaceTabs.label")}
|
||||||
|
description={t("workspaceTabs.labelDesc")}
|
||||||
|
checked={allTabs}
|
||||||
|
onChange={(on) => {
|
||||||
|
writeAllTabs(on);
|
||||||
|
setAllTabs(on);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
id="language"
|
id="language"
|
||||||
title={t("language.title")}
|
title={t("language.title")}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
"changes.binary": "Binary file — not shown.",
|
"changes.binary": "Binary file — not shown.",
|
||||||
"changes.collapse": "Collapse the changes panel",
|
"changes.collapse": "Collapse the changes panel",
|
||||||
|
"changes.closeRepo": "Close repository",
|
||||||
|
"changes.closeOtherRepos": "Close other repositories",
|
||||||
"changes.collapseAll": "Collapse every file",
|
"changes.collapseAll": "Collapse every file",
|
||||||
"changes.collapseAllFolders": "Collapse all folders",
|
"changes.collapseAllFolders": "Collapse all folders",
|
||||||
"changes.collapseContext": "Show fewer surrounding lines",
|
"changes.collapseContext": "Show fewer surrounding lines",
|
||||||
@@ -295,7 +297,6 @@
|
|||||||
"menu.remove": "Delete workspace…",
|
"menu.remove": "Delete workspace…",
|
||||||
"panel.changes": "Changes",
|
"panel.changes": "Changes",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
"panel.folder": "Folder to show changes for",
|
|
||||||
"replay.placeholder": "Message to continue this session…",
|
"replay.placeholder": "Message to continue this session…",
|
||||||
"replay.hint": "Sending resumes the session in a free pane.",
|
"replay.hint": "Sending resumes the session in a free pane.",
|
||||||
"replay.send": "Send",
|
"replay.send": "Send",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"outputs": "Outputs",
|
"outputs": "Outputs",
|
||||||
"connectors": "Connectors",
|
"connectors": "Connectors",
|
||||||
"empty": "Nothing yet",
|
"empty": "Nothing yet",
|
||||||
|
"resize": "Resize side panel",
|
||||||
"hide": "Hide side panel",
|
"hide": "Hide side panel",
|
||||||
"show": "Activity panel",
|
"show": "Activity panel",
|
||||||
"copyPath": "Copy path: {{path}}",
|
"copyPath": "Copy path: {{path}}",
|
||||||
@@ -114,9 +115,18 @@
|
|||||||
"uploads": "Uploads",
|
"uploads": "Uploads",
|
||||||
"files": "Files read",
|
"files": "Files read",
|
||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
|
"skills": "Skills",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"noPreview": "No preview for this file here",
|
"noPreview": "No preview for this file here",
|
||||||
"truncated": "Showing the first part of this file"
|
"truncated": "Showing the first part of this file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"interrupted": {
|
||||||
|
"title": "Resume interrupted sessions?",
|
||||||
|
"message": "These sessions stopped unexpectedly (the machine shut down, tmux was killed, or the CLI crashed). Tick the ones to resume — each re-reads its whole conversation.",
|
||||||
|
"resume_one": "Resume {{count}} session",
|
||||||
|
"resume_other": "Resume {{count}} sessions",
|
||||||
|
"none": "Don't resume",
|
||||||
|
"failed": "Couldn't resume {{name}}: {{message}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -895,6 +895,12 @@
|
|||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"workspaceTabs": {
|
||||||
|
"title": "Workspace tabs",
|
||||||
|
"description": "Which sessions the session view's tab bar holds. Saved to your account.",
|
||||||
|
"label": "Tabs from every workspace",
|
||||||
|
"labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Terminal font",
|
"title": "Terminal font",
|
||||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
"changes.binary": "File nhị phân — không hiển thị.",
|
"changes.binary": "File nhị phân — không hiển thị.",
|
||||||
"changes.collapse": "Thu gọn bảng thay đổi",
|
"changes.collapse": "Thu gọn bảng thay đổi",
|
||||||
|
"changes.closeRepo": "Đóng repository",
|
||||||
|
"changes.closeOtherRepos": "Đóng các repository khác",
|
||||||
"changes.collapseAll": "Thu gọn tất cả file",
|
"changes.collapseAll": "Thu gọn tất cả file",
|
||||||
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
|
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
|
||||||
"changes.collapseContext": "Bớt dòng xung quanh",
|
"changes.collapseContext": "Bớt dòng xung quanh",
|
||||||
@@ -295,7 +297,6 @@
|
|||||||
"menu.remove": "Xóa workspace…",
|
"menu.remove": "Xóa workspace…",
|
||||||
"panel.changes": "Thay đổi",
|
"panel.changes": "Thay đổi",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
"panel.folder": "Thư mục để xem thay đổi",
|
|
||||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||||
"replay.send": "Gửi",
|
"replay.send": "Gửi",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"outputs": "Kết quả",
|
"outputs": "Kết quả",
|
||||||
"connectors": "Kết nối",
|
"connectors": "Kết nối",
|
||||||
"empty": "Chưa có gì",
|
"empty": "Chưa có gì",
|
||||||
|
"resize": "Kéo giãn thanh bên",
|
||||||
"hide": "Ẩn thanh bên",
|
"hide": "Ẩn thanh bên",
|
||||||
"show": "Bảng hoạt động",
|
"show": "Bảng hoạt động",
|
||||||
"copyPath": "Sao chép đường dẫn: {{path}}",
|
"copyPath": "Sao chép đường dẫn: {{path}}",
|
||||||
@@ -114,9 +115,18 @@
|
|||||||
"uploads": "Tệp tải lên",
|
"uploads": "Tệp tải lên",
|
||||||
"files": "Tệp đã đọc",
|
"files": "Tệp đã đọc",
|
||||||
"memory": "Bộ nhớ",
|
"memory": "Bộ nhớ",
|
||||||
|
"skills": "Skills",
|
||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"noPreview": "Không xem trước được tệp này ở đây",
|
"noPreview": "Không xem trước được tệp này ở đây",
|
||||||
"truncated": "Chỉ hiện phần đầu của tệp"
|
"truncated": "Chỉ hiện phần đầu của tệp"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"interrupted": {
|
||||||
|
"title": "Tiếp tục các phiên bị gián đoạn?",
|
||||||
|
"message": "Các phiên này dừng ngoài ý muốn (tắt máy ngang, tmux bị tắt, hoặc CLI bị lỗi). Đánh dấu những phiên muốn tiếp tục — mỗi phiên sẽ đọc lại toàn bộ hội thoại.",
|
||||||
|
"resume_one": "Tiếp tục {{count}} phiên",
|
||||||
|
"resume_other": "Tiếp tục {{count}} phiên",
|
||||||
|
"none": "Không tiếp tục",
|
||||||
|
"failed": "Không tiếp tục được {{name}}: {{message}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -895,6 +895,12 @@
|
|||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"workspaceTabs": {
|
||||||
|
"title": "Tab của workspace",
|
||||||
|
"description": "Thanh tab của màn hình session chứa những session nào. Lưu theo tài khoản.",
|
||||||
|
"label": "Tab của mọi workspace",
|
||||||
|
"labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Font terminal",
|
"title": "Font terminal",
|
||||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
||||||
|
|||||||
@@ -922,6 +922,10 @@ export const api = {
|
|||||||
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
|
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
|
||||||
* and the skills / MCP servers it used (a Chat pane's side panel). */
|
* and the skills / MCP servers it used (a Chat pane's side panel). */
|
||||||
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
|
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
|
||||||
|
/** GET /api/sessions/:id/image — an image the session's Read tool read,
|
||||||
|
* wherever it lies (local mode only), for an `<img>`. */
|
||||||
|
readImageUrl: (id: string, path: string) =>
|
||||||
|
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
|
||||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||||
* workspace, deletes nothing). */
|
* workspace, deletes nothing). */
|
||||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||||
@@ -2499,6 +2503,15 @@ export const api = {
|
|||||||
/** DELETE /api/run/:id - kill the tmux session. */
|
/** DELETE /api/run/:id - kill the tmux session. */
|
||||||
kill: (id: string) =>
|
kill: (id: string) =>
|
||||||
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||||
|
/** GET /api/run/interrupted - sessions whose pane died under them (reboot,
|
||||||
|
* killed tmux, crash), newest first; the caller's own workspaces only. */
|
||||||
|
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
|
||||||
|
/** POST /api/run/interrupted/dismiss - stop offering these sessions. */
|
||||||
|
dismissInterrupted: (sessionIds: string[]) =>
|
||||||
|
request<{ dismissed: number }>("/run/interrupted/dismiss", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sessionIds }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────── Alerts API ──────────────────────────────
|
// ────────────────────────────────── Alerts API ──────────────────────────────
|
||||||
@@ -4369,6 +4382,24 @@ export interface RunStartArgs {
|
|||||||
proxy?: string;
|
proxy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One session an unexpected stop left behind (GET /api/run/interrupted). */
|
||||||
|
export interface InterruptedRun {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
lane_id: number;
|
||||||
|
tool: LaneTool;
|
||||||
|
cwd: string;
|
||||||
|
model: string | null;
|
||||||
|
permission_mode: PermissionMode | null;
|
||||||
|
effort: EffortLevel | null;
|
||||||
|
prompt_preview: string | null;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
lane_title: string;
|
||||||
|
lane_cwd: string;
|
||||||
|
session_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||||
* name (`agenthub-lane-<laneId>`). */
|
* name (`agenthub-lane-<laneId>`). */
|
||||||
export interface RunHandle {
|
export interface RunHandle {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @file chatDraft.ts — a Chat pane's unsent prompt (`PaneChat`): its text and
|
||||||
|
* attachments, kept per SESSION in this browser's localStorage
|
||||||
|
* (`agenthub.chat.draft.<sessionId>`), so it survives the pane switching to
|
||||||
|
* its terminal (which unmounts the chat) and a page reload — as the terminal's
|
||||||
|
* own half-typed line survives in tmux. An image's preview (a blob URL) does
|
||||||
|
* not outlive the page, so a restored attachment keeps only its path and name.
|
||||||
|
* Cleared once the prompt is sent (an empty draft removes the key).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PromptImage } from "../components/run/RunSetup";
|
||||||
|
|
||||||
|
const key = (sessionId: string) => `agenthub.chat.draft.${sessionId}`;
|
||||||
|
|
||||||
|
export interface ChatDraft {
|
||||||
|
prompt: string;
|
||||||
|
images: PromptImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readChatDraft(sessionId: string): ChatDraft {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(
|
||||||
|
localStorage.getItem(key(sessionId)) || "null"
|
||||||
|
) as Partial<ChatDraft> | null;
|
||||||
|
return {
|
||||||
|
prompt: typeof v?.prompt === "string" ? v.prompt : "",
|
||||||
|
images: Array.isArray(v?.images)
|
||||||
|
? v.images
|
||||||
|
.filter((i) => typeof i?.path === "string" && typeof i.name === "string")
|
||||||
|
.map((i) => ({ path: i.path, name: i.name }))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { prompt: "", images: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeChatDraft(sessionId: string, prompt: string, images: PromptImage[]) {
|
||||||
|
try {
|
||||||
|
if (!prompt && !images.length) localStorage.removeItem(key(sessionId));
|
||||||
|
else
|
||||||
|
localStorage.setItem(
|
||||||
|
key(sessionId),
|
||||||
|
JSON.stringify({ prompt, images: images.map(({ path, name }) => ({ path, name })) })
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* storage full or blocked: the draft lives for this page only */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
* its session back in a tab of its own. The tabs are a viewer preference,
|
* its session back in a tab of its own. The tabs are a viewer preference,
|
||||||
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
|
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
|
||||||
* stored pre-tab single layout reads as one tab), never on the server.
|
* stored pre-tab single layout reads as one tab), never on the server.
|
||||||
|
*
|
||||||
|
* With Settings → Appearance → "Tabs from every workspace" on, ONE set of tabs
|
||||||
|
* (stored under `all`) holds every workspace's sessions: a leaf is then a
|
||||||
|
* `paneKey` (lane id × `MAX_SLOTS` + slot) instead of a bare slot.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,6 +28,12 @@
|
|||||||
export const MAX_PANES = 4;
|
export const MAX_PANES = 4;
|
||||||
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
|
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
|
||||||
export const MAX_SLOTS = 10;
|
export const MAX_SLOTS = 10;
|
||||||
|
/** A pane's id in the every-workspace tabs: unique across lanes, still a number. */
|
||||||
|
export const paneKey = (laneId: number, slot: number) => laneId * MAX_SLOTS + slot;
|
||||||
|
/** The slot of a leaf — itself, or a `paneKey`'s slot part. */
|
||||||
|
export const slotOfKey = (key: number) => key % MAX_SLOTS;
|
||||||
|
/** The tabs' storage name: a lane id, or `all` for the every-workspace tabs. */
|
||||||
|
export type GroupsScope = number | "all";
|
||||||
const MIN_RATIO = 0.1;
|
const MIN_RATIO = 0.1;
|
||||||
const STORAGE_KEY = "agenthub.workspace.paneLayout";
|
const STORAGE_KEY = "agenthub.workspace.paneLayout";
|
||||||
|
|
||||||
@@ -223,17 +233,17 @@ export function layoutRects(node: PaneNode | null): {
|
|||||||
return { panes, dividers };
|
return { panes, dividers };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNode(raw: unknown): PaneNode | null {
|
function parseNode(raw: unknown, max: number): PaneNode | null {
|
||||||
if (!raw || typeof raw !== "object") return null;
|
if (!raw || typeof raw !== "object") return null;
|
||||||
const n = raw as Record<string, unknown>;
|
const n = raw as Record<string, unknown>;
|
||||||
if (n.type === "pane") {
|
if (n.type === "pane") {
|
||||||
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_SLOTS
|
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < max
|
||||||
? pane(n.slot as number)
|
? pane(n.slot as number)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
|
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
|
||||||
const a = parseNode(n.a);
|
const a = parseNode(n.a, max);
|
||||||
const b = parseNode(n.b);
|
const b = parseNode(n.b, max);
|
||||||
if (!a || !b) return null;
|
if (!a || !b) return null;
|
||||||
const ratio = typeof n.ratio === "number" && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5;
|
const ratio = typeof n.ratio === "number" && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5;
|
||||||
return split(n.dir, a, b, ratio);
|
return split(n.dir, a, b, ratio);
|
||||||
@@ -241,9 +251,9 @@ function parseNode(raw: unknown): PaneNode | null {
|
|||||||
|
|
||||||
/** A stored layout, checked: anything malformed, a slot out of range, a slot
|
/** A stored layout, checked: anything malformed, a slot out of range, a slot
|
||||||
* shown twice or more than `MAX_PANES` panes is no layout at all. */
|
* shown twice or more than `MAX_PANES` panes is no layout at all. */
|
||||||
export function parseLayout(raw: unknown): PaneLayout {
|
export function parseLayout(raw: unknown, max = MAX_SLOTS): PaneLayout {
|
||||||
if (!raw || typeof raw !== "object") return EMPTY;
|
if (!raw || typeof raw !== "object") return EMPTY;
|
||||||
const root = parseNode((raw as { root?: unknown }).root);
|
const root = parseNode((raw as { root?: unknown }).root, max);
|
||||||
const shown = leaves(root);
|
const shown = leaves(root);
|
||||||
if (!root || shown.length > MAX_PANES || new Set(shown).size !== shown.length) return EMPTY;
|
if (!root || shown.length > MAX_PANES || new Set(shown).size !== shown.length) return EMPTY;
|
||||||
const focus = (raw as { focus?: unknown }).focus;
|
const focus = (raw as { focus?: unknown }).focus;
|
||||||
@@ -389,7 +399,7 @@ export function pruneGroups(g: PaneGroups, live: Set<number>, keep: Set<number>)
|
|||||||
|
|
||||||
/** Stored tabs, checked: a malformed tab, or one showing a session an
|
/** Stored tabs, checked: a malformed tab, or one showing a session an
|
||||||
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
|
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
|
||||||
export function parseGroups(raw: unknown): PaneGroups {
|
export function parseGroups(raw: unknown, max = MAX_SLOTS): PaneGroups {
|
||||||
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
|
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
|
||||||
const r = raw as { groups?: unknown; active?: unknown };
|
const r = raw as { groups?: unknown; active?: unknown };
|
||||||
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
|
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
|
||||||
@@ -400,7 +410,7 @@ export function parseGroups(raw: unknown): PaneGroups {
|
|||||||
// a dropped tab ahead of it does not shift the selection onto its neighbour.
|
// a dropped tab ahead of it does not shift the selection onto its neighbour.
|
||||||
let active = 0;
|
let active = 0;
|
||||||
list.forEach((item, i) => {
|
list.forEach((item, i) => {
|
||||||
const l = parseLayout(item);
|
const l = parseLayout(item, max);
|
||||||
const shown = leaves(l.root);
|
const shown = leaves(l.root);
|
||||||
if (!l.root || shown.some((s) => seen.has(s))) return;
|
if (!l.root || shown.some((s) => seen.has(s))) return;
|
||||||
shown.forEach((s) => seen.add(s));
|
shown.forEach((s) => seen.add(s));
|
||||||
@@ -420,11 +430,14 @@ function readAll(): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadGroups(laneId: number): PaneGroups {
|
export function loadGroups(laneId: GroupsScope): PaneGroups {
|
||||||
return parseGroups(readAll()[String(laneId)]);
|
return parseGroups(
|
||||||
|
readAll()[String(laneId)],
|
||||||
|
laneId === "all" ? Number.MAX_SAFE_INTEGER : MAX_SLOTS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveGroups(laneId: number, g: PaneGroups): void {
|
export function saveGroups(laneId: GroupsScope, g: PaneGroups): void {
|
||||||
const all = readAll();
|
const all = readAll();
|
||||||
if (g.groups.length) all[String(laneId)] = g;
|
if (g.groups.length) all[String(laneId)] = g;
|
||||||
else delete all[String(laneId)];
|
else delete all[String(laneId)];
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* @file promptAttach.ts
|
* @file promptAttach.ts
|
||||||
* @description What a prompt box can attach besides a fresh upload: a project
|
* @description What a prompt box can attach besides a fresh upload: a project
|
||||||
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
||||||
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
|
* `PromptEditor`'s drop) or from a Chat pane's activity panel
|
||||||
|
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
|
||||||
* last day (`recentUploads`, kept in this browser). An attachment is a path
|
* last day (`recentUploads`, kept in this browser). An attachment is a path
|
||||||
* sent with the prompt, so nothing here touches the server.
|
* sent with the prompt, so nothing here touches the server.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -26,21 +27,59 @@ export function projectAttachment(rel: string): Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
|
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
|
||||||
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
|
* text too, so a drop elsewhere (a terminal, an editor) gets the path. A row
|
||||||
|
* under a `data-drag-root` (an extra folder's section) lies outside the
|
||||||
|
* pane's folder, so it carries its absolute path instead. */
|
||||||
export function dragProjectPath(e: DragEvent, rel: string) {
|
export function dragProjectPath(e: DragEvent, rel: string) {
|
||||||
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
|
const root = (e.currentTarget as HTMLElement | null)?.closest?.<HTMLElement>("[data-drag-root]")
|
||||||
e.dataTransfer.setData("text/plain", rel);
|
?.dataset.dragRoot;
|
||||||
|
const path = root ? `${root.replace(/[\\/]+$/, "")}/${rel}` : rel;
|
||||||
|
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(path)));
|
||||||
|
e.dataTransfer.setData("text/plain", path);
|
||||||
e.dataTransfer.effectAllowed = "copy";
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The attachment a drop carries, if it is one of ours. */
|
/** Start a drag carrying attachments as they are (an activity panel's files,
|
||||||
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
|
* already absolute); plain text too, like `dragProjectPath`. */
|
||||||
|
export function dragAttachments(e: DragEvent, items: Attachment[]) {
|
||||||
|
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(items));
|
||||||
|
e.dataTransfer.setData("text/plain", items.map((a) => a.path.replace(/^@/, "")).join(" "));
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The attachments a drop carries, if they are ours: one, or a list. */
|
||||||
|
export function droppedAttachments(dt: DataTransfer | null | undefined): Attachment[] {
|
||||||
try {
|
try {
|
||||||
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
|
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
|
||||||
const a = v as Attachment | null;
|
return (Array.isArray(v) ? v : [v])
|
||||||
return a && typeof a.path === "string" && typeof a.name === "string"
|
.filter(
|
||||||
? { path: a.path, name: a.name }
|
(a): a is Attachment =>
|
||||||
: null;
|
!!a &&
|
||||||
|
typeof (a as Attachment).path === "string" &&
|
||||||
|
typeof (a as Attachment).name === "string"
|
||||||
|
)
|
||||||
|
.map((a) => ({ path: a.path, name: a.name }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||||
|
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||||
|
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||||
|
|
||||||
|
/** Start a drag carrying a skill; dropped on a prompt box it becomes `/name`. */
|
||||||
|
export function dragSkill(e: DragEvent, name: string) {
|
||||||
|
e.dataTransfer.setData(SKILL_MIME, name);
|
||||||
|
e.dataTransfer.setData("text/plain", `/${name}`);
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The skill a drop carries, if it is one of ours. */
|
||||||
|
export function droppedSkill(dt: DataTransfer | null | undefined): string | null {
|
||||||
|
try {
|
||||||
|
const name = dt?.getData(SKILL_MIME) ?? "";
|
||||||
|
return SKILL_RE.test(name) ? name : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export const SETTINGS_INDEX: {
|
|||||||
to: "#language",
|
to: "#language",
|
||||||
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
section: "appearance",
|
||||||
|
titleKey: "workspaceTabs.title",
|
||||||
|
descKey: "workspaceTabs.description",
|
||||||
|
keywords: ["tab", "workspace", "session", "combo", "split", "không gian làm việc"],
|
||||||
|
to: "#workspace-tabs",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
section: "appearance",
|
section: "appearance",
|
||||||
titleKey: "terminalFont.title",
|
titleKey: "terminalFont.title",
|
||||||
|
|||||||
@@ -1344,6 +1344,7 @@ export interface UpdateStatusPayload {
|
|||||||
situation?:
|
situation?:
|
||||||
| "plugin_managed"
|
| "plugin_managed"
|
||||||
| "release_available"
|
| "release_available"
|
||||||
|
| "diverged_from_release"
|
||||||
| "tracking_canonical"
|
| "tracking_canonical"
|
||||||
| "fork_or_diverged_tracking"
|
| "fork_or_diverged_tracking"
|
||||||
| "feature_branch"
|
| "feature_branch"
|
||||||
@@ -1919,7 +1920,9 @@ export interface WSMessage {
|
|||||||
/** A lane's stack finished coming up (or failed to), with fresh facts. */
|
/** A lane's stack finished coming up (or failed to), with fresh facts. */
|
||||||
| "lane_runtime"
|
| "lane_runtime"
|
||||||
/** A lane's profile hook exited, with its status code. */
|
/** A lane's profile hook exited, with its status code. */
|
||||||
| "lane_hook_result";
|
| "lane_hook_result"
|
||||||
|
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||||
|
| "runs_interrupted";
|
||||||
/** The message body, whose concrete shape is selected by `type` above. */
|
/** The message body, whose concrete shape is selected by `type` above. */
|
||||||
data:
|
data:
|
||||||
| Session
|
| Session
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const PREF_KEYS = [
|
|||||||
"agenthub.defaultTool",
|
"agenthub.defaultTool",
|
||||||
"agenthub.permissionMode",
|
"agenthub.permissionMode",
|
||||||
"agenthub.tour",
|
"agenthub.tour",
|
||||||
|
"agenthub.workspace.allTabs",
|
||||||
] as const;
|
] as const;
|
||||||
const SYNCED = new Set<string>(PREF_KEYS);
|
const SYNCED = new Set<string>(PREF_KEYS);
|
||||||
const PUSH_DELAY_MS = 400;
|
const PUSH_DELAY_MS = 400;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @file workspaceTabs — Settings → Appearance → "Tabs from every workspace": a
|
||||||
|
* viewer preference in `localStorage` (`agenthub.workspace.allTabs`, synced to
|
||||||
|
* the account through `lib/userPrefs.ts`). On, the session view's tab bar
|
||||||
|
* holds every workspace's live sessions and a combo may mix them; off (the
|
||||||
|
* default), it shows only the selected workspace's. Read when the Workspace
|
||||||
|
* page mounts.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ALL_TABS_KEY = "agenthub.workspace.allTabs";
|
||||||
|
|
||||||
|
export function readAllTabs(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(ALL_TABS_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAllTabs(on: boolean) {
|
||||||
|
try {
|
||||||
|
if (on) window.localStorage.setItem(ALL_TABS_KEY, "1");
|
||||||
|
else window.localStorage.removeItem(ALL_TABS_KEY);
|
||||||
|
} catch {
|
||||||
|
/* storage blocked — the default */
|
||||||
|
}
|
||||||
|
}
|
||||||
+337
-132
@@ -19,7 +19,8 @@
|
|||||||
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
|
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
|
||||||
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
|
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
|
||||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||||
* the old one until saved or discarded). Errors are toasts.
|
* the old one until saved or discarded). Its bottom fold, Workspace context,
|
||||||
|
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -55,7 +56,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
|
import {
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelRightClose,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
ChartColumn,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type {
|
import type {
|
||||||
CwdSuggestion,
|
CwdSuggestion,
|
||||||
@@ -66,6 +75,7 @@ import type {
|
|||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
|
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import { useWorkLanes } from "../hooks/useLanes";
|
import { useWorkLanes } from "../hooks/useLanes";
|
||||||
@@ -86,28 +96,35 @@ import {
|
|||||||
} from "../components/run/PaneGrid";
|
} from "../components/run/PaneGrid";
|
||||||
import {
|
import {
|
||||||
type Edge,
|
type Edge,
|
||||||
|
type GroupsScope,
|
||||||
type PaneGroups,
|
type PaneGroups,
|
||||||
type PaneLayout,
|
type PaneLayout,
|
||||||
MAX_PANES,
|
MAX_PANES,
|
||||||
|
MAX_SLOTS,
|
||||||
activeLayout,
|
activeLayout,
|
||||||
closeInLayout,
|
closeInLayout,
|
||||||
groupOf,
|
groupOf,
|
||||||
|
layoutRects,
|
||||||
leaves,
|
leaves,
|
||||||
moveInLayout,
|
moveInLayout,
|
||||||
openInGroups,
|
openInGroups,
|
||||||
pane,
|
pane,
|
||||||
|
paneKey,
|
||||||
pruneGroups,
|
pruneGroups,
|
||||||
removeSlot,
|
removeSlot,
|
||||||
|
slotOfKey,
|
||||||
updateActive,
|
updateActive,
|
||||||
} from "../lib/paneLayout";
|
} from "../lib/paneLayout";
|
||||||
|
import { readAllTabs } from "../lib/workspaceTabs";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
|
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
|
||||||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||||||
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
import PipelineMap from "../components/lanes/PipelineMap";
|
||||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||||
|
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||||
@@ -121,6 +138,8 @@ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
|||||||
/** localStorage key for the last width the user dragged the panel to — what
|
/** localStorage key for the last width the user dragged the panel to — what
|
||||||
* it reopens at after the step control closes it. */
|
* it reopens at after the step control closes it. */
|
||||||
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
|
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
|
||||||
|
/** localStorage key for whether the Changes panel's Workspace context fold is open. */
|
||||||
|
const CONTEXT_OPEN_KEY = "agenthub.workspace.contextOpen";
|
||||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||||
|
|
||||||
@@ -169,7 +188,11 @@ function slotOf(runId: string | null | undefined): number {
|
|||||||
* into the tab on screen, at the 2×2 grid's next place. Every live run
|
* into the tab on screen, at the 2×2 grid's next place. Every live run
|
||||||
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
||||||
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
||||||
* run ends closes; the last tab gone leaves for the home. */
|
* run ends closes; the last tab gone leaves for the home. With `allTabs`
|
||||||
|
* (Settings → Appearance) the tabs hold EVERY workspace's live runs, stored
|
||||||
|
* once (`all`): a leaf is a `paneKey` (lane × 10 + slot), each pane is bound
|
||||||
|
* to its own run's lane, and focusing one selects its workspace (the URL's
|
||||||
|
* `?lane=` follows `?run=`). */
|
||||||
function ConsoleArea({
|
function ConsoleArea({
|
||||||
lanes,
|
lanes,
|
||||||
selectedLaneId,
|
selectedLaneId,
|
||||||
@@ -180,6 +203,7 @@ function ConsoleArea({
|
|||||||
onNewSession,
|
onNewSession,
|
||||||
onOpenRun,
|
onOpenRun,
|
||||||
onRunsChanged,
|
onRunsChanged,
|
||||||
|
allTabs = false,
|
||||||
}: {
|
}: {
|
||||||
lanes: Lane[];
|
lanes: Lane[];
|
||||||
selectedLaneId: number | null;
|
selectedLaneId: number | null;
|
||||||
@@ -200,18 +224,33 @@ function ConsoleArea({
|
|||||||
) => void;
|
) => void;
|
||||||
/** A tab was opened or closed: re-read the run list now. */
|
/** A tab was opened or closed: re-read the run list now. */
|
||||||
onRunsChanged: () => void;
|
onRunsChanged: () => void;
|
||||||
|
/** Tabs from every workspace, not only the selected one's. */
|
||||||
|
allTabs?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
||||||
|
// Every workspace's runs, each `slot` rewritten to its `paneKey`, so the tab
|
||||||
|
// bar and the grid — which key everything by slot — need no other change.
|
||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(activeRuns?.items ?? [])
|
(activeRuns?.items ?? [])
|
||||||
.filter((r) => r.laneId === selectedLaneId && r.status === "running")
|
.filter((r) =>
|
||||||
|
allTabs
|
||||||
|
? r.laneId !== null && r.status === "running"
|
||||||
|
: r.laneId === selectedLaneId && r.status === "running"
|
||||||
|
)
|
||||||
|
.map((r) => (allTabs ? { ...r, slot: paneKey(r.laneId as number, r.slot ?? 0) } : r))
|
||||||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
||||||
[activeRuns, selectedLaneId]
|
[activeRuns, selectedLaneId, allTabs]
|
||||||
);
|
);
|
||||||
const [groups, setGroups] = usePaneGroups(selectedLaneId);
|
// A run id's leaf: its slot, or with `allTabs` its `paneKey`.
|
||||||
|
const keyOf = (runId: string) => {
|
||||||
|
const l = laneOfRun(runId);
|
||||||
|
return allTabs && l !== null ? paneKey(l, slotOf(runId)) : slotOf(runId);
|
||||||
|
};
|
||||||
|
const scope: GroupsScope | null = allTabs ? "all" : selectedLaneId;
|
||||||
|
const [groups, setGroups] = usePaneGroups(scope);
|
||||||
// The latest tabs, updated the moment a change is made: two changes in one
|
// The latest tabs, updated the moment a change is made: two changes in one
|
||||||
// commit (an open request and a prune) must build on each other.
|
// commit (an open request and a prune) must build on each other.
|
||||||
const groupsRef = useRef(groups);
|
const groupsRef = useRef(groups);
|
||||||
@@ -225,7 +264,7 @@ function ConsoleArea({
|
|||||||
// remembered layout loses its dead panes on arrival; after it a shown pane
|
// remembered layout loses its dead panes on arrival; after it a shown pane
|
||||||
// closes on its terminal's exit, not on the polled list — one bad read (the
|
// closes on its terminal's exit, not on the polled list — one bad read (the
|
||||||
// server answers an empty list on any tmux error) must not wipe a split.
|
// server answers an empty list on any tmux error) must not wipe a split.
|
||||||
const listed = useRef<number | null>(null);
|
const listed = useRef<GroupsScope | null>(null);
|
||||||
// Runs seen ending here (`id@startedAt`): a list not yet refreshed must not
|
// Runs seen ending here (`id@startedAt`): a list not yet refreshed must not
|
||||||
// send the last pane to one of them.
|
// send the last pane to one of them.
|
||||||
const exited = useRef(new Set<string>());
|
const exited = useRef(new Set<string>());
|
||||||
@@ -236,20 +275,34 @@ function ConsoleArea({
|
|||||||
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
|
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
|
||||||
const [sessionsVersion, setSessionsVersion] = useState(0);
|
const [sessionsVersion, setSessionsVersion] = useState(0);
|
||||||
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
|
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
|
||||||
|
// The workspaces the tabs belong to: the selected one, or with `allTabs`
|
||||||
|
// every one holding a tab.
|
||||||
|
const tabLaneIds = allTabs
|
||||||
|
? [...new Set(tabs.map((r) => r.laneId as number))].join(",")
|
||||||
|
: String(selectedLaneId ?? "");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedLaneId === null || !tabSessionIds.replace(/,/g, "")) return;
|
if (!tabLaneIds || !tabSessionIds.replace(/,/g, "")) return;
|
||||||
let live = true;
|
let live = true;
|
||||||
api.sessions
|
Promise.all(
|
||||||
.list({ lane: selectedLaneId, limit: 50 })
|
tabLaneIds.split(",").map((id) => api.sessions.list({ lane: Number(id), limit: 50 }))
|
||||||
.then((r) => live && setTabSessions(new Map(r.sessions.map((x) => [x.id, x]))))
|
)
|
||||||
|
.then(
|
||||||
|
(rs) => live && setTabSessions(new Map(rs.flatMap((r) => r.sessions.map((x) => [x.id, x]))))
|
||||||
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
|
}, [tabLaneIds, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
|
||||||
|
const laneName = (r: RunHandle) => {
|
||||||
|
const l = lanes.find((x) => x.id === r.laneId);
|
||||||
|
return l ? l.title || l.cwd.split("/").filter(Boolean).pop() || l.cwd : "";
|
||||||
|
};
|
||||||
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
|
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
|
||||||
const label = (r: RunHandle) =>
|
const label = (r: RunHandle) => {
|
||||||
runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
|
const name = runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
|
||||||
|
return allTabs ? `${laneName(r)} · ${name}` : name;
|
||||||
|
};
|
||||||
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
|
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
|
||||||
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
||||||
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
||||||
@@ -272,18 +325,19 @@ function ConsoleArea({
|
|||||||
followFocus(next);
|
followFocus(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Another workspace: nothing pending or exited carries over.
|
// Another workspace's tabs: nothing pending or exited carries over.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pending.current.clear();
|
pending.current.clear();
|
||||||
exited.current.clear();
|
exited.current.clear();
|
||||||
}, [selectedLaneId]);
|
}, [scope]);
|
||||||
|
|
||||||
// An open request (a new nonce): focus, replace or split. Re-run when the
|
// An open request (a new nonce): focus, replace or split. Re-run when the
|
||||||
// lane arrives on a cold load; only this lane's own tabs.
|
// lane arrives on a cold load; only this lane's own tabs.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!attachRequest || selectedLaneId === null) return;
|
if (!attachRequest || selectedLaneId === null) return;
|
||||||
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return;
|
const own = allTabs ? "\\d+" : String(selectedLaneId);
|
||||||
const slot = slotOf(attachRequest.runId);
|
if (!new RegExp(`^agenthub-lane-${own}(-[1-9])?$`).test(attachRequest.runId)) return;
|
||||||
|
const slot = keyOf(attachRequest.runId);
|
||||||
// The URL catching up with a focus change: by now that pane may be closed
|
// The URL catching up with a focus change: by now that pane may be closed
|
||||||
// or swapped, and re-opening it would undo what the user just did. Back/
|
// or swapped, and re-opening it would undo what the user just did. Back/
|
||||||
// Forward onto such an entry puts the real focus back in the URL instead.
|
// Forward onto such an entry puts the real focus back in the URL instead.
|
||||||
@@ -316,8 +370,8 @@ function ConsoleArea({
|
|||||||
else if (list !== activeRuns) expired.add(s);
|
else if (list !== activeRuns) expired.add(s);
|
||||||
if (live.has(s) || list !== activeRuns) pending.current.delete(s);
|
if (live.has(s) || list !== activeRuns) pending.current.delete(s);
|
||||||
}
|
}
|
||||||
const first = listed.current !== selectedLaneId;
|
const first = listed.current !== scope;
|
||||||
listed.current = selectedLaneId;
|
listed.current = scope;
|
||||||
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
|
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
|
||||||
const keep = first
|
const keep = first
|
||||||
? new Set(pending.current.keys())
|
? new Set(pending.current.keys())
|
||||||
@@ -374,14 +428,16 @@ function ConsoleArea({
|
|||||||
const free = lane ? freeSlot(lane.id, tabs) : null;
|
const free = lane ? freeSlot(lane.id, tabs) : null;
|
||||||
const splitTools = lane ? newTabTools(lane, aiTools) : [];
|
const splitTools = lane ? newTabTools(lane, aiTools) : [];
|
||||||
const newSplit = async (target: number, edge: Edge, tool?: string) => {
|
const newSplit = async (target: number, edge: Edge, tool?: string) => {
|
||||||
if (!lane || free === null) return;
|
// With `allTabs`, the split starts in the target pane's own workspace.
|
||||||
const laneId = lane.id;
|
const into = allTabs ? lanes.find((l) => l.id === Math.floor(target / MAX_SLOTS)) : lane;
|
||||||
|
if (!into || freeSlot(into.id, tabs) === null) return;
|
||||||
|
const laneId = into.id;
|
||||||
try {
|
try {
|
||||||
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
|
const body = tool ? newTabBody(tool, into, aiTools) : { shell: true };
|
||||||
// The server picks the slot: the list `free` came from may be stale.
|
// The server picks the slot: the list `free` came from may be stale.
|
||||||
const slot = slotOf(await startInFreeTab(laneId, body));
|
const slot = keyOf(await startInFreeTab(laneId, body));
|
||||||
onRunsChanged();
|
onRunsChanged();
|
||||||
if (laneRef.current !== laneId) return;
|
if (!allTabs && laneRef.current !== laneId) return;
|
||||||
pending.current.set(slot, activeRuns);
|
pending.current.set(slot, activeRuns);
|
||||||
change((l) => moveInLayout(l, slot, target, edge));
|
change((l) => moveInLayout(l, slot, target, edge));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -389,10 +445,10 @@ function ConsoleArea({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const requested = attachRequest ? [slotOf(attachRequest.runId)] : [];
|
const requested = attachRequest ? [keyOf(attachRequest.runId)] : [];
|
||||||
// No layout yet (a first visit, a request not yet applied, or no run at
|
// No layout yet (a first visit, a request not yet applied, or no run at
|
||||||
// all): one pane, so the console always has its single render site.
|
// all): one pane, so the console always has its single render site.
|
||||||
const fallback = requested[0] ?? 0;
|
const fallback = requested[0] ?? (allTabs ? paneKey(selectedLaneId ?? 0, 0) : 0);
|
||||||
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
|
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
|
||||||
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
|
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
|
||||||
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
|
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
|
||||||
@@ -400,6 +456,13 @@ function ConsoleArea({
|
|||||||
const change: LayoutUpdate = (fn) =>
|
const change: LayoutUpdate = (fn) =>
|
||||||
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
|
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
|
||||||
const shown = leaves(view.root);
|
const shown = leaves(view.root);
|
||||||
|
// A pane spanning the grid's full width or height has room for its chat's
|
||||||
|
// right panel to start open; a quarter-sized one starts it collapsed.
|
||||||
|
const spanning = new Set(
|
||||||
|
layoutRects(view.root)
|
||||||
|
.panes.filter((r) => r.w === 1 || r.h === 1)
|
||||||
|
.map((r) => r.slot)
|
||||||
|
);
|
||||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
||||||
const focusSlot = view.focus;
|
const focusSlot = view.focus;
|
||||||
|
|
||||||
@@ -453,8 +516,9 @@ function ConsoleArea({
|
|||||||
onOpenRun(runId, { split, replace: true });
|
onOpenRun(runId, { split, replace: true });
|
||||||
}}
|
}}
|
||||||
sessions={tabSessions}
|
sessions={tabSessions}
|
||||||
|
workspaceOf={allTabs ? laneName : undefined}
|
||||||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||||||
onClosed={(runId) => ended(slotOf(runId))}
|
onClosed={(runId) => ended(keyOf(runId))}
|
||||||
onHome={onNewSession}
|
onHome={onNewSession}
|
||||||
// While one pane is shown, its split menus (a split pane has its own).
|
// While one pane is shown, its split menus (a split pane has its own).
|
||||||
actions={
|
actions={
|
||||||
@@ -491,8 +555,9 @@ function ConsoleArea({
|
|||||||
renderPane={(slot) => (
|
renderPane={(slot) => (
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
laneId={selectedLaneId}
|
// With `allTabs` a pane is bound to its own run's workspace.
|
||||||
slot={slot}
|
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||||||
|
slot={slotOfKey(slot)}
|
||||||
binaryStatus={binaryStatus}
|
binaryStatus={binaryStatus}
|
||||||
activeRuns={activeRuns}
|
activeRuns={activeRuns}
|
||||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||||
@@ -500,9 +565,9 @@ function ConsoleArea({
|
|||||||
onRunExit={() => ended(slot)}
|
onRunExit={() => ended(slot)}
|
||||||
// Only the focused pane takes the keyboard when it appears.
|
// Only the focused pane takes the keyboard when it appears.
|
||||||
autoFocus={slot === focusSlot}
|
autoFocus={slot === focusSlot}
|
||||||
solo={shown.length === 1}
|
panelOpen={spanning.has(slot)}
|
||||||
attachRequest={
|
attachRequest={
|
||||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -612,6 +677,9 @@ export function Workspace() {
|
|||||||
> | null>(null);
|
> | null>(null);
|
||||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||||
|
// Settings → Appearance: tabs from every workspace. Read on mount — the
|
||||||
|
// switch lives on another page.
|
||||||
|
const [allTabs] = useState(readAllTabs);
|
||||||
|
|
||||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||||
// localStorage and never reaches the server — same rule as the lane strip's
|
// localStorage and never reaches the server — same rule as the lane strip's
|
||||||
@@ -630,6 +698,22 @@ export function Workspace() {
|
|||||||
/* private mode / disabled storage: the panel just forgets between visits */
|
/* private mode / disabled storage: the panel just forgets between visits */
|
||||||
}
|
}
|
||||||
}, [changesOpen]);
|
}, [changesOpen]);
|
||||||
|
// The Changes panel's bottom fold (instructions, files, folders): folded by
|
||||||
|
// default so the git sections keep the height.
|
||||||
|
const [contextOpen, setContextOpen] = useState(() => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(CONTEXT_OPEN_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CONTEXT_OPEN_KEY, contextOpen ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
/* disabled storage: the fold just forgets between visits */
|
||||||
|
}
|
||||||
|
}, [contextOpen]);
|
||||||
// A preset name, or the fraction a splitter drag left (stored as a number).
|
// A preset name, or the fraction a splitter drag left (stored as a number).
|
||||||
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
|
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -709,9 +793,27 @@ export function Workspace() {
|
|||||||
? sessionParam
|
? sessionParam
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// The workspace's extra folders: each is its own repo the Changes panel can
|
// An unsaved whole-file edit in a Changes panel section, by section key. While
|
||||||
// show instead (`?source=`), picked in its header. Read per workspace, and
|
// one is open the panel stays on the tree it was opened in — moving it
|
||||||
// again from the home's Folder list whenever it adds or removes one.
|
// (another workspace, session or worktree) would remount it and drop the
|
||||||
|
// edit — until the user says to discard it, or saves.
|
||||||
|
const [dirtyBy, setDirtyBy] = useState<Record<string, boolean>>({});
|
||||||
|
const changesDirty = Object.values(dirtyBy).some(Boolean);
|
||||||
|
const dirtyFor = useCallback(
|
||||||
|
(key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const wantPanel =
|
||||||
|
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||||||
|
const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
|
||||||
|
const wantPanelKey = keyOf(wantPanel);
|
||||||
|
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||||
|
const panelKey = keyOf(panelAt);
|
||||||
|
|
||||||
|
// The workspace's extra folders: each is its own repo, stacked under the
|
||||||
|
// workspace folder's in the Changes panel (`?source=`). Read for the
|
||||||
|
// workspace the panel shows, and again from the home's Folder list whenever
|
||||||
|
// it adds or removes one.
|
||||||
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -723,35 +825,42 @@ export function Workspace() {
|
|||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
const panelLaneId = panelAt?.laneId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedLaneId === null) return;
|
if (panelLaneId === null) return;
|
||||||
api.lanes.sources(selectedLaneId).then(
|
api.lanes.sources(panelLaneId).then(
|
||||||
(v) => takeGitFolders(selectedLaneId, v),
|
(v) => takeGitFolders(panelLaneId, v),
|
||||||
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
() => setGitFolders({ laneId: panelLaneId, items: [] })
|
||||||
);
|
);
|
||||||
}, [selectedLaneId, takeGitFolders]);
|
}, [panelLaneId, takeGitFolders]);
|
||||||
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||||
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||||
// A pick holds for its own workspace, while that folder is still listed.
|
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||||
const gitSource =
|
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||||
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||||
? gitSourcePick.id
|
const sourceCtx = useContextMenu<string>();
|
||||||
: undefined;
|
// Bumped after a close here or an edit in either context list (the home's,
|
||||||
|
// the Changes panel's fold), so every one on screen re-reads.
|
||||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
const [sourcesVersion, setSourcesVersion] = useState(0);
|
||||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
const sourcesChanged = (laneId: number, view: LaneSourcesView) => {
|
||||||
// session, worktree or folder) would remount it and drop the edit — until
|
takeGitFolders(laneId, view);
|
||||||
// the user says to discard it, or saves.
|
setSourcesVersion((n) => n + 1);
|
||||||
const [changesDirty, setChangesDirty] = useState(false);
|
};
|
||||||
const wantPanel =
|
const closeSources = async (laneId: number, keys: string[]) => {
|
||||||
selectedLaneId === null
|
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
|
||||||
? null
|
try {
|
||||||
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
let view: LaneSourcesView | null = null;
|
||||||
const keyOf = (p: typeof wantPanel) =>
|
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
|
||||||
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
if (view) takeGitFolders(laneId, view);
|
||||||
const wantPanelKey = keyOf(wantPanel);
|
} catch (err) {
|
||||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
const panelKey = keyOf(panelAt);
|
api.lanes.sources(laneId).then(
|
||||||
|
(v) => takeGitFolders(laneId, v),
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSourcesVersion((n) => n + 1);
|
||||||
|
};
|
||||||
const asking = useRef(false);
|
const asking = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wantPanelKey === panelKey) return;
|
if (wantPanelKey === panelKey) return;
|
||||||
@@ -764,7 +873,7 @@ export function Workspace() {
|
|||||||
void confirmDiscard().then((ok) => {
|
void confirmDiscard().then((ok) => {
|
||||||
asking.current = false;
|
asking.current = false;
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setChangesDirty(false);
|
setDirtyBy({});
|
||||||
setPanelAt(wantPanel);
|
setPanelAt(wantPanel);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||||
@@ -780,8 +889,12 @@ export function Workspace() {
|
|||||||
const onOpen = (e: Event) => {
|
const onOpen = (e: Event) => {
|
||||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||||
if (laneId !== panelLaneRef.current) return;
|
if (laneId !== panelLaneRef.current) return;
|
||||||
// Its path is the workspace folder's: back from an extra folder first.
|
// Its path is the workspace folder's: unfold that section.
|
||||||
setGitSourcePick(null);
|
setFoldedSources((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete("");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setChangesOpen(true);
|
setChangesOpen(true);
|
||||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||||
};
|
};
|
||||||
@@ -792,10 +905,7 @@ export function Workspace() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!changesOpen) setChangesRequest(null);
|
if (!changesOpen) setChangesRequest(null);
|
||||||
}, [changesOpen]);
|
}, [changesOpen]);
|
||||||
// Not on a folder pick: the request above makes one, to land in the panel
|
useEffect(() => setChangesRequest(null), [panelKey]);
|
||||||
// it remounts; the picker drops the request itself.
|
|
||||||
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
|
||||||
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
|
||||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||||
const afterDrop = (fn: () => void) => {
|
const afterDrop = (fn: () => void) => {
|
||||||
if (!changesDirty) return fn();
|
if (!changesDirty) return fn();
|
||||||
@@ -1035,7 +1145,8 @@ export function Workspace() {
|
|||||||
<WorkspaceHome
|
<WorkspaceHome
|
||||||
lane={currentLane}
|
lane={currentLane}
|
||||||
onRenamed={() => void refreshLanes()}
|
onRenamed={() => void refreshLanes()}
|
||||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||||
|
sourcesVersion={sourcesVersion}
|
||||||
onMenuAction={(action) => {
|
onMenuAction={(action) => {
|
||||||
if (action === "new-here") {
|
if (action === "new-here") {
|
||||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||||
@@ -1211,10 +1322,12 @@ export function Workspace() {
|
|||||||
? () => setSearchParams({ lane: String(currentLane.id) })
|
? () => setSearchParams({ lane: String(currentLane.id) })
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
allTabs={allTabs}
|
||||||
onOpenRun={(runId, opts) =>
|
onOpenRun={(runId, opts) =>
|
||||||
currentLane &&
|
currentLane &&
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
{ lane: String(currentLane.id), run: runId },
|
// A run names its own workspace (another one's with `allTabs`).
|
||||||
|
{ lane: String(laneOfRun(runId) ?? currentLane.id), run: runId },
|
||||||
{
|
{
|
||||||
// Following the focus, or a tab click, is not a new
|
// Following the focus, or a tab click, is not a new
|
||||||
// history entry.
|
// history entry.
|
||||||
@@ -1268,34 +1381,6 @@ export function Workspace() {
|
|||||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||||
{tLanes("panel.changes")}
|
{tLanes("panel.changes")}
|
||||||
</span>
|
</span>
|
||||||
{/* Which repo the panel shows: the workspace's folder or
|
|
||||||
one of its extra folders. Only when it has any. */}
|
|
||||||
{folders.length > 0 && (
|
|
||||||
<select
|
|
||||||
data-testid="panel-git-folder"
|
|
||||||
value={gitSource ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
setChangesRequest(null);
|
|
||||||
setGitSourcePick(
|
|
||||||
e.target.value
|
|
||||||
? { laneId: currentLane.id, id: Number(e.target.value) }
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
aria-label={tLanes("panel.folder")}
|
|
||||||
title={tLanes("panel.folder")}
|
|
||||||
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
|
|
||||||
</option>
|
|
||||||
{folders.map((f) => (
|
|
||||||
<option key={f.id} value={f.id}>
|
|
||||||
{f.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{/* The opened session's analysis page — a page of its own. */}
|
{/* The opened session's analysis page — a page of its own. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1350,31 +1435,149 @@ export function Workspace() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||||
{panelAt && (
|
{panelAt &&
|
||||||
<LaneChangesPanel
|
[
|
||||||
// A new tree is a new panel: no read for the previous one
|
{ key: "", source: undefined as number | undefined },
|
||||||
// can land in it. `panelAt` holds still while an edit is
|
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
||||||
// unsaved (see above).
|
].map(({ key, source }, i) => {
|
||||||
key={panelKey}
|
const panel = (
|
||||||
laneId={panelAt.laneId}
|
<LaneChangesPanel
|
||||||
session={panelAt.session}
|
// A new tree is a new panel: no read for the previous
|
||||||
source={panelAt.source}
|
// one can land in it. `panelAt` holds still while an
|
||||||
full={changesFull}
|
// edit is unsaved (see above).
|
||||||
wide={changesRatio(changesSize) >= 0.45}
|
key={`${panelKey}:${key}`}
|
||||||
onDirtyChange={setChangesDirty}
|
laneId={panelAt.laneId}
|
||||||
openRequest={panelAt.source ? null : changesRequest}
|
session={panelAt.session}
|
||||||
// A sibling worktree forks from the lane's SOURCE repo when
|
source={source}
|
||||||
// it has one (this lane is itself a worktree of it); an
|
full={changesFull}
|
||||||
// adopted lane is its own repo.
|
wide={changesRatio(changesSize) >= 0.45}
|
||||||
onNewLane={(branch) => {
|
onDirtyChange={dirtyFor(key)}
|
||||||
setAddLanePrefill({
|
openRequest={source ? null : changesRequest}
|
||||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
// A sibling worktree forks from the lane's SOURCE repo
|
||||||
branch,
|
// when it has one (this lane is itself a worktree of
|
||||||
});
|
// it); an adopted lane is its own repo.
|
||||||
setAddLaneOpen(true);
|
onNewLane={(branch) => {
|
||||||
}}
|
setAddLanePrefill({
|
||||||
/>
|
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||||
|
branch,
|
||||||
|
});
|
||||||
|
setAddLaneOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// No extra folders: the one panel, as it always was.
|
||||||
|
if (!folders.length) return panel;
|
||||||
|
// Else every repo is a section, stacked like VS Code's
|
||||||
|
// Source Control; the open ones share the height. A
|
||||||
|
// folded one stays mounted (CSS), so an unsaved edit in
|
||||||
|
// it survives.
|
||||||
|
const folded = foldedSources.has(key);
|
||||||
|
const name = source
|
||||||
|
? folders[i - 1]!.name
|
||||||
|
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={key}
|
||||||
|
data-testid="panel-git-source"
|
||||||
|
// A row dragged from an extra folder lies outside
|
||||||
|
// the pane's folder: `dragProjectPath` makes it absolute.
|
||||||
|
data-drag-root={source ? folders[i - 1]!.path : undefined}
|
||||||
|
className={`flex flex-col border-b border-border-light ${
|
||||||
|
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={!folded}
|
||||||
|
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||||
|
onClick={() =>
|
||||||
|
setFoldedSources((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (!next.delete(key)) next.add(key);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{folded ? (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</button>
|
||||||
|
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||||||
|
{panel}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{panelAt && sourceCtx.menu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={sourceCtx.menu.x}
|
||||||
|
y={sourceCtx.menu.y}
|
||||||
|
onClose={sourceCtx.close}
|
||||||
|
testId="panel-git-source-menu"
|
||||||
|
>
|
||||||
|
{sourceCtx.menu.item !== "" && (
|
||||||
|
<MenuItem
|
||||||
|
testId="panel-git-source-close"
|
||||||
|
onSelect={() => {
|
||||||
|
const key = sourceCtx.menu!.item;
|
||||||
|
sourceCtx.close();
|
||||||
|
void closeSources(panelAt.laneId, [key]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tLanes("changes.closeRepo")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
testId="panel-git-source-close-others"
|
||||||
|
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
|
||||||
|
onSelect={() => {
|
||||||
|
const key = sourceCtx.menu!.item;
|
||||||
|
sourceCtx.close();
|
||||||
|
void closeSources(
|
||||||
|
panelAt.laneId,
|
||||||
|
folders.map((f) => String(f.id)).filter((k) => k !== key)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tLanes("changes.closeOtherRepos")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
|
{/* The workspace's context — the home's right column, compact. */}
|
||||||
|
<section
|
||||||
|
data-testid="panel-workspace-context"
|
||||||
|
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
|
||||||
|
contextOpen ? "max-h-[45%]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={contextOpen}
|
||||||
|
onClick={() => setContextOpen((v) => !v)}
|
||||||
|
className="flex shrink-0 items-center gap-1 py-1.5 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{contextOpen ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
{tLanes("home.contextPanel")}
|
||||||
|
</button>
|
||||||
|
{contextOpen && (
|
||||||
|
<div className="min-h-0 overflow-y-auto pb-2">
|
||||||
|
<WorkspaceContext
|
||||||
|
lane={currentLane}
|
||||||
|
compact
|
||||||
|
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||||
|
sourcesVersion={sourcesVersion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
) : (
|
) : (
|
||||||
@@ -1397,14 +1600,16 @@ export function Workspace() {
|
|||||||
{tLanes("changes.title")}
|
{tLanes("changes.title")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{panelAt && (
|
{panelAt &&
|
||||||
<LaneGitRailCounts
|
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
||||||
laneId={panelAt.laneId}
|
<LaneGitRailCounts
|
||||||
session={panelAt.session}
|
key={source ?? ""}
|
||||||
source={panelAt.source}
|
laneId={panelAt.laneId}
|
||||||
onShowAll={() => setChangesOpen(true)}
|
session={panelAt.session}
|
||||||
/>
|
source={source}
|
||||||
)}
|
onShowAll={() => setChangesOpen(true)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
||||||
* it at the last dragged width, else 25/75. The width survives a reload.
|
* it at the last dragged width, else 25/75. The width survives a reload.
|
||||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||||
* lane strip's order.
|
* lane strip's order. Its bottom fold is the workspace's context, compact.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -95,6 +95,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
: [],
|
: [],
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
// Closing a folder's section removes it from the workspace.
|
||||||
|
removeSource: r({
|
||||||
|
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||||
|
}),
|
||||||
|
memory: r({ files: [] }),
|
||||||
gitTree: r({ entries: [] }),
|
gitTree: r({ entries: [] }),
|
||||||
// A file opened in the panel on a chat's request.
|
// A file opened in the panel on a chat's request.
|
||||||
changes: r({ available: false }),
|
changes: r({ available: false }),
|
||||||
@@ -244,32 +249,66 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
|
it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => {
|
||||||
await openPanel();
|
|
||||||
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
|
|
||||||
// The workspace folder, then its folders — never a context file.
|
|
||||||
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
|
|
||||||
vi.mocked(api.lanes.git).mockClear();
|
vi.mocked(api.lanes.git).mockClear();
|
||||||
fireEvent.change(pick, { target: { value: "7" } });
|
await openPanel();
|
||||||
await settle();
|
const sections = screen.getAllByTestId("panel-git-source");
|
||||||
|
// The workspace folder, then its folders — never a context file.
|
||||||
|
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
||||||
|
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
||||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||||
// A chat's file is the workspace folder's: the panel goes back there.
|
// A folded section keeps its panel mounted, only hidden.
|
||||||
|
const toggle = sections[0]!.querySelector("button")!;
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
||||||
|
// A chat's file is the workspace folder's: its section unfolds.
|
||||||
act(() => {
|
act(() => {
|
||||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||||
});
|
});
|
||||||
await settle();
|
await settle();
|
||||||
expect(pick.value).toBe("");
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has no folder picker for a workspace with no extra folders", async () => {
|
it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => {
|
||||||
|
await openPanel();
|
||||||
|
const [main, shop] = screen.getAllByTestId("panel-git-source");
|
||||||
|
fireEvent.contextMenu(main!.querySelector("button")!);
|
||||||
|
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
fireEvent.contextMenu(shop!.querySelector("button")!);
|
||||||
|
fireEvent.click(screen.getByTestId("panel-git-source-close"));
|
||||||
|
await settle();
|
||||||
|
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||||
|
// The last folder gone: back to the one plain panel.
|
||||||
|
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds the workspace context at the bottom, and a folder removed there leaves the stack", async () => {
|
||||||
|
await openPanel();
|
||||||
|
const fold = screen.getByTestId("panel-workspace-context");
|
||||||
|
const toggle = fold.querySelector("button")!;
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByTestId("workspace-context")).toBeNull();
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
await settle();
|
||||||
|
expect(window.localStorage.getItem("agenthub.workspace.contextOpen")).toBe("1");
|
||||||
|
expect(screen.getByText("notes.md")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove shop" }));
|
||||||
|
await settle();
|
||||||
|
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||||
|
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
<Workspace />
|
<Workspace />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
await settle();
|
await settle();
|
||||||
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
|
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses from its narrow width too, back to the rail", async () => {
|
it("collapses from its narrow width too, back to the rail", async () => {
|
||||||
|
|||||||
@@ -6,8 +6,17 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||||
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
|
import {
|
||||||
|
render,
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
createEvent,
|
||||||
|
fireEvent,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
@@ -61,6 +70,10 @@ const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
|||||||
vi.mock("../../components/lanes/startSession", () => ({
|
vi.mock("../../components/lanes/startSession", () => ({
|
||||||
paneRunId: (id: number, slot: number) =>
|
paneRunId: (id: number, slot: number) =>
|
||||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||||
|
laneOfRun: (runId: string) => {
|
||||||
|
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
|
||||||
|
return m ? Number(m[1]) : null;
|
||||||
|
},
|
||||||
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
|
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
|
||||||
const busy = new Set(
|
const busy = new Set(
|
||||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||||
@@ -287,6 +300,10 @@ vi.mock("../../lib/eventBus", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/run/PaneChat", () => ({
|
||||||
|
PaneChat: () => <div data-testid="pane-chat" />,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../components/run/TerminalView", () => ({
|
vi.mock("../../components/run/TerminalView", () => ({
|
||||||
// `exit-<runId>` stands in for the server's exit frame.
|
// `exit-<runId>` stands in for the server's exit frame.
|
||||||
TerminalView: ({
|
TerminalView: ({
|
||||||
@@ -310,6 +327,7 @@ vi.mock("../../components/run/TerminalView", () => ({
|
|||||||
|
|
||||||
import { Workspace } from "../Workspace";
|
import { Workspace } from "../Workspace";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { setSessionChat } from "../../lib/chatUi";
|
||||||
|
|
||||||
class ObserverStub {
|
class ObserverStub {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -630,6 +648,30 @@ describe("console tabs", () => {
|
|||||||
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("with tabs from every workspace on, shows another workspace's session and selects it on click", async () => {
|
||||||
|
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
|
||||||
|
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
|
||||||
|
function LaneParam() {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
return <span data-testid="lane-param">{params.get("lane")}</span>;
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
||||||
|
<Workspace />
|
||||||
|
<LaneParam />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
// Leaves are `paneKey`s: lane × 10 + slot.
|
||||||
|
expect(screen.getByTestId("console-tab-10")).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByTestId("console-tab-21")).toHaveTextContent("Lane B");
|
||||||
|
await userEvent.setup().click(screen.getByTestId("console-tab-21"));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("lane-param")).toHaveTextContent("2"));
|
||||||
|
expect(screen.getByTestId("console-tab-21")).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("closing the active tab stops its session and opens the next one", async () => {
|
it("closing the active tab stops its session and opens the next one", async () => {
|
||||||
withRuns([live(0), live(1)]);
|
withRuns([live(0), live(1)]);
|
||||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
@@ -706,6 +748,37 @@ describe("split panes", () => {
|
|||||||
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
|
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps each session's chat view when two tabs join into one combo", async () => {
|
||||||
|
setSessionChat("sess-0", true);
|
||||||
|
setSessionChat("sess-1", true);
|
||||||
|
const items = [
|
||||||
|
{ ...live(0), sessionId: "sess-0" },
|
||||||
|
{ ...live(1), sessionId: "sess-1" },
|
||||||
|
];
|
||||||
|
withRuns(items as never);
|
||||||
|
// As the server does: GET /api/run/:id is the list's own handle.
|
||||||
|
const get = vi.mocked(api.run.get).getMockImplementation()!;
|
||||||
|
onTestFinished(() => void vi.mocked(api.run.get).mockImplementation(get));
|
||||||
|
vi.mocked(api.run.get).mockImplementation(
|
||||||
|
async (id) => items.find((r) => r.id === id) as never
|
||||||
|
);
|
||||||
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
// Tab b dragged onto the middle of tab a (jsdom: every rect is 0 wide).
|
||||||
|
const drop = createEvent.drop(screen.getByTestId("console-tab-0"), {
|
||||||
|
dataTransfer: {
|
||||||
|
types: ["application/x-agenthub-tab"],
|
||||||
|
getData: (t: string) => (t === "application/x-agenthub-tab" ? "1" : ""),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(drop, "clientX", { value: 0 });
|
||||||
|
fireEvent(screen.getByTestId("console-tab-0"), drop);
|
||||||
|
await settle();
|
||||||
|
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||||
|
await settle();
|
||||||
|
for (const s of [0, 1])
|
||||||
|
expect(within(screen.getByTestId(`pane-${s}`)).getByTestId("pane-chat")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
|
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
|
||||||
withRuns([live(0), live(1), live(2)]);
|
withRuns([live(0), live(1), live(2)]);
|
||||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
|||||||
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.6.0 (rebuild client to match)
|
UI build v2.7.1 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export default {
|
|||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||||
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
||||||
|
// Tailwind's stock stack minus Georgia: Windows picks Georgia, which has
|
||||||
|
// no precomposed Vietnamese glyphs, so Chrome splits "ế" into "ê" + a
|
||||||
|
// tone mark it cannot place and the chat reads "Chuyê ́n".
|
||||||
|
serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"],
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||||
|
|||||||
+21
-5
@@ -698,7 +698,7 @@ otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
|
|||||||
|
|
||||||
Every `/api/lanes/:id/git…` route also takes `?source=<id>`: the read or write
|
Every `/api/lanes/:id/git…` route also takes `?source=<id>`: the read or write
|
||||||
then works in one of the workspace's extra folders (Context → Folder), each
|
then works in one of the workspace's extra folders (Context → Folder), each
|
||||||
its own repo — the Changes panel's folder picker. The tree is resolved from the
|
its own repo — a section of its own in the Changes panel. The tree is resolved from the
|
||||||
`lane_sources` ROW (`sourceFolder` in `server/lib/lane-sources.js`): a `folder`
|
`lane_sources` ROW (`sourceFolder` in `server/lib/lane-sources.js`): a `folder`
|
||||||
row of THIS workspace that is still a directory — otherwise `404 ENOSOURCE`
|
row of THIS workspace that is still a directory — otherwise `404 ENOSOURCE`
|
||||||
(another workspace's row and a context-file row included), never a fall back
|
(another workspace's row and a context-file row included), never a fall back
|
||||||
@@ -1571,7 +1571,7 @@ curl http://localhost:4820/api/sessions/sess_abc123
|
|||||||
GET /api/sessions/:id/outline
|
GET /api/sessions/:id/outline
|
||||||
```
|
```
|
||||||
|
|
||||||
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `agenthub-drops-<uid>` dir that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
|
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `<data dir>/drops/` dir — `agenthub-drops-<uid>` in older transcripts — that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:4820/api/sessions/sess_abc123/outline
|
curl http://localhost:4820/api/sessions/sess_abc123/outline
|
||||||
@@ -1582,7 +1582,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
|
|||||||
"progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
|
"progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
|
||||||
"outputs": ["/home/me/work/Progress report.xlsx"],
|
"outputs": ["/home/me/work/Progress report.xlsx"],
|
||||||
"files": ["/home/me/work/data/kpi.csv"],
|
"files": ["/home/me/work/data/kpi.csv"],
|
||||||
"uploads": ["/tmp/agenthub-drops-1000/3f9a0c1d2e4b5a6c-minutes.xlsx"],
|
"uploads": ["/home/me/.claude/agenthub/drops/3f9a0c1d2e4b5a6c-minutes.xlsx"],
|
||||||
"memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
|
"memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
|
||||||
"skills": ["xlsx"],
|
"skills": ["xlsx"],
|
||||||
"mcp": ["vercel_vercel"]
|
"mcp": ["vercel_vercel"]
|
||||||
@@ -1591,6 +1591,20 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### Get an Image the Session Read
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sessions/:id/image?path=<absolute path>
|
||||||
|
```
|
||||||
|
|
||||||
|
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` in server mode and for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### Get Session Stats
|
#### Get Session Stats
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -2580,10 +2594,12 @@ GET /api/run/tmux { available: bool } — whether tmux is on
|
|||||||
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
||||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||||
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
||||||
|
GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50
|
||||||
|
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
|
||||||
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
||||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
||||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
|
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
|
||||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); only a name drop-image/drop-file made → 404 otherwise
|
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); only a name drop-image/drop-file made → 404 otherwise
|
||||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||||
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
|
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
|
||||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||||
@@ -2604,7 +2620,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
|||||||
|
|
||||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||||
|
|
||||||
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is flipped to `abandoned` once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
|
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is ended once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. It becomes `interrupted` when an agent session (not a Terminal tab, `session_id` known) died under the user — a reboot, a killed tmux, a crash; a Claude session the user ended (`SessionEnd` with reason `prompt_input_exit`/`logout`) and every row with no session become `abandoned`. Only Claude reports why it ended, so any other tool's vanished pane counts as interrupted. A pane the dashboard closed (Stop, close tab, Quit) is already `killed`. `GET /api/run/interrupted` lists the newest interrupted row of each session no newer run holds (`{id, session_id, lane_id, tool, cwd, model, permission_mode, effort, prompt_preview, started_at, ended_at, lane_title, lane_cwd, session_name}`), only the caller's own workspaces in server mode — the app-wide **Resume interrupted sessions?** dialog reads it; dismissing flips them to `abandoned`. The heal beat broadcasts `runs_interrupted` (no payload) whenever it ended a row, and every client refetches. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.6.0
|
version: 2.7.1
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.6.0",
|
"version": "2.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.6.0",
|
"version": "2.7.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.6.0",
|
"version": "2.7.1",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"dev:client": "cd client && npm run dev",
|
"dev:client": "cd client && npm run dev",
|
||||||
"build": "cd client && npm run build",
|
"build": "cd client && npm run build",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"setup": "npm install && (cd client && npm install) && npm run link-cli",
|
"setup": "npm install && npm --prefix client install && npm run link-cli",
|
||||||
"update:pull-setup": "git pull --ff-only && npm run setup",
|
"update:pull-setup": "git pull --ff-only && npm run setup",
|
||||||
"install-hooks": "node scripts/install-hooks.js",
|
"install-hooks": "node scripts/install-hooks.js",
|
||||||
"seed": "node scripts/seed.js",
|
"seed": "node scripts/seed.js",
|
||||||
|
|||||||
@@ -155,9 +155,10 @@ function reloadPanes() {
|
|||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
// psmux (Windows) ignores -F: `name: 1 windows (…)`; a name holds no `:`.
|
||||||
names = out
|
names = out
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.split(":")[0].trim())
|
||||||
.filter((s) => s.startsWith("agenthub-lane-"));
|
.filter((s) => s.startsWith("agenthub-lane-"));
|
||||||
} catch {
|
} catch {
|
||||||
// No tmux server at all means no open sessions — nothing to reload, which
|
// No tmux server at all means no open sessions — nothing to reload, which
|
||||||
|
|||||||
@@ -3100,7 +3100,7 @@ describe("GET /api/sessions/:id/outline", () => {
|
|||||||
hook_type: "UserPromptSubmit",
|
hook_type: "UserPromptSubmit",
|
||||||
data: {
|
data: {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
prompt: `compare these ${drop}\n/tmp/agenthub-drops-1000/fedcba9876543210.png`,
|
prompt: `compare these ${drop}\n/home/u/.claude/agenthub/drops/fedcba9876543210.png`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mem = "/home/u/.claude/projects/-w/memory/release.md";
|
const mem = "/home/u/.claude/projects/-w/memory/release.md";
|
||||||
@@ -3113,10 +3113,46 @@ describe("GET /api/sessions/:id/outline", () => {
|
|||||||
await pre("Write", { file_path: mem, content: "x" });
|
await pre("Write", { file_path: mem, content: "x" });
|
||||||
|
|
||||||
const { body } = await fetch(`/api/sessions/${sid}/outline`);
|
const { body } = await fetch(`/api/sessions/${sid}/outline`);
|
||||||
assert.deepEqual(body.uploads, ["/tmp/agenthub-drops-1000/fedcba9876543210.png", drop]);
|
assert.deepEqual(body.uploads, ["/home/u/.claude/agenthub/drops/fedcba9876543210.png", drop]);
|
||||||
// a.ts was read, then edited: an output, no longer a file it only used.
|
// a.ts was read, then edited: an output, no longer a file it only used.
|
||||||
assert.deepEqual(body.files, ["/w/src/c.ts"]);
|
assert.deepEqual(body.files, ["/w/src/c.ts"]);
|
||||||
assert.deepEqual(body.memory, [mem]);
|
assert.deepEqual(body.memory, [mem]);
|
||||||
assert.deepEqual(body.outputs, ["/w/src/b.ts", "/w/src/a.ts"]);
|
assert.deepEqual(body.outputs, ["/w/src/b.ts", "/w/src/a.ts"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/sessions/:id/image", () => {
|
||||||
|
it("serves only an image the session itself read", async () => {
|
||||||
|
const sid = "read-image-" + Date.now();
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-read-image-"));
|
||||||
|
const png = path.join(dir, "shot.png");
|
||||||
|
const other = path.join(dir, "other.png");
|
||||||
|
const txt = path.join(dir, "notes.txt");
|
||||||
|
fs.writeFileSync(png, "PNGBYTES");
|
||||||
|
fs.writeFileSync(other, "OTHER");
|
||||||
|
fs.writeFileSync(txt, "text");
|
||||||
|
const read = (file_path) =>
|
||||||
|
post("/api/hooks/event", {
|
||||||
|
hook_type: "PostToolUse",
|
||||||
|
data: { session_id: sid, tool_name: "Read", tool_input: { file_path }, tool_response: {} },
|
||||||
|
});
|
||||||
|
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
|
||||||
|
await read(png);
|
||||||
|
await read(txt);
|
||||||
|
const url = (p) => `/api/sessions/${sid}/image?path=${encodeURIComponent(p)}`;
|
||||||
|
|
||||||
|
const ok = await fetch(url(png));
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal(ok.headers["content-type"], "image/png");
|
||||||
|
assert.equal(ok.headers["x-content-type-options"], "nosniff");
|
||||||
|
assert.match(ok.headers["content-security-policy"], /sandbox/);
|
||||||
|
assert.equal(ok.body, "PNGBYTES");
|
||||||
|
|
||||||
|
// Never read by this session, not an image, not a session.
|
||||||
|
assert.equal((await fetch(url(other))).status, 404);
|
||||||
|
assert.equal((await fetch(url(txt))).status, 400);
|
||||||
|
assert.equal((await fetch(`/api/sessions/nope-image/image?path=${png}`)).status, 404);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
assert.equal((await fetch(url(png))).status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -86,6 +86,53 @@ describe("dashboard-runs", () => {
|
|||||||
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
|
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers for resume only the sessions whose pane died under them", () => {
|
||||||
|
const { db } = require("../db");
|
||||||
|
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (20, 'w', '/i')").run();
|
||||||
|
const session = db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, ?, 'active', '/i')"
|
||||||
|
);
|
||||||
|
for (const id of ["s-boot", "s-exit", "s-signal", "s-codex"]) session.run(id, id);
|
||||||
|
const end = db.prepare(
|
||||||
|
"INSERT INTO events (session_id, event_type, data) VALUES (?, 'SessionEnd', ?)"
|
||||||
|
);
|
||||||
|
end.run("s-exit", JSON.stringify({ reason: "prompt_input_exit" }));
|
||||||
|
end.run("s-signal", JSON.stringify({ reason: "other" }));
|
||||||
|
start("agenthub-lane-20", { laneId: 20, sessionId: "s-boot" });
|
||||||
|
start("agenthub-lane-20-1", { laneId: 20, sessionId: "s-exit" });
|
||||||
|
start("agenthub-lane-20-2", { laneId: 20, sessionId: "s-signal" });
|
||||||
|
start("agenthub-lane-20-3", { laneId: 20, sessionId: "s-codex", tool: "codex" });
|
||||||
|
start("agenthub-lane-20-4", { laneId: 20, tool: "shell" });
|
||||||
|
start("agenthub-lane-20-5", { laneId: 20 }); // no session known: nothing to resume
|
||||||
|
|
||||||
|
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-20"));
|
||||||
|
const status = (id) => runs.getRun(id).status;
|
||||||
|
assert.equal(status("agenthub-lane-20"), "interrupted");
|
||||||
|
assert.equal(status("agenthub-lane-20-1"), "abandoned", "the user's own /exit");
|
||||||
|
assert.equal(status("agenthub-lane-20-2"), "interrupted", "a signal is not the user");
|
||||||
|
assert.equal(status("agenthub-lane-20-3"), "interrupted");
|
||||||
|
assert.equal(status("agenthub-lane-20-5"), "abandoned");
|
||||||
|
const offered = () =>
|
||||||
|
runs
|
||||||
|
.listInterrupted()
|
||||||
|
.map((r) => r.session_id)
|
||||||
|
.sort();
|
||||||
|
assert.deepEqual(offered(), ["s-boot", "s-codex", "s-signal"]);
|
||||||
|
assert.deepEqual(runs.listInterrupted(99), [], "another owner's workspace");
|
||||||
|
|
||||||
|
// Resumed by any path: a newer run of the session takes it off the list.
|
||||||
|
start("agenthub-lane-20-6", {
|
||||||
|
laneId: 20,
|
||||||
|
sessionId: "s-signal",
|
||||||
|
startedAt: Date.now() + 1000,
|
||||||
|
});
|
||||||
|
assert.deepEqual(offered(), ["s-boot", "s-codex"]);
|
||||||
|
|
||||||
|
assert.equal(runs.dismissInterrupted(["s-boot"], 99), 0, "not the caller's");
|
||||||
|
assert.equal(runs.dismissInterrupted(["s-boot", "s-codex"]), 2);
|
||||||
|
assert.deepEqual(offered(), []);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a hookless tool's session the lane of the run it started in", () => {
|
it("gives a hookless tool's session the lane of the run it started in", () => {
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
start("agenthub-lane-7-1", {
|
start("agenthub-lane-7-1", {
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ describe("lane-sources instructions and launch", () => {
|
|||||||
let argv = null;
|
let argv = null;
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||||
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
|
// The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js).
|
||||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
|
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6);
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
||||||
|
|||||||
@@ -482,10 +482,11 @@ describe("lane start input", () => {
|
|||||||
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
||||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||||
// After `--` only the fixed size-wait wrapper that execs the user's own
|
// After `--` only the fixed size-wait wrapper that execs the user's own
|
||||||
// default shell (issue #55) — nothing from the request.
|
// default shell (issue #55) in the lane's folder (#69) — nothing from
|
||||||
|
// the request.
|
||||||
const argv = created.slice(created.indexOf("--") + 1);
|
const argv = created.slice(created.indexOf("--") + 1);
|
||||||
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
|
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
|
||||||
assert.equal(argv.length, 3);
|
assert.deepEqual(argv.slice(3), ["sh", cwd]);
|
||||||
assert.match(argv[2], /exec "\$SHELL" -l$/);
|
assert.match(argv[2], /exec "\$SHELL" -l$/);
|
||||||
assert.ok(!created.includes("ignored"));
|
assert.ok(!created.includes("ignored"));
|
||||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||||
|
|||||||
@@ -227,4 +227,46 @@ describe("attachBinary on Windows", () => {
|
|||||||
process.env.PATH = saved;
|
process.env.PATH = saved;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches through node-pty's bundled conpty.dll on Windows only", () => {
|
||||||
|
// The inbox ConPTY re-renders psmux's output and leaves stray cells
|
||||||
|
// (torn left edge, bars mid-screen) wherever its glyph widths disagree
|
||||||
|
// with xterm.js's.
|
||||||
|
for (const [platform, dll] of [
|
||||||
|
["win32", true],
|
||||||
|
["linux", undefined],
|
||||||
|
]) {
|
||||||
|
let opts;
|
||||||
|
ptyAttach.__setSpawnImpl((_f, _a, o) => {
|
||||||
|
opts = o;
|
||||||
|
return makeFakePty();
|
||||||
|
});
|
||||||
|
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24, platform });
|
||||||
|
assert.equal(opts.useConptyDll, dll);
|
||||||
|
ptyAttach.__resetGroups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds a Windows pane's input that follows a paste until psmux has delivered it (psmux#766)", async () => {
|
||||||
|
const paste = "\x1b[200~hello\x1b[201~";
|
||||||
|
for (const [platform, held] of [
|
||||||
|
["win32", true],
|
||||||
|
["linux", false],
|
||||||
|
]) {
|
||||||
|
const fakePty = makeFakePty();
|
||||||
|
ptyAttach.__setSpawnImpl(() => fakePty);
|
||||||
|
tmux.__setExecImpl(() => "");
|
||||||
|
const ws = makeFakeWs();
|
||||||
|
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform });
|
||||||
|
ws.__emitter.emit("message", Buffer.from(paste), false);
|
||||||
|
ws.__emitter.emit("message", Buffer.from("\r"), false);
|
||||||
|
assert.deepEqual(fakePty.__writes, held ? [paste] : [paste, "\r"], platform);
|
||||||
|
if (held) {
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
assert.deepEqual(fakePty.__writes, [paste, "\r"]);
|
||||||
|
}
|
||||||
|
ptyAttach.__resetGroups();
|
||||||
|
}
|
||||||
|
tmux.__reset();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const assert = require("node:assert/strict");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
|
||||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
@@ -80,7 +81,7 @@ describe("pty-run", () => {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const child = spawn("sh", ["-c", argv[2]], {
|
const child = spawn("sh", ["-c", argv[2], "sh", os.tmpdir()], {
|
||||||
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
|
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
|
||||||
});
|
});
|
||||||
let out = "";
|
let out = "";
|
||||||
@@ -168,7 +169,7 @@ describe("pty-run", () => {
|
|||||||
const respawn = calls.find((c) => c[0] === "respawn-pane");
|
const respawn = calls.find((c) => c[0] === "respawn-pane");
|
||||||
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
|
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
|
||||||
const argv = respawn.slice(respawn.indexOf("--") + 1);
|
const argv = respawn.slice(respawn.indexOf("--") + 1);
|
||||||
assert.equal(argv[0], "claude");
|
assert.deepEqual(argv.slice(3, 6), ["sh", "/tmp/repo", "claude"]);
|
||||||
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
|
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -507,21 +508,41 @@ describe("pty-run", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The CLI's own command line: what tmux passes after the `--`, less the
|
/** The CLI's own command line: what tmux passes after the `--`, less the
|
||||||
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
|
* `sh -c '<cd; wait for a client>; exec "$@"' sh <cwd>` wrapper spawnRun
|
||||||
|
* puts first. */
|
||||||
function paneArgv(newSessionCall) {
|
function paneArgv(newSessionCall) {
|
||||||
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||||
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
||||||
assert.match(argv[2], /session_attached.*exec "\$@"$/);
|
assert.match(argv[2], /^cd -- "\$1".*session_attached.*exec "\$@"$/);
|
||||||
return argv.slice(4);
|
return argv.slice(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
|
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
|
||||||
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
|
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
|
||||||
assert.deepEqual(pty.paneArgv(argv, "win32"), argv);
|
assert.deepEqual(pty.paneArgv(argv, "C:\\w", "win32"), argv);
|
||||||
assert.deepEqual(pty.paneArgv(null, "win32"), []);
|
assert.deepEqual(pty.paneArgv(null, "C:\\w", "win32"), []);
|
||||||
assert.equal(pty.paneArgv(argv, "linux")[0], "sh");
|
assert.equal(pty.paneArgv(argv, "/w", "linux")[0], "sh");
|
||||||
assert.deepEqual(pty.paneArgv(argv, "linux").slice(-3), argv);
|
assert.deepEqual(pty.paneArgv(argv, "/w", "linux").slice(-3), argv);
|
||||||
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
|
assert.match(pty.paneArgv(null, "/w", "linux")[2], /exec "\$SHELL" -l$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a Windows claude's teammates in-process — psmux cannot split a pane for them", () => {
|
||||||
|
const win = pty.buildClaudeArgv({ platform: "win32" });
|
||||||
|
assert.deepEqual(
|
||||||
|
win.slice(win.indexOf("--teammate-mode"), win.indexOf("--teammate-mode") + 2),
|
||||||
|
["--teammate-mode", "in-process"]
|
||||||
|
);
|
||||||
|
assert.ok(!pty.buildClaudeArgv({ platform: "linux" }).includes("--teammate-mode"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a pane in its folder even where tmux skips -c (issue #69)", () => {
|
||||||
|
// A tmux server whose own cwd was deleted (a pruned plugin cache dir)
|
||||||
|
// ignores `-c` and hands every pane that deleted cwd.
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pane-cwd-"));
|
||||||
|
const argv = pty.paneArgv(["pwd"], dir, "linux");
|
||||||
|
const out = execFileSync(argv[0], argv.slice(1), { cwd: "/", encoding: "utf8" });
|
||||||
|
assert.equal(fs.realpathSync(out.trim()), fs.realpathSync(dir));
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
|
it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
|
||||||
@@ -578,6 +599,7 @@ describe("pty-run", () => {
|
|||||||
[
|
[
|
||||||
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
||||||
"sh",
|
"sh",
|
||||||
|
os.tmpdir(),
|
||||||
"printf",
|
"printf",
|
||||||
"%s|",
|
"%s|",
|
||||||
"x y",
|
"x y",
|
||||||
|
|||||||
@@ -321,7 +321,20 @@ describe("GET /api/run/drop/:name", () => {
|
|||||||
fs.unlinkSync(saved.body.path);
|
fs.unlinkSync(saved.body.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves no file that is not a dropped image", async () => {
|
it("serves a dropped text file back as plain text that runs nothing", async () => {
|
||||||
|
const saved = await fetchJson("/api/run/drop-file?name=pasted-text.txt", {
|
||||||
|
method: "POST",
|
||||||
|
raw: { type: "text/plain", buf: Buffer.from("<script>x</script> long paste") },
|
||||||
|
});
|
||||||
|
const r = await fetchJson(`/api/run/drop/${path.basename(saved.body.path)}`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.headers["content-type"], "text/plain; charset=utf-8");
|
||||||
|
assert.equal(r.headers["x-content-type-options"], "nosniff");
|
||||||
|
assert.match(r.headers["content-security-policy"], /sandbox/);
|
||||||
|
fs.unlinkSync(saved.body.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves no file that is not a dropped image or text file", async () => {
|
||||||
const link = path.join(
|
const link = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
`agenthub-drops-${os.userInfo().uid}`,
|
`agenthub-drops-${os.userInfo().uid}`,
|
||||||
@@ -447,3 +460,31 @@ describe("/api/run/:id/permission-mode", () => {
|
|||||||
assert.equal(r.status, 404);
|
assert.equal(r.status, 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("interrupted sessions", () => {
|
||||||
|
let srv;
|
||||||
|
before(async () => {
|
||||||
|
srv = http.createServer(createApp());
|
||||||
|
await new Promise((r) => srv.listen(0, r));
|
||||||
|
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => new Promise((r) => srv.close(r)));
|
||||||
|
|
||||||
|
it("lists and dismisses, refusing a body that is not a list of ids", async () => {
|
||||||
|
const list = await fetchJson("/api/run/interrupted");
|
||||||
|
assert.equal(list.status, 200);
|
||||||
|
assert.ok(Array.isArray(list.body.items));
|
||||||
|
const bad = await fetchJson("/api/run/interrupted/dismiss", {
|
||||||
|
method: "POST",
|
||||||
|
body: { sessionIds: "s-1" },
|
||||||
|
});
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||||
|
const ok = await fetchJson("/api/run/interrupted/dismiss", {
|
||||||
|
method: "POST",
|
||||||
|
body: { sessionIds: ["s-none"] },
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal(ok.body.dismissed, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ describe("tmux wrapper", () => {
|
|||||||
assert.deepEqual(calls, ["list-sessions"]);
|
assert.deepEqual(calls, ["list-sessions"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads psmux's list-sessions, which ignores -F, as bare names (Windows)", () => {
|
||||||
|
// psmux prints its default line whatever `-F` asks for; read as names,
|
||||||
|
// every pane looked dead and the heal beat closed its run within seconds.
|
||||||
|
tmux.__setExecImpl((args) =>
|
||||||
|
args[0] === "list-sessions"
|
||||||
|
? "agenthub-lane-2-5: 1 windows (created Sat Oct 10 11:44:39 2026)\r\n" +
|
||||||
|
"agenthub-lane-2: 1 windows (created Sat Oct 10 10:43:16 2026)\r\n"
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
assert.deepEqual([...tmux.liveSessions()], ["agenthub-lane-2-5", "agenthub-lane-2"]);
|
||||||
|
assert.deepEqual(tmux.listSessions("agenthub-lane-"), ["agenthub-lane-2-5", "agenthub-lane-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("liveSessions is empty when tmux answers that no server runs (or is not installed)", () => {
|
it("liveSessions is empty when tmux answers that no server runs (or is not installed)", () => {
|
||||||
tmux.__setExecImpl(() => {
|
tmux.__setExecImpl(() => {
|
||||||
throw Object.assign(new Error("no server running"), { status: 1 });
|
throw Object.assign(new Error("no server running"), { status: 1 });
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* @file GET /api/sessions/:id/transcript keeps a long conversation turn whole:
|
||||||
|
* the chat shows an assistant's answer and a user's prompt as they were, while
|
||||||
|
* a tool's output — the part that can be megabytes — is still capped.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-long-text-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
|
||||||
|
const ID = "5a1e0c2e-0000-4000-8000-000000000001";
|
||||||
|
const CWD = "/w/long";
|
||||||
|
const LONG = "Câu hỏi dài. ".repeat(3000); // ~39K chars, past the old 10 KB cap
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const file = path.join(
|
||||||
|
process.env.CLAUDE_HOME,
|
||||||
|
"projects",
|
||||||
|
CWD.replace(/[^a-zA-Z0-9]/g, "-"),
|
||||||
|
`${ID}.jsonl`
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const lines = [
|
||||||
|
{ type: "user", message: { role: "user", content: LONG } },
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: LONG },
|
||||||
|
{ type: "tool_use", id: "t1", name: "Bash", input: { command: "cat big" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "t1", content: LONG }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||||
|
db.prepare("INSERT INTO sessions (id, status, cwd) VALUES (?, 'completed', ?)").run(ID, CWD);
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server?.close();
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch {}
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transcript long text", () => {
|
||||||
|
it("keeps a long prompt and answer whole and still caps a tool's output", async () => {
|
||||||
|
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||||
|
const [prompt, answer, result] = body.messages;
|
||||||
|
assert.equal(prompt.content[0].text, LONG);
|
||||||
|
assert.equal(answer.content[0].text, LONG);
|
||||||
|
assert.ok(result.content[0].output.endsWith("[truncated]"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -291,6 +291,39 @@ describe("getUpdatesStatus — release drives update_available", () => {
|
|||||||
assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/);
|
assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not offer a fast-forward when HEAD holds commits the newer release lacks", async () => {
|
||||||
|
// The release squashed the local wip commits into a new one: neither side
|
||||||
|
// contains the other, so `merge --ff-only` could only fail.
|
||||||
|
const repo = makeVersionedRepo(tmpDir, "diverged", "1.5.0", null);
|
||||||
|
git(repo, ["checkout", "-b", "rel"]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repo, "package.json"),
|
||||||
|
JSON.stringify({ name: "fx", version: "1.6.0" })
|
||||||
|
);
|
||||||
|
git(repo, [...IDENT, "commit", "-am", "release"]);
|
||||||
|
git(repo, ["tag", "v1.6.0"]);
|
||||||
|
git(repo, ["checkout", "main"]);
|
||||||
|
fs.writeFileSync(path.join(repo, "wip.txt"), "wip");
|
||||||
|
git(repo, [...IDENT, "add", "."]);
|
||||||
|
git(repo, [...IDENT, "commit", "-m", "wip"]);
|
||||||
|
const status = await getUpdatesStatus(repo, {
|
||||||
|
skipFetch: true,
|
||||||
|
latestRelease: {
|
||||||
|
ok: true,
|
||||||
|
tag: "v1.6.0",
|
||||||
|
name: "1.6.0",
|
||||||
|
url: "u",
|
||||||
|
published_at: null,
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(status.update_available, true);
|
||||||
|
assert.equal(status.situation, "diverged_from_release");
|
||||||
|
assert.doesNotMatch(status.manual_command, /--ff-only/);
|
||||||
|
assert.match(status.situation_note, /v1\.6\.0/);
|
||||||
|
});
|
||||||
|
|
||||||
it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => {
|
it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => {
|
||||||
const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0");
|
const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0");
|
||||||
const status = await getUpdatesStatus(repo, {
|
const status = await getUpdatesStatus(repo, {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const os = require("os");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
|
|
||||||
const { getUpdatesStatus, npmInvocation } = require("../lib/update-check");
|
const { getUpdatesStatus, npmInvocation, execNpm } = require("../lib/update-check");
|
||||||
|
|
||||||
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
|
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
|
||||||
// very suite) sets in its own environment — those leak to every child
|
// very suite) sets in its own environment — those leak to every child
|
||||||
@@ -199,3 +199,49 @@ describe("npmInvocation (the checkout's Update now)", () => {
|
|||||||
assert.deepEqual(npm.args.slice(-2), ["run", "build"]);
|
assert.deepEqual(npm.args.slice(-2), ["run", "build"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("npm run setup (the checkout's Update now)", () => {
|
||||||
|
it("never changes directory: cmd.exe's ( ) is no subshell, so a `cd` leaks into the next step", () => {
|
||||||
|
// On Windows `(cd client && npm install) && npm run link-cli` ran
|
||||||
|
// link-cli inside client/ — "Missing script" — and Update now failed.
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
|
||||||
|
assert.doesNotMatch(pkg.scripts.setup, /\bcd\b/);
|
||||||
|
assert.match(pkg.scripts.setup, /npm --prefix client install/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("execNpm (the checkout's Update now)", () => {
|
||||||
|
let dir;
|
||||||
|
before(() => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "exec-npm-"));
|
||||||
|
const noise = "for (let i = 0; i < 60; i++) console.error('npm warn noise ' + i);";
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "fx",
|
||||||
|
version: "1.0.0",
|
||||||
|
scripts: {
|
||||||
|
fail: `node -e "${noise} console.error('ERR the real cause'); process.exit(1)"`,
|
||||||
|
slow: `node -e "setTimeout(() => {}, 60000)"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("names the failed step and keeps the end of npm's output, where the error is", async () => {
|
||||||
|
await assert.rejects(execNpm(dir, ["run", "fail"]), (err) => {
|
||||||
|
assert.match(err.message, /^npm run fail failed/);
|
||||||
|
assert.match(err.message, /ERR the real cause/);
|
||||||
|
assert.doesNotMatch(err.message, /noise 0\b/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a step was stopped at the time limit instead of echoing its warnings", async () => {
|
||||||
|
await assert.rejects(execNpm(dir, ["run", "slow"], { timeout: 3000 }), (err) => {
|
||||||
|
assert.match(err.message, /npm run slow did not finish within/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+4
-3
@@ -763,8 +763,9 @@ function startBackgroundServices() {
|
|||||||
const live = liveSessions();
|
const live = liveSessions();
|
||||||
if (!live) return; // tmux could not be asked: skip, never end every run
|
if (!live) return; // tmux could not be asked: skip, never end every run
|
||||||
healAllRunIds(live);
|
healAllRunIds(live);
|
||||||
// A pane that quit (Ctrl+C, `exit`) ends its run record too.
|
// A pane that quit (Ctrl+C, `exit`) ends its run record too; one that
|
||||||
reconcileOrphans((id) => live.has(id));
|
// died under its agent (killed tmux, crash) is offered for resume.
|
||||||
|
if (reconcileOrphans((id) => live.has(id)) > 0) broadcast("runs_interrupted", {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("run heal failed:", err.message);
|
console.warn("run heal failed:", err.message);
|
||||||
}
|
}
|
||||||
@@ -814,7 +815,7 @@ function startBackgroundServices() {
|
|||||||
const { hasSession } = require("./lib/tmux");
|
const { hasSession } = require("./lib/tmux");
|
||||||
const reconciled = reconcileOrphans(hasSession);
|
const reconciled = reconcileOrphans(hasSession);
|
||||||
if (reconciled > 0) {
|
if (reconciled > 0) {
|
||||||
console.log(`[runs] reconciled ${reconciled} orphan run(s) → abandoned`);
|
console.log(`[runs] reconciled ${reconciled} orphan run(s) → interrupted/abandoned`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("dashboard-runs reconciliation failed:", err.message);
|
console.warn("dashboard-runs reconciliation failed:", err.message);
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
* under `<id>@<its started_at>` (flagged `abandoned` if it never said it
|
* under `<id>@<its started_at>` (flagged `abandoned` if it never said it
|
||||||
* ended) — history is kept, and `getRun(<pane name>)` is always the pane's
|
* ended) — history is kept, and `getRun(<pane name>)` is always the pane's
|
||||||
* current run. A row is flagged dead only once its tmux session is gone
|
* current run. A row is flagged dead only once its tmux session is gone
|
||||||
* (`reconcileOrphans(isAlive)`): panes outlive a server restart.
|
* (`reconcileOrphans(isAlive)`): panes outlive a server restart. A session
|
||||||
|
* whose pane died under it is kept `interrupted` until the user resumes or
|
||||||
|
* dismisses it (`listInterrupted`, `dismissInterrupted`).
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -256,13 +258,21 @@ function claimToolSessions(sessionId = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runningStmt = db.prepare(
|
const runningStmt = db.prepare(
|
||||||
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
"SELECT id, tool, session_id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||||
);
|
);
|
||||||
const abandonStmt = db.prepare(`
|
const endStmt = db.prepare(`
|
||||||
UPDATE dashboard_runs
|
UPDATE dashboard_runs
|
||||||
SET status = 'abandoned',
|
SET status = @status,
|
||||||
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
WHERE id = ? AND status IN ('running', 'spawning')
|
WHERE id = @id AND status IN ('running', 'spawning')
|
||||||
|
`);
|
||||||
|
// Claude says why it ended: `/exit`, Ctrl+C/Ctrl+D and `/logout` are the
|
||||||
|
// user's own; a signal (tmux killed) reports `other` and a power cut nothing.
|
||||||
|
const userExitStmt = db.prepare(`
|
||||||
|
SELECT 1 FROM events
|
||||||
|
WHERE session_id = ? AND event_type = 'SessionEnd'
|
||||||
|
AND json_extract(data, '$.reason') IN ('prompt_input_exit', 'logout')
|
||||||
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// A shell has no session, so its row only tracks the open tab: once closed it
|
// A shell has no session, so its row only tracks the open tab: once closed it
|
||||||
@@ -272,20 +282,32 @@ const pruneShellsStmt = db.prepare(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flags `abandoned` every row still marked running/spawning whose pane is
|
* Ends every row still marked running/spawning whose pane is gone. A pane is
|
||||||
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
|
* a tmux session, which outlives a server restart, so liveness is asked of
|
||||||
* A pane is a tmux session, which outlives a server restart, so liveness is
|
* `isAlive(id)` (tmux), never assumed from the restart. Run at boot and on
|
||||||
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
|
* the server's run-heal beat, which also drops every closed Terminal tab's row.
|
||||||
* and on the server's run-heal beat, which also drops every closed Terminal
|
*
|
||||||
* tab's row.
|
* A pane AgentHub closed itself (Stop, close tab, Quit) is already `killed`.
|
||||||
|
* Of the rest, an agent session the user did not exit is `interrupted` — a
|
||||||
|
* reboot, a killed tmux, a crash — and offered for resume
|
||||||
|
* (`listInterrupted`); everything else is `abandoned`. Only Claude reports
|
||||||
|
* how it ended, so any other tool's vanished pane counts as interrupted.
|
||||||
*
|
*
|
||||||
* Returns the number of rows updated.
|
* Returns the number of rows updated.
|
||||||
*/
|
*/
|
||||||
function reconcileOrphans(isAlive = () => false) {
|
function reconcileOrphans(isAlive = () => false) {
|
||||||
try {
|
try {
|
||||||
let changes = 0;
|
let changes = 0;
|
||||||
for (const { id } of runningStmt.all()) {
|
for (const row of runningStmt.all()) {
|
||||||
if (!isAlive(id)) changes += abandonStmt.run(id).changes;
|
if (isAlive(row.id)) continue;
|
||||||
|
const interrupted =
|
||||||
|
row.tool !== "shell" &&
|
||||||
|
!!row.session_id &&
|
||||||
|
!(row.tool === "claude" && userExitStmt.get(row.session_id));
|
||||||
|
changes += endStmt.run({
|
||||||
|
id: row.id,
|
||||||
|
status: interrupted ? "interrupted" : "abandoned",
|
||||||
|
}).changes;
|
||||||
}
|
}
|
||||||
pruneShellsStmt.run();
|
pruneShellsStmt.run();
|
||||||
return changes;
|
return changes;
|
||||||
@@ -294,6 +316,50 @@ function reconcileOrphans(isAlive = () => false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The newest interrupted run of each session that nothing ran since — a
|
||||||
|
// session resumed by any path drops out — of workspaces `@owner` owns (any
|
||||||
|
// owner when null). A workspace removed since takes its rows with it.
|
||||||
|
const interruptedStmt = db.prepare(`
|
||||||
|
SELECT r.id, r.session_id, r.lane_id, r.tool, r.cwd, r.model, r.permission_mode, r.effort,
|
||||||
|
r.prompt_preview, r.started_at, r.ended_at,
|
||||||
|
l.title AS lane_title, l.cwd AS lane_cwd, s.name AS session_name
|
||||||
|
FROM dashboard_runs r
|
||||||
|
JOIN lanes l ON l.id = r.lane_id
|
||||||
|
LEFT JOIN sessions s ON s.id = r.session_id
|
||||||
|
WHERE r.status = 'interrupted' AND r.session_id IS NOT NULL
|
||||||
|
AND (@owner IS NULL OR l.owner_id = @owner)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM dashboard_runs n
|
||||||
|
WHERE n.session_id = r.session_id AND n.id != r.id
|
||||||
|
AND (n.started_at >= r.started_at OR n.status IN ('running', 'spawning'))
|
||||||
|
)
|
||||||
|
ORDER BY r.ended_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
const dismissStmt = db.prepare(`
|
||||||
|
UPDATE dashboard_runs SET status = 'abandoned'
|
||||||
|
WHERE status = 'interrupted' AND session_id = @sessionId
|
||||||
|
AND lane_id IN (SELECT id FROM lanes WHERE @owner IS NULL OR owner_id = @owner)
|
||||||
|
`);
|
||||||
|
|
||||||
|
/** Sessions whose pane went away without the user ending them, newest first. */
|
||||||
|
function listInterrupted(ownerId = null) {
|
||||||
|
try {
|
||||||
|
return interruptedStmt.all({ owner: ownerId });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user answered: these sessions are no longer offered for resume. */
|
||||||
|
function dismissInterrupted(sessionIds, ownerId = null) {
|
||||||
|
let changes = 0;
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
changes += dismissStmt.run({ sessionId, owner: ownerId }).changes;
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
recordRun,
|
recordRun,
|
||||||
patchRun,
|
patchRun,
|
||||||
@@ -301,6 +367,8 @@ module.exports = {
|
|||||||
getRun,
|
getRun,
|
||||||
forgetShellRun,
|
forgetShellRun,
|
||||||
reconcileOrphans,
|
reconcileOrphans,
|
||||||
|
listInterrupted,
|
||||||
|
dismissInterrupted,
|
||||||
proxyForSession,
|
proxyForSession,
|
||||||
laneForSession,
|
laneForSession,
|
||||||
claimToolSessions,
|
claimToolSessions,
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ function attachBinary(platform = process.platform) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// psmux (Windows) holds a bracketed paste ~350 ms before the pane sees it,
|
||||||
|
// and input written meanwhile lands INSIDE it, before ESC[201~ (psmux#766):
|
||||||
|
// the Chat view's Enter, 100 ms after its paste, became a pasted newline and
|
||||||
|
// the prompt sat in the composer as "[Pasted text #1]". Input that follows a
|
||||||
|
// paste therefore waits until this long after it; 500 ms already arrived after
|
||||||
|
// the paste in that issue's measurements.
|
||||||
|
const PASTE_HOLD_MS = 700;
|
||||||
|
|
||||||
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
|
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
|
|
||||||
@@ -91,7 +99,7 @@ function drive(group, v) {
|
|||||||
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
|
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
|
||||||
* `tmux attach-session -t <runId>` per call.
|
* `tmux attach-session -t <runId>` per call.
|
||||||
*/
|
*/
|
||||||
function attach(ws, runId, { cols, rows }) {
|
function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||||
validateRunId(runId);
|
validateRunId(runId);
|
||||||
// Retroactively covers sessions started before mouse mode was wired into
|
// Retroactively covers sessions started before mouse mode was wired into
|
||||||
// newSession (or before a server restart picked that change up) — see
|
// newSession (or before a server restart picked that change up) — see
|
||||||
@@ -117,6 +125,12 @@ function attach(ws, runId, { cols, rows }) {
|
|||||||
name: "xterm-256color",
|
name: "xterm-256color",
|
||||||
cols: lead ? lead.cols : cols || 80,
|
cols: lead ? lead.cols : cols || 80,
|
||||||
rows: lead ? lead.rows : rows || 24,
|
rows: lead ? lead.rows : rows || 24,
|
||||||
|
// Windows' inbox ConPTY re-renders psmux's output instead of passing it
|
||||||
|
// through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻,
|
||||||
|
// Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen.
|
||||||
|
// The conpty.dll + OpenConsole.exe in @lydell/node-pty-win32-* is the
|
||||||
|
// newer one VS Code uses for the same reason. Ignored elsewhere.
|
||||||
|
...(platform === "win32" && { useConptyDll: true }),
|
||||||
});
|
});
|
||||||
const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
|
const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
|
||||||
group.viewers.add(me);
|
group.viewers.add(me);
|
||||||
@@ -146,6 +160,29 @@ function attach(ws, runId, { cols, rows }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every keystroke goes through here, in order; see PASTE_HOLD_MS.
|
||||||
|
const holdsPaste = platform === "win32";
|
||||||
|
let holdUntil = 0;
|
||||||
|
let held = [];
|
||||||
|
const flush = () => {
|
||||||
|
const q = held;
|
||||||
|
held = [];
|
||||||
|
try {
|
||||||
|
q.forEach(write);
|
||||||
|
} catch {
|
||||||
|
/* the attach exited while its input was held — nothing to write to */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function write(text) {
|
||||||
|
const wait = holdUntil - Date.now();
|
||||||
|
if (held.length || wait > 0) {
|
||||||
|
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pty.write(text);
|
||||||
|
if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS;
|
||||||
|
}
|
||||||
|
|
||||||
pty.onExit(({ exitCode }) => {
|
pty.onExit(({ exitCode }) => {
|
||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
|
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
|
||||||
@@ -167,7 +204,7 @@ function attach(ws, runId, { cols, rows }) {
|
|||||||
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
||||||
const text = data.toString("utf8");
|
const text = data.toString("utf8");
|
||||||
if (binary) {
|
if (binary) {
|
||||||
pty.write(text);
|
write(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The browser's WebSocket API sends a JS string as a text frame, and
|
// The browser's WebSocket API sends a JS string as a text frame, and
|
||||||
@@ -181,7 +218,7 @@ function attach(ws, runId, { cols, rows }) {
|
|||||||
try {
|
try {
|
||||||
msg = JSON.parse(text);
|
msg = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
pty.write(text);
|
write(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
||||||
@@ -193,7 +230,7 @@ function attach(ws, runId, { cols, rows }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pty.write(text);
|
write(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
|||||||
+32
-12
@@ -81,20 +81,29 @@ const WAIT_FOR_CLIENT =
|
|||||||
const CLIENT_WAIT_MS = 2000;
|
const CLIENT_WAIT_MS = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A pane's tmux command: `argv` behind {@link WAIT_FOR_CLIENT}, or — with no
|
* `sh` that moves into `$1` itself. tmux applies `-c` only while its server's
|
||||||
* `argv` — the login shell behind it. Windows has no `sh` (a WinGet tmux runs
|
* own cwd still exists: the server is forked from the dashboard's cwd — a
|
||||||
* native programs), and an `sh -c` there died at once and took the session
|
* plugin cache dir, deleted by `pruneRetiredPluginRoots` after an update the
|
||||||
* with it, so it gets the command itself, or nothing for tmux's own default
|
* server outlives — and from then on every pane started in that deleted dir,
|
||||||
* shell.
|
* where `claude` dies at once and a shell prints `getcwd` errors (issue #69).
|
||||||
|
*/
|
||||||
|
const IN_CWD = 'cd -- "$1" 2>/dev/null; shift;';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pane's tmux command, run in `cwd`: `argv` behind {@link WAIT_FOR_CLIENT},
|
||||||
|
* or — with no `argv` — the login shell behind it. Windows has no `sh` (a
|
||||||
|
* WinGet tmux runs native programs), and an `sh -c` there died at once and
|
||||||
|
* took the session with it, so it gets the command itself, or nothing for
|
||||||
|
* tmux's own default shell.
|
||||||
* ponytail: no wait for the attach on Windows, so a pane may first draw at
|
* ponytail: no wait for the attach on Windows, so a pane may first draw at
|
||||||
* 80×24 (issue #55); port the wait to PowerShell if that shows.
|
* 80×24 (issue #55); port the wait to PowerShell if that shows.
|
||||||
*/
|
*/
|
||||||
function paneArgv(argv, platform = process.platform) {
|
function paneArgv(argv, cwd, platform = process.platform) {
|
||||||
if (platform === "win32") return argv || [];
|
if (platform === "win32") return argv || [];
|
||||||
// argv rides as sh's positional parameters, never inside the script text.
|
// cwd and argv ride as sh's positional parameters, never inside the script text.
|
||||||
return argv
|
return argv
|
||||||
? ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv]
|
? ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$@"`, "sh", cwd, ...argv]
|
||||||
: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`];
|
: ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$SHELL" -l`, "sh", cwd];
|
||||||
}
|
}
|
||||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||||
@@ -168,6 +177,7 @@ function buildClaudeArgv({
|
|||||||
addDirs = [],
|
addDirs = [],
|
||||||
systemPromptFile = null,
|
systemPromptFile = null,
|
||||||
proxyArgv = [],
|
proxyArgv = [],
|
||||||
|
platform = process.platform,
|
||||||
}) {
|
}) {
|
||||||
const argv = ["claude"];
|
const argv = ["claude"];
|
||||||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||||||
@@ -175,6 +185,15 @@ function buildClaudeArgv({
|
|||||||
// always ends it — after the prompt it would eat the prompt as a directory.
|
// always ends it — after the prompt it would eat the prompt as a directory.
|
||||||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||||||
argv.push("--permission-mode", permissionMode || "auto");
|
argv.push("--permission-mode", permissionMode || "auto");
|
||||||
|
// Inside a pane Claude Code sees $TMUX and opens agent-team teammates with
|
||||||
|
// `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads
|
||||||
|
// back is wrong and the follow-up respawn-pane/set-option land on the
|
||||||
|
// leader's own pane — the session is replaced by a bare cmd.exe. psmux also
|
||||||
|
// answers a target that does not exist (`list-panes -t <s>:claude-swarm`)
|
||||||
|
// with the CURRENT pane instead of an error, so any teammate path can hit
|
||||||
|
// the leader: Claude 2.1.296 dies on a named Agent call (older builds only
|
||||||
|
// failed with "Could not determine pane count for swarm window").
|
||||||
|
if (platform === "win32") argv.push("--teammate-mode", "in-process");
|
||||||
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
|
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
|
||||||
argv.push(...proxyArgv);
|
argv.push(...proxyArgv);
|
||||||
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
||||||
@@ -614,7 +633,7 @@ function spawnRun(args) {
|
|||||||
|
|
||||||
if (!tmux.hasSession(id)) {
|
if (!tmux.hasSession(id)) {
|
||||||
codexPool?.place();
|
codexPool?.place();
|
||||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
|
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
|
||||||
record();
|
record();
|
||||||
schedulePaste();
|
schedulePaste();
|
||||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||||
@@ -625,7 +644,7 @@ function spawnRun(args) {
|
|||||||
// still answered 200. Run the argv in the pane the user already sees
|
// still answered 200. Run the argv in the pane the user already sees
|
||||||
// instead of erroring or opening a second session.
|
// instead of erroring or opening a second session.
|
||||||
codexPool?.place();
|
codexPool?.place();
|
||||||
tmux.sendCommand(id, paneCmd, env, cwd);
|
tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
|
||||||
record();
|
record();
|
||||||
schedulePaste();
|
schedulePaste();
|
||||||
}
|
}
|
||||||
@@ -677,7 +696,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
|||||||
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
||||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||||||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env: paneEnv });
|
tmux.newSession({ name: id, cwd, argv: paneArgv(null, cwd), env: paneEnv });
|
||||||
dashboardRuns?.recordRun({
|
dashboardRuns?.recordRun({
|
||||||
id,
|
id,
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
@@ -758,4 +777,5 @@ module.exports = {
|
|||||||
MAX_PANE_SLOT,
|
MAX_PANE_SLOT,
|
||||||
SHELL_COMMANDS,
|
SHELL_COMMANDS,
|
||||||
paneArgv,
|
paneArgv,
|
||||||
|
buildClaudeArgv,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ function broadcastAudience(type, data) {
|
|||||||
case "lane_runtime":
|
case "lane_runtime":
|
||||||
return { owners: laneOwners(d.laneId) };
|
return { owners: laneOwners(d.laneId) };
|
||||||
case "update_status":
|
case "update_status":
|
||||||
|
// No payload: each client refetches its own `GET /api/run/interrupted`.
|
||||||
|
case "runs_interrupted":
|
||||||
return null;
|
return null;
|
||||||
// A member's prompt box refetches its command list on these, but the
|
// A member's prompt box refetches its command list on these, but the
|
||||||
// payload names the host's profiles and files: members get the type only.
|
// payload names the host's profiles and files: members get the type only.
|
||||||
|
|||||||
+15
-4
@@ -38,7 +38,9 @@ const { execFile, execFileSync } = require("node:child_process");
|
|||||||
// try/catch and treats failure as "no session" — piping tmux's own
|
// try/catch and treats failure as "no session" — piping tmux's own
|
||||||
// "error connecting to ..." noise to the process's stderr just spams the
|
// "error connecting to ..." noise to the process's stderr just spams the
|
||||||
// journal on every poll while no tmux server is running yet.
|
// journal on every poll while no tmux server is running yet.
|
||||||
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
|
// cwd "/": a tmux server is forked from the call that starts it and keeps
|
||||||
|
// that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69).
|
||||||
|
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
|
||||||
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
||||||
|
|
||||||
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
||||||
@@ -194,6 +196,15 @@ function __reset() {
|
|||||||
// too and is shared; any other failure is not.
|
// too and is shared; any other failure is not.
|
||||||
const LIST_TTL_MS = 1000;
|
const LIST_TTL_MS = 1000;
|
||||||
const LIST_ARGS = ["list-sessions", "-F", "#{session_name}"];
|
const LIST_ARGS = ["list-sessions", "-F", "#{session_name}"];
|
||||||
|
// psmux (Windows) ignores `-F` and prints `name: 1 windows (created …)`. A
|
||||||
|
// session name never holds a `:` (tmux rewrites it), so the text before the
|
||||||
|
// first one is the name in either form.
|
||||||
|
const sessionNames = (out) =>
|
||||||
|
String(out)
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.split(":")[0].trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
let listed = new Map(); // member login ("" = ours) → { at, out } | { at, err }
|
let listed = new Map(); // member login ("" = ours) → { at, out } | { at, err }
|
||||||
let listGen = 0; // bumped by a change this process makes: drops lists in flight
|
let listGen = 0; // bumped by a change this process makes: drops lists in flight
|
||||||
function listOutput(acct = null) {
|
function listOutput(acct = null) {
|
||||||
@@ -204,7 +215,7 @@ function listOutput(acct = null) {
|
|||||||
return hit.out;
|
return hit.out;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const out = String(execImpl(LIST_ARGS));
|
const out = sessionNames(execImpl(LIST_ARGS));
|
||||||
listed.set("", { at: Date.now(), out });
|
listed.set("", { at: Date.now(), out });
|
||||||
return out;
|
return out;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -250,7 +261,7 @@ function refreshList(acct) {
|
|||||||
// about another pane) is not believed.
|
// about another pane) is not believed.
|
||||||
(out) =>
|
(out) =>
|
||||||
done({
|
done({
|
||||||
out: String(out)
|
out: sessionNames(out)
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((n) => ownedBy(n.trim(), acct))
|
.filter((n) => ownedBy(n.trim(), acct))
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
@@ -306,7 +317,7 @@ function exact() {
|
|||||||
if (exactTargets !== null) return exactTargets;
|
if (exactTargets !== null) return exactTargets;
|
||||||
let first;
|
let first;
|
||||||
try {
|
try {
|
||||||
first = String(execImpl(["list-sessions", "-F", "#{session_name}"]))
|
first = sessionNames(execImpl(LIST_ARGS))
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
.find(Boolean);
|
.find(Boolean);
|
||||||
|
|||||||
@@ -94,19 +94,32 @@ function npmInvocation(args, env = process.env) {
|
|||||||
return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest });
|
return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `npm run setup` installs the client's dependencies more than once and
|
||||||
|
// `npm run build` type-checks before bundling; on Windows (Defender scanning
|
||||||
|
// every file) each step ran past the old 5-minute cap.
|
||||||
|
const NPM_TIMEOUT_MS = 20 * 60_000;
|
||||||
|
|
||||||
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
|
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
|
||||||
* {@link applyUpdate} runs after fast-forwarding. */
|
* {@link applyUpdate} runs after fast-forwarding. A failure names its step and
|
||||||
|
* carries the END of npm's output — the error, not the warnings before it. */
|
||||||
function execNpm(cwd, args, opts = {}) {
|
function execNpm(cwd, args, opts = {}) {
|
||||||
const timeout = opts.timeout ?? 300_000;
|
const timeout = opts.timeout ?? NPM_TIMEOUT_MS;
|
||||||
const npm = npmInvocation(args);
|
const npm = npmInvocation(args);
|
||||||
|
const step = `npm ${args.join(" ")}`;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile(
|
execFile(
|
||||||
npm.file,
|
npm.file,
|
||||||
npm.args,
|
npm.args,
|
||||||
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell },
|
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell },
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
if (err) reject(new Error(stderr || err.message || String(err)));
|
if (!err) return resolve(String(stdout).trim());
|
||||||
else resolve(String(stdout).trim());
|
if (err.killed) {
|
||||||
|
return reject(
|
||||||
|
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
|
||||||
|
reject(new Error(`${step} failed:\n${tail}`));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -698,6 +711,16 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
|||||||
`This dashboard runs from the Claude Code plugin cache (marketplace '${pluginInstall.marketplace}'), ` +
|
`This dashboard runs from the Claude Code plugin cache (marketplace '${pluginInstall.marketplace}'), ` +
|
||||||
`so it can't update itself. Run the commands above, then '/reload-plugins' in Claude Code — ` +
|
`so it can't update itself. Run the commands above, then '/reload-plugins' in Claude Code — ` +
|
||||||
`or just run '/agenthub-update', which does both and rebuilds the runtime.`;
|
`or just run '/agenthub-update', which does both and rebuilds the runtime.`;
|
||||||
|
} else if (release.ok && releaseIsNewer && commitsAheadOfRelease > 0) {
|
||||||
|
// HEAD carries commits the release lacks (e.g. local wip that the release
|
||||||
|
// squashed into a new commit): `merge --ff-only` can only fail, and how to
|
||||||
|
// reconcile is the user's call, so fetch the tag and say why.
|
||||||
|
situation = "diverged_from_release";
|
||||||
|
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote} --tags`];
|
||||||
|
situationNote =
|
||||||
|
`Your checkout has ${commitsAheadOfRelease} commit(s) that release ${release.tag} doesn't contain, ` +
|
||||||
|
`so it can't be fast-forwarded. If they're already in the release, move onto it with ` +
|
||||||
|
`'git reset --keep ${release.tag}'; otherwise merge or rebase onto ${release.tag}.`;
|
||||||
} else if (release.ok && releaseIsNewer) {
|
} else if (release.ok && releaseIsNewer) {
|
||||||
// Move onto the release TAG rather than the branch tip: the tag is what
|
// Move onto the release TAG rather than the branch tip: the tag is what
|
||||||
// was published, and a fast-forward keeps the tracking branch intact (a
|
// was published, and a fast-forward keeps the tracking branch intact (a
|
||||||
@@ -870,4 +893,5 @@ module.exports = {
|
|||||||
repoUrlFor,
|
repoUrlFor,
|
||||||
isShallowClone,
|
isShallowClone,
|
||||||
npmInvocation,
|
npmInvocation,
|
||||||
|
execNpm,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const PREF_KEYS = [
|
|||||||
"agenthub.permissionMode",
|
"agenthub.permissionMode",
|
||||||
"agenthub.tour",
|
"agenthub.tour",
|
||||||
"agenthub.chatUi",
|
"agenthub.chatUi",
|
||||||
|
"agenthub.workspace.allTabs",
|
||||||
];
|
];
|
||||||
const MAX_VALUE = 8 * 1024;
|
const MAX_VALUE = 8 * 1024;
|
||||||
|
|
||||||
|
|||||||
+42
-4
@@ -226,6 +226,29 @@ router.get("/history", (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sessions whose pane went away without the user ending them (a reboot, a
|
||||||
|
* killed tmux, a crash), for the "resume interrupted sessions" prompt. Only
|
||||||
|
* the caller's own workspaces in server mode: an admin is not offered a
|
||||||
|
* developer's sessions.
|
||||||
|
*/
|
||||||
|
router.get("/interrupted", (req, res) => {
|
||||||
|
const dr = require("../lib/dashboard-runs");
|
||||||
|
res.json({ items: dr.listInterrupted(scope.ownerFor(req)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The user answered the prompt: stop offering these sessions. Resumes nothing. */
|
||||||
|
router.post("/interrupted/dismiss", (req, res) => {
|
||||||
|
const ids = req.body?.sessionIds;
|
||||||
|
if (!Array.isArray(ids) || ids.length > 100 || !ids.every((id) => typeof id === "string")) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: { code: "EBADFIELD", message: "sessionIds must be an array of session ids" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const dr = require("../lib/dashboard-runs");
|
||||||
|
res.json({ dismissed: dr.dismissInterrupted(ids, scope.ownerFor(req)) });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forget one closed Terminal tab (`tool = 'shell'`) from the history at once,
|
* Forget one closed Terminal tab (`tool = 'shell'`) from the history at once,
|
||||||
* ahead of the run-heal beat. A live pane is refused; another tool's row is a 404.
|
* ahead of the run-heal beat. A live pane is refused; another tool's row is a 404.
|
||||||
@@ -475,9 +498,10 @@ router.post("/", (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// A dropped image, shown back in the session's conversation. Only a name
|
// A dropped image, shown back in the session's conversation, or a dropped text
|
||||||
// drop-image/drop-file made (random hex prefix, no separator) and only an image
|
// file (a long paste, #68), previewed in its Uploads/Files read list. Only a name
|
||||||
// type; raster only and under a sandbox CSP, like `/git/raw`.
|
// drop-image/drop-file made (random hex prefix, no separator) and only those
|
||||||
|
// types; raster images, text as text/plain, under a sandbox CSP, like `/git/raw`.
|
||||||
const DROP_IMAGE_TYPES = {
|
const DROP_IMAGE_TYPES = {
|
||||||
png: "image/png",
|
png: "image/png",
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
@@ -485,10 +509,24 @@ const DROP_IMAGE_TYPES = {
|
|||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
webp: "image/webp",
|
webp: "image/webp",
|
||||||
};
|
};
|
||||||
|
const DROP_TEXT_TYPES = new Set([
|
||||||
|
"txt",
|
||||||
|
"md",
|
||||||
|
"markdown",
|
||||||
|
"csv",
|
||||||
|
"tsv",
|
||||||
|
"log",
|
||||||
|
"json",
|
||||||
|
"xml",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
]);
|
||||||
router.get("/drop/:name", (req, res) => {
|
router.get("/drop/:name", (req, res) => {
|
||||||
const name = String(req.params.name);
|
const name = String(req.params.name);
|
||||||
const m = /^[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.([A-Za-z]+)$/.exec(name);
|
const m = /^[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.([A-Za-z]+)$/.exec(name);
|
||||||
const type = m && DROP_IMAGE_TYPES[m[1].toLowerCase()];
|
const ext = m && m[1].toLowerCase();
|
||||||
|
const type =
|
||||||
|
ext && (DROP_IMAGE_TYPES[ext] || (DROP_TEXT_TYPES.has(ext) && "text/plain; charset=utf-8"));
|
||||||
// Opened first, then checked on the descriptor (no window to swap the name
|
// Opened first, then checked on the descriptor (no window to swap the name
|
||||||
// for a link after the check): a regular file this account wrote. NOFOLLOW
|
// for a link after the check): a regular file this account wrote. NOFOLLOW
|
||||||
// refuses a link outright; NONBLOCK keeps a FIFO from hanging the loop.
|
// refuses a link outright; NONBLOCK keeps a FIFO from hanging the loop.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { broadcast } = require("../websocket");
|
|||||||
const { calculateCost, attachAgentCosts } = require("./pricing");
|
const { calculateCost, attachAgentCosts } = require("./pricing");
|
||||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||||
const { memberId, sessionWhere, canSeeSession } = require("../lib/scope");
|
const { memberId, sessionWhere, canSeeSession } = require("../lib/scope");
|
||||||
|
const { isServerMode } = require("../lib/auth");
|
||||||
const {
|
const {
|
||||||
projectsDirFor,
|
projectsDirFor,
|
||||||
sessionProjectsDirs,
|
sessionProjectsDirs,
|
||||||
@@ -379,9 +380,10 @@ const outlineEvents = db.prepare(
|
|||||||
);
|
);
|
||||||
// A file the composer attached (`/api/run/drop-*` names it), as its path is
|
// A file the composer attached (`/api/run/drop-*` names it), as its path is
|
||||||
// pasted into the prompt; and a Claude Code memory file.
|
// pasted into the prompt; and a Claude Code memory file.
|
||||||
|
// The drop dir is `<data dir>/drops/`; older transcripts name `<tmp>/agenthub-drops-<uid>/`.
|
||||||
const UPLOAD_RE =
|
const UPLOAD_RE =
|
||||||
/(?:^|\s)(\S*\/agenthub-drops-\d+\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
|
/(?:^|\s)(\S*\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
|
||||||
const isUpload = (p) => /\/agenthub-drops-\d+\//.test(p);
|
const isUpload = (p) => /\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}/.test(p);
|
||||||
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
|
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
|
||||||
/** Records `p` once, newest last. */
|
/** Records `p` once, newest last. */
|
||||||
const touch = (set, p) => {
|
const touch = (set, p) => {
|
||||||
@@ -474,6 +476,61 @@ router.get("/:id/outline", (req, res) => {
|
|||||||
res.json(sessionOutline(req.params.id));
|
res.json(sessionOutline(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
|
||||||
|
// pane's Files read preview when it lies outside the workspace (a screenshot
|
||||||
|
// under ~/.cache, /tmp). The path is authorized only by that event, so: raster
|
||||||
|
// types only, under a sandbox CSP with nosniff; never for a pushed session
|
||||||
|
// (another machine's paths), and not in server mode, where a member's hook
|
||||||
|
// could name a host-only file that this process would then read as the host.
|
||||||
|
const READ_IMAGE_TYPES = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
};
|
||||||
|
const READ_IMAGE_MAX = 20 * 1024 * 1024;
|
||||||
|
const sessionReadFile = db.prepare(
|
||||||
|
`SELECT 1 FROM events
|
||||||
|
WHERE session_id = ? AND event_type = 'PostToolUse' AND tool_name = 'Read'
|
||||||
|
AND json_extract(data, '$.tool_input.file_path') = ?
|
||||||
|
LIMIT 1`
|
||||||
|
);
|
||||||
|
router.get("/:id/image", async (req, res) => {
|
||||||
|
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||||
|
const session = stmts.getSession.get(req.params.id);
|
||||||
|
if (!session) return fail(404, "NOT_FOUND", "Session not found");
|
||||||
|
if (isServerMode() || String(session.source || "").startsWith("push:")) {
|
||||||
|
return fail(403, "EUNSUPPORTED", "not available for this session");
|
||||||
|
}
|
||||||
|
const file = typeof req.query.path === "string" ? req.query.path : "";
|
||||||
|
const type = READ_IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
|
||||||
|
if (!path.isAbsolute(file) || !type) return fail(400, "ENOTIMAGE", "not an image path");
|
||||||
|
if (!sessionReadFile.get(session.id, file)) {
|
||||||
|
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||||
|
}
|
||||||
|
let fh;
|
||||||
|
try {
|
||||||
|
// NONBLOCK: a FIFO named like an image must not hang the event loop.
|
||||||
|
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
||||||
|
const st = await fh.stat();
|
||||||
|
if (!st.isFile()) return fail(404, "ENOTFOUND", "no such image");
|
||||||
|
if (st.size > READ_IMAGE_MAX) return fail(400, "ETOOBIG", "image is larger than 20 MB");
|
||||||
|
const buf = await fh.readFile();
|
||||||
|
res.set({
|
||||||
|
"Content-Type": type,
|
||||||
|
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
});
|
||||||
|
res.send(buf);
|
||||||
|
} catch {
|
||||||
|
if (!res.headersSent) fail(404, "ENOTFOUND", "no such image");
|
||||||
|
} finally {
|
||||||
|
await fh?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** The `/:id/stats` body — also frozen into a session snapshot. */
|
/** The `/:id/stats` body — also frozen into a session snapshot. */
|
||||||
function sessionStats(sessionId) {
|
function sessionStats(sessionId) {
|
||||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||||
@@ -912,7 +969,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
type: "user",
|
type: "user",
|
||||||
sender: "user", // local slash-command I/O is the human's own action
|
sender: "user", // local slash-command I/O is the human's own action
|
||||||
timestamp: entry.timestamp || null,
|
timestamp: entry.timestamp || null,
|
||||||
content: [{ type: "text", text: truncate(sysText, 10240) }],
|
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
||||||
line: num,
|
line: num,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -941,7 +998,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
type: "user",
|
type: "user",
|
||||||
sender: isSystem ? "system" : "user",
|
sender: isSystem ? "system" : "user",
|
||||||
timestamp: entry.timestamp || att.timestamp || null,
|
timestamp: entry.timestamp || att.timestamp || null,
|
||||||
content: [{ type: "text", text: truncate(prompt, 10240) }],
|
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
||||||
line: num,
|
line: num,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -952,11 +1009,11 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
if (entry.type === "user") {
|
if (entry.type === "user") {
|
||||||
const msgContent = entry.message?.content;
|
const msgContent = entry.message?.content;
|
||||||
if (typeof msgContent === "string") {
|
if (typeof msgContent === "string") {
|
||||||
content.push({ type: "text", text: truncate(msgContent, 10240) });
|
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
||||||
} else if (Array.isArray(msgContent)) {
|
} else if (Array.isArray(msgContent)) {
|
||||||
for (const block of msgContent) {
|
for (const block of msgContent) {
|
||||||
if (block.type === "text" && block.text) {
|
if (block.type === "text" && block.text) {
|
||||||
content.push({ type: "text", text: truncate(block.text, 10240) });
|
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||||
} else if (block.type === "tool_result") {
|
} else if (block.type === "tool_result") {
|
||||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||||
const diff = patchCounts(entry.toolUseResult);
|
const diff = patchCounts(entry.toolUseResult);
|
||||||
@@ -982,7 +1039,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
if (Array.isArray(msgContent)) {
|
if (Array.isArray(msgContent)) {
|
||||||
for (const block of msgContent) {
|
for (const block of msgContent) {
|
||||||
if (block.type === "text" && block.text) {
|
if (block.type === "text" && block.text) {
|
||||||
content.push({ type: "text", text: truncate(block.text, 10240) });
|
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||||
} else if (block.type === "thinking" && block.thinking) {
|
} else if (block.type === "thinking" && block.thinking) {
|
||||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||||
} else if (block.type === "tool_use") {
|
} else if (block.type === "tool_use") {
|
||||||
@@ -1189,7 +1246,7 @@ function codexMessage(entry, num) {
|
|||||||
const content = (p.content || [])
|
const content = (p.content || [])
|
||||||
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
|
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
|
||||||
.filter((t) => t.trim() && !(p.role === "user" && CODEX_INJECTED.test(t.trimStart())))
|
.filter((t) => t.trim() && !(p.role === "user" && CODEX_INJECTED.test(t.trimStart())))
|
||||||
.map((t) => ({ type: "text", text: truncate(t, 10240) }));
|
.map((t) => ({ type: "text", text: truncate(t, TEXT_MAX) }));
|
||||||
return content.length ? msg(p.role, p.role, content) : null;
|
return content.length ? msg(p.role, p.role, content) : null;
|
||||||
}
|
}
|
||||||
case "reasoning": {
|
case "reasoning": {
|
||||||
@@ -1229,7 +1286,9 @@ function codexMessage(entry, num) {
|
|||||||
}
|
}
|
||||||
case "agent_message": {
|
case "agent_message": {
|
||||||
const t = codexText(p.content);
|
const t = codexText(p.content);
|
||||||
return t.trim() ? msg("user", "system", [{ type: "text", text: truncate(t, 10240) }]) : null;
|
return t.trim()
|
||||||
|
? msg("user", "system", [{ type: "text", text: truncate(t, TEXT_MAX) }])
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -1259,6 +1318,12 @@ function patchCounts(r) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||||
|
// the model's output and what the user typed. Only tool payloads and thinking
|
||||||
|
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||||
|
// against a pathological line, not a long answer.
|
||||||
|
const TEXT_MAX = 1024 * 1024;
|
||||||
|
|
||||||
function truncate(str, maxLen) {
|
function truncate(str, maxLen) {
|
||||||
if (!str || str.length <= maxLen) return str;
|
if (!str || str.length <= maxLen) return str;
|
||||||
return str.slice(0, maxLen) + "[truncated]";
|
return str.slice(0, maxLen) + "[truncated]";
|
||||||
|
|||||||
Reference in New Issue
Block a user