diff --git a/CLAUDE.md b/CLAUDE.md index ba9a9f18..d17523d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.22 (2026-10-23) — Users' today/7/30-day spend is booked like the cost routes (every tool counts); a page adopts a running plugin update and keeps its progress chip._ 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.8.23 (2026-10-23) — sidebar workspace groups; a parent folder's inner repos in Changes (opt-in); a session's sub-agents count in its tokens._ 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 - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -44,15 +44,15 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. -- **The dashboard's git writes are these twenty-two, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) is its own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. +- **The dashboard's git writes are these twenty-two, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. - **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. - **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`). AgentHub never reads the IDE's data. - **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'/agenthub-hook.sh' ` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, `grok` — also recognised by its payload's camelCase `hookEventName` + `workspaceRoot`, since Grok runs the Claude hooks — else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`. -- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; a global prefix the account cannot write (a system-wide Node, root's `/usr/local`) makes the install `--prefix ~/.local` instead, never sudo; an npm update is refused (`ENOTNPM`) for a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects. Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name. +- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; a global prefix the account cannot write (a system-wide Node, root's `/usr/local`) makes the install `--prefix ~/.local` instead, never sudo; an npm update is refused (`ENOTNPM`) for a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects — the one way off that site is the catalog entry's own `install.redirect`, an EXACT https URL (never a site wildcard). Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name. - **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (opencode `options.apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free. -- **A lane's card position is a viewer preference; its name is a lane fact.** The sidebar workspace list's drag/Alt+↑/↓ order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input. +- **A lane's card position is a viewer preference; its name is a lane fact.** The sidebar workspace list's drag/Alt+↑/↓ order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Its groups (`client/src/lib/workspaceGroups.ts`) are the same kind of viewer arrangement — no lane column, nothing in the CLI — but kept per ACCOUNT as the synced preference `agenthub.workspace.groups` (`/api/me/prefs`, 64 KB), so they outlive the browser; deleting a group only ungroups. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input. - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. - **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.) - **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane--<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale. diff --git a/client/src/components/WorkspaceTree.tsx b/client/src/components/WorkspaceTree.tsx index 71082b54..c5c6be98 100644 --- a/client/src/components/WorkspaceTree.tsx +++ b/client/src/components/WorkspaceTree.tsx @@ -15,6 +15,16 @@ * first, no owner last, groups foldable — `collapsedOwners`); one this user * may not change (`canEditLane`) is read only — no menu, no new session, and a * session opens its analysis page (`/sessions/`), never a pane. + * The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the + * server like the other preferences) arrange the list under foldable headers — + * each group in its order, then "Ungrouped" — inside each owner group when + * there are owner groups. A workspace joins a group from its menu, by a drop + * on the group's header, by a drag that lands it below a row of that + * group, or by Alt+↑/↓ past the group's edge; a header is dragged onto + * another to reorder the groups, and its menu renames, moves, folds every + * group, adds a new workspace straight into it or deletes it (its + * workspaces stay, ungrouped). A folded header still shows the worst state + * of its workspaces (needs you, error, running) and marks the selected one. * @author Nguyễn Ngọc Trí Vĩ */ @@ -30,7 +40,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; -import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react"; +import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react"; import { api, type CwdSuggestion, type RunHandle } from "../lib/api"; import type { Lane } from "../lib/types"; import { useLanes } from "../hooks/useLanes"; @@ -46,7 +56,31 @@ import { writeLaneOrder, } from "../lib/laneOrderStorage"; import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs"; -import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow"; +import { + arrangeByGroup, + assignGroup, + createGroup, + deleteGroup, + groupOf, + moveGroup, + moveGroupBefore, + pruneWorkspaceGroups, + renameGroup, + setAllFolded, + setGroupFolded, + useWorkspaceGroups, + type WorkspaceGroup, +} from "../lib/workspaceGroups"; +import { confirmDialog } from "../lib/confirm"; +import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu"; +import { + LANE_DRAG_TYPE, + WorkspaceRow, + isLaneDrag, + workspaceDot, + workspaceDotLabel, + workspaceName, +} from "./lanes/WorkspaceRow"; import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu"; import { UserAvatar } from "./users/UserAvatar"; @@ -98,8 +132,23 @@ export function WorkspaceTree() { ); const [addOpen, setAddOpen] = useState(false); const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null); + // "New workspace in this group": the group the added workspace joins. + const [addToGroup, setAddToGroup] = useState(null); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [orderTick, setOrderTick] = useState(0); + const groups = useWorkspaceGroups(); + const hasGroups = groups.groups.length > 0; + const [renamingGroup, setRenamingGroup] = useState(null); + const [groupMenu, setGroupMenu] = useState<{ + group: WorkspaceGroup; + x: number; + y: number; + } | null>(null); + // A removed workspace leaves the stored groups (they hold ids only). + const laneIdsKey = lanes.map((l) => l.id).join(","); + useEffect(() => { + if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number)); + }, [laneIdsKey]); // A slot-0 pane starts, ends or learns its session id exactly when a lane's // status or session moves, so that is when the live-pane list is re-read; @@ -126,8 +175,9 @@ export function WorkspaceTree() { return () => window.removeEventListener(LIVE_RUNS_EVENT, take); }, []); - const openAdd = (sourceRepo?: string) => { + const openAdd = (sourceRepo?: string, groupId: string | null = null) => { setAddPrefill(sourceRepo ? { sourceRepo } : null); + setAddToGroup(groupId); setAddOpen(true); api.run .cwds() @@ -148,8 +198,9 @@ export function WorkspaceTree() { const toolFilter = tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null; const sorted = useMemo(() => { - const list = sortByStoredOrder(lanes, order).filter( - (l) => !toolFilter || l.tool === toolFilter + const list = arrangeByGroup( + sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter), + groups ); if (!grouped) return list; // Stable: each owner's workspaces keep the stored order inside the group. @@ -157,7 +208,7 @@ export function WorkspaceTree() { return [...list].sort( (a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b)) ); - }, [lanes, order, toolFilter, grouped, meId]); + }, [lanes, order, toolFilter, grouped, meId, groups]); const q = query.trim().toLowerCase(); const nameHit = (l: Lane) => workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q); @@ -190,6 +241,26 @@ export function WorkspaceTree() { setOrderTick((n) => n + 1); }; + // Alt+↑/↓. Past the edge of its group the workspace stays where it is on + // screen and joins the next group instead (the arrangement would otherwise + // put it straight back): last of the group above, first of the one below — + // opened if folded, so the focused row stays shown. The shown order is + // written as it stands, so the store agrees. Never across an owner's section. + const nudge = (from: number, to: number) => { + const lane = sorted[from]; + const next = sorted[to]; + if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) { + const target = groupOf(groups, next.id); + if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) { + assignGroup(lane.id, target?.id ?? null); + if (target?.folded) setGroupFolded(target.id, false); + reorder((ids) => [...ids]); + return; + } + } + reorder((ids) => moveLane(ids, from, to)); + }; + // A row only starts a drag; the whole section takes the drop, so a gap — a // workspace's session list, the space above or below a row — lands too. // `dropAt` is the insertion point in `sorted`, drawn as a line. @@ -223,11 +294,74 @@ export function WorkspaceTree() { const raw = e.dataTransfer.getData(LANE_DRAG_TYPE); // Number("") is 0: an empty payload must not move the first row. const from = raw === "" ? NaN : Number(raw); + if (!Number.isInteger(from)) return; + const lane = sorted[from]; + // Dropped on a group's header: into that group, at its top. + const header = (e.target as HTMLElement).closest("[data-group-drop]"); + if (header && lane) { + const gid = header.dataset.groupDrop || null; + assignGroup(lane.id, gid); + const first = sorted.findIndex( + (l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id + ); + if (first >= 0) reorder((ids) => moveBefore(ids, from, first)); + return; + } const before = dropIndexAt(e.clientY); - if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before)); + // Elsewhere it joins the group of the row it now sits under (the line + // drawn above a header closes the section before it). + if (lane && hasGroups) { + const rest = sorted.filter((l) => l.id !== lane.id); + const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length; + const neighbour = rest[at - 1] ?? rest[0]; + if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null); + } + reorder((ids) => moveBefore(ids, from, before)); }, }; + // A group no workspace is in yet still shows (to be renamed, or dropped on), + // just above the ungrouped ones; a search hides it. + const emptyGroups = q + ? [] + : groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id)); + const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1; + const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => ( + l.id === selected.lane)} + onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)} + folded={!!g?.folded && !q} + renaming={!!g && renamingGroup === g.id} + first={first} + onToggle={() => g && setGroupFolded(g.id, !g.folded)} + onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })} + onRenameEnd={(name) => { + setRenamingGroup(null); + if (g && name !== null) renameGroup(g.id, name); + }} + /> + ); + + const newGroup = (laneIds: number[] = []) => { + const id = createGroup(t("tree.groupDefaultName"), laneIds); + setRenamingGroup(id); + }; + const removeGroup = async (g: WorkspaceGroup) => { + const ok = await confirmDialog({ + title: t("tree.groupDelete"), + message: t("tree.groupDeleteConfirm", { name: g.name }), + confirmLabel: t("tree.groupDelete"), + }); + if (!ok) return; + deleteGroup(g.id); + }; + const rename = async (lane: Lane, title: string | null) => { setRenamingId(null); if (title === null || title === (lane.title || "")) return; @@ -304,6 +438,16 @@ export function WorkspaceTree() { > + )} + {i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))} + {sectionStart && + sectionShown && + !folded && + groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)} {dropAt === i && } - {visible(lane) && !folded && ( + {visible(lane) && !hiddenRow && ( setExpanded(lane.id, !expanded(lane.id))} onNewSession={() => select(lane.id)} - onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))} + onReorder={nudge} onMenu={(x, y) => setMenu({ lane, x, y })} onRenameStart={() => setRenamingId(lane.id)} onRenameEnd={(title) => void rename(lane, title)} /> )} - {expanded(lane.id) && !folded && ( + {expanded(lane.id) && !hiddenRow && ( ); })} + {firstUngrouped === -1 && + emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))} {dropAt === sorted.length && (
@@ -440,8 +601,92 @@ export function WorkspaceTree() { y={menu.y} onClose={() => setMenu(null)} onAction={(a) => onMenuAction(menu.lane, a)} + groups={groups.groups} + groupId={groups.of[menu.lane.id] ?? null} + onGroup={(target) => { + const id = menu.lane.id; + setMenu(null); + if (target === "new") newGroup([id]); + else assignGroup(id, target); + }} /> )} + {groupMenu && ( + setGroupMenu(null)} + testId={`workspace-group-menu-${groupMenu.group.id}`} + > + { + setRenamingGroup(groupMenu.group.id); + setGroupMenu(null); + }} + > + {t("tree.groupRename")} + + { + moveGroup(groupMenu.group.id, -1); + setGroupMenu(null); + }} + > + {t("tree.groupMoveUp")} + + { + moveGroup(groupMenu.group.id, 1); + setGroupMenu(null); + }} + > + {t("tree.groupMoveDown")} + + + { + const g = groupMenu.group; + setGroupMenu(null); + openAdd(undefined, g.id); + }} + > + {t("tree.groupAddWorkspace")} + + { + setGroupMenu(null); + setAllFolded(true); + }} + > + {t("tree.groupFoldAll")} + + { + setGroupMenu(null); + setAllFolded(false); + }} + > + {t("tree.groupUnfoldAll")} + + + { + const g = groupMenu.group; + setGroupMenu(null); + void removeGroup(g); + }} + > + {t("tree.groupDelete")} + + + )} {destructive && ( { setAddOpen(false); setAddPrefill(null); + setAddToGroup(null); }} onAdded={(lane) => { + if (addToGroup) assignGroup(lane.id, addToGroup); void refreshLanes(); select(lane.id); }} @@ -472,9 +719,167 @@ export function WorkspaceTree() { ); } +/** What a group header's drag carries: the group's id. */ +const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group"; +const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) => + !!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE); + +// needsYou outranks an error, an error a running agent; idle/active say nothing. +const URGENCY: Record = { needsYou: 3, error: 2, running: 1 }; +/** The most urgent of `lanes` by its dot, or null when all are at rest. */ +function worstOf(lanes: Lane[]): Lane | null { + let best: Lane | null = null; + let top = 0; + for (const l of lanes) { + const u = URGENCY[workspaceDotLabel(l)] ?? 0; + if (u > top) [best, top] = [l, u]; + } + return best; +} + /** A workspace's owner group: its user id, or "none". */ const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id)); +/** + * A section's header: a user group (foldable, menu, inline rename) or, with + * `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`). + */ +function GroupHeader({ + group, + count, + worst, + holdsSelected, + folded, + renaming, + first, + onToggle, + onMenu, + onRenameEnd, + onGroupDrop, +}: { + group: WorkspaceGroup | null; + count: number; + /** The most urgent workspace inside, drawn as its dot while folded. */ + worst: Lane | null; + /** The open workspace is in this group (marked while folded). */ + holdsSelected: boolean; + folded: boolean; + renaming: boolean; + first: boolean; + onToggle: () => void; + onMenu: (x: number, y: number) => void; + /** The typed name, or null when the edit was cancelled. */ + onRenameEnd: (name: string | null) => void; + /** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */ + onGroupDrop: (draggedId: string) => void; +}) { + const { t } = useTranslation("lanes"); + const [draft, setDraft] = useState(group?.name ?? ""); + const [dropOver, setDropOver] = useState(false); + useEffect(() => { + if (renaming) setDraft(group?.name ?? ""); + }, [renaming, group?.name]); + const label = group ? group.name : t("tree.ungrouped"); + return ( +
{ + if (!group) return; + e.preventDefault(); + onMenu(e.clientX, e.clientY); + }} + // A group (never "Ungrouped") is dragged by its header to reorder. + draggable={!!group && !renaming} + onDragStart={(e) => { + if (!group) return; + e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + if (!isGroupDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDropOver(true); + }} + onDragLeave={() => setDropOver(false)} + onDrop={(e) => { + setDropOver(false); + if (!isGroupDrag(e)) return; + e.preventDefault(); + const id = e.dataTransfer.getData(GROUP_DRAG_TYPE); + if (id) onGroupDrop(id); + }} + className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`} + > + {renaming && group ? ( + setDraft(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + onBlur={() => onRenameEnd(draft)} + onKeyDown={(e) => { + if (e.key === "Enter") onRenameEnd(draft); + else if (e.key === "Escape") onRenameEnd(null); + }} + className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary" + /> + ) : ( + + )} + {group && !renaming && ( + + )} +
+ ); +} + /** Where a dragged workspace would land: a line on its parent's top edge. */ function DropLine() { return ( diff --git a/client/src/components/__tests__/WorkspaceTree.test.tsx b/client/src/components/__tests__/WorkspaceTree.test.tsx index 44256a55..9481940a 100644 --- a/client/src/components/__tests__/WorkspaceTree.test.tsx +++ b/client/src/components/__tests__/WorkspaceTree.test.tsx @@ -6,7 +6,9 @@ * workspace's OWN sessions only, a session's state dot, rename and archive, * selection * by URL, Alt+arrow reordering kept in localStorage, double-click rename, the - * search, the row menu's items by kind and tool, and the status dot. + * search, the row menu's items by kind and tool, the status dot, and the + * user's own groups (menu, Alt+arrow past a group's edge, a folded group's + * state dot, dragging a header to reorder, fold all). * @author Nguyễn Ngọc Trí Vĩ */ @@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => { }; }); +// The Add modal reports the workspace it made (id 2 here); the tree does the rest. +vi.mock("../lanes/AddLaneModal", () => ({ + AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => ( +
{panel} @@ -1695,7 +1774,18 @@ export function Workspace() { onClose={sourceCtx.close} testId="panel-git-source-menu" > - {sourceCtx.menu.item !== "" && ( + {innerMode === "all" && ( + { + sourceCtx.close(); + loadInnerRepos(panelAt.laneId, true); + }} + > + {tLanes("changes.rescanRepos")} + + )} + {/^\d+$/.test(sourceCtx.menu.item) && ( { @@ -1777,7 +1867,11 @@ export function Workspace() { {panelAt && - [undefined, ...folders.map((f) => f.id)].map((source) => ( + [ + ...(showRoot ? [undefined] : []), + ...repos.map((r): GitSource => r.path), + ...folders.map((f): GitSource => f.id), + ].map((source) => ( { : [], }) ), + // The repos inside the workspace folder (`?repo=`): none by default. + repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }), // 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 }], @@ -330,6 +332,79 @@ describe("Workspace — the Changes panel's width", () => { expect(screen.queryByTestId("panel-git-source")).toBeNull(); }); + it("neither walks a parent folder nor shows its repos until the account turns it on", async () => { + vi.mocked(api.lanes.repos).mockClear(); + vi.mocked(api.lanes.repos).mockResolvedValue({ + root: false, + items: [{ path: "api", name: "api" }], + truncated: false, + }); + render( + + + + ); + await settle(); + const rail = screen.queryByTestId("lane-changes-expand"); + if (rail) { + fireEvent.click(rail); + await settle(); + } + expect(api.lanes.repos).not.toHaveBeenCalled(); + expect(screen.queryByTestId("panel-git-source")).toBeNull(); + vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false }); + }); + + it("gives each repo inside a parent folder its own section, without the folder's own", async () => { + // Settings → Git → Changes panel: show the repositories inside. + window.localStorage.setItem("agenthub.changes.innerRepos", "all"); + vi.mocked(api.lanes.repos).mockResolvedValue({ + root: false, + items: [ + { path: "api", name: "api" }, + { path: "apps/web", name: "apps/web" }, + ], + truncated: false, + }); + vi.mocked(api.lanes.git).mockClear(); + render( + + + + ); + await settle(); + const rail = screen.queryByTestId("lane-changes-expand"); + if (rail) { + fireEvent.click(rail); + await settle(); + } + const sections = screen.getAllByTestId("panel-git-source"); + expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([ + "api", + "apps/web", + ]); + expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" }); + // An inner repo is the folder's own: its menu rescans, never closes it. + fireEvent.contextMenu(sections[1]!.querySelector("button")!); + expect(screen.queryByTestId("panel-git-source-close")).toBeNull(); + fireEvent.click(screen.getByTestId("panel-git-source-rescan")); + expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true); + // A chat's file inside an inner repo opens in that repo's section. + fireEvent.click(sections[1]!.querySelector("button")!); + expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false"); + act(() => { + openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts"); + }); + await settle(); + expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true"); + expect(api.lanes.gitFile).toHaveBeenCalledWith( + { lane: 2, repo: "apps/web" }, + "src/x.ts", + "work" + ); + vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false }); + }); + it("is one plain panel for a workspace with no extra folders", async () => { render( diff --git a/client/src/pages/__tests__/Workspace.test.tsx b/client/src/pages/__tests__/Workspace.test.tsx index 23f14f7c..78777d3f 100644 --- a/client/src/pages/__tests__/Workspace.test.tsx +++ b/client/src/pages/__tests__/Workspace.test.tsx @@ -123,6 +123,7 @@ vi.mock("../../lib/api", async (importOriginal) => { lanes: { // The Changes panel's folder picker reads the workspace's extra folders. sources: vi.fn().mockResolvedValue({ sources: [] }), + repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }), list: vi.fn().mockImplementation(async () => { recordCall("GET", "/api/lanes"); return { lanes: lanesToReturn, counts: countsToReturn }; diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 767b1c99..ef93cf16 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.8.22 (rebuild client to match) + UI build v2.8.23 (rebuild client to match)

diff --git a/docs/API.md b/docs/API.md index 0a5ace46..6d176763 100644 --- a/docs/API.md +++ b/docs/API.md @@ -971,6 +971,34 @@ folder below its repo root) applies to it as to a workspace folder, and `GET /git` reports `base` as that repo's own default branch. A folder that is not a git repo answers `{available: false}`. +#### Repos inside a parent workspace folder (`?repo=`) + +A workspace whose folder holds several repos side by side +(`~/work/shop/{api,web,apps/admin}`) lists them with: + +```http +GET /api/lanes/:id/repos[?refresh=1] +→ { "root": false, "items": [{ "path": "api", "name": "api" }, …], "truncated": false } +``` + +`server/lib/nested-repos.js` walks the disk only (no git call): every directory +up to 2 levels below the workspace folder holding a `.git` entry (a directory, +or a submodule's / linked worktree's file), never through a symlink, a dot +folder or `node_modules`/`vendor`/`dist`/`build`/`target`, never inside a repo +it already found; at most 50 repos and 5000 directory entries. Cached 30 s per +folder; `?refresh=1` reads again. `root` is whether the workspace folder +itself is in a repo. The Changes panel calls it only for an account that +turned on Settings → Git → Changes panel (`agenthub.changes.innerRepos` = +`all`; off by default). + +Every `/api/lanes/:id/git…` and `/fs…` route also takes `?repo=` (the +`path` above): it is accepted only when that walk found it — re-walked at most +every 2 s for a name the cache lacks — otherwise `404 ENOREPO`, never a fall +back to the workspace folder. Like an extra folder, the repo is its own +boundary under every guard above, and `GET /git` reports its own default +branch as `base`. The Changes panel shows one section per repo (the workspace +folder's own section only when it is itself a repo). + #### Remove a session's worktree ```http @@ -2929,7 +2957,7 @@ GET /api/run/interrupted {items} — sessions whose pane died under 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) POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under /drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention) -POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named -; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost +POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named -; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel). Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost POST /api/run/local-paths {paths:[…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD 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 — for an admin, then the host's, where their host-folder workspaces drop); 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) @@ -2980,7 +3008,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `/ai-tool-config-backups//` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`). -**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g @latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash `; `self` runs ` ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools. +**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g @latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage — or, after a redirect, exactly the catalog entry's `install.redirect` URL, e.g. omp's `omp.sh/install` → its GitHub raw script —, ≤ 1 MB) and run as `bash `; `self` runs ` ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools. ### AI tool login profiles diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 0ad7b74c..edd51208 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -513,7 +513,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa ### token_usage_hourly -The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session. +The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event — the session's own transcript plus its sub-agents' (`/subagents/agent-*.jsonl`, `server/lib/subagent-usage.js`; the same sum goes into `token_usage`, so a sub-agent on the session's own model is counted too; Workflow agents stay under their own `workflow` tier) — (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session. --- diff --git a/docs/LANES.md b/docs/LANES.md index cc89f94c..d685a2af 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -398,13 +398,14 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page - **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID, Sleep and Close tab (live); the sidebar's **New session** row Open, Open in split, Sleep and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID, Sleep and Close tab; a pane header (split view) Show here, Split right/down with a new terminal, Close pane, Sleep and Stop session. Sleep is offered only for an AI pane (a shell holds no session). In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles), an Artifacts card (Details, its buttons' actions, plus Copy link for a page — also on its `⋯` button) and a Users row (Edit and Open home in Files for an admin, Copy username / email). In the File Explorer (`FileExplorer.tsx`), besides an item and the empty area: a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path-bar crumb (Open, Download as zip, Paste into this folder, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash's empty area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash — asked first, off for an empty one). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from. - **Chat view of a live pane** — each SESSION of a Claude, Codex, omp, Cursor or Antigravity pane (`CHAT_TOOLS` in `client/src/lib/chatUi.ts`; the last three learn their session id from their pollers' `claimToolSessions`, and the transcript route reads omp's journal, Cursor's agent transcript and agy's `transcript_full.jsonl` step log) shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — an Agent/Task step shows instead one muted line — its status as an icon, then type · model · time · tokens · tool calls — and its report as prose, with **View conversation ›**, which puts that subagent's own conversation in the report's place as a thread hung under the step by a line on the left (a nested `ConversationView` in its `compact` layout: its task as the prompt bubble, every step, its reply; as tall as it is up to 24rem, then it scrolls) (`client/src/components/conversation/AgentDetails.tsx`; the agent is found by the result's `agent` facts, the `agentId:` it names or the `tool_use_id` of `GET /api/sessions/:id/transcripts`, and a background one is closed by its ``), as does a click on a line of the background-agents card, under that line — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`) — or while the terminal itself shows the turn at work (its own busy line, `BUSY_LINE`/`readRunBusy` in `client/src/lib/panePaste.ts`: Claude's and Codex's `esc to interrupt`, agy's `Thinking… (… esc to cancel)`; for omp and Cursor, whose line is unknown, output drawn in the last 2 s), read every 700 ms while the pane is on screen, so a tool that sends no hooks gets it too —, an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). Stop also ends the conversation's live line (`stopped` on `ConversationView`/`MessageList`) until the transcript gains a newer line: an Esc before the model wrote anything puts the prompt back in Claude Code's input box and writes no `[Request interrupted by user]` line, so the transcript still ends on the prompt and would read as **Thinking…** for 15 minutes (issue #74). An Esc pressed in the terminal itself is not seen by the chat. An image attached in the box reaches the transcript as its saved path — in a Claude pane as an `@` mention (`claudeMention` in `client/src/lib/promptAttach.ts`, which Claude reads at submit, an image as an image; a trailing space keeps its `@` suggester from taking the Enter), as is a file dropped or pasted on a Claude terminal and every file a new or resumed Claude session is started with: a bare pasted path became `[Image #n]` only when the TUI caught the paste in time, and stayed a plain `C:\…\drops\…png` on a Windows dashboard (psmux), while a path in the command-line prompt is never an image; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box; a Codex pane has **Permission mode** and **Model** buttons that type `/permissions` and `/model` — Codex's own numbered pickers (the model, then its reasoning level), answered on the menu card below, each row's description column read as its hint. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A menu the CLI asks with — AskUserQuestion (one question or several, multiSelect too), a permission or trust prompt, Codex's approval, a first-start login menu — is answered from the chat: it is read off the terminal underneath every 700 ms while the pane is on screen (`parsePaneMenu`/`readRunMenu` in `client/src/lib/panePaste.ts`: two numbered rows or more from 1, the `❯`/`›` cursor on one of them, no `─` rule right above the first — a reply's numbered list has no cursor, a list typed into Claude's input box sits under its rule) and shown in the prompt box's place as a card of buttons (`PaneMenuCard` in `PaneChat.tsx`, with each option's description and a multiSelect box's state; the box is hidden with CSS, never unmounted, so its draft survives the question); a permission prompt — a menu with a **Yes…** and a **No…** row: Claude's tool approval, Codex's command approval, the folder-trust question — is laid out as its own card (`PermissionMenuCard`): the tool and its command (Claude draws them over a dashed `╌` rule above the question, read as the menu's `detail`), the CLI's own answers as buttons, and **Deny with a note**, which picks the "No, and tell … differently" row and then sends the note as the next message once the CLI is back at its prompt; a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row — **Next** while more questions follow — is reached with the arrows and Enter; an AskUserQuestion that asks several questions shows its question tabs above the card, each ticked once answered, with ‹ › buttons that press ←/→ in the pane (`stepRunMenu`), **Cancel (Esc)** presses Esc, and the **Type something** row opens a field inside the card whose text, on Enter, is typed there as the answer (`answerRunMenu`: the arrows to that row, Ctrl+U over any text already in it, the text as a bracketed paste, Enter — none in a multiSelect, where Submit is still to pick). That row presses no key: a second press of its digit would type the digit into the CLI's own field. Once text is typed in that row its label is the text, so it is found as the last numbered row above **Chat about this**. Like Send, it only presses keys in the pane, so no new route exists for it. A menu with no numbers (the first-start theme picker) still needs the terminal. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a shimmer that sweeps a few times and rests (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the menu card above answers it from the chat. AskUserQuestion fires no `Notification`, so a menu read off the pane counts as waiting too — for this line and the hidden tab's **● Waiting**. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. A subagent launched in the background returns at once (its result is "Async agent launched…"), so the turn ends while it still works: until a `` names its call, a live chat lists it under the conversation — its type, its description, how long it has run and, once its own transcript was read, the step it is on and its context size (↓, its newest prompt), like the terminal's footer (`backgroundAgents`/`BackgroundAgents`/`agentProgress` in `MessageList.tsx`; the tail is re-read every 4 s through the existing `GET /api/sessions/:id/transcript?agent_id=`, only the lines after those already read, and only while the card is on screen) — and the last turn reads **Replied in … · N agents still running** with a spinner instead of Done (issue #81). A launch older than 6 hours with no notification is taken as ended (a CLI that quit, a resumed old session). The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible and the pane is on screen (`useOnScreen` in `client/src/hooks/useOnScreen.ts`: a pane of another workspace or tab stays mounted but `display:none`, and polls nothing until it is shown again, then catches up once), and on a `new_event` of its session at most every 1.5 s (`EVENT_FETCH_MS`), and within 300 ms of a `transcript_changed` of its session (`PUSH_FETCH_MS`: the server's projects-tree watcher saw that Claude transcript grow, `pushTranscript` in `server/index.js`, at most every 250 ms per session), and at most once a second while the terminal underneath draws (`notePaneOutput`/`onPaneOutput` in `client/src/lib/panePaste.ts`, fed by `TerminalView` — any tool, hooks or not) — so a reply appears as soon as Claude writes it, not at the next poll. The TUI draws no reply as it streams (it shows the spinner and a token count until the block is done), so the chat has no streaming text. The tail read (`?after=`) skips the lines the chat already has before parsing them. While the chat covers the terminal, the terminal is moved out of the viewport with a transform (`covered` on `TerminalView`), which stops xterm drawing without changing its size — the menu reading below still works. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. An AskUserQuestion is not folded into the collapsed work line: it is its own card (`AskCard`) showing each question with the option the user picked or what they typed — the `answers` the transcript route reads off Claude Code's `toolUseResult` —, "declined" when they refused it. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible and the pane is on screen; the server keeps the last answer per session until its events change). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) once the session has an output, and otherwise — no output yet, or a quarter-sized pane (e.g. four panes at once) — collapsed into a file button with the number of outputs at the chat's top right. The user's own open or close is kept per session for the browser tab (`sessionStorage` `agenthub.chat.panel.`), so switching the pane to its terminal and back keeps it; a split or a close that changes the pane's shape drops it and brings the default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one (Claude Code offers those tools to its newer models only with `CLAUDE_CODE_ENABLE_TODO_TOOLS=1`, so `spawnRun` puts it in every Claude pane's env; a user's `settings.json` `env` can still set `"0"`): how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A file path in a reply or your message is a link — absolute, or relative with a folder and an extension (`server/lib/x.js`, resolved against the pane's folder by `resolveIn` in `client/src/lib/resolvePath.ts`), a `:line` suffix included, bare, in inline code or as a markdown link's target (`[x](src/x.ts#L12)`; `MarkdownContent.tsx`) — and opens like an Edited-files row: in the Changes panel when it lies in the workspace's folder, else in the preview. A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path. - **Artifacts in the chat** — like Claude Desktop, a page, document, diagram, table or React component the model wrote (`isArtifact` in `client/src/lib/artifactView.ts`: `.html/.htm`, `.svg`, `.md/.markdown/.mdx`, `.mmd/.mermaid`, `.csv/.tsv`, and a `.jsx/.tsx` written whole by a Write — one it merely edited is project code) gets an **artifact card** in the conversation (`ArtifactCard` in `MessageList.tsx`: an icon by kind, its name, "Web page / Document / Diagram / React component / Table · Click to open"), shown as soon as the file is made, even mid-turn; the turn's **Edited N files** card lists the other files. A click opens it in the chat's own side panel (`onOpenArtifact` → `PaneChat`, not the workspace's Changes panel), which the Outputs list opens too: `FilePreview` in `client/src/components/run/ChatFilePreview.tsx` with **Preview | Code** (Code is the file highlighted with line numbers, `CodeBlock`), its **versions** (‹ Version n of m ›, rebuilt from the conversation's Write/Edit/MultiEdit inputs by `artifactVersions` — the transcript keeps those inputs up to 256 KB, `server/lib/transcript-message.js`; an edit to text the conversation never showed is a version of unknown content, the file on disk standing in for the newest), **Copy** (the text), **Download**, **Save to Artifacts** (a workspace file through `POST /api/artifacts/file` by its path, anything else — an older version, a file outside the workspace — through `POST /api/artifacts/file/upload`; shared to others from the Artifacts page), **Full screen** (over the whole page) and Close; clicking the name copies its path. Preview renders Markdown, a CSV as a table, an image, and runs HTML, Mermaid (pinned `mermaid@11.4.1`) and React (`react@18.3.1` + `@babel/standalone@7.26.4` + Tailwind's play CDN, a default-exported component, `react`/`react-dom` the only imports) in an `