diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6b905519..fdc3e296 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -340,7 +340,7 @@ graph TD | `server/lib/ai-tools.js` | The AI Tools catalog: one JSON per AI coding CLI (`server/data/ai-tools/`, the synced newer-release copy, `DASHBOARD_AI_TOOLS_DIR`, rising precedence), PATH detection, enable/default state (`/ai-tools.json`). `launcher`/`install`/`update` are honoured only in built-in and release files. | | `server/lib/shell-path.js` | Merges the login shell's PATH into the server's (`$SHELL -ilc`, 5 s) and keeps the shell's own order (`shellPath()`) for install jobs. | | `server/lib/ai-tool-jobs.js` | The one-at-a-time install/update runner behind `POST /api/ai-tools/:id/install|update` and `/update-all`: argv only, stdin closed, login-shell npm, vendor scripts downloaded and run with `bash`, a tool's own updater; log in memory. | -| `server/lib/ai-tool-versions.js` | npm-managed check (binary under `npm prefix -g`) and version badges, cached 24 h; dropped on Refresh and after each job. | +| `server/lib/ai-tool-versions.js` | npm-managed check (binary under `npm prefix -g`, used by update-all to skip an npm updater for a tool installed another way) and `GET /api/ai-tools/versions`, cached 24 h; dropped on Refresh and after each job. The UI asks for no version: every updater gets Update. | | `server/lib/ai-tools-sync.js` | Daily copy of the newest release's AI Tools catalog into `/runtime/ai-tools-remote/`, used only while that release is newer than the build. Off with `AGENTHUB_AI_TOOLS_SYNC=0`. | | `server/lib/ai-tool-icons.js` | Server-side favicon cache (`/favicon.ico` of the catalog domain, raster by magic bytes, ≤ 256 KB) behind `GET /api/ai-tools/:id/icon`. | | `server/lib/data-transfer.js` | Full-dataset export/import ("backup / restore"). `buildExportBundle(db, stmts)` serializes every user-owned table (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) into one versioned JSON bundle (`format: "agenthub-export"`); machine-bound/secret tables (push_subscriptions, webhook_targets/deliveries, alert_events) are excluded. `importExportBundle(db, bundle)` restores it session-atomically inside one transaction with `defer_foreign_keys` ON: a session already present (by UUID) is skipped WHOLE (with its agents/events/token_usage/workflows) so re-import and cross-machine merges never duplicate or clobber; events are re-inserted without their non-portable autoincrement id; config rows use `INSERT OR IGNORE` on their natural key. Backs `GET/POST /api/settings/{export,import}` and `agenthub import-data` | @@ -617,7 +617,7 @@ graph LR | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) | | `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map (only while a pipeline template is installed — none ships), and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | -| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer, plus an **AgentHub** sub-tab (`components/settings/ClaudeAgentHubSettings.tsx`) holding the dashboard's own Claude Code settings — Model Pricing (`GET/PUT/DELETE /api/pricing`, `POST /api/settings/reset-pricing`), hook status + Reinstall (`GET /api/settings/info`, `POST /api/settings/reinstall-hooks`) and CLAUDE_HOME (`GET/PUT /api/settings/claude-home`; a PUT answers `routers.missing`, the lanes naming an AI Router profile the new home's store lacks, and the UI warns); its **Reset Defaults** sends `{scope: "claude"}`. They were on the dashboard tab until #40 part B, and a tool's own settings go in that tool's tab from now on. Both editable home directories render through `components/settings/HomeDirSetting.tsx` and are saved to AgentHub's settings file (`server/lib/settings-env.js`: `~/.config/agenthub/settings.env`, never the plugin cache's `.env`; load order real environment > the checkout's `.env` > that file), with a warning when the real environment or `.env` owns the variable (`env_owned`); both PUTs carry `sameOriginGuard`. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Its **AgentHub** sub-tab holds Model Pricing for OpenAI models (`components/settings/PricingSettings.tsx`, the same table Claude Code's AgentHub sub-tab shows filtered to `claude-…` rows, each with a **Show all models** switch) CODEX_HOME (`GET/PUT /api/settings/codex-home`; a save re-writes every codex lane's AI Router layer into the new home via `reapplyCodexLayers` in `server/lib/router-apply.js`) and the **Codex hooks** card (`components/settings/CodexHooksSettings.tsx`, `GET/POST/DELETE /api/settings/codex-hooks`). **antigravity** tab (`?tool=antigravity`) → `AntigravityConfig`: Model Pricing for Gemini models (`PricingSettings vendor="google"`) and the **Antigravity hooks** card (`components/settings/AntigravityHooksSettings.tsx`, `GET/POST/DELETE /api/settings/antigravity-hooks`). A tool's tab is hidden while that tool is disabled in AI Tools. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | +| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. An **AI Tools** button at the tab bar's right edge (`PageTabs` `actions`) opens `components/settings/AiToolsModal.tsx` around `AiToolsSettings.tsx`; `/settings#ai-tools` (the Add-lane modal's link) opens it too, and closing drops the hash. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer whose **Overview** also renders `components/settings/ClaudeAgentHubSettings.tsx` (there is no AgentHub sub-tab; the overview's path list leaves CLAUDE_HOME to that editor) holding the dashboard's own Claude Code settings — Model Pricing (`GET/PUT/DELETE /api/pricing`, `POST /api/settings/reset-pricing`), hook status + Reinstall (`GET /api/settings/info`, `POST /api/settings/reinstall-hooks`) and CLAUDE_HOME (`GET/PUT /api/settings/claude-home`; a PUT answers `routers.missing`, the lanes naming an AI Router profile the new home's store lacks, and the UI warns); its **Reset Defaults** sends `{scope: "claude"}`. They were on the dashboard tab until #40 part B, and a tool's own settings go in that tool's tab from now on. Both editable home directories render through `components/settings/HomeDirSetting.tsx` and are saved to AgentHub's settings file (`server/lib/settings-env.js`: `~/.config/agenthub/settings.env`, never the plugin cache's `.env`; load order real environment > the checkout's `.env` > that file), with a warning when the real environment or `.env` owns the variable (`env_owned`); both PUTs carry `sameOriginGuard`. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Its **Overview** also holds (no AgentHub sub-tab) Model Pricing for OpenAI models (`components/settings/PricingSettings.tsx`, the same table Claude Code's Overview shows filtered to `claude-…` rows, each with a **Show all models** switch) CODEX_HOME (`GET/PUT /api/settings/codex-home`; a save re-writes every codex lane's AI Router layer into the new home via `reapplyCodexLayers` in `server/lib/router-apply.js`) and the **Codex hooks** card (`components/settings/CodexHooksSettings.tsx`, `GET/POST/DELETE /api/settings/codex-hooks`). **antigravity** tab (`?tool=antigravity`) → `AntigravityConfig`: Model Pricing for Gemini models (`PricingSettings vendor="google"`) and the **Antigravity hooks** card (`components/settings/AntigravityHooksSettings.tsx`, `GET/POST/DELETE /api/settings/antigravity-hooks`). A tool's tab is hidden while that tool is disabled in AI Tools. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | | `/*` | NotFound | None (static 404 page) | ### Activity Feed Interaction Model @@ -1583,7 +1583,7 @@ too. The Workspace's external-session list (attach, `claude --resume`) and the run form's resume picker offer `tool = 'claude'` rows only. -**Codex hooks** (`server/lib/codex-hooks.js`, Settings → Codex → AgentHub → +**Codex hooks** (`server/lib/codex-hooks.js`, Settings → Codex → Overview → Codex hooks, `GET/POST/DELETE /api/settings/codex-hooks`) are installed only on a click. Install merges one group per event — `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `Stop`, @@ -2477,7 +2477,7 @@ graph LR ## Model Name Formatting (Client) -The `client/src/lib/format.ts` module exports a `formatModelName()` utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere **except** the Model Pricing table in Settings → Claude Code → AgentHub, which shows raw patterns for pricing rule configuration). +The `client/src/lib/format.ts` module exports a `formatModelName()` utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere **except** the Model Pricing table in Settings → Claude Code → Overview, which shows raw patterns for pricing rule configuration). **Transformation rules:** diff --git a/CLAUDE.md b/CLAUDE.md index 83f03d94..2bbaabb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.8.12 (2026-09-28) + ANTIGRAVITY PARITY (#40 part D: `server/lib/antigravity-usage.js` reads agy's own per-conversation SQLite files `~/.gemini/antigravity-cli/conversations/.db` read-only every `DASHBOARD_ANTIGRAVITY_POLL_MS` — tokens from `gen_metadata`'s protobuf `ModelUsageStats`, cwd from `trajectory_metadata_blob`, name/start from `brain//…/transcript_full.jsonl` — into `tool = 'antigravity'` sessions, mtime per file in the new `antigravity_conversations` table, and sets idle the lane of a session gone quiet that the hooks left running; `server/lib/antigravity-hooks.js` installs on a click the named hook `agenthub` (PreInvocation, PostToolUse, Stop — NEVER PreToolUse) into the global `~/.gemini/config/hooks.json` through `~/.gemini/config/agenthub-hook.sh`, `GET/POST/DELETE /api/settings/antigravity-hooks`; `routes/hooks.js` `antigravityHookInput` maps a CLI hook — `conversationId` → session, `workspacePaths[0]` → cwd, first PreInvocation → UserPromptSubmit — and ignores the IDE's; `server/lib/antigravity-home.js` (`AGENTHUB_GEMINI_DIR`); Gemini families in `pricing-sync.js`; the new Settings → **Antigravity** tab `client/src/pages/AntigravityConfig.tsx` with `PricingSettings vendor="google"` and `AntigravityHooksSettings.tsx`. Its review fixed: an empty `-wal` (our own read-only open leaves one) is not activity; activity counts the session's newest hook event, with a 10-minute window mid-turn; an agy Stop carrying an error lands as `error`, one with `fullyIdle: false` is dropped, and the first PreInvocation after a Stop is a prompt; `agyCliDirs()` for a symlinked `~/.gemini`; both tools' hook installers refuse to overwrite or remove a forwarder that is not theirs (`EFOREIGN`) and keep hooks.json's mode. The C2 review also fixed: Codex hooks matched by exact command, one hook at a time, so a user's hook in our group or with a similar name survives; `sameOriginGuard` on the codex-hooks writes; `codexHomes()` in `codex-home.js` for a symlinked CODEX_HOME; a missing main agent created for a poll-imported Codex session; `refreshScript` leaving a foreign file alone; Refresh on the card. The whole-change review also fixed: settings load real env > repo `.env` > the settings file (located after `.env` loads, so `AGENTHUB_SETTINGS_FILE` may live there), a `.env` key `env_owned` too, `writeSetting` replacing every earlier line for its key and writing through a symlink, `validateHomeDir` comparing device+inode with the home and each ancestor, `sameOriginGuard` on both home PUTs, a claude-home PUT answering `routers.missing` (`lanesWithMissingRouter` in `router-apply.js`), CLAUDE_HOME-derived paths read per call in the hook status, `install-hooks.js` and `import-history.js`; `reset-pricing` `{scope: "claude"}` from the Claude tab; `GET /api/sessions?tool=` before the limit; `clear-data` emptying `codex_rollouts` and `antigravity_conversations`, plus `idx_codex_rollouts_thread`; Compaction tagging for claude only, a Codex sub-agent's folded tool event (`_codexChild`) moving no session, agent or lane status, and hooks owning a Codex session's status only while current (`hooksCurrent`: newest event ≥ newest journal write − 2 min, also in `settleActive`); `scriptIsOurs` treating other content, an unreadable file or a directory as foreign, a symlinked hooks.json emptied through the link; agy's quiet window 10 min for any session with hook events (2 min without), a settle completing its agents and clearing waiting flags, a PreInvocation a prompt also with no prior event, a non-active session or a last event >2 min old, a `terminationReason` matching /error|fail/ an error Stop, a file that is not agy's recorded with `session_id ''` and retried on change or at start (anything else next pass), and the session id taken from the file name) + CODEX HOOKS (#40 part C2: `server/lib/codex-hooks.js` installs — only on a click in Settings → Codex → AgentHub → **Codex hooks**, `GET/POST/DELETE /api/settings/codex-hooks` — one group per event (SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd) into `$CODEX_HOME/hooks.json`, all through the stable forwarder `$CODEX_HOME/agenthub-hook.sh` (content rewritten each start by `refreshScript`, command never changes, because Codex trust is keyed on the definition); `scripts/hook-handler.js codex` posts `tool: "codex"`; `routes/hooks.js` creates `tool = 'codex'` sessions with no `transcript_path`, skips every Claude-transcript read for them, maps `PermissionRequest` → `Notification` and `Interrupt` → `Stop`, folds a sub-agent thread's hooks into the root session (`threadOf` in `codex-usage.js`), and `touchLaneFromHook` resolves the lane by (cwd, tool) with detection for claude only; a Codex session with events keeps its hook-given status in `codex-usage.js`, and `codex-ingest.js` skips a lane whose hooks are current. Also from the C1 review: one `codex_rollouts` row per thread, the resume picker lists Claude sessions only, a failed price fetch waits 15 min, a fork skipped until its first own total, 16 MB per read, usage rewritten whole, root-thread cwd/start/model, the startup cleanup and a lane purge scoped by tool, and the codex lane mirror deciding once per lane with a second of future-mtime slack) + CODEX SESSIONS + TOKEN COST (#40 part C1: `server/lib/codex-usage.js` reads every Codex rollout journal into `sessions` (new column `tool`, `'claude'` default, `'codex'` here) and `token_usage` — offset + parse state per file in the new `codex_rollouts` table, a fork's copied parent history skipped by total vector, sub-agent threads summed into the root session, input = input − cached, exact totals via `setTokenUsage`, no `transcript_path`; `startCodexUsagePoll` on the `DASHBOARD_CODEX_POLL_MS` beat; `allRolloutFiles()` exported from `codex-ingest.js`; `pricing-sync.js` also prices `gpt-…`/`o…`/`codex-…` from LiteLLM's `openai` entries and `reset-pricing` syncs right after reseeding; `AGENTHUB_PRICING_SYNC=0` in `scripts/test-server.js`; `client/src/components/settings/PricingSettings.tsx` = the pricing table with a `vendor` filter + **Show all models**, in both tools' AgentHub sub-tabs; `SessionToolBadge.tsx`; the Workspace attach/resume list offering `tool = 'claude'` rows only) + per-tool SETTINGS (#40 part B: Model Pricing, Hook Configuration and CLAUDE_HOME moved from Settings → Dashboard to the Claude Code tab's **AgentHub** sub-tab, `client/src/components/settings/ClaudeAgentHubSettings.tsx` — a tool's own settings go in that tool's tab; `setClaudeHome`/`setCodexHome` refuse any dir that is or contains the OS home (`EBADHOME`, `validateHomeDir` in `server/lib/settings-env.js`) and persist to AgentHub's settings file `~/.config/agenthub/settings.env` (`AGENTHUB_SETTINGS_FILE`; real env > repo `.env` > that file, loaded only when `server/index.js` is main — never the plugin cache's `.env`, which an update wipes), a first CLAUDE_HOME change pinning `DASHBOARD_DATA_DIR` so the DB stays put; CODEX_HOME editable in Codex → AgentHub (`/api/settings/codex-home`, `HomeDirSetting.tsx`, `reapplyCodexLayers` re-writing codex lanes' router layers into the new home); a codex pane is started with the dashboard's `CODEX_HOME` through tmux `-e`, in `server/lib/pty-run.js`) + AI Tools INSTALL/UPDATE (#40 part A2: catalog fields `install`/`update` honoured only in built-in and release files; `server/lib/ai-tool-jobs.js` runs ONE job at a time as an argv — the login shell's npm, a downloaded vendor script via `bash `, or a tool's own updater; `server/lib/ai-tool-versions.js` offers an npm update only when the binary resolves under `npm prefix -g`; `server/lib/ai-tools-sync.js` copies the newest release's catalog daily when it is newer than the build, off with `AGENTHUB_AI_TOOLS_SYNC=0`; `server/lib/ai-tool-icons.js` caches raster favicons server-side; `shellPath()` in `shell-path.js`; `repoUrlFor` in `update-check.js`; six `/api/ai-tools` routes; Install/Update/Update tools in `AiToolsSettings.tsx`) + the pipeline layer going DORMANT (no template ships: `server/data/pipelines/` deleted, `EMPTY_PIPELINE`, `ENOPIPELINE`, test templates under `server/__tests__/fixtures/pipelines/`) and the workflow skills leaving the plugin (`skills/ship-feature`, `skills/ship-feature-lane`, the 18 stage/ship commands, all four `agents/`, `agenthub skills install`, `agenthub lanes agents install`, `agenthub ship-full|ship-quick`, `POST /api/skills/install`, `POST /api/lanes/:id/agents/install`, and the Superpowers auto-install with `server/lib/skill-plugins.js` and its doctor row) + the AI TOOLS catalog (#40 part A1: `server/lib/ai-tools.js` loads one JSON per AI coding CLI from `server/data/ai-tools/` — 38 tools, data from Orca, MIT — plus overrides in `DASHBOARD_AI_TOOLS_DIR`, detects each by walking PATH, and keeps enable/default in `/ai-tools.json`; `server/lib/shell-path.js` merges the login shell's PATH (`$SHELL -ilc`, 5 s, off with `AGENTHUB_SHELL_PATH=0`) at startup and on Refresh; `/api/ai-tools` GET/refresh/PUT; Settings → Dashboard → **AI Tools** (`client/src/components/settings/AiToolsSettings.tsx`) and the shared `client/src/hooks/useAiTools.ts`, which replaced `LANE_TOOL_LABEL`; the Add-lane modal's **Default ()** + **Other…** picker; the built-in router profile now labelled plain `Native`, rendered **Native ()**; the Claude Code/Codex settings tabs hidden while that tool is disabled; a per-run **Bypass permissions** switch; `POST /api/run` now forwarding `sandbox`/`approval`/`resumeLast`, which it had dropped) + `antigravity` lanes (a third `lanes.tool`, running Antigravity's `agy` CLI; no pipeline, no AI Router, no model/effort picker; the lane card no longer renders a bare tool chip, and the prompt box names the lane's CLI via `LANE_TOOL_LABEL` in `client/src/lib/types.ts`) + a lane whose pane EXITED being released on a server beat (`healAllRunIds` in `server/routes/lanes.js`, every `DASHBOARD_RUN_HEAL_MS`=10 s from `server/index.js`) — a codex/antigravity pane fires no hook, so nothing else re-read it and the lane stayed `running` + a codex Start no longer sending `resumeLast` (only **Resume last session** does) + per-CHANGE-BLOCK review bars (issue #50: `stage-hunk`/`discard-hunk` take an optional `block` — the index of a run of consecutive +/- lines inside the `@@` hunk — and `onlyBlock` in `server/lib/git-diff.js` cuts git's fresh hunk down to it, folding every other block into the side being checked; `changeBlocks`/`LaneDiffChange` in `LaneDiffView.tsx` hang one bar per block, and the arrows land on `data-change-index`) + `codex` lanes losing their pipeline (`codex-ingest.js` mirrors only idle↔running — no stage detection or auto-advance, no `keepDetection`; `codex-nudge.js` deleted; every server start runs `clearCodexPipelines` (`server/lib/lanes.js`: a codex lane's stage columns, `lane_stage_events` and `lane_features` wiped) and `removeStageDoc` (`server/lib/lane-agents.js`: the old `` block taken out of each codex lane's `AGENTS.md`, the file deleted when nothing else is left); `agenthub lanes agents install` installs nothing on a codex lane; the Workspace hides stage, time-on-stage, pipeline/feature pickers and PipelineMap for one) + `info/exclude` entries AgentHub writes into a lane going to the COMMON git dir with the lane's path prefix (`server/lib/git-exclude.js`, used by `router-apply.js` and `lane-mcp.js` — a linked worktree's own `info/exclude` is never read by git, and a subdir lane has no `.git`) + macOS start-at-login (`scripts/launchd-service.js`: the LaunchAgent `~/Library/LaunchAgents/io.smartgift.agenthub.plist`, same API as `systemd-service.js`, chosen by `scripts/service-manager.js` — which the bootstrap, `agenthub service` and `/agenthub-doctor` now require instead of `systemd-service` directly) + v1.8.6 (2026-09-24) + four fixes found by the #37 evaluation, shipped without #37 itself: tmux targets are EXACT (`=name` for has-/kill-/attach-session, `=name:` for pane and option targets, in `server/lib/tmux.js`, `pty-attach.js` and `scripts/cc-update-all-helper.js`) because a bare `-t agenthub-lane-1` resolves by prefix to `agenthub-lane-12` once lane 1 is gone; `getRun`/`killRun` in `server/lib/pty-run.js` refuse any id that is not `agenthub-lane-`, so `DELETE /api/run/main` can no longer kill a user's own tmux session; `POST /api/lanes/:id/router/apply` reports `restart_required` only when the pane's status is `running` (it was always true, since `getRun` returns a `gone` handle); and a lane's `needs_action` is cleared only by the session that raised it (new nullable `lanes.needs_action_session`, `server/routes/hooks.js`) + macOS support, code-audited and untested on a Mac (#22): `server/index.js` deletes a DENYLIST of parent-session variables at startup (`PARENT_SESSION_VARS`: CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_*, CLAUDE_PLUGIN_ROOT, AI_AGENT, …, plus GIT_EDITOR only when it is Claude Code's `true`; user config such as CLAUDE_CONFIG_DIR/CLAUDE_CODE_USE_BEDROCK/ANTHROPIC_* passes through), because a server started from a Claude Code session handed them through tmux to every lane pane; the `~/.local/bin/agenthub` launcher falling back to `node` on PATH when its pinned node is gone (`linkCli` in `scripts/plugin-bootstrap.js`); `liveServers` ignoring a discovery entry whose `startedAt` predates this boot, so a recycled pid is never a live dashboard; lane cwds stored by real path (`canonicalCwd` in `server/lib/lanes.js`, used by `createLane`, `POST /api/lanes/ensure` and the liveness reaper — `/tmp` becomes `/private/tmp`, a not-yet-existing path through its deepest existing ancestor, existing rows untouched); an `__setExecImpl` seam in `server/lib/session-liveness.js`; and the `INSTALL.md` § macOS section with the Mac checklist + issue #34's empty-repo half — `publish` as a git write (`server/lib/git-ops.js`: `remote add origin -- ` + `push -u origin HEAD`, only in a repo with a commit and NO remote, URL allowlisted by `validateRemoteUrl` to https-without-userinfo/ssh:///scp-like, `origin` removed again on a failed push; `ENOCOMMITS`/`EREMOTEEXISTS`/`EBADURL`), an unborn repo being readable (`gitFacts`/`statusCounts` in `server/lib/worktree.js`: `head: null`, new `unborn`, branch via `symbolic-ref`), `POST /api/lanes/worktree` answering `400 EEMPTYREPO` instead of leaving a `failed` row, and `LaneGitBar.tsx` gaining **Publish…** (no remote), **Publish branch** (remote but no upstream) and a commit-first hint (unborn) while `AddLaneModal.tsx` steers an empty repo to Repo mode + a dashboard fetch/pull/push that needs a git LOGIN asking for one in the git bar (issue #45: `409 EGITAUTH` with `auth`, resent with `credentials`; AgentHub's own shared store `server/lib/git-credentials.js` + helper `server/lib/git-credential-helper.js`, `/api/git-credentials`, Settings → **Git credentials** = `client/src/pages/GitCredentialsConfig.tsx`) + MERGE and conflict resolution from the dashboard (issue #45 part B: `merge`/`resolve`/`merge-commit`/`merge-abort` in `server/lib/git-ops.js`, `gitFacts.merging`/`conflicts`, the git bar's **Merge into current** and diverged-upstream **Merge**, the Changes panel's merge banner and **Merge conflicts** group, and the per-block conflict view `client/src/components/lanes/LaneConflictView.tsx` over the pure parser `client/src/lib/conflicts.ts` — keep mine/theirs/both per block, saved through the whole-file editor's `PUT /git/file` + `resolve done`, no new API) + drag-and-drop of an image onto the lane terminal (`TerminalView.tsx` uploads to `POST /api/run/drop-image` in `server/routes/run.js` — raw body, png/jpeg/gif/webp only, random name under `/agenthub-drops-/`, never a name from the request — then `term.paste()`s the returned path, which is how `claude` attaches it) + Claude Opus 5.5 getting its own pricing row (`claude-opus-5-5%` at 4/20/0.2/5/8, fast 8/40 — `claude-opus-5%` had been pricing it as Opus 5) + AUTOMATIC pricing (`server/lib/pricing-sync.js`, started in `startBackgroundServices`: a model family used in `token_usage` with no `%` row of its own is INSERTED from LiteLLM's public price list, never updated, off with `AGENTHUB_PRICING_SYNC=0`) + the Changes panel at **Full screen** moving the whole Files view (finder + expand-all + tree, one `browsePane`) into a left SIDEBAR and dropping the Files tab and in-place editing (`full` prop on `LaneChangesPanel` from `Workspace.tsx`; a whole file opens for READING and enters the editor only on a click on its text — `onEditAt` on `LaneFileView`, caret at the clicked line, a drag-selection is not a click; Cancel returns to reading, and only a diff keeps an Edit button) and height-locking the page (`viewportLocked` also true at full, else the panel shrank to content with no live run) + the overview splitting into **Staged Changes** / **Changes** groups in the `work` range + a changed tree row tinted whole (`treeMark` tone carries a `bg-status-*/10`) + the Files tree listing untracked-not-ignored files and carrying each entry's git status (`treeStatus`/`treeEntries`/`TREE_LS` in `server/lib/git-diff.js`: file `status` = porcelain code, folder `changed: true`, from ONE `laneStatus` per tree read; `treeMark` in `LaneChangesPanel.tsx` paints M/U/A/D/R) + split panes on ONE lane (every Workspace pane shows the strip's selected lane, each in its own tmux session: `slot` 0–3 on `POST /api/lanes/:id/start`, `agenthub-lane-` for slot 0 = `run_id`, `agenthub-lane--` for 1–3 — `runIdForLane(id, slot)`/`MAX_PANE_SLOT` in `server/lib/pty-run.js`, the `-[1-3]` suffix accepted by `pty-attach.js` and `websocket.js`; `stop`/`reset`/`remove` kill every slot via `laneRunIds`; a pane's Active runs hides what the other panes show; no per-pane lane picker) + the server suite running on a PRIVATE tmux server (`npm run test:server` = `scripts/test-server.js`: drops `TMUX`, sets `TMUX_TMPDIR` to a temp dir, kills that server after — suites spawn and kill real `agenthub-lane-` sessions with ids from their own DB, and on the default server that killed the user's real lane panes, including the one running the tests) + the whole-file EDITOR (`PUT /api/lanes/:id/git/file`, `server/lib/lane-file-write.js`: the read path's guards plus existing-text-file-only and a `baseSha` stale check → `409 ESTALE`; `readFileAt` returns that `sha`) + the per-hunk controls moving into an editor-style review bar under each hunk's last changed line (`HunkBar` in `LaneDiffView.tsx`: ^/v, `n of N`, Undo, Keep) + **Unstage all** on the ONE commit/Stage-all row + Changes sizes Narrow (30%) / Medium (50/50) / Full screen, no 70/30 + the lane card's needs-you banner gone (the strip tile keeps it) + per-HUNK Stage/Undo in the Changes panel's Review and single-file views (`stage-hunk`/`discard-hunk` in `server/lib/git-ops.js`: the hunk is named by its `@@` line and re-matched against a FRESH `git diff -U HEAD` by `hunkPatch` in `git-diff.js`, then `git apply --cached` / `git apply -R` from a temp file — `409 EHUNKGONE` when it moved, `400 EUNTRACKED` on an untracked file; `discard-hunk` is the SECOND irreversible git action and carries the same `confirm: true` gate and two-click UI; the controls reach every `HunkHeader` through a context in `LaneDiffView.tsx`), each Review file header sticky with its own stage/unstage/discard (`FileActions`, shared with the overview row) + the Changes panel gaining a FULL-width step (`CHANGES_SIZES` in `Workspace.tsx`: narrow → 50/50 → 70/30 → full → closed; at full the `lane-detail` section gets `hidden` — CSS, so the one console render site stays mounted), **Stage all** plus the staged-only commit box ABOVE the overview list and sticky (as is the Files finder), and **Expand all folders** being ONE `GET /api/lanes/:id/git/tree-all` (`listTreeAll` in `server/lib/git-diff.js`: one `ls-files`, every level shaped like `listTree`'s) instead of a request — and a `git ls-files` — per folder + the lane's git section MOVING from the lane card to the top of the Changes panel (`client/src/components/lanes/LaneGitHeader.tsx`: `useLaneGitFacts`, the working-copy line, `LaneGitFilesPanel` and `LaneGitBar`, sharing ONE facts read with `LaneChangesPanel`, which refreshes it after every stage/unstage/discard/commit while a git bar action re-reads the diff; `onNewLane` comes from `Workspace.tsx`, and `LaneCard` has no git row and no `onNewLaneFromRepo` any more) and the git bar losing its commit UI — the overview's staged-only box is the ONE commit path, the server's `commit` without `only` is unchanged; the git line and its icon-only actions are ONE row in one card, the panel toolbar ONE wrapping row, and `LaneCard` hides itself (`empty:hidden`) when it has nothing to show + the run form's `/` and `@` autocomplete being REAL again (`PromptEditor` in `client/src/components/run/RunSetup.tsx` — the `2f39f4e` rewrite had left a bare textarea: `/` offers the run cwd's project + user commands and skills, via `api.ccConfig.commands|skills(scope, cwd)`, plus the CLI built-ins, claude lanes only; `@` offers `GET /api/run/files`; Enter picks while the list is open and sends otherwise) + the console's **Active runs** listing only the CURRENT lane's runs and external sessions (`LaneConsolePane.tsx`: runs by `laneId`, sessions by the longest path-boundary lane cwd, `owningLaneId`) + v1.8.3 (2026-09-23) + `GET /api/settings/info` reporting `server.memory.heapLimit` (V8's `heap_size_limit`), which the Dashboard's V8 Heap bar and Health Score now divide by instead of `heapTotal` + every `claude plugin install|update -y` (`cc-plugin-cli.js`, `skill-plugins.js`, both detached update helpers, `/agenthub-update`) retrying WITHOUT `-y` when an older Claude Code answers `unknown option '-y'` + `LaneDiffView.tsx` WINDOWING a diff above `WINDOW_THRESHOLD` (500) items at a fixed `ROW_PX` row height (`visibleRange`, spacers, a `ch` min-width off the longest line; at or below the threshold the DOM is unchanged) and clamping its mirrored split scroll (`clampScroll`/`follow`) + a git action that git itself refuses (a numeric exec exit status) answering 409 `EGIT` instead of 500 with a number for a code (`gitErrorCode` in `server/routes/lanes.js`), and one `parseNumstat` (`server/lib/git-diff.js`) shared by the Changes panel and `gitFacts.files` + the Dashboard heatmap ramp mixing `--accent` into `--surface-0` so it reads on every theme + the lane git surface being HARDENED (`server/lib/git-diff.js`, `server/lib/git-ops.js`, `server/lib/worktree.js`: every lane git call runs with `GIT_LITERAL_PATHSPECS=1` and `-z` porcelain, is scoped to the LANE's cwd — a subdir lane sees and acts on only its own paths, `gitFacts.files` included — and reads through `lanePath` at every rev; any file whose basename is `settings.local.json`, the lane router key file, is refused as `ESECRET`/403 on disk and at every rev; `commit` refuses with `ESTAGEDOUTSIDE` while anything outside the lane is staged, checked with `--no-renames`; `push` without a resolvable remote is `ENOREMOTE`; git writes serialize on the repo's absolute git dir, not the lane id) + the xterm terminal following the selected DARK theme (`client/src/lib/terminalTheme.ts`, live via `term.options.theme`) and chart colours using structural tokens + the Add-lane modal asking `POST /api/lanes/ensure` with `exact: true` (#42) — without it a directory NESTED inside an existing lane resolves to that parent lane with `created: false`, so the modal closed on the parent and no new card ever appeared; `exact` counts only a lane on that very directory, and a `created: false` answer now names the lane that owns the folder instead of closing. The CLI, the run console and Sagi keep the parent lookup + Settings → **Appearance** (`client/src/components/settings/AppearanceSettings.tsx`: mode Dark/Light/**System** — `prefers-color-scheme`, followed live — plus one NAMED theme remembered per mode: dark Default/Darcula/Apple Dark, light Default/Apple Light/IntelliJ Light/Canvas). A theme is a CSS-variable override block in `client/src/index.css` keyed by `data-theme` on `` (`.dark[data-theme=…]` / `[data-mode="light"][data-theme=…]`, `data-mode` is set on `` too), and `--tint-base`/light `--syn-plain` are now `var(--surface-3)`/`var(--fg-primary)` so no theme restates them; `hooks/useTheme.ts` is a `useSyncExternalStore` reader over `localStorage` (`theme` = `dark|light|system`, `agenthub.theme.dark|light`) that caches NOTHING in the module, so the Sidebar and Settings can never disagree — do not reintroduce per-instance `useState` there; an inline `\n\n[x](https://a.example)'} + /> + ); + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("img")).toBeNull(); + const link = screen.getByRole("link", { name: "x" }); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noreferrer noopener"); + }); + + it("shows frontmatter as a block, not as a heading", () => { + const { container } = render( + + ); + expect(container.querySelector(".md-frontmatter")).toHaveTextContent("name: demo"); + expect(screen.queryByRole("heading")).toBeNull(); + expect(screen.getByText("Body text")).toBeInTheDocument(); + }); + + it("knows a markdown path", () => { + expect(isMarkdown("/a/SKILL.md")).toBe(true); + expect(isMarkdown("README.markdown")).toBe(true); + expect(isMarkdown("config.toml")).toBe(false); + expect(isMarkdown(null)).toBe(false); + }); +}); + +describe("FileViewer — markdown preview", () => { + const data = { text: "# Hello\n", truncated: false } as never; + + it("switches a markdown file between raw and preview", async () => { + render(); + expect(screen.queryByTestId("markdown-preview")).toBeNull(); + expect(screen.getByText("# Hello")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Preview" })); + expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Raw" })); + expect(screen.queryByTestId("markdown-preview")).toBeNull(); + }); + + it("offers no preview for another file type", () => { + render(); + expect(screen.queryByRole("button", { name: "Preview" })).toBeNull(); + }); +}); diff --git a/client/src/components/config/FileViewer.tsx b/client/src/components/config/FileViewer.tsx index 8fbd751e..6b21b810 100644 --- a/client/src/components/config/FileViewer.tsx +++ b/client/src/components/config/FileViewer.tsx @@ -3,15 +3,17 @@ * @description Read-only file-content modal for the config explorer. Takes * already-fetched data as props (path/data/error) rather than fetching * itself, so it stays tool-agnostic — the orchestrator (Claude or Codex - * config page) owns the fetch. + * config page) owns the fetch. A markdown file gets a Raw / Preview switch + * (`MarkdownPreview`); Raw is the default. * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { FileText, X, AlertCircle } from "lucide-react"; +import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react"; import type { CcFileResponse } from "../../lib/api"; import { CopyButton } from "./configPrimitives"; +import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; export function FileViewer({ state, @@ -21,6 +23,8 @@ export function FileViewer({ onClose: () => void; }) { const { t } = useTranslation("ccConfig"); + const [preview, setPreview] = useState(false); + const canPreview = isMarkdown(state.path) && !!state.data; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -42,6 +46,17 @@ export function FileViewer({ {state.path} + {canPreview && ( + + )} + )} {/* Only the working file is editable: a commit is history. */} {/* The whole file enters the editor by a click on its text; only the diff, which has no whole text to click, keeps @@ -1442,7 +1461,8 @@ export function LaneChangesPanel({ {t(noEditReason(text))} ) : draft === null && whole ? ( - text && ( + text && + !mdPreview && ( {t("changes.clickToEdit")} @@ -1503,7 +1523,11 @@ export function LaneChangesPanel({ className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none" /> ) : whole ? ( - text ? ( + text && mdPreview ? ( +
+ +
+ ) : text ? ( ({ routerApply: vi.fn(), }, aiRouters: { list: vi.fn() }, - aiTools: { list: vi.fn() }, + aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` }, run: { start: vi.fn() }, }, })); @@ -784,14 +784,20 @@ describe("AddLaneModal — tool selection", () => { const dirField = screen.getByLabelText("Directory"); await focusField(user, dirField); await user.type(dirField, "/tmp/demo"); - const other = await screen.findByRole("combobox", { name: "Other…" }); - // Installed + enabled only, and never the default itself. - expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([ - "", - "codex", - "gemini", - ]); - await user.selectOptions(other, "codex"); + // One button showing the default; it opens every installed + enabled tool. + const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ }); + expect(picker).toHaveAttribute("aria-expanded", "false"); + await user.click(picker); + const list = screen.getByRole("group", { name: /Which CLI drives this lane/ }); + expect( + within(list) + .getAllByRole("button") + .map((b) => b.textContent) + ).toEqual(["Claude Code", "Codex", "Gemini"]); + await user.click(within(list).getByRole("button", { name: "Codex" })); + // The picked tool replaces the default on the button, and the list closes. + expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull(); await user.click(screen.getByRole("button", { name: /add lane|create/i })); await waitFor(() => @@ -802,7 +808,8 @@ describe("AddLaneModal — tool selection", () => { it("offers only Native for a tool that cannot use an AI Router", async () => { renderModal({}); const user = userEvent.setup(); - await user.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "gemini"); + await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ })); + await user.click(screen.getByRole("button", { name: "Gemini" })); expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked(); expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled(); }); diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx index 4f7250e0..6da8309c 100644 --- a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx +++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx @@ -321,6 +321,34 @@ describe("LaneChangesPanel", () => { expect(screen.getByText("Staged Changes")).toBeInTheDocument(); }); + it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => { + vi.mocked(api.lanes.gitTree).mockResolvedValue({ + available: true, + path: "", + entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }], + truncated: false, + }); + vi.mocked(api.lanes.gitFile).mockResolvedValue({ + available: true, + path: "README.md", + rev: "work", + binary: false, + truncated: false, + lines: ["# Hello", "", "- one"], + sha: "c".repeat(40), + }); + const user = userEvent.setup(); + render(); + const sidebar = await screen.findByTestId("lane-changes-sidebar"); + await user.click(await within(sidebar).findByText("README.md")); + await screen.findByTestId("lane-file-view"); + await user.click(screen.getByTestId("lane-file-preview")); + expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); + expect(screen.queryByTestId("lane-file-view")).toBeNull(); + await user.click(screen.getByTestId("lane-file-preview")); + expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello"); + }); + it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => { vi.mocked(api.lanes.gitTree).mockResolvedValue({ available: true, diff --git a/client/src/components/settings/AiToolsModal.tsx b/client/src/components/settings/AiToolsModal.tsx new file mode 100644 index 00000000..3fd5ea7d --- /dev/null +++ b/client/src/components/settings/AiToolsModal.tsx @@ -0,0 +1,67 @@ +/** + * @file AiToolsModal.tsx + * @description The AI Tools dialog opened from the Settings tab bar's right + * edge: a header with the title, description and a close button around + * `AiToolsSettings`. Escape, the backdrop and the X close it; an Escape a + * nested ConfirmModal already handled (defaultPrevented) is left to it. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Bot, X } from "lucide-react"; +import { AiToolsSettings } from "./AiToolsSettings"; + +/** + * @param props.onClose - Called on Escape, a backdrop click or the X. + * @returns The modal. + */ +export function AiToolsModal({ onClose }: { onClose: () => void }) { + const { t } = useTranslation("settings"); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !e.defaultPrevented) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl" + > +
+
+

+ + {t("aiTools.title")} +

+

{t("aiTools.description")}

+
+ +
+ +
+
+ ); +} diff --git a/client/src/components/settings/AiToolsSettings.tsx b/client/src/components/settings/AiToolsSettings.tsx index 7c738f07..bfda6bd3 100644 --- a/client/src/components/settings/AiToolsSettings.tsx +++ b/client/src/components/settings/AiToolsSettings.tsx @@ -1,44 +1,32 @@ /** * @file AiToolsSettings.tsx - * @description Settings → AI Tools: every AI coding CLI in the server's catalog - * (GET /api/ai-tools), split into the ones found on PATH — each with - * Enabled/Disabled, Set default and, when the catalog names one, Update with a - * new-version badge — and the ones available to install, with Install where - * the catalog knows how. Install/Update/Update tools confirm the exact command + * @description The body of the AI Tools modal (Settings tab bar → AI Tools, + * `AiToolsModal.tsx`): every AI coding CLI in the server's catalog + * (GET /api/ai-tools). Installed ones are rows with an enable switch, a + * Default badge or Set default, and Update whenever the catalog names an + * updater — no version lookup, the updater itself knows whether it has work. + * The ones available to install fold behind a disclosure, with Install where + * the catalog knows how. Install/Update/Update all confirm the exact command * first, run as ONE server job at a time and show its log under the row - * (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH - * and re-detects. Icons are the server's cached favicons, a letter otherwise. - * A disabled tool is hidden from Add lane; existing lanes keep working. + * (polled from GET /api/ai-tools/job; a finished job folds to one line). + * Refresh re-reads the login shell's PATH and re-detects. Icons come from + * `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep + * working. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react"; +import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react"; import { useAiTools } from "../../hooks/useAiTools"; import { ConfirmModal } from "../ConfirmModal"; -import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api"; +import { AiToolIcon } from "../AiToolIcon"; +import { api, type AiTool, type AiToolJob } from "../../lib/api"; type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" }; -function Avatar({ tool }: { tool: AiTool }) { - const [failed, setFailed] = useState(false); - if (!failed) { - return ( - setFailed(true)} - className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1" - /> - ); - } - return ( - - {tool.label.charAt(0).toUpperCase()} - - ); -} +const GHOST_BTN = + "inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50"; function Homepage({ tool }: { tool: AiTool }) { const { t } = useTranslation("settings"); @@ -57,12 +45,51 @@ function Homepage({ tool }: { tool: AiTool }) { ); } +function Switch({ + tool, + disabled, + onToggle, +}: { + tool: AiTool; + disabled: boolean; + onToggle: () => void; +}) { + const { t } = useTranslation("settings"); + return ( + + ); +} + function JobLog({ job }: { job: AiToolJob }) { const { t } = useTranslation("settings"); const ref = useRef(null); + // A finished job folds to its status line; running or failed stays open. + const [open, setOpen] = useState(job.status !== "ok"); + useEffect(() => { + if (job.status === "ok") setOpen(false); + }, [job.status]); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; - }, [job.log]); + }, [job.log, open]); const state = job.status === "running" ? t("aiTools.jobRunning") @@ -71,18 +98,29 @@ function JobLog({ job }: { job: AiToolJob }) { : t("aiTools.jobFailed", { code: job.exitCode ?? "?" }); return (
-

- {state} -

-
-        {job.log}
-      
+
+ + {state} + + {job.status !== "running" && ( + + )} +
+ {open && ( +
+          {job.log}
+        
+ )}
); } @@ -93,31 +131,16 @@ export function AiToolsSettings() { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [job, setJob] = useState(null); - const [versions, setVersions] = useState>({}); const [confirm, setConfirm] = useState(null); - const sectionRef = useRef(null); + const [showAvailable, setShowAvailable] = useState(false); - // The Add-lane modal links here as /settings#ai-tools; the section renders - // after the browser's own hash jump, so it has to scroll itself into view. - useEffect(() => { - if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.(); - }, []); - - // Deferred like useAiTools' load: an unreachable server (or a mocked api - // without these calls) degrades to "no job, no badges", never a throw. - const loadVersions = () => - Promise.resolve() - .then(() => api.aiTools.versions()) - .then(setVersions) - .catch(() => undefined); - - // A page opened mid-job picks it up; versions are cached a day server-side. + // A modal opened mid-job picks it up. Deferred like useAiTools' load: an + // unreachable server (or a mocked api without it) means "no job". useEffect(() => { Promise.resolve() .then(() => api.aiTools.job()) .then(setJob) .catch(() => undefined); - void loadVersions(); }, []); const running = job?.status === "running"; @@ -127,10 +150,7 @@ export function AiToolsSettings() { try { const j = await api.aiTools.job(); setJob(j); - if (j?.status !== "running") { - void reload(); - void loadVersions(); - } + if (j?.status !== "running") void reload(); } catch { /* next tick */ } @@ -160,14 +180,10 @@ export function AiToolsSettings() { if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url }); return ""; }; - // Mirrors the server's update-all: badged npm tools, then every self updater. - const updateAllTools = tools.filter( - (x) => - x.installed && - (x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable)) - ); - const canUpdate = (tool: AiTool) => - !!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true); + const installed = tools.filter((x) => x.installed); + const available = tools.filter((x) => !x.installed); + // Mirrors the server's update-all: every installed tool with an updater. + const updateAllTools = installed.filter((x) => x.update); const startConfirmed = async () => { const c = confirm; @@ -186,8 +202,6 @@ export function AiToolsSettings() { const jobBusy = running || busy !== null; const logFor = (id: string | null) => (job && job.tool === id ? : null); - const installed = tools.filter((x) => x.installed); - const available = tools.filter((x) => !x.installed); const confirmLines = confirm?.kind === "update-all" @@ -197,149 +211,139 @@ export function AiToolsSettings() { : []; return ( -
-

- - {t("aiTools.title")} -

-

{t("aiTools.description")}

- -
-
-
-

- {t("aiTools.installed", { count: installed.length })} -

-
+
+
+
+

+ {t("aiTools.installed", { count: installed.length })} +

+
+ {updateAllTools.length > 0 && ( - -
+ )} +
- {error &&

{error}

} - {logFor(null)} - {loaded && installed.length === 0 && ( -

{t("aiTools.noneInstalled")}

- )} -
    - {installed.map((tool) => { - const isDefault = tool.id === defaultId; - const v = versions[tool.id]; - return ( -
  • - -
    -
    - {tool.label} - - {v?.updateAvailable && v.latest && ( - - {t("aiTools.newVersion", { version: v.latest })} - - )} -
    -
    - {tool.command} - {v?.installed ? ` · v${v.installed}` : ""} - {tool.update?.kind === "npm" && v && !v.npmManaged && ( - · {t("aiTools.outsideNpm")} - )} +
    + {error &&

    {error}

    } + {logFor(null)} + {loaded && installed.length === 0 && ( +

    {t("aiTools.noneInstalled")}

    + )} +
      + {installed.map((tool) => { + const isDefault = tool.id === defaultId; + return ( +
    • +
      +
      + +
      +
      + {tool.label} + + {isDefault && ( + + {t("aiTools.default")} + + )} +
      +
      + {tool.command} +
      - {canUpdate(tool) && ( - - )} - {isDefault ? ( - ✓ {t("aiTools.default")} - ) : ( - - )} - + )} +
      +
      + {!isDefault && ( + + )} +
      + + onToggle={() => void run(tool.id, () => update(tool.id, { enabled: !tool.enabled })) } - className={`w-24 rounded-md border px-2 py-1 text-xs disabled:opacity-50 ${ - tool.enabled - ? "border-status-success/40 bg-status-success/10 text-status-success" - : "border-border bg-surface-2 text-fg-muted" - }`} - > - {tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")} - - {logFor(tool.id)} -
    • - ); - })} -
    -
    + /> +
+ {logFor(tool.id)} + + ); + })} + +
- {available.length > 0 && ( -
-

- {t("aiTools.available", { count: available.length })} -

+ {available.length > 0 && ( +
+ + {showAvailable && (
    {available.map((tool) => (
  • - - - {tool.label} - + + + {tool.label} + {tool.command} + {tool.install && (
  • ))}
-
- )} -
+ )} +
+ )} -
+ ); } diff --git a/client/src/components/settings/AntigravityHooksSettings.tsx b/client/src/components/settings/AntigravityHooksSettings.tsx index af9f8aca..1dd10589 100644 --- a/client/src/components/settings/AntigravityHooksSettings.tsx +++ b/client/src/components/settings/AntigravityHooksSettings.tsx @@ -1,6 +1,6 @@ /** * @file AntigravityHooksSettings.tsx - * @description Settings → Antigravity → AgentHub → Antigravity hooks: whether + * @description Settings → Antigravity → Antigravity hooks: whether * AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched * off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is * written until the user clicks; agy asks for no trust, and has no permission diff --git a/client/src/components/settings/ClaudeAgentHubSettings.tsx b/client/src/components/settings/ClaudeAgentHubSettings.tsx index f859541d..b20a8b85 100644 --- a/client/src/components/settings/ClaudeAgentHubSettings.tsx +++ b/client/src/components/settings/ClaudeAgentHubSettings.tsx @@ -1,11 +1,10 @@ /** * @file ClaudeAgentHubSettings.tsx - * @description Settings → Claude Code → AgentHub: the dashboard's own settings - * that belong to Claude Code — Model Pricing for Claude models (the shared - * `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed - * into Claude Code with a Reinstall button, and the CLAUDE_HOME the server - * reads transcripts and config from. Moved here from Settings → Dashboard - * (#40 part B). + * @description The dashboard's own settings that belong to Claude Code, shown + * under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads + * transcripts and config from, the AgentHub hooks installed into Claude Code + * with a Reinstall button, and Model Pricing for Claude models (the shared + * `PricingSettings` table, with Reset Defaults). * @author Nguyễn Ngọc Trí Vĩ */ @@ -125,7 +124,32 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () = return (
- + + api.settings.claudeHome + .get() + .then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned })) + } + save={(p) => + api.settings.claudeHome.set(p).then((r) => { + void load(); + onHomeChanged?.(); + const missing = r.routers?.missing ?? []; + return { + value: r.claude_home, + envOwned: !!r.env_owned, + warning: missing.length + ? t("claudeHome.routersMissing", { lanes: missing.join(", ") }) + : null, + }; + }) + } + /> {error && (
{error} @@ -192,32 +216,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
- - api.settings.claudeHome - .get() - .then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned })) - } - save={(p) => - api.settings.claudeHome.set(p).then((r) => { - void load(); - onHomeChanged?.(); - const missing = r.routers?.missing ?? []; - return { - value: r.claude_home, - envOwned: !!r.env_owned, - warning: missing.length - ? t("claudeHome.routersMissing", { lanes: missing.join(", ") }) - : null, - }; - }) - } - /> +
); } diff --git a/client/src/components/settings/CodexHooksSettings.tsx b/client/src/components/settings/CodexHooksSettings.tsx index 17572bb6..a9373994 100644 --- a/client/src/components/settings/CodexHooksSettings.tsx +++ b/client/src/components/settings/CodexHooksSettings.tsx @@ -1,6 +1,6 @@ /** * @file CodexHooksSettings.tsx - * @description Settings → Codex → AgentHub → Codex hooks: whether AgentHub's + * @description Settings → Codex → Overview → Codex hooks: whether AgentHub's * hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with * Install/Reinstall and Remove. Nothing is written until the user clicks; * Codex itself asks the user to trust the hooks before it runs them, and this diff --git a/client/src/components/settings/HomeDirSetting.tsx b/client/src/components/settings/HomeDirSetting.tsx index 868aa6b7..6adf3c6a 100644 --- a/client/src/components/settings/HomeDirSetting.tsx +++ b/client/src/components/settings/HomeDirSetting.tsx @@ -1,8 +1,9 @@ /** * @file HomeDirSetting.tsx * @description One editable home directory (CLAUDE_HOME in Claude Code → - * AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the - * server's refusal inline, the value in effect, and a warning when the real + * Overview, CODEX_HOME in Codex → Overview): a path field with Save, the + * server's refusal inline, the saved value while the field differs from it, + * and a warning when the real * environment sets the variable (the environment wins at the next start, so a * saved value would not survive a restart). The server validates and persists; * this component only loads, submits and reports. @@ -105,7 +106,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load {error &&

{error}

} - {current && ( + {/* The saved value, only while the field says something else. */} + {current && input !== current.value && (

{t("homeDir.current")} {current.value}

diff --git a/client/src/components/settings/PricingSettings.tsx b/client/src/components/settings/PricingSettings.tsx index 6e9476ed..af250e34 100644 --- a/client/src/components/settings/PricingSettings.tsx +++ b/client/src/components/settings/PricingSettings.tsx @@ -1,7 +1,8 @@ /** * @file PricingSettings.tsx * @description Model Pricing — the per-model rates every session's cost is - * computed from — as shown in a tool's Settings → AgentHub sub-tab: Claude + * computed from — as shown on a tool's Settings tab (Overview for Claude Code + * and Codex, the Antigravity tab itself): Claude * Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o…`, * `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a * **Show all models** switch lists every row, and a row added outside the diff --git a/client/src/components/settings/__tests__/AiToolsSettings.test.tsx b/client/src/components/settings/__tests__/AiToolsSettings.test.tsx index 26157caa..af5afff5 100644 --- a/client/src/components/settings/__tests__/AiToolsSettings.test.tsx +++ b/client/src/components/settings/__tests__/AiToolsSettings.test.tsx @@ -1,9 +1,12 @@ /** * @file AiToolsSettings.test.tsx - * @description Settings → AI Tools: installed rows carry Enabled/Disabled and - * Set default, available rows a homepage link, Refresh re-detects, a - * disabled tool's Settings tab disappears from the hub, and Install/Update - * confirm the exact command, then show the polled job log. + * @description The AI Tools modal: the Settings tab bar's button and the + * `#ai-tools` deep link open it, Escape closes it; installed rows carry an + * enable switch and Set default, available rows fold behind a disclosure with + * a homepage link, Refresh re-detects, a disabled tool's Settings tab + * disappears from the hub, every updater gets Update without a version + * lookup, and Install/Update confirm the exact command, then show the polled + * job log, folded once it is done. * @author Nguyễn Ngọc Trí Vĩ */ @@ -29,7 +32,6 @@ vi.mock("../../../lib/api", async (importOriginal) => { updateTool: vi.fn(), updateAll: vi.fn(), job: vi.fn(), - versions: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon`, }, }, @@ -71,7 +73,6 @@ beforeEach(() => { vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW); vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW); vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null); - vi.mocked(api.aiTools.versions).mockReset().mockResolvedValue({}); vi.mocked(api.aiTools.install).mockReset(); vi.mocked(api.aiTools.updateTool).mockReset(); vi.mocked(api.aiTools.updateAll).mockReset(); @@ -93,7 +94,7 @@ describe("AiToolsSettings", () => { it("splits installed tools from the ones available to install", async () => { render(); expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument(); - expect(screen.getByText("Available to install (1)")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" })); const claude = screen.getByTestId("ai-tool-claude"); expect(within(claude).getByText(/Default/)).toBeInTheDocument(); const aider = screen.getByTestId("ai-tool-aider"); @@ -120,10 +121,8 @@ describe("AiToolsSettings", () => { const user = userEvent.setup(); render(); await screen.findByTestId("ai-tool-claude"); - const before = vi.mocked(api.aiTools.versions).mock.calls.length; await user.click(screen.getByRole("button", { name: "Refresh" })); expect(api.aiTools.refresh).toHaveBeenCalledOnce(); - expect(vi.mocked(api.aiTools.versions).mock.calls.length).toBe(before + 1); }); }); @@ -154,11 +153,9 @@ describe("AiToolsSettings — install and update", () => { }; beforeEach(() => { vi.mocked(api.aiTools.list).mockResolvedValue(A2); - vi.mocked(api.aiTools.versions).mockResolvedValue({ - codex: { npmManaged: false, installed: null, latest: null, updateAvailable: false }, - gemini: { npmManaged: true, installed: "0.60.0", latest: "0.61.0", updateAvailable: true }, - }); }); + const openAvailable = async () => + userEvent.click(await screen.findByRole("button", { name: /Available to install/ })); it("confirms the exact npm command, then polls and shows the log", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -169,6 +166,7 @@ describe("AiToolsSettings — install and update", () => { .mockResolvedValueOnce(null) .mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" })); render(); + await user.click(await screen.findByRole("button", { name: /Available to install/ })); const qwen = await screen.findByTestId("ai-tool-qwen"); await user.click(within(qwen).getByRole("button", { name: "Install" })); expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent( @@ -182,6 +180,8 @@ describe("AiToolsSettings — install and update", () => { vi.advanceTimersByTime(1100); }); expect(await within(qwen).findByText("Done")).toBeInTheDocument(); + expect(within(qwen).queryByTestId("ai-tool-log")).toBeNull(); // folded once done + await user.click(within(qwen).getByRole("button", { name: "Show log" })); expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package"); const calls = vi.mocked(api.aiTools.job).mock.calls.length; await act(async () => { @@ -196,6 +196,7 @@ describe("AiToolsSettings — install and update", () => { it("names the vendor script for a script install", async () => { const user = userEvent.setup(); render(); + await openAvailable(); const cursor = await screen.findByTestId("ai-tool-cursor"); await user.click(within(cursor).getByRole("button", { name: "Install" })); expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent( @@ -203,35 +204,34 @@ describe("AiToolsSettings — install and update", () => { ); }); - it("badges a newer npm version and offers no npm update outside npm", async () => { + it("offers Update on every installed tool with an updater", async () => { render(); - const gemini = await screen.findByTestId("ai-tool-gemini"); - expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument(); - expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument(); - const codex = screen.getByTestId("ai-tool-codex"); - expect(within(codex).getByText(/Installed outside npm/)).toBeInTheDocument(); - expect(within(codex).queryByRole("button", { name: "Update" })).toBeNull(); + for (const id of ["claude", "codex", "gemini"]) { + const row = await screen.findByTestId(`ai-tool-${id}`); + expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument(); + } }); - it("Update tools lists the badged npm tools and the self updaters", async () => { + it("Update all lists every installed tool's updater", async () => { const user = userEvent.setup(); render(); - await screen.findByText("v0.61.0 available"); - await user.click(screen.getByRole("button", { name: "Update tools" })); + await user.click(await screen.findByRole("button", { name: "Update all (3)" })); const cmd = screen.getByTestId("ai-tool-confirm-command"); expect(cmd).toHaveTextContent("claude update"); expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest"); - expect(cmd).not.toHaveTextContent("@openai/codex"); + expect(cmd).toHaveTextContent("npm install -g @openai/codex@latest"); }); - it("disables Update tools when nothing has an update", async () => { + it("hides Update all when no tool has an updater", async () => { vi.mocked(api.aiTools.list).mockResolvedValue(VIEW); render(); - expect(await screen.findByRole("button", { name: "Update tools" })).toBeDisabled(); + await screen.findByTestId("ai-tool-claude"); + expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull(); }); it("falls back to the letter when the icon fails", async () => { const { container } = render(); + await openAvailable(); await screen.findByTestId("ai-tool-qwen"); const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!; expect(img).toBeTruthy(); @@ -256,4 +256,42 @@ describe("SettingsHub — AI tool tabs", () => { expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument(); expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull(); }); + + it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => { + vi.doMock("../../../pages/Settings", () => ({ Settings: () => null })); + const { SettingsHub } = await import("../../../pages/SettingsHub"); + render( + + + + ); + expect(screen.queryByRole("dialog")).toBeNull(); + await userEvent.click(await screen.findByRole("button", { name: "AI Tools" })); + const dialog = screen.getByRole("dialog", { name: "AI Tools" }); + expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("opens the modal for the /settings#ai-tools deep link", async () => { + vi.doMock("../../../pages/Settings", () => ({ Settings: () => null })); + const { SettingsHub } = await import("../../../pages/SettingsHub"); + render( + + + + ); + expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument(); + }); +}); + +describe("AiToolIcon", () => { + it("retries the icon when the same instance shows another tool", async () => { + const { AiToolIcon } = await import("../../AiToolIcon"); + const { container, rerender } = render(); + fireEvent.error(container.querySelector("img")!); + expect(screen.getByText("C")).toBeInTheDocument(); + rerender(); + expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy(); + }); }); diff --git a/client/src/components/settings/__tests__/HomeDirSetting.test.tsx b/client/src/components/settings/__tests__/HomeDirSetting.test.tsx index 282507da..c90c3c6b 100644 --- a/client/src/components/settings/__tests__/HomeDirSetting.test.tsx +++ b/client/src/components/settings/__tests__/HomeDirSetting.test.tsx @@ -7,7 +7,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "i18next"; @@ -43,11 +43,15 @@ describe("HomeDirSetting", () => { expect(button).toBeDisabled(); expect(container.querySelector("section#x-home")).not.toBeNull(); + // The saved value shows only while the field says something else. + expect(screen.queryByText("/a", { selector: "code" })).toBeNull(); await user.clear(input); await user.type(input, "/b"); + expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument(); await user.click(button); expect(saveFn).toHaveBeenCalledWith("/b"); - expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText("/a", { selector: "code" })).toBeNull()); + expect(screen.getByDisplayValue("/b")).toBeInTheDocument(); expect(screen.queryByText(/set by the environment/)).toBeNull(); }); diff --git a/client/src/i18n/locales/en/ccConfig.json b/client/src/i18n/locales/en/ccConfig.json index 1d7cbc06..96c038d1 100644 --- a/client/src/i18n/locales/en/ccConfig.json +++ b/client/src/i18n/locales/en/ccConfig.json @@ -24,8 +24,7 @@ "hooks": "Hooks", "keybindings": "Keybindings", "settings": "Settings", - "memory": "Memory", - "agenthub": "AgentHub" + "memory": "Memory" }, "overview": { "rootsTitle": "Roots", @@ -51,7 +50,9 @@ "modified": "Modified", "scopeLabel": "Scope", "noFrontmatter": "No frontmatter", - "redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side." + "redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.", + "preview": "Preview", + "raw": "Raw" }, "skills": { "name": "Name", diff --git a/client/src/i18n/locales/en/codexConfig.json b/client/src/i18n/locales/en/codexConfig.json index c3e41edd..48f3a640 100644 --- a/client/src/i18n/locales/en/codexConfig.json +++ b/client/src/i18n/locales/en/codexConfig.json @@ -18,8 +18,7 @@ "memory": "Memory", "config": "Config", "mcp": "MCP servers", - "plugins": "Plugins", - "agenthub": "AgentHub" + "plugins": "Plugins" }, "overview": { "rootsTitle": "Roots", diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 0cd414af..78d00025 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -53,8 +53,7 @@ "addLaneSetupTitle": "Setup", "addLaneTitleLabel": "Title", "addLaneTitlePlaceholder": "Optional", - "addLaneToolDefault": "Default ({{tool}})", - "addLaneToolOther": "Other…", + "addLaneToolDefaultBadge": "Default", "addLaneNoTools": "No AI tool is installed and enabled.", "addLaneNoToolsLink": "Open Settings → AI Tools", "addLaneRouterNativeTool": "Native ({{tool}})", @@ -258,6 +257,8 @@ "changes.save": "Save", "changes.cancelEdit": "Cancel", "changes.clickToEdit": "Click the text to edit", + "changes.preview": "Preview", + "changes.raw": "Raw", "changes.editorLabel": "Edit {{path}}", "changes.prevHunk": "Previous change", "changes.nextHunk": "Next change", diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 71a6f43b..1101b032 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -530,9 +530,7 @@ "homepageOf": "{{tool}} homepage", "install": "Install", "update": "Update", - "updateTools": "Update tools", - "newVersion": "v{{version}} available", - "outsideNpm": "Installed outside npm — update it the way you installed it", + "updateAll": "Update all ({{count}})", "runScript": "Download {{url}} and run it with bash", "confirmInstall": "Install {{tool}}?", "confirmUpdate": "Update {{tool}}?", @@ -542,7 +540,9 @@ "cancel": "Cancel", "jobRunning": "Running…", "jobOk": "Done", - "jobFailed": "Failed (exit {{code}})" + "jobFailed": "Failed (exit {{code}})", + "showLog": "Show log", + "hideLog": "Hide log" }, "appearance": { "title": "Appearance", diff --git a/client/src/i18n/locales/vi/ccConfig.json b/client/src/i18n/locales/vi/ccConfig.json index 99efc107..d134e883 100644 --- a/client/src/i18n/locales/vi/ccConfig.json +++ b/client/src/i18n/locales/vi/ccConfig.json @@ -24,8 +24,7 @@ "hooks": "Hooks", "keybindings": "Phím tắt", "settings": "Cài đặt", - "memory": "Bộ nhớ", - "agenthub": "AgentHub" + "memory": "Bộ nhớ" }, "overview": { "rootsTitle": "Thư mục gốc", @@ -51,7 +50,9 @@ "modified": "Đã sửa", "scopeLabel": "Phạm vi", "noFrontmatter": "Không có frontmatter", - "redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ." + "redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ.", + "preview": "Xem trước", + "raw": "Mã nguồn" }, "skills": { "name": "Tên", diff --git a/client/src/i18n/locales/vi/codexConfig.json b/client/src/i18n/locales/vi/codexConfig.json index 4cb0c03c..ff5fb391 100644 --- a/client/src/i18n/locales/vi/codexConfig.json +++ b/client/src/i18n/locales/vi/codexConfig.json @@ -18,8 +18,7 @@ "memory": "Bộ nhớ", "config": "Cấu hình", "mcp": "Máy chủ MCP", - "plugins": "Plugin", - "agenthub": "AgentHub" + "plugins": "Plugin" }, "overview": { "rootsTitle": "Thư mục gốc", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 5ba1f031..500b102c 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -53,8 +53,7 @@ "addLaneSetupTitle": "Thiết lập", "addLaneTitleLabel": "Tiêu đề", "addLaneTitlePlaceholder": "Không bắt buộc", - "addLaneToolDefault": "Mặc định ({{tool}})", - "addLaneToolOther": "Khác…", + "addLaneToolDefaultBadge": "Mặc định", "addLaneNoTools": "Chưa có AI tool nào được cài và bật.", "addLaneNoToolsLink": "Mở Cài đặt → AI Tools", "addLaneRouterNativeTool": "Native ({{tool}})", @@ -258,6 +257,8 @@ "changes.save": "Lưu", "changes.cancelEdit": "Huỷ", "changes.clickToEdit": "Bấm vào nội dung để sửa", + "changes.preview": "Xem trước", + "changes.raw": "Mã nguồn", "changes.editorLabel": "Sửa {{path}}", "changes.prevHunk": "Thay đổi trước", "changes.nextHunk": "Thay đổi sau", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index acdd0ebb..987c716b 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -530,9 +530,7 @@ "homepageOf": "Trang chủ {{tool}}", "install": "Cài đặt", "update": "Cập nhật", - "updateTools": "Cập nhật tool", - "newVersion": "Có bản v{{version}}", - "outsideNpm": "Cài ngoài npm — cập nhật theo cách bạn đã cài", + "updateAll": "Cập nhật tất cả ({{count}})", "runScript": "Tải {{url}} và chạy bằng bash", "confirmInstall": "Cài {{tool}}?", "confirmUpdate": "Cập nhật {{tool}}?", @@ -542,7 +540,9 @@ "cancel": "Huỷ", "jobRunning": "Đang chạy…", "jobOk": "Xong", - "jobFailed": "Lỗi (exit {{code}})" + "jobFailed": "Lỗi (exit {{code}})", + "showLog": "Xem log", + "hideLog": "Ẩn log" }, "appearance": { "title": "Giao diện", diff --git a/client/src/index.css b/client/src/index.css index 9de7ef92..05d6b998 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -441,3 +441,142 @@ .animate-pulse-dot { animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + +/* Rendered markdown (MarkdownPreview.tsx): theme tokens only, so every theme + reads it; no typography plugin. */ +.md-preview { + color: rgb(var(--fg-secondary)); + font-size: 13px; + line-height: 1.6; + overflow-wrap: anywhere; +} +.md-preview > :first-child { + margin-top: 0; +} +.md-preview h1, +.md-preview h2, +.md-preview h3, +.md-preview h4, +.md-preview h5, +.md-preview h6 { + color: rgb(var(--fg-primary)); + font-weight: 600; + line-height: 1.3; + margin: 1.4em 0 0.6em; +} +.md-preview h1 { + font-size: 1.6em; + padding-bottom: 0.3em; + border-bottom: 1px solid rgb(var(--border)); +} +.md-preview h2 { + font-size: 1.3em; + padding-bottom: 0.25em; + border-bottom: 1px solid rgb(var(--border)); +} +.md-preview h3 { + font-size: 1.12em; +} +.md-preview h4, +.md-preview h5, +.md-preview h6 { + font-size: 1em; +} +.md-preview p, +.md-preview ul, +.md-preview ol, +.md-preview blockquote, +.md-preview pre, +.md-preview table { + margin: 0 0 0.9em; +} +.md-preview ul { + list-style: disc; + padding-left: 1.5em; +} +.md-preview ol { + list-style: decimal; + padding-left: 1.5em; +} +.md-preview li + li { + margin-top: 0.2em; +} +.md-preview li > ul, +.md-preview li > ol { + margin: 0.2em 0 0; +} +.md-preview li.task-list-item { + list-style: none; + margin-left: -1.3em; +} +.md-preview li.task-list-item input { + margin-right: 0.4em; + vertical-align: middle; +} +.md-preview a { + color: rgb(var(--accent)); + text-decoration: underline; + text-underline-offset: 2px; +} +.md-preview strong { + color: rgb(var(--fg-primary)); + font-weight: 600; +} +.md-preview code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.88em; + padding: 0.1em 0.35em; + border-radius: 4px; + background: rgb(var(--surface-3)); + color: rgb(var(--fg-primary)); +} +.md-preview pre, +.md-preview .md-frontmatter { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; + padding: 0.75em 1em; + border-radius: 6px; + border: 1px solid rgb(var(--border)); + background: rgb(var(--surface-0)); + overflow-x: auto; + white-space: pre; +} +.md-preview .md-frontmatter { + margin: 0 0 1em; + color: rgb(var(--fg-muted)); +} +.md-preview pre code { + padding: 0; + background: none; + font-size: inherit; +} +.md-preview blockquote { + padding: 0 1em; + border-left: 3px solid rgb(var(--border)); + color: rgb(var(--fg-muted)); +} +.md-preview hr { + margin: 1.5em 0; + border: 0; + border-top: 1px solid rgb(var(--border)); +} +.md-preview table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; +} +.md-preview th, +.md-preview td { + padding: 0.35em 0.75em; + border: 1px solid rgb(var(--border)); +} +.md-preview th { + color: rgb(var(--fg-primary)); + font-weight: 600; + background: rgb(var(--surface-2)); +} +.md-preview img { + max-width: 100%; +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index ba95a9f6..f57c2d51 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2011,7 +2011,6 @@ export const api = { /** GET /api/ai-tools/job - the current or last job, or null. */ job: () => request("/ai-tools/job"), /** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */ - versions: () => request>("/ai-tools/versions"), /** The server-cached icon; an cannot send the bearer header, so the token rides the query. */ iconUrl: (id: string) => { const token = dashboardToken(); @@ -3090,15 +3089,6 @@ export interface AiToolJob { log: string; } -/** One row of GET /api/ai-tools/versions. */ -export interface AiToolVersion { - /** Its binary lives under npm's global prefix, so an npm update replaces it. */ - npmManaged: boolean; - installed: string | null; - latest: string | null; - updateAvailable: boolean; -} - export interface AiRouter { /** Identifier, derived from the label. Stable: a lane stores this. */ name: string; diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index b33af7e4..e1fcf3e4 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -7,8 +7,9 @@ * low-risk text-file surfaces (skills, agents, commands, output styles, * CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP, * hooks-in-settings, and settings.json files stay read-only - those have - * concurrent-write races with the live CLI. Its AgentHub sub-tab holds the - * dashboard's own Claude Code settings (pricing, hooks, CLAUDE_HOME). + * concurrent-write races with the live CLI. Its Overview also holds the + * dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path + * list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`). * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -59,7 +60,6 @@ import { useTranslation } from "react-i18next"; import { eventBus } from "../lib/eventBus"; import { Boxes, - Gauge, RefreshCw, Search, Sparkles, @@ -181,8 +181,7 @@ type TabKey = | "hooks" | "keybindings" | "settings" - | "memory" - | "agenthub"; + | "memory"; interface TabDef { key: TabKey; @@ -203,7 +202,6 @@ const TABS: TabDef[] = [ { key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" }, { key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" }, { key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" }, - { key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" }, ]; interface PageState { @@ -243,7 +241,7 @@ const EMPTY_STATE: PageState = { export function CcConfig() { const { t } = useTranslation("ccConfig"); const [tab, setTab] = useState("overview"); - // Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it. + // Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them. const [agenthubKey, setAgenthubKey] = useState(0); const [scope, setScope] = useState("all"); const [data, setData] = useState(EMPTY_STATE); @@ -530,72 +528,72 @@ export function CcConfig() { - {/* AgentHub's own Claude Code settings are cards of their own, not a - list over this tab's search box and scope. */} - {tab === "agenthub" ? ( - void fetchAll()} /> - ) : ( -
- {tab !== "overview" && ( -
- - setSearch(e.target.value)} - placeholder={t("common.search")} - className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1" - /> - {search && ( - - )} - {isMutable(tab) && tab !== "memory" && ( - - )} - {/* The memory tab's own "new" is a different thing: the two +
+ {tab !== "overview" && ( +
+ + setSearch(e.target.value)} + placeholder={t("common.search")} + className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1" + /> + {search && ( + + )} + {isMutable(tab) && tab !== "memory" && ( + + )} + {/* The memory tab's own "new" is a different thing: the two CLAUDE.md cards below are this machine's user + current-project files, while this creates one in ANOTHER project's directory. */} - {tab === "memory" && ( - - )} -
- )} -
- openCreate("memory", s)} - onEditAuto={openEditAuto} - onDeleteAuto={openDeleteAuto} - onCreateAuto={openCreateAuto} - onChanged={fetchAll} - onToast={setToast} - /> + {tab === "memory" && ( + + )}
+ )} +
+ openCreate("memory", s)} + onEditAuto={openEditAuto} + onDeleteAuto={openDeleteAuto} + onCreateAuto={openCreateAuto} + onChanged={fetchAll} + onToast={setToast} + />
+
+ + {/* AgentHub's own Claude Code settings follow the overview, outside the + list's search box and scope. */} + {tab === "overview" && ( + void fetchAll()} /> )} {viewer && setViewer(null)} />} @@ -1069,12 +1067,6 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) { {t("overview.rootsTitle")}
- */ @@ -20,7 +20,6 @@ import { useTranslation } from "react-i18next"; import { eventBus } from "../lib/eventBus"; import { Boxes, - Gauge, RefreshCw, Search, Sparkles, @@ -82,16 +81,7 @@ import type { CodexSkill, } from "../lib/api"; -type TabKey = - | "overview" - | "skills" - | "agents" - | "rules" - | "memory" - | "config" - | "mcp" - | "plugins" - | "agenthub"; +type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins"; interface TabDef { key: TabKey; @@ -108,7 +98,6 @@ const TABS: TabDef[] = [ { key: "config", icon: SettingsIcon, i18nKey: "tabs.config" }, { key: "mcp", icon: Server, i18nKey: "tabs.mcp" }, { key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" }, - { key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" }, ]; function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" { @@ -160,7 +149,7 @@ export function CodexConfig() { const { t } = useTranslation("codexConfig"); const { t: tSettings } = useTranslation("settings"); const [tab, setTab] = useState("overview"); - // Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it. + // Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them. const [agenthubKey, setAgenthubKey] = useState(0); // Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json. const [hooksKey, setHooksKey] = useState(0); @@ -400,12 +389,72 @@ export function CodexConfig() { - {/* AgentHub's own Codex settings are cards of their own, not a list. A - new CODEX_HOME re-reads every list, since they all live under it. */} - {tab === "agenthub" ? ( +
+ {tab !== "overview" && tab !== "memory" && tab !== "config" && ( +
+ + setSearch(e.target.value)} + placeholder={t("common.search")} + className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1" + /> + {search && ( + + )} + {isNamedArtifact(tab) && ( + + )} +
+ )} + {/* The memory tab hides the search bar, but still needs its own + "new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not + the user/project files listed below. */} + {tab === "memory" && ( +
+ +
+ )} +
+ +
+
+ + {/* AgentHub's own Codex settings follow the overview. A new CODEX_HOME + re-reads every list, since they all live under it. */} + {tab === "overview" && (
- - -
- ) : ( -
- {tab !== "overview" && tab !== "memory" && tab !== "config" && ( -
- - setSearch(e.target.value)} - placeholder={t("common.search")} - className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1" - /> - {search && ( - - )} - {isNamedArtifact(tab) && ( - - )} -
- )} - {/* The memory tab hides the search bar, but still needs its own - "new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not - the user/project files listed below. */} - {tab === "memory" && ( -
- -
- )} -
- -
+ +
)} @@ -829,7 +818,6 @@ function OverviewPanel({ overview }: { overview: CodexOverview | null }) { {t("overview.rootsTitle")}
- */ /* ============================================================================= @@ -60,7 +60,6 @@ import { useTranslation } from "react-i18next"; import { DollarSign, Palette, - Bot, RefreshCw, Database, HardDrive, @@ -112,7 +111,6 @@ import { RemoteSources } from "../components/RemoteSources"; import { Skeleton } from "../components/Skeleton"; import { AlertsNotifications } from "../components/AlertsNotifications"; import { AppearanceSettings } from "../components/settings/AppearanceSettings"; -import { AiToolsSettings } from "../components/settings/AiToolsSettings"; import type { WSMessage } from "../lib/types"; // In-page navigation for the (dense) Settings screen. Each entry maps to a @@ -124,7 +122,6 @@ const SETTINGS_SECTIONS: { Icon: typeof DollarSign; }[] = [ { id: "appearance", labelKey: "appearance.title", Icon: Palette }, - { id: "ai-tools", labelKey: "aiTools.title", Icon: Bot }, { id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch }, { id: "import", labelKey: "import.title", fallback: "Import", Icon: History }, { @@ -626,8 +623,6 @@ export function Settings() { - - {/* ─── LANES ─── */}

diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index 964febb7..10579aa9 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -1,12 +1,13 @@ /** * @file SettingsHub.tsx - * @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools. + * @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there. * @author Nguyễn Ngọc Trí Vĩ */ -import type { ComponentType } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useCallback, useEffect, useState, type ComponentType } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { + Bot, Boxes, KeyRound, Orbit, @@ -22,6 +23,7 @@ import { AntigravityConfig } from "./AntigravityConfig"; import { AiRouterConfig } from "./AiRouterConfig"; import { GitCredentialsConfig } from "./GitCredentialsConfig"; import { useAiTools } from "../hooks/useAiTools"; +import { AiToolsModal } from "../components/settings/AiToolsModal"; interface ToolTab { id: string; @@ -85,6 +87,19 @@ export function SettingsHub() { (tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false ); const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB; + const location = useLocation(); + const navigate = useNavigate(); + const [aiToolsOpen, setAiToolsOpen] = useState(false); + + useEffect(() => { + if (location.hash === "#ai-tools") setAiToolsOpen(true); + }, [location.hash]); + + const closeAiTools = useCallback(() => { + setAiToolsOpen(false); + // Drop the deep link, or a reload would open the modal again. + if (location.hash) navigate({ search: location.search }, { replace: true }); + }, [location.hash, location.search, navigate]); return (
@@ -97,9 +112,21 @@ export function SettingsHub() { Icon, label: t(labelKey), }))} + actions={ + + } /> + {aiToolsOpen && }
); } diff --git a/client/src/pages/__tests__/CodexConfig.test.tsx b/client/src/pages/__tests__/CodexConfig.test.tsx index aba2a516..ad70636e 100644 --- a/client/src/pages/__tests__/CodexConfig.test.tsx +++ b/client/src/pages/__tests__/CodexConfig.test.tsx @@ -123,7 +123,7 @@ afterEach(() => { }); describe("CodexConfig", () => { - it("edits CODEX_HOME on the AgentHub sub-tab and reports lanes whose router layer failed", async () => { + it("edits CODEX_HOME on the Overview and reports lanes whose router layer failed", async () => { stubCodexApi({}); codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false }); codexHomeMock.set.mockResolvedValue({ @@ -136,7 +136,8 @@ describe("CodexConfig", () => { await settle(); const overviewCalls = codexConfigMock.overview.mock.calls.length; - await userEvent.click(screen.getByRole("tab", { name: /agenthub/i })); + // No AgentHub sub-tab: its cards sit under the Overview, the page's first tab. + expect(screen.queryByRole("tab", { name: /agenthub/i })).toBeNull(); const input = await screen.findByDisplayValue("/home/.codex"); expect(await screen.findByText("Codex hooks")).toBeInTheDocument(); expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument(); diff --git a/client/src/pages/__tests__/Workspace.test.tsx b/client/src/pages/__tests__/Workspace.test.tsx index ceeb52c3..faf257ec 100644 --- a/client/src/pages/__tests__/Workspace.test.tsx +++ b/client/src/pages/__tests__/Workspace.test.tsx @@ -58,6 +58,7 @@ vi.mock("../../lib/api", async (importOriginal) => { return { ...actual, api: { + aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` }, lanes: { list: vi.fn().mockImplementation(async () => { recordCall("GET", "/api/lanes"); 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 13d96634..46484f43 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -1474,35 +1474,6 @@ exports[`screen snapshots > Claude Config 1`] = ` 0 -

Claude Config 1`] = `
-
-
@@ -2602,6 +2493,378 @@ exports[`screen snapshots > Claude Config 1`] = `
+
+
+

+ + + + CLAUDE_HOME +

+

+ Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately. +

+

+ Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes). +

+
+
+ + +
+
+
+
+

+ + + + + + + Hook Configuration +

+

+ Claude Code hooks forward events to the dashboard in real time. +

+
+
+
+ + + + + + + All hooks installed + +
+ +
+
+

+ /tmp/settings.json +

+
+
+
+
+
+

+ + + + + Model Pricing + +

+

+ Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate. +

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ Pattern + + Model + + Input + + Output + + Cache Read + + Cache Write (5m) + + Cache Write (1h) + + Fast Input + + Fast Output + + Actions +
+ No Claude model prices. Reset Defaults restores them. +
+
+
+
`; @@ -7025,47 +7288,6 @@ exports[`screen snapshots > Settings 1`] = ` Appearance -
-
-

- - - - - - - - - AI Tools -

-

- AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane. -

-
-
-
-

- Installed (0 detected) -

-
- - -
-
-
    -
-
-
{ }); // Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B): -// they live behind the Claude Code tab's AgentHub sub-tab, not on Dashboard. +// they sit under the Claude Code tab's Overview, not on Dashboard or on the +// other sub-tabs. describe("Claude Code's AgentHub settings", () => { - it("shows pricing, hooks and CLAUDE_HOME on the AgentHub sub-tab only", async () => { + it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => { const dashboard = render( @@ -611,12 +612,13 @@ describe("Claude Code's AgentHub settings", () => { ); await settle(); - expect(container.querySelector("section#pricing")).toBeNull(); - fireEvent.click(screen.getByTestId("cc-tab-agenthub")); - await settle(); + expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull(); for (const id of ["pricing", "hooks", "claude-home"]) { expect(container.querySelector(`section#${id}`)).not.toBeNull(); } + fireEvent.click(screen.getByTestId("cc-tab-skills")); + await settle(); + expect(container.querySelector("section#pricing")).toBeNull(); }); }); diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 2a6a8b39..69ea8ed4 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -66,7 +66,7 @@ graph TB **Database Location:** - **Canonical (default):** `~/.claude/agenthub/dashboard.db` — shared by `npm start`, `npm run dev`, and the desktop app when it uses the same data dir - **Override:** set `DASHBOARD_DATA_DIR` (directory) or `DASHBOARD_DB_PATH` (file path) for tests or custom deployments -- **Pinned on a CLAUDE_HOME change:** the data dir defaults to `/agenthub`, so saving a new CLAUDE_HOME from Settings → Claude Code → AgentHub first writes the data dir in use as `DASHBOARD_DATA_DIR` to AgentHub's settings file (`~/.config/agenthub/settings.env`, or `$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`). The next start keeps the same database instead of opening an empty one under the new home. An explicit `DASHBOARD_DATA_DIR` is left alone +- **Pinned on a CLAUDE_HOME change:** the data dir defaults to `/agenthub`, so saving a new CLAUDE_HOME from Settings → Claude Code → Overview first writes the data dir in use as `DASHBOARD_DATA_DIR` to AgentHub's settings file (`~/.config/agenthub/settings.env`, or `$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`). The next start keeps the same database instead of opening an empty one under the new home. An explicit `DASHBOARD_DATA_DIR` is left alone - **Legacy:** repo-local `./data/dashboard.db` is migrated into the canonical location on first launch (see `server/db.js`) - **Pre-rebrand:** an install that predates AgentHub keeps its data in `~/.claude/agent-dashboard/`. Server startup renames that directory to `~/.claude/agenthub/` once, before anything opens the database (`migrateLegacyDataDir()` in `server/lib/claude-home.js`). It is a same-filesystem rename, so it is instant even at multiple gigabytes and never leaves two copies. If the rename fails, or `DASHBOARD_DATA_DIR` is set, nothing moves and the old directory keeps being used. diff --git a/docs/I18N.md b/docs/I18N.md index ea39c63e..c842e7c9 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -201,7 +201,7 @@ Formatting utilities are centralized in `client/src/lib/format.ts`. `formatTime`, `formatDateTime`, and `fmtCostFull` use locale-aware `toLocale*` APIs. Timestamp parsing normalizes timezone-less SQLite datetime strings to UTC before display formatting. -`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Model Pricing table (Settings → Claude Code → AgentHub, Settings → Codex → AgentHub and Settings → Antigravity), which shows raw patterns for pricing rule configuration. +`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Model Pricing table (Settings → Claude Code → Overview, Settings → Codex → Overview and Settings → Antigravity), which shows raw patterns for pricing rule configuration. ```mermaid flowchart LR diff --git a/docs/LANES.md b/docs/LANES.md index 79bc6f88..dc087321 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -1113,7 +1113,7 @@ on every hook, live — so it only ever sees `claude` lanes. touches a `codex` lane sharing the same directory, nor a Codex hook the `claude` one — and runs detection for `claude` lanes only. Codex lanes carry no pipeline, so nothing detects or advances their stage. Codex's own hooks are -opt-in (Settings → Codex → AgentHub → **Codex hooks**; Codex then asks you to +opt-in (Settings → Codex → Overview → **Codex hooks**; Codex then asks you to trust them): with them a codex lane gets `running`/`idle` as each turn starts and ends, and "Codex needs your permission: …" as its `needs_action` while Codex waits on an approval. A sub-agent thread's tool calls are folded onto @@ -1128,7 +1128,7 @@ start and end): every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) it lists `$CODEX_HOME/sessions///
/rollout-*.jsonl` (`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this -reads; it is editable in Settings → Codex → AgentHub, and a save re-writes each +reads; it is editable in Settings → Codex → Overview, and a save re-writes each codex lane's AI Router layer into the new home — a pane already running keeps the old one until it restarts), reads each thread's `cwd` from the `session_meta` on the file's first line, and calls the owning lane `running` @@ -1703,7 +1703,7 @@ agenthub stage [--evidence "..."] # Report the current stage ## Lanes on any AI tool -A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Dashboard → **AI Tools** lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with **Enabled/Disabled** and **Set default**, and what is available to install with a homepage link. The Add-lane modal offers **Default ()** and **Other…**; `agenthub lanes add --tool ` does the same from a terminal. +A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). The **AI Tools** button at the right of the Settings tab bar opens a dialog (also `/settings#ai-tools`) that lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-lane modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool ` does the same from a terminal. A catalog file: diff --git a/scripts/import-history.js b/scripts/import-history.js index a60a7169..a0fe8a31 100644 --- a/scripts/import-history.js +++ b/scripts/import-history.js @@ -32,7 +32,7 @@ const { const { extractFirstUserText } = require("../server/lib/transcript-cache"); const CLAUDE_DIR = getClaudeHome(); // The projects dir is read per call, not at load: CLAUDE_HOME can change while -// the dashboard runs (Settings → Claude Code → AgentHub). +// the dashboard runs (Settings → Claude Code → Overview). // Max session files a directory sweep scans synchronously before it yields to // the event loop. A long synchronous scan of a large ~/.claude/projects tree — diff --git a/server/README.md b/server/README.md index 64c43dde..53cd845b 100644 --- a/server/README.md +++ b/server/README.md @@ -542,7 +542,7 @@ Request body shape: | `POST` | `/api/ai-tools/:id/update` | Same-origin guarded: start the update job; npm only for an npm-managed tool (`server/lib/ai-tool-versions.js`), else `409 ENOTNPM`. `400 ENOUPDATE`, `409 ENOTINSTALLED`/`EBUSY`. | | `POST` | `/api/ai-tools/update-all` | Same-origin guarded: one job over every npm-managed tool with a newer version plus every self updater. `400 ENOTHING`. | | `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. | -| `GET` | `/api/ai-tools/versions` | npm version badges, cached 24 h. | +| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. | | `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. | | `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). | @@ -595,7 +595,7 @@ renders a node as `done` (see `docs/LANES.md#stage-detection`). A `codex` lane carries no pipeline, so nothing detects or advances its stage. Codex's hooks are opt-in (`server/lib/codex-hooks.js`, installed from Settings → -Codex → AgentHub and trusted by the user inside Codex); a hook posted with +Codex → Overview and trusted by the user inside Codex); a hook posted with `tool: "codex"` reaches only the codex lane at its cwd and creates a `tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise `server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose diff --git a/server/__tests__/ai-tool-jobs.test.js b/server/__tests__/ai-tool-jobs.test.js index 5f83342b..f6640547 100644 --- a/server/__tests__/ai-tool-jobs.test.js +++ b/server/__tests__/ai-tool-jobs.test.js @@ -371,7 +371,12 @@ describe("jobs", () => { } }); - it("update-all runs the badged npm tools and every self updater", async () => { + it("update-all runs every npm-managed tool and every self updater, asking npm no version", async () => { + let viewed = 0; + versions.__setExec(async (_f, args) => { + if (args[0] === "view") viewed++; + return { stdout: args[0] === "prefix" ? `${prefix}\n` : "1.0.0\n" }; + }); await jobs.startUpdateAll(); const done = await settle(); assert.equal(done.status, "ok"); @@ -379,5 +384,6 @@ describe("jobs", () => { spawned.map((s) => s.argv.slice(1).join(" ")), ["install -g npm-tool@latest", "update --yes"] ); + assert.equal(viewed, 0); }); }); diff --git a/server/__tests__/ai-tools-route.test.js b/server/__tests__/ai-tools-route.test.js index ad8c8ce0..e12b7363 100644 --- a/server/__tests__/ai-tools-route.test.js +++ b/server/__tests__/ai-tools-route.test.js @@ -164,7 +164,7 @@ describe("install / update / icons", () => { const { body } = await request("GET", "/api/ai-tools"); const codex = body.tools.find((t) => t.id === "codex"); assert.deepEqual(codex.install, { kind: "npm", package: "@openai/codex" }); - assert.deepEqual(codex.update, { kind: "npm" }); + assert.deepEqual(codex.update, { kind: "self", args: ["update"] }); // standalone or npm assert.equal(body.catalog.source, "builtin"); }); diff --git a/server/data/ai-tools/codex.json b/server/data/ai-tools/codex.json index 182531ef..235aa87b 100644 --- a/server/data/ai-tools/codex.json +++ b/server/data/ai-tools/codex.json @@ -14,6 +14,9 @@ "package": "@openai/codex" }, "update": { - "kind": "npm" + "kind": "self", + "args": [ + "update" + ] } } diff --git a/server/data/ai-tools/hermes.json b/server/data/ai-tools/hermes.json index 54560b2b..40ad75e0 100644 --- a/server/data/ai-tools/hermes.json +++ b/server/data/ai-tools/hermes.json @@ -15,5 +15,12 @@ "--yolo" ], "homepageUrl": "https://hermes-agent.nousresearch.com/docs/", - "faviconDomain": "nousresearch.com" + "faviconDomain": "nousresearch.com", + "update": { + "kind": "self", + "args": [ + "update", + "--yes" + ] + } } diff --git a/server/data/ai-tools/openclaw.json b/server/data/ai-tools/openclaw.json index 51d93adc..13c0d9d9 100644 --- a/server/data/ai-tools/openclaw.json +++ b/server/data/ai-tools/openclaw.json @@ -9,5 +9,12 @@ "delayMs": 3000 }, "homepageUrl": "https://github.com/openclaw/openclaw", - "faviconDomain": "openclaw.ai" + "faviconDomain": "openclaw.ai", + "update": { + "kind": "self", + "args": [ + "update", + "--yes" + ] + } } diff --git a/server/lib/ai-tool-jobs.js b/server/lib/ai-tool-jobs.js index a6c9cd1a..c213ed3a 100644 --- a/server/lib/ai-tool-jobs.js +++ b/server/lib/ai-tool-jobs.js @@ -247,17 +247,19 @@ async function start(id, action) { return view(j); } -/** One job that updates every npm-managed tool with a newer version and every self-updater. */ +/** + * One job that runs every installed tool's updater — its own, or npm's when + * npm manages it (one installed another way is skipped). No version lookup: + * each updater knows whether it has anything to do. + */ async function startUpdateAll() { claim(); let steps = []; try { - const v = await versions.versions(); for (const t of aiTools.list()) { if (!aiTools.detect(t.id).installed || !t.update) continue; - if (t.update.kind === "self" || (t.update.kind === "npm" && v[t.id]?.updateAvailable)) { - steps.push(...(await stepsFor(t.id, "update"))); - } + if (t.update.kind === "npm" && !(await versions.npmManaged(t.id))) continue; + steps.push(...(await stepsFor(t.id, "update"))); } if (!steps.length) throw makeErr("ENOTHING", "no AI tool has an update to run"); } finally {