Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0dd5af22f | |||
| 7cb4f64781 | |||
| 69a2c37cd2 | |||
| b3fe10a4f3 | |||
| 8ecb96e9fc | |||
| c47dd230d4 | |||
| 46e3899945 | |||
| 5d8fc9bb98 | |||
| c8ea18f594 | |||
| 64173a5428 | |||
| 943ba03b76 | |||
| 3a8be55b55 |
@@ -25,6 +25,7 @@ make while you work here as Codex.
|
||||
## Validation expectations
|
||||
- Backend changes: run `npm run test:server` when possible.
|
||||
- Frontend changes: run `npm run test:client` when possible.
|
||||
- Full suites take 5–6 minutes, so while iterating run only what the change touches: `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Run the full suite once before committing, and always when the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib) — the per-screen snapshots catch that.
|
||||
- If any check is skipped, report it explicitly.
|
||||
|
||||
## Safety expectations
|
||||
|
||||
+1
-1
@@ -2323,7 +2323,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
||||
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
|
||||
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
|
||||
|
||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
|
||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.7.1 (2026-10-10) — the UI build type-checks test files too (`tsc -b`); a test that skips required props breaks every update._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.7.5 (2026-10-13) — the chat shows each AskUserQuestion with the user's answer; while iterating, run only the tests a change touches._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -9,7 +9,7 @@ _Last synced: v2.7.1 (2026-10-10) — the UI build type-checks test files too (`
|
||||
## Repo map
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions, no tokens)).
|
||||
- `client/`: React + Vite UI.
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||||
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
|
||||
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
|
||||
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||
@@ -57,7 +57,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-<id>` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane-<id>-<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale.
|
||||
- **The whole-file editor is the one non-git write on the lane file surface, and it lives outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at. Do not let it create files or touch a binary.
|
||||
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
|
||||
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
||||
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
||||
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
|
||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
|
||||
@@ -96,6 +96,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
||||
## Testing and verification policy
|
||||
- Backend changes: run `npm run test:server` before finishing.
|
||||
- Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass.
|
||||
- Full suites take 5–6 minutes — while iterating run only what the change touches (`npm run test:server -- server/__tests__/<name>.test.js`; in `client/` `NODE_ENV=test npx vitest related <changed files> --run`), then the full suite once before committing, and always when touching something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib).
|
||||
- If you cannot run a verification step, state exactly what was not run and why.
|
||||
|
||||
## Change guidelines by area
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* user chose. A browser's picker cannot give a page that path, so the dialog
|
||||
* opens on the server's desktop — which is only this user's screen when the
|
||||
* browser runs on the same machine. Anywhere else (a LAN browser, a server
|
||||
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
|
||||
* the path.
|
||||
* with no desktop or no zenity/kdialog) it falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure) — for a file field
|
||||
* too, which then lists files and picks one on click.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,12 +16,8 @@ import { useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { toast } from "../lib/toast";
|
||||
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
||||
|
||||
/** Server answers that mean "no native picker for this browser" — not a fault. */
|
||||
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
|
||||
|
||||
export function PathPickButton({
|
||||
kind = "dir",
|
||||
start,
|
||||
@@ -47,15 +43,10 @@ export function PathPickButton({
|
||||
try {
|
||||
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
|
||||
if (r.path) onPick(r.path);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// A folder always has the in-app browser to fall back on, whatever went
|
||||
// wrong with the native one (a crashed dialog, one already open).
|
||||
if (kind === "dir") setFallback(true);
|
||||
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
|
||||
else {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} catch {
|
||||
// The in-app browser is the fallback whatever went wrong with the
|
||||
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
|
||||
setFallback(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -80,6 +71,7 @@ export function PathPickButton({
|
||||
<div className="contents" onClick={(e) => e.stopPropagation()}>
|
||||
<FolderBrowseModal
|
||||
open={fallback}
|
||||
kind={kind}
|
||||
initialPath={start?.trim() || undefined}
|
||||
onSelect={onPick}
|
||||
onClose={() => setFallback(false)}
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
@@ -215,6 +216,7 @@ function SagiBody() {
|
||||
const [hover, setHover] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
const brain = useSagiBrain();
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
@@ -348,7 +350,7 @@ function SagiBody() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/appearance#pet");
|
||||
navigate(href("appearance#pet"));
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -49,7 +49,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { settingsHref } from "../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
@@ -186,6 +187,12 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
|
||||
// link's own match (the path) cannot tell when it is the one on screen.
|
||||
const location = useLocation();
|
||||
const settingsOn =
|
||||
location.pathname.startsWith("/settings") ||
|
||||
new URLSearchParams(location.search).has("settings");
|
||||
// Checking for (and applying) an update acts on the host: an admin's.
|
||||
const canUpdate = usePermissions().isAdmin;
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
@@ -533,9 +540,9 @@ export function Sidebar({
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
to={settingsHref("", location)}
|
||||
title={collapsed ? t("nav:settings") : undefined}
|
||||
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
|
||||
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file SplashScreen.tsx
|
||||
* @description Branding splash shown once per browser session on app load. A
|
||||
* dark-tech "constellation" overlay built around the node-graph brand mark:
|
||||
* dark-tech "constellation" overlay built around the SmartGift brand mark:
|
||||
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
|
||||
* in a staggered cascade. The tagline and the subtext pair are picked at
|
||||
* random (per mount) from localized pools in `splash.json`, so the copy is
|
||||
@@ -143,7 +143,7 @@ export function SplashScreen() {
|
||||
<div className="splash-grain" aria-hidden="true" />
|
||||
|
||||
<div className="splash-content">
|
||||
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
|
||||
{/* Brand mark - the SmartGift logo */}
|
||||
<div className="splash-mark" aria-hidden="true">
|
||||
<span className="splash-mark-glow" />
|
||||
<BrandMark />
|
||||
@@ -167,74 +167,10 @@ export function SplashScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
|
||||
* with animated connector lines and pulsing outer nodes. */
|
||||
/** The SmartGift mark — the same file as the favicon, local so the splash
|
||||
* needs no network. */
|
||||
function BrandMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
|
||||
<defs>
|
||||
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#818cf8" />
|
||||
</linearGradient>
|
||||
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
className="splash-hex"
|
||||
points="16,2 28,9 28,23 16,30 4,23 4,9"
|
||||
fill="url(#splashBg)"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
|
||||
<line
|
||||
className="splash-line"
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="16"
|
||||
y2="7"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-2"
|
||||
x1="18.6"
|
||||
y1="17.5"
|
||||
x2="24"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-3"
|
||||
x1="13.4"
|
||||
y1="17.5"
|
||||
x2="8"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
|
||||
<circle
|
||||
className="splash-node splash-node-2"
|
||||
cx="24.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
<circle
|
||||
className="splash-node splash-node-3"
|
||||
cx="7.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
|
||||
}
|
||||
|
||||
/** Faint background constellation: a handful of nodes joined by thin lines that
|
||||
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
|
||||
animation: splashGlowPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
|
||||
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
|
||||
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
|
||||
.splash-line-2 { animation-delay: 0.62s; }
|
||||
.splash-line-3 { animation-delay: 0.74s; }
|
||||
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
|
||||
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
|
||||
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
|
||||
|
||||
.splash-greeting {
|
||||
display: flex;
|
||||
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
|
||||
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
|
||||
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
|
||||
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
|
||||
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
|
||||
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
|
||||
.splash-star,
|
||||
.splash-mark,
|
||||
.splash-mark-glow,
|
||||
.splash-hex,
|
||||
.splash-line,
|
||||
.splash-node,
|
||||
.splash-dot,
|
||||
.splash-greeting,
|
||||
.splash-tagline,
|
||||
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
opacity: 1 !important;
|
||||
stroke-dashoffset: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file PathPickButton.test.tsx
|
||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||
* path fills the field, a Cancel changes nothing, a folder falls back to the
|
||||
* in-app browser on ANY native-picker failure, and a file falls back to a
|
||||
* "type the path" hint only when no picker exists for this browser.
|
||||
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
||||
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
||||
* EREMOTE included).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { api } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
|
||||
render(<PathPickButton kind="file" onPick={vi.fn()} />);
|
||||
const button = screen.getByRole("button");
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
|
||||
it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
|
||||
const onPick = vi.fn();
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/home/u/.ssh",
|
||||
parent: "/home/u",
|
||||
entries: [
|
||||
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
|
||||
],
|
||||
});
|
||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true);
|
||||
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||
await userEvent.click(await within(dialog).findByText("id_ed25519"));
|
||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
* the reader is there: anything that makes it taller — a new line, the live
|
||||
* step, an image loading, the pane shown again after being hidden by CSS
|
||||
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
|
||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
|
||||
* chat passes in whether the CLI waits on the user (`waiting`, with
|
||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
||||
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -62,7 +66,7 @@ import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
@@ -89,6 +93,17 @@ interface ConversationViewProps {
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
follow?: number;
|
||||
/** The CLI is asking the user something (a permission prompt). */
|
||||
waiting?: boolean;
|
||||
/** Shows the pane's terminal, where that question is answered. */
|
||||
onOpenTerminal?: () => void;
|
||||
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
|
||||
stopped?: number;
|
||||
/** The newest reply's prompt size (input + cache) and model: how full the
|
||||
* context is. */
|
||||
onContext?: (tokens: number, model: string | null) => void;
|
||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConversationView({
|
||||
@@ -98,9 +113,27 @@ export function ConversationView({
|
||||
onOpenFile,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
stopped = 0,
|
||||
onContext,
|
||||
onNotStarted,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -178,6 +211,7 @@ export function ConversationView({
|
||||
limit: 50,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
@@ -189,6 +223,7 @@ export function ConversationView({
|
||||
// to show yet, and the poll below picks it up once it exists.
|
||||
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
||||
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
setNotStarted(notYet);
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
@@ -228,6 +263,7 @@ export function ConversationView({
|
||||
limit: 50,
|
||||
});
|
||||
if (gen !== genRef.current) return; // another transcript is shown now
|
||||
setNotStarted(false);
|
||||
if (result.messages.length === 0) return;
|
||||
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -494,7 +530,9 @@ export function ConversationView({
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||
(the outline's file button floats there). */}
|
||||
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
@@ -516,6 +554,22 @@ export function ConversationView({
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.notStartedBody")}
|
||||
</p>
|
||||
{onOpenTerminal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTerminal}
|
||||
className="btn-secondary mt-3 text-xs"
|
||||
>
|
||||
{t("conversation.openTerminal")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
@@ -524,7 +578,15 @@ export function ConversationView({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
|
||||
<MessageList
|
||||
messages={messages}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||
* same links for plain text.
|
||||
* same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||
* of how serious it is.
|
||||
*
|
||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||
@@ -256,6 +259,22 @@ const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
|
||||
// "2fd0c903" stay text.
|
||||
const NUM_RE =
|
||||
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
|
||||
const numStart = (text: string, i: number) =>
|
||||
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
|
||||
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
|
||||
const LABEL_RE =
|
||||
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
|
||||
const LABEL_CLASS = (word: string) =>
|
||||
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
|
||||
? "text-status-danger"
|
||||
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
|
||||
? "text-status-warning"
|
||||
: "text-accent";
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
@@ -354,7 +373,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||
if (boldM) {
|
||||
push(
|
||||
<strong className="font-semibold text-fg-primary">
|
||||
<strong className="font-bold text-accent">
|
||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||
</strong>
|
||||
);
|
||||
@@ -419,6 +438,24 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
|
||||
if (labelM) {
|
||||
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
|
||||
i += labelM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
|
||||
if (numM) {
|
||||
push(
|
||||
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
|
||||
{numM[0]}
|
||||
</span>
|
||||
);
|
||||
i += numM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf += text[i]!;
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -6,18 +6,27 @@
|
||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||
* the Conversation tab on the Session detail page. An image the prompt box
|
||||
* attached reaches the transcript as its saved path; a user turn shows the
|
||||
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
||||
* image instead (`GET /api/run/drop/:name`) — any other attached file as a
|
||||
* chip that opens its preview through `onOpenFile` —, and Claude Code's own
|
||||
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
||||
* a System row; URLs and absolute paths in a turn are links (a path opens
|
||||
* through `onOpenFile`). Each assistant turn that changed
|
||||
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
|
||||
* like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed
|
||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
||||
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is
|
||||
* every file in it) drags into a prompt box as an `@path` attachment. An
|
||||
* AskUserQuestion is its own card (`AskCard`), not part of the collapsed work
|
||||
* line: each question with the answer the user picked or typed. A thinking block that carries text is
|
||||
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||
* now — the call still running, else "Thinking…" — and for how long.
|
||||
* now — the calls still running (several when it runs them in parallel),
|
||||
* else "Thinking…", or the question the CLI waits on with a way to its
|
||||
* terminal (`waiting`) — and, like the terminal's status line, how long the
|
||||
* turn has run and the output tokens it has made; a call running long shows
|
||||
* its own time, and a line silent for minutes turns amber. A turn that ended
|
||||
* closes with how long it took and its output.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -66,7 +75,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { memo, useEffect, useState, useMemo } from "react";
|
||||
import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "../../i18n";
|
||||
@@ -85,6 +94,11 @@ import {
|
||||
Cog,
|
||||
FileText,
|
||||
Loader2,
|
||||
Check,
|
||||
Hand,
|
||||
SquareTerminal,
|
||||
MessageCircleQuestion,
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
|
||||
@@ -130,6 +144,7 @@ const SENDER_STYLES: Record<
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
||||
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
|
||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
@@ -140,6 +155,14 @@ interface MessageListProps {
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** The session is running in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** The CLI is asking the user something (a permission prompt): the live
|
||||
* line says so instead of "Thinking…". */
|
||||
waiting?: boolean;
|
||||
/** Shows the pane's terminal, where that question is answered. */
|
||||
onOpenTerminal?: () => void;
|
||||
/** Bumped by the chat's Stop: the turn on screen is over, even when the
|
||||
* transcript never says so. */
|
||||
stopped?: number;
|
||||
}
|
||||
|
||||
/** Build a map from tool_use id → tool_result for matching */
|
||||
@@ -158,7 +181,7 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
||||
|
||||
/** The whole prompt a message was answered from: `input_tokens` is only its
|
||||
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
|
||||
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
return (
|
||||
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
||||
);
|
||||
@@ -205,12 +228,32 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact pill for /command invocations parsed out of TUI markup. */
|
||||
/** Typed text with its `/command` and `@path` tokens as chips, the rest linkified. */
|
||||
function typedText(text: string): React.ReactNode[] {
|
||||
return splitPrompt(text).map((tok, i) =>
|
||||
tok.kind === "text" ? (
|
||||
<Fragment key={i}>{linkify(tok.text)}</Fragment>
|
||||
) : (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-md px-1.5 py-0.5 font-mono text-[0.92em] font-medium ${TOKEN_TONE[tok.kind]}`}
|
||||
>
|
||||
{tok.text}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact pill for /command invocations parsed out of TUI markup: the
|
||||
* command in the prompt box's colour, its arguments after it. */
|
||||
function CommandPill({ display }: { display: string }) {
|
||||
const [name, ...args] = display.split(" ");
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
|
||||
<span className="text-status-success/70">›</span>
|
||||
<span className="break-all">{display}</span>
|
||||
<div className="inline-flex max-w-full items-baseline gap-2 rounded-lg border border-accent/25 bg-accent/10 px-2.5 py-1 font-mono text-sm">
|
||||
<span className="font-semibold text-accent">{name}</span>
|
||||
{args.length > 0 && (
|
||||
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -422,7 +465,9 @@ type Item =
|
||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
|
||||
| { kind: "ask"; key: string; block: TranscriptContent }
|
||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
|
||||
| { kind: "done"; key: string; ms: number; output: number };
|
||||
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||
|
||||
@@ -431,9 +476,20 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
// The file-changing calls of the turn so far, closed into one card when the
|
||||
// human speaks again (or the list ends).
|
||||
let edits: TranscriptContent[] = [];
|
||||
// The turn's prompt time, its newest assistant line and its output so far,
|
||||
// for the "Done in …" line under a turn that ended.
|
||||
let start: string | null = null;
|
||||
let reply: TranscriptMessage | null = null;
|
||||
let output = outputCounter();
|
||||
const endTurn = (key: string) => {
|
||||
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
|
||||
edits = [];
|
||||
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
|
||||
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
|
||||
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
|
||||
}
|
||||
reply = null;
|
||||
output = outputCounter();
|
||||
};
|
||||
messages.forEach((msg, idx) => {
|
||||
const key = keys[idx]!;
|
||||
@@ -443,6 +499,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
if (sender === "user") {
|
||||
endTurn(key);
|
||||
start = msg.timestamp ?? null;
|
||||
return items.push({ kind: "user", key, msg });
|
||||
}
|
||||
if (sender !== "assistant") {
|
||||
@@ -465,10 +522,15 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
}
|
||||
return items.push({ kind: "row", key, msg });
|
||||
}
|
||||
reply = msg;
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
if (block.type === "tool_use") {
|
||||
// A question to the user is a turn of the conversation, not hidden work.
|
||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||
} else if (block.type === "tool_use") {
|
||||
if (last?.kind === "work") last.blocks.push(block);
|
||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
||||
@@ -553,14 +615,31 @@ function EditedFiles({
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
|
||||
export function MessageList({
|
||||
messages,
|
||||
loading,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
stopped = 0,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const pending = useMemo(
|
||||
() => (live ? workingOn(messages, toolResultMap) : null),
|
||||
[live, messages, toolResultMap]
|
||||
);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
const lastKey = keys[keys.length - 1] ?? null;
|
||||
// The newest line when Stop was pressed. Esc before the model wrote anything
|
||||
// puts the prompt back in Claude Code's input box and writes no
|
||||
// "[Request interrupted…]" line (issue #74), so the transcript still ends on
|
||||
// the prompt: no live line until a newer one arrives.
|
||||
const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null });
|
||||
if (stop.n !== stopped) setStop({ n: stopped, on: lastKey });
|
||||
const halted = stop.on !== null && stop.on === lastKey;
|
||||
const pending = useMemo(
|
||||
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
|
||||
[live, halted, messages, toolResultMap]
|
||||
);
|
||||
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
|
||||
if (loading) {
|
||||
@@ -598,6 +677,14 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
return (
|
||||
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
||||
);
|
||||
case "ask":
|
||||
return (
|
||||
<AskCard
|
||||
key={item.key}
|
||||
block={item.block}
|
||||
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
|
||||
/>
|
||||
);
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
@@ -609,9 +696,19 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
case "done":
|
||||
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
{pending && (
|
||||
<RunningStep
|
||||
{...pending}
|
||||
start={stats?.start ?? null}
|
||||
tokens={stats?.output ?? 0}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
@@ -621,88 +718,334 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
// written (a crash, a kill) — stop saying it works.
|
||||
const IDLE_AFTER_MS = 15 * 60_000;
|
||||
|
||||
/** What the model is doing while its turn is not over: the call still
|
||||
* waiting for its result (`block`), else thinking (`block: null`); `since`
|
||||
* is that line's time. Null once the turn ended. */
|
||||
/** What the model is doing while its turn is not over: the calls of the turn
|
||||
* still waiting for their results (`blocks`, oldest first — parallel calls
|
||||
* finish in any order), `since` the oldest one's time, `last` the newest
|
||||
* line's; `blocks` is empty while it thinks. Null once the turn ended. */
|
||||
export function workingOn(
|
||||
messages: TranscriptMessage[],
|
||||
results: Map<string, TranscriptContent>,
|
||||
now = Date.now()
|
||||
): { block: TranscriptContent | null; since: string | null } | null {
|
||||
): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null {
|
||||
const blocks: TranscriptContent[] = [];
|
||||
let since: string | null = null;
|
||||
let last: string | null = null;
|
||||
// The newest line's verdict: undefined until it is found.
|
||||
let alive: boolean | undefined;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
// A rename marker or a harness line says nothing about the turn.
|
||||
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
|
||||
const since = msg.timestamp ?? null;
|
||||
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
|
||||
const at = msg.timestamp ?? null;
|
||||
const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
|
||||
const newest = alive === undefined;
|
||||
if (newest) last = at;
|
||||
if (msg.type === "assistant") {
|
||||
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
|
||||
if (call?.id && !results.has(call.id)) return { block: call, since };
|
||||
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
|
||||
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
|
||||
// "end_turn": over, whatever the transcript failed to pair.
|
||||
if (newest && msg.stop_reason === "end_turn") return null;
|
||||
for (const b of [...msg.content].reverse()) {
|
||||
if (b.type === "tool_use" && b.id && !results.has(b.id)) {
|
||||
blocks.unshift(b);
|
||||
since = at;
|
||||
}
|
||||
}
|
||||
// "tool_use": a call follows. None (an older server): over.
|
||||
if (newest) alive = msg.stop_reason === "tool_use" && fresh;
|
||||
continue;
|
||||
}
|
||||
// A prompt or a tool result: the model answers it next — unless the user stopped it.
|
||||
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
|
||||
if (text.startsWith("[Request interrupted")) return null;
|
||||
return fresh ? { block: null, since } : null;
|
||||
if (newest) alive = fresh;
|
||||
// The prompt that opened the turn: nothing before it is this turn's.
|
||||
if (text) break;
|
||||
}
|
||||
return null;
|
||||
if (blocks.length) return { blocks, since, last };
|
||||
return alive ? { blocks, since: null, last } : null;
|
||||
}
|
||||
|
||||
/** Claude Desktop's live line: the step running now and for how long. */
|
||||
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
|
||||
/** Output tokens counted once per API call: Claude Code writes one line per
|
||||
* content block, each repeating its call's usage — consecutive lines with
|
||||
* the same prompt size are one call. */
|
||||
function outputCounter() {
|
||||
let total = 0;
|
||||
let call: string | null = null;
|
||||
let callOutput = 0;
|
||||
return {
|
||||
add(u: NonNullable<TranscriptMessage["usage"]>) {
|
||||
const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`;
|
||||
if (key !== call) {
|
||||
total += callOutput;
|
||||
call = key;
|
||||
callOutput = 0;
|
||||
}
|
||||
callOutput = Math.max(callOutput, u.output_tokens || 0);
|
||||
},
|
||||
get total() {
|
||||
return total + callOutput;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The live turn so far, as the terminal counts it: `start` is the prompt
|
||||
* that opened it, `output` the output tokens of every API call since. */
|
||||
export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } {
|
||||
const output = outputCounter();
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.type === "assistant") {
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
continue;
|
||||
}
|
||||
const sender = msg.sender ?? "user";
|
||||
if (sender === "user" && msg.content.some((c) => c.type === "text")) {
|
||||
return { start: msg.timestamp ?? null, output: output.total };
|
||||
}
|
||||
}
|
||||
return { start: null, output: output.total };
|
||||
}
|
||||
|
||||
// A call running this long shows its own time beside the turn's.
|
||||
const STEP_SHOWN_MS = 30_000;
|
||||
// No new line for this long: the line turns amber — the step may be stuck.
|
||||
const QUIET_MS = 3 * 60_000;
|
||||
|
||||
/** A number that counts up to its new value instead of jumping (none when
|
||||
* the user asked for reduced motion). */
|
||||
function useCountUp(value: number): number {
|
||||
const [shown, setShown] = useState(value);
|
||||
const shownRef = useRef(value);
|
||||
useEffect(() => {
|
||||
const from = shownRef.current;
|
||||
if (from === value) return;
|
||||
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduce || typeof requestAnimationFrame === "undefined") {
|
||||
shownRef.current = value;
|
||||
setShown(value);
|
||||
return;
|
||||
}
|
||||
const t0 = performance.now();
|
||||
let raf = 0;
|
||||
const step = (t: number) => {
|
||||
const k = Math.min(1, (t - t0) / 600);
|
||||
shownRef.current = Math.round(from + (value - from) * k);
|
||||
setShown(shownRef.current);
|
||||
if (k < 1) raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [value]);
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** A call's one-line title: its description (Claude Code's Bash
|
||||
* `description`), else the tool and its target. */
|
||||
function stepTitle(block: TranscriptContent): string {
|
||||
const input = block.input as Record<string, unknown> | undefined;
|
||||
return typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** The terminal's live line: the step running now (or the question the CLI
|
||||
* waits on), then the turn's time and output tokens so far. */
|
||||
function RunningStep({
|
||||
blocks,
|
||||
since,
|
||||
last,
|
||||
start,
|
||||
tokens,
|
||||
waiting,
|
||||
onOpenTerminal,
|
||||
}: {
|
||||
blocks: TranscriptContent[];
|
||||
since: string | null;
|
||||
last: string | null;
|
||||
start: string | null;
|
||||
tokens: number;
|
||||
waiting: boolean;
|
||||
onOpenTerminal?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const input = block?.input as Record<string, unknown> | undefined;
|
||||
const title = !block
|
||||
? t("conversation.working")
|
||||
: typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
const counted = useCountUp(tokens);
|
||||
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
|
||||
const first = blocks[0];
|
||||
const step = first ? age(since) : 0;
|
||||
const quiet = !waiting && age(last) > QUIET_MS;
|
||||
const title = waiting
|
||||
? first
|
||||
? t("conversation.waitingFor", { step: stepTitle(first) })
|
||||
: t("conversation.waiting")
|
||||
: first
|
||||
? stepTitle(first)
|
||||
: t("conversation.working");
|
||||
const extra = [
|
||||
start && formatMs(age(start)),
|
||||
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
|
||||
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
|
||||
].filter(Boolean);
|
||||
const amber = waiting || quiet;
|
||||
return (
|
||||
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
{since && (
|
||||
<span className="flex-shrink-0 font-mono text-xs">
|
||||
{formatMs(now - parseDate(since).getTime())}
|
||||
<div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{waiting ? (
|
||||
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
|
||||
) : (
|
||||
<Loader2
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
|
||||
{title}
|
||||
</span>
|
||||
{blocks.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll((v) => !v)}
|
||||
aria-expanded={showAll}
|
||||
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
|
||||
>
|
||||
{t("conversation.moreSteps", { count: blocks.length - 1 })}
|
||||
</button>
|
||||
)}
|
||||
{step > STEP_SHOWN_MS && (
|
||||
<span
|
||||
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
|
||||
>
|
||||
{formatMs(step)}
|
||||
</span>
|
||||
)}
|
||||
{extra.length > 0 && (
|
||||
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
|
||||
)}
|
||||
{waiting && onOpenTerminal && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-open-terminal"
|
||||
onClick={onOpenTerminal}
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
|
||||
>
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
{t("conversation.openTerminal")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showAll && blocks.length > 1 && (
|
||||
<ul className="ml-5 space-y-0.5 text-xs">
|
||||
{blocks.map((b, i) => (
|
||||
<li key={b.id ?? i} className="truncate">
|
||||
{stepTitle(b)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a turn that ended: how long it took and the output it made. */
|
||||
/** An AskUserQuestion: each question with the answer the user picked or
|
||||
* typed (the result's `answers`), "declined" when they refused it, no answer
|
||||
* yet while the CLI still asks. */
|
||||
function AskCard({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const input = block.input as { questions?: unknown } | undefined;
|
||||
const questions = (Array.isArray(input?.questions) ? input.questions : [])
|
||||
.map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null))
|
||||
.filter((q): q is string => typeof q === "string");
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-ask"
|
||||
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
{questions.map((q, i) => {
|
||||
const answer = result?.answers?.[q];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-start gap-1.5 text-fg-secondary">
|
||||
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{q}</span>
|
||||
</div>
|
||||
{answer && (
|
||||
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
|
||||
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||
<span className="whitespace-pre-wrap">{answer}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{result?.is_error && (
|
||||
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnDone({ ms, output }: { ms: number; output: number }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
return (
|
||||
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<Check className="h-3 w-3 flex-shrink-0" />
|
||||
{[
|
||||
t("conversation.turnDone", { time: formatMs(ms) }),
|
||||
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
// What the prompt box sends for an attached image: its saved path under the
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
|
||||
// What the prompt box sends for an attached file: its saved path under the
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
||||
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||
const DROP_IMAGE =
|
||||
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
const DROP_PATH =
|
||||
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
|
||||
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i;
|
||||
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
|
||||
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
||||
|
||||
/** A user turn's text without its dropped-image paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), and those images' names. */
|
||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||
/** A user turn's text without its dropped-file paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), the images' names and the other
|
||||
* files' full paths. */
|
||||
export function splitDropImages(text: string): {
|
||||
text: string;
|
||||
images: string[];
|
||||
files: string[];
|
||||
} {
|
||||
const images: string[] = [];
|
||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||
images.push(name);
|
||||
const files: string[] = [];
|
||||
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
|
||||
if (IMAGE_EXT.test(name)) images.push(name);
|
||||
else files.push(full);
|
||||
return "";
|
||||
});
|
||||
return {
|
||||
text: images.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
text:
|
||||
images.length || files.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
images,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -713,6 +1056,30 @@ function imageSourcePaths(msg: TranscriptMessage): string[] | null {
|
||||
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
|
||||
}
|
||||
|
||||
/** A dropped file other than an image: a chip that opens its preview when the
|
||||
* page can (`OpenFileContext`). */
|
||||
function DropFile({ path }: { path: string }) {
|
||||
const open = useContext(OpenFileContext);
|
||||
const name = path
|
||||
.split(/[\\/]/)
|
||||
.pop()!
|
||||
.replace(/^[0-9a-f]{16}-?/i, "");
|
||||
const ext = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toUpperCase();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!open}
|
||||
onClick={() => open?.(path)}
|
||||
title={path}
|
||||
className="flex max-w-64 items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-left text-xs enabled:hover:border-accent/50"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate text-fg-primary">{name}</span>
|
||||
{ext && <span className="shrink-0 font-mono text-[10px] text-fg-muted">{ext}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DropImage({ name }: { name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
@@ -760,6 +1127,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
[msg.content]
|
||||
);
|
||||
const images = blocks.flatMap((b) => b?.images ?? []);
|
||||
const files = blocks.flatMap((b) => b?.files ?? []);
|
||||
const hasText = blocks.some((b) => b?.text);
|
||||
return (
|
||||
<div
|
||||
@@ -767,7 +1135,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||
className="group flex flex-col items-end gap-1"
|
||||
>
|
||||
{images.length > 0 && (
|
||||
{images.length + files.length > 0 && (
|
||||
<div
|
||||
data-testid="chat-user-images"
|
||||
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
||||
@@ -775,6 +1143,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
{images.map((name) => (
|
||||
<DropImage key={name} name={name} />
|
||||
))}
|
||||
{files.map((path) => (
|
||||
<DropFile key={path} path={path} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasText && (
|
||||
@@ -792,7 +1163,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{linkify(stripAnsi(text).trim())}
|
||||
{typedText(stripAnsi(text).trim())}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(text, b)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @file ConversationView.notYet.test.tsx
|
||||
* @description A live pane's new session has no row until its first hook:
|
||||
* its 404 shows an empty chat, not "Session not found"; a replay still shows it.
|
||||
* its 404 shows no "Session not found" (a replay still shows it) but says the
|
||||
* session has not started — the CLI may be asking in the terminal, e.g. to
|
||||
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
|
||||
@@ -34,4 +36,23 @@ describe("ConversationView before a session's first hook", () => {
|
||||
render(<ConversationView sessionId="gone" fill />);
|
||||
expect(await screen.findByText("Session not found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
|
||||
const onOpenTerminal = vi.fn();
|
||||
const onNotStarted = vi.fn();
|
||||
render(
|
||||
<ConversationView
|
||||
sessionId="new"
|
||||
fill
|
||||
live
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onNotStarted={onNotStarted}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
|
||||
expect(screen.queryByText("No conversation records found.")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Open terminal"));
|
||||
expect(onOpenTerminal).toHaveBeenCalled();
|
||||
expect(onNotStarted).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,43 @@ describe("<MarkdownContent />", () => {
|
||||
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
|
||||
});
|
||||
|
||||
it("highlights an ISO date-time as one number", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual([
|
||||
"2026-10-12T08:00",
|
||||
"2026-10-12T08:00:00Z",
|
||||
"2026-10-12T08:00:00.123+07:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not highlight numbers inside inline code", () => {
|
||||
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
|
||||
expect(container.querySelector("span.tabular-nums")).toBeNull();
|
||||
});
|
||||
|
||||
it("colours a flag label by how serious it is", () => {
|
||||
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
|
||||
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
|
||||
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
|
||||
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
|
||||
expect(screen.queryByText("Note")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty input safely", () => {
|
||||
const { container } = render(<MarkdownContent text="" />);
|
||||
// Wrapper exists but no block elements
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file MessageList.ask.test.tsx
|
||||
* @description An AskUserQuestion call is not folded into the collapsed work
|
||||
* line: the chat shows it as its own card — each question with the answer the
|
||||
* user picked or typed, "declined" when they refused it, nothing yet while the
|
||||
* CLI still waits.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = "2026-10-13T08:00:00.000Z";
|
||||
const user = (text: string): TranscriptMessage =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: at,
|
||||
content: [{ type: "text", text }],
|
||||
}) as TranscriptMessage;
|
||||
const ask = (id: string, questions: string[]): TranscriptMessage =>
|
||||
({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
|
||||
{
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: "AskUserQuestion",
|
||||
input: {
|
||||
questions: questions.map((question) => ({
|
||||
question,
|
||||
header: "Pick",
|
||||
options: [{ label: "Yes" }, { label: "No" }],
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}) as TranscriptMessage;
|
||||
const result = (id: string, extra: Record<string, unknown>) =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_result", id, output: "x", ...extra }],
|
||||
}) as TranscriptMessage;
|
||||
|
||||
describe("an AskUserQuestion card", () => {
|
||||
it("shows each question with the user's answer, outside the work line", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("go"),
|
||||
ask("q1", ["Commit?", "Push?"]),
|
||||
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||
expect(within(card).getByText("only after review")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
|
||||
});
|
||||
|
||||
it("says declined when refused, and shows no answer while still asked", () => {
|
||||
const { rerender } = render(
|
||||
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||
expect(within(card).queryByText(/declined/i)).toBeNull();
|
||||
rerender(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
|
||||
/>
|
||||
);
|
||||
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file MessageList.images.test.tsx
|
||||
* @description An image the prompt box attached reaches the transcript as its
|
||||
* saved drop path; the user's turn shows the image (served by
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||
* other path as typed. Claude Code turns a pasted drop path into an image
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page; any other
|
||||
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
|
||||
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||
* URLs and absolute paths in a turn are links.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,7 +19,7 @@ describe("dropped images in a user turn", () => {
|
||||
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
||||
expect(
|
||||
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
|
||||
// Windows' temp dir, no prefix name.
|
||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||
"0123456789abcdef.webp",
|
||||
@@ -28,16 +28,36 @@ describe("dropped images in a user turn", () => {
|
||||
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||
text: "fix this",
|
||||
images: [SHOT],
|
||||
files: [],
|
||||
});
|
||||
// Not a drop, or not an image: left as typed.
|
||||
for (const text of [
|
||||
"/home/me/shot.png",
|
||||
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
|
||||
]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [] });
|
||||
// Any other dropped file comes out by its full path.
|
||||
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
|
||||
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
|
||||
// Not a drop: left as typed.
|
||||
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a dropped file as a chip that opens its preview", () => {
|
||||
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
|
||||
const opened: string[] = [];
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T08:00:00.000Z",
|
||||
content: [{ type: "text", text: `summarise ${pdf}` }],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).not.toHaveTextContent("0123456789abcdef");
|
||||
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
|
||||
expect(opened).toEqual([pdf]);
|
||||
});
|
||||
|
||||
it("shows the image instead of its path, full size on click", () => {
|
||||
const messages = [
|
||||
{
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line.
|
||||
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||
* shows as a chip.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, workLabel, workingOn } from "../MessageList";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
}
|
||||
|
||||
describe("MessageList — sender attribution", () => {
|
||||
it("shows a typed /command and @path as chips in the user's bubble", () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
|
||||
]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("/review @src/a.ts now");
|
||||
expect(within(bubble).getByText("/review").className).toContain("text-accent");
|
||||
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
|
||||
});
|
||||
|
||||
it("labels each row by its sender, not blanket 'User'", () => {
|
||||
const messages: TranscriptMessage[] = [
|
||||
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
||||
@@ -126,11 +142,17 @@ describe("MessageList — sender attribution", () => {
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
const messages = [
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
|
||||
msg({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||
content: [{ type: "text", text: "test it" }],
|
||||
}),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||
timestamp: new Date(Date.now() - 5_000).toISOString(),
|
||||
usage: { input_tokens: 2, output_tokens: 3500 },
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
@@ -144,7 +166,8 @@ describe("MessageList — sender attribution", () => {
|
||||
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
expect(row).toHaveTextContent(/1m \ds/);
|
||||
// Like the terminal: the turn's time since the prompt, and its output.
|
||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
@@ -159,6 +182,121 @@ describe("MessageList — sender attribution", () => {
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
});
|
||||
|
||||
// Issue #74: Esc before the model wrote anything takes the prompt back into
|
||||
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
|
||||
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
|
||||
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
|
||||
const prompt = msg({
|
||||
sender: "user",
|
||||
timestamp: new Date(Date.now() - 14_000).toISOString(),
|
||||
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
|
||||
});
|
||||
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
|
||||
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
// The next prompt is a new turn: its Thinking… shows.
|
||||
const next = msg({
|
||||
sender: "user",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [{ type: "text", text: "again" }],
|
||||
});
|
||||
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
|
||||
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||
});
|
||||
|
||||
it("counts a turn's output once per API call, since its prompt", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const line = (cache: number, out: number) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "text", text: "x" }],
|
||||
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
|
||||
});
|
||||
const messages = [
|
||||
msg({ sender: "user" }),
|
||||
line(10, 999), // an earlier turn
|
||||
msg({ sender: "user", timestamp: at }),
|
||||
line(100, 40),
|
||||
line(100, 40), // the same call's second block
|
||||
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
|
||||
line(200, 25),
|
||||
];
|
||||
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
|
||||
});
|
||||
|
||||
it("keeps every parallel call of the turn running until its own result", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
|
||||
});
|
||||
const done = (id: string) =>
|
||||
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
// Claude Code writes each parallel call on its own line; one result is in.
|
||||
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
|
||||
const results = new Map([["b", m[4]!.content[0]!]]);
|
||||
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
|
||||
// A call left unpaired in an EARLIER turn is not this one's.
|
||||
const next = [
|
||||
...m,
|
||||
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
|
||||
];
|
||||
expect(workingOn(next, results, now)?.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("says when the CLI waits on the user, and offers the terminal", () => {
|
||||
let opened = 0;
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
live
|
||||
waiting
|
||||
onOpenTerminal={() => opened++}
|
||||
messages={[
|
||||
msg({ sender: "user", timestamp: new Date().toISOString() }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
|
||||
fireEvent.click(screen.getByTestId("chat-open-terminal"));
|
||||
expect(opened).toBe(1);
|
||||
});
|
||||
|
||||
it("closes an ended turn with its time and output", () => {
|
||||
const reply = (out: number, stop_reason: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: "2026-06-26T08:16:08.000Z",
|
||||
stop_reason,
|
||||
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
});
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
|
||||
});
|
||||
|
||||
it("knows what a live turn is doing until it ends", () => {
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
@@ -189,10 +327,11 @@ describe("MessageList — sender attribution", () => {
|
||||
);
|
||||
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
||||
|
||||
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
|
||||
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
|
||||
const thinking = { blocks: [], since: null, last: at };
|
||||
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
|
||||
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
|
||||
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
||||
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
||||
const stopped = msg({
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||
* a function that moves the cursor to the next / previous changed block.
|
||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||
* Chat chip at the end of its first line (inline, so it covers no code),
|
||||
* Chat chip floating above its last line, at the column where it ends,
|
||||
* which hands the panel the selection's 1-based lines.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
showTooltip,
|
||||
type Tooltip,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
class ChatChip extends WidgetType {
|
||||
constructor(
|
||||
readonly start: number,
|
||||
readonly end: number,
|
||||
readonly build: (start: number, end: number) => HTMLElement
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(o: ChatChip) {
|
||||
return this.start === o.start && this.end === o.end;
|
||||
}
|
||||
toDOM() {
|
||||
return this.build(this.start, this.end);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add to Chat inline at the end of a non-empty selection's first line —
|
||||
* the empty space right of the code, so it covers no line. `build` makes its
|
||||
* DOM from the selection's first and last 1-based line. A selection ending
|
||||
* at a line's start (a whole-line drag) does not take that line. */
|
||||
/** Add to Chat floating just above the selection's last line, at the column
|
||||
* where the selection ends — so a long line never pushes it out of view.
|
||||
* `build` makes its DOM from the selection's first and last 1-based line. A
|
||||
* selection ending at a line's start (a whole-line drag) does not take that
|
||||
* line, and the chip then sits at the start of the last line it does take. */
|
||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||
return StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return deco;
|
||||
return StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(tip, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return tip;
|
||||
const { from, to, empty } = tr.state.selection.main;
|
||||
if (empty) return Decoration.none;
|
||||
if (empty) return null;
|
||||
const doc = tr.state.doc;
|
||||
const first = doc.lineAt(from);
|
||||
const last = doc.lineAt(to);
|
||||
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
|
||||
const widget = new ChatChip(first.number, end, build);
|
||||
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
|
||||
const wholeLine = to === last.from && last.number > first.number;
|
||||
const end = wholeLine ? last.number - 1 : last.number;
|
||||
return {
|
||||
pos: wholeLine ? doc.line(end).from : to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = build(first.number, end);
|
||||
dom.classList.add("cm-chat-chip");
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
provide: (f) => showTooltip.from(f),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,9 +350,19 @@ const theme = EditorView.theme({
|
||||
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||
// Doubled classes: drawSelection's base theme scopes its light/dark colours
|
||||
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
|
||||
// plain `.cm-selectionBackground` never outranks.
|
||||
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
background: "rgb(var(--accent) / 0.3)",
|
||||
},
|
||||
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
|
||||
border: "1px solid rgb(var(--accent) / 0.6)",
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
},
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
|
||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-changedText`]: { background: "none" },
|
||||
@@ -544,7 +549,7 @@ export default function DiffEditor({
|
||||
b.dataset.testid = "lane-editor-add-to-chat";
|
||||
b.textContent = cb.current.t("changes.addToChat");
|
||||
b.className =
|
||||
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
|
||||
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
|
||||
// Keep the editor's focus (and so its selection) through the click.
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
/**
|
||||
* @file A server-backed folder browser for the Add Lane modal's path inputs.
|
||||
* Browsers refuse to expose an absolute filesystem path from a native folder
|
||||
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||||
* button. Browsers refuse to expose an absolute filesystem path from a native
|
||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
|
||||
* up-one-level button and a click-to-descend list, since this tool is
|
||||
* local-first and the server already trusts arbitrary local paths.
|
||||
* (immediate subdirectories of a path, plus its files for `kind="file"`)
|
||||
* instead — breadcrumb-free, just an up-one-level button and a
|
||||
* click-to-descend list; in file mode a click on a file picks it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
||||
import { FolderOpen, FolderGit2, ArrowUp, File } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
|
||||
export function FolderBrowseModal({
|
||||
open,
|
||||
kind = "dir",
|
||||
initialPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** `file` lists files too, and picking one is the only way to select. */
|
||||
kind?: "dir" | "file";
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
@@ -33,7 +36,7 @@ export function FolderBrowseModal({
|
||||
const load = async (path?: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
setListing(await api.lanes.browse(path));
|
||||
setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -65,7 +68,7 @@ export function FolderBrowseModal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("browse.title")}
|
||||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
@@ -94,10 +97,16 @@ export function FolderBrowseModal({
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => load(entry.path)}
|
||||
onClick={() => {
|
||||
if (!entry.file) return void load(entry.path);
|
||||
onSelect(entry.path);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
{entry.file ? (
|
||||
<File className="h-3.5 w-3.5 text-fg-muted" />
|
||||
) : entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
@@ -118,17 +127,19 @@ export function FolderBrowseModal({
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
</button>
|
||||
{kind === "dir" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,6 +277,13 @@ function HomeBody({
|
||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||
});
|
||||
setPrompt("");
|
||||
// Here, not only through the effect: the navigate below unmounts this
|
||||
// home in the same batch, so the effect never sees the empty prompt.
|
||||
try {
|
||||
localStorage.removeItem(draftKey);
|
||||
} catch {
|
||||
/* storage unavailable: nothing was kept */
|
||||
}
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
|
||||
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// Inline at the end of the first selected line, never a tooltip over the
|
||||
// line above it.
|
||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
||||
// A tooltip above the last selected line, never inline at a line's end
|
||||
// (a long line pushed it out of view).
|
||||
expect(chip.classList.contains("cm-tooltip")).toBe(true);
|
||||
expect(chip.closest(".cm-line")).toBeNull();
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from "react-router-dom";
|
||||
import type { Lane, Session } from "../../../lib/types";
|
||||
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
||||
|
||||
@@ -241,6 +241,27 @@ describe("WorkspaceHome", () => {
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||
});
|
||||
|
||||
it("drops the sent prompt's draft even though opening the session unmounts the home", async () => {
|
||||
// As the Workspace page: `&run=` replaces the home with the session view.
|
||||
function HomeUntilRun() {
|
||||
const [params] = useSearchParams();
|
||||
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
|
||||
<Routes>
|
||||
<Route path="*" element={<HomeUntilRun />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "sent once");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-home-start")).toBeNull());
|
||||
expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
|
||||
@@ -171,8 +171,16 @@ export function OnboardingTour() {
|
||||
style = { left: right + 16, top };
|
||||
} else if (rect.bottom + 180 < window.innerHeight) {
|
||||
style = { left, top: rect.bottom + 12 };
|
||||
} else if (rect.top > 200) {
|
||||
style = { left, top: rect.top - 12, transform: "translateY(-100%)" };
|
||||
} else {
|
||||
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
|
||||
// A target filling the screen (the live session): no room above or
|
||||
// below, so the card sits inside its bottom-right corner.
|
||||
style = {
|
||||
left: Math.min(Math.max(12, rect.right - W - 16), window.innerWidth - W - 12),
|
||||
top: Math.min(rect.bottom, window.innerHeight) - 16,
|
||||
transform: "translateY(-100%)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file ChatOutline.tsx
|
||||
* @description The right-hand panel of a Chat pane, laid out like
|
||||
* Claude Desktop's activity panel: the session's Progress (its
|
||||
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
|
||||
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one — how many
|
||||
* steps are done of how many, a bar, and the step in progress highlighted), the Outputs
|
||||
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
||||
* this session" — the files attached to its prompts, the files it read and the
|
||||
* memory files it used and its skills (a click opens the list with a
|
||||
@@ -18,6 +19,7 @@
|
||||
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
@@ -106,7 +108,8 @@ function Section({
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
/** Beside the title: a count, or a "3/7". */
|
||||
count?: number | string;
|
||||
action?: ReactNode;
|
||||
/** Takes the panel's free height and scrolls inside it. */
|
||||
grow?: boolean;
|
||||
@@ -209,6 +212,8 @@ export function ChatOutline({
|
||||
onOpenList: (list: OutlineList) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const href = useSettingsHref();
|
||||
const doneSteps = o.progress.filter((p) => p.status === "completed").length;
|
||||
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
||||
const close = (
|
||||
<button
|
||||
@@ -226,16 +231,40 @@ export function ChatOutline({
|
||||
<aside
|
||||
data-testid="chat-outline"
|
||||
aria-label={t("chat.outline.panel")}
|
||||
// Over the chat in a narrow pane (the container is PaneChat's root),
|
||||
// Over the conversation in a narrow pane (the container is PaneChat's
|
||||
// root; `--chat-area` keeps the prompt box below it uncovered),
|
||||
// beside it once the pane has room, as wide as PaneChat's splitter says.
|
||||
className="absolute inset-y-3 right-3 z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
|
||||
className="absolute right-3 top-3 bottom-[calc(100%-var(--chat-area,100%)+0.75rem)] z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
|
||||
>
|
||||
{o.progress.length > 0 && (
|
||||
<Section title={t("chat.outline.progress")} action={close}>
|
||||
<Section
|
||||
title={t("chat.outline.progress")}
|
||||
count={`${doneSteps}/${o.progress.length}`}
|
||||
action={close}
|
||||
>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={o.progress.length}
|
||||
aria-valuenow={doneSteps}
|
||||
aria-label={t("chat.outline.progress")}
|
||||
className="mx-1.5 mb-1.5 h-1 overflow-hidden rounded-full bg-surface-3"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-solid transition-[width] duration-500"
|
||||
style={{ width: `${(doneSteps / o.progress.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{o.progress.map((task) => {
|
||||
const done = task.status === "completed";
|
||||
const now = task.status === "in_progress";
|
||||
return (
|
||||
<div key={task.id} className={rowClass} title={task.subject}>
|
||||
<div
|
||||
key={task.id}
|
||||
className={`${rowClass} ${now ? "bg-accent/10 font-medium" : ""}`}
|
||||
title={task.subject}
|
||||
aria-current={now ? "step" : undefined}
|
||||
>
|
||||
{done ? (
|
||||
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
@@ -329,7 +358,7 @@ export function ChatOutline({
|
||||
icon={Plug}
|
||||
label={t("chat.outline.connectors")}
|
||||
names={o.mcp}
|
||||
to="/settings/agents/claude-code?tab=mcp"
|
||||
to={href("agents/claude-code?tab=mcp")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||
* and `workspaceOf` names each tab's workspace before its own name.
|
||||
* and `workspaceOf` names each tab's workspace before its own name. Settings
|
||||
* opened on a workspace is one more tab at the bar's end (`settings`, kept
|
||||
* until its X), shown instead of the panes while it is the active one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,8 +45,9 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -152,6 +155,9 @@ interface Tab {
|
||||
|
||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
||||
|
||||
/** The Settings tab's key among the tabs' (a group's key is its slots). */
|
||||
const SETTINGS_KEY = "settings";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs: runs,
|
||||
@@ -164,6 +170,7 @@ export function ConsoleTabs({
|
||||
sessions,
|
||||
onRenamed,
|
||||
workspaceOf,
|
||||
settings,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
@@ -187,9 +194,12 @@ export function ConsoleTabs({
|
||||
onHome?: () => void;
|
||||
/** Right end of the bar (the single pane's split menus). */
|
||||
actions?: ReactNode;
|
||||
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
|
||||
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const settingsHref = useSettingsHref();
|
||||
const aiTools = useAiTools();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// The tab being renamed (its run id) and the draft name.
|
||||
@@ -397,8 +407,9 @@ export function ConsoleTabs({
|
||||
};
|
||||
|
||||
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
|
||||
const keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
|
||||
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
|
||||
const last = tabs.length - 1;
|
||||
const last = keys.length - 1;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? i === last
|
||||
@@ -413,11 +424,14 @@ export function ConsoleTabs({
|
||||
: e.key === "End"
|
||||
? last
|
||||
: null;
|
||||
if (to === null || !tabs[to]) return;
|
||||
const key = to === null ? undefined : keys[to];
|
||||
if (key === undefined) return;
|
||||
e.preventDefault();
|
||||
tabRefs.current.get(tabs[to].key)?.focus();
|
||||
tabRefs.current.get(key)?.focus();
|
||||
};
|
||||
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
|
||||
const tabStop = settings?.active
|
||||
? SETTINGS_KEY
|
||||
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
|
||||
|
||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
||||
@@ -456,7 +470,7 @@ export function ConsoleTabs({
|
||||
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const active = tab.index === groups.active;
|
||||
const active = !settings?.active && tab.index === groups.active;
|
||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||
const state = tabState(tab);
|
||||
if (single && renaming === single.id) {
|
||||
@@ -569,6 +583,48 @@ export function ConsoleTabs({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{settings && (
|
||||
<div
|
||||
className={`group relative flex shrink-0 items-center rounded-t-md ${
|
||||
settings.active
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{settings.active && (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={settings.active}
|
||||
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(SETTINGS_KEY, el);
|
||||
else tabRefs.current.delete(SETTINGS_KEY);
|
||||
}}
|
||||
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
|
||||
data-testid="console-tab-settings"
|
||||
onClick={settings.onOpen}
|
||||
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
{t("tabs.settings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="console-tab-settings-close"
|
||||
onClick={settings.onClose}
|
||||
aria-label={t("tabs.closeSettings")}
|
||||
title={t("tabs.closeSettings")}
|
||||
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
|
||||
settings.active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={menuRef} className="relative flex items-center px-1.5">
|
||||
<button
|
||||
@@ -618,7 +674,10 @@ export function ConsoleTabs({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${item} !text-fg-muted`}
|
||||
onClick={() => navigate("/settings/agents")}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
navigate(settingsHref("agents"));
|
||||
}}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("tabs.agentSettings")}
|
||||
|
||||
@@ -12,11 +12,18 @@
|
||||
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
||||
* permission or trust prompt, Codex's approval — is read off the terminal
|
||||
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
||||
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
|
||||
* a field in the card, whose text is typed there as the answer
|
||||
* (`answerRunMenu`).
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
* outputs, what the session used), open at first in a pane spanning the
|
||||
* grid's full width or height (`panelOpen`) and collapsed into a file button
|
||||
* with the output count in a quarter-sized one; it sits beside the
|
||||
* grid's full width or height (`panelOpen`) once the session has an output,
|
||||
* else collapsed into a file button with the output count; the user's own
|
||||
* open/close is kept per session for the tab (sessionStorage), so the chat
|
||||
* leaving for the terminal and back keeps it; it sits beside the
|
||||
* conversation when the pane is wide and over it when narrow (a CSS container
|
||||
* query on the pane, not the window), dragged wider or narrower by its
|
||||
* splitter there (a fraction of the pane, remembered per browser). A file
|
||||
@@ -28,18 +35,29 @@
|
||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
|
||||
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||
* leaves them as typed; switching to the terminal pastes them into its input
|
||||
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
|
||||
* conversation to its end and keeps it there (`follow`), so the message and
|
||||
* the step running after it come into sight on their own. While the session's
|
||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
|
||||
* Stop in Send's place: Esc pressed in the pane (`interruptRun`). While the
|
||||
* CLI asks the user something (the main agent awaiting input on a
|
||||
* Notification — a permission prompt), the chat's live line says so and
|
||||
* offers the terminal (`showTerminal`); before the session has started (no
|
||||
* row yet: a trust prompt, say, answered only there) anything typed into the
|
||||
* pane is confirmed first, so an Enter does not pick that prompt's
|
||||
* preselected "No, exit" unasked. The box shows how full the context
|
||||
* is (the newest reply's prompt size against the model's window,
|
||||
* `contextWindow`), and a hidden browser tab is marked while the session
|
||||
* waits on the user or once its turn ended (`lib/tabBadge.ts`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { ChatAddMenu } from "./ChatAddMenu";
|
||||
@@ -48,11 +66,22 @@ import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||
import { Select } from "../Select";
|
||||
import { Divider } from "./PaneGrid";
|
||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||
import { Divider, showTerminal } from "./PaneGrid";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { setTabBadge } from "../../lib/tabBadge";
|
||||
import { fmt } from "../../lib/format";
|
||||
import { agentAwaitingReason } from "../../lib/types";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
@@ -65,11 +94,68 @@ const MODES: [PermissionMode, string][] = [
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
const readOutlineChoice = (sessionId: string) => {
|
||||
const v = sessionStorage.getItem(outlineKey(sessionId));
|
||||
return v === null ? null : v === "1";
|
||||
};
|
||||
const storedSide = () => {
|
||||
const f = Number(localStorage.getItem(SIDE_KEY));
|
||||
return f ? clampSide(f) : null;
|
||||
};
|
||||
|
||||
/** The model's context window. Claude's 200K models are the 4.5 line and
|
||||
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
export function contextWindow(model: string | null): number | null {
|
||||
if (!model?.startsWith("claude-")) return null;
|
||||
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
||||
? 200_000
|
||||
: 1_000_000;
|
||||
}
|
||||
|
||||
/** How full the context is: a ring and a percentage, amber then red as
|
||||
* Claude Code's auto-compact comes near. */
|
||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||
const { t } = useTranslation("run");
|
||||
const size = contextWindow(model);
|
||||
if (!size || !tokens) return null;
|
||||
const pct = Math.min(100, Math.round((tokens / size) * 100));
|
||||
const tone =
|
||||
pct >= 90 ? "text-status-danger" : pct >= 75 ? "text-status-warning" : "text-fg-muted";
|
||||
const ring = 2 * Math.PI * 6;
|
||||
return (
|
||||
<span
|
||||
data-testid="pane-chat-context"
|
||||
title={t("chat.contextTitle", { used: fmt(tokens), size: fmt(size) })}
|
||||
className={`flex items-center gap-1 px-1 text-xs tabular-nums ${tone}`}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 -rotate-90" aria-hidden>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeDasharray={`${(pct / 100) * ring} ${ring}`}
|
||||
/>
|
||||
</svg>
|
||||
{pct}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||
const { t } = useTranslation(["run", "lanes"]);
|
||||
@@ -118,6 +204,108 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
||||
);
|
||||
}
|
||||
|
||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||
* pane; its "Type something" row opens a field right there instead —
|
||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
||||
function PaneMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onAnswer,
|
||||
onCancel,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onAnswer: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const submit = () => draft.trim() && onAnswer(draft);
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
{menu.question && (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{menu.options.map((o, i) =>
|
||||
typing && i === menu.typeAt ? (
|
||||
<div
|
||||
key={`${i}-type`}
|
||||
className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1"
|
||||
>
|
||||
<span className="w-4 flex-shrink-0 font-mono text-xs text-fg-muted">{o.n}</span>
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="pane-chat-menu-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") setTyping(false);
|
||||
}}
|
||||
placeholder={t("chat.menu.placeholder")}
|
||||
aria-label={t("chat.menu.placeholder")}
|
||||
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-menu-send"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
key={`${i}-${o.label}`}
|
||||
type="button"
|
||||
onClick={() => (i === menu.typeAt ? setTyping(true) : onChoose(i))}
|
||||
className={`flex items-start gap-2 rounded-lg border px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 ${
|
||||
i === menu.cursor ? "border-accent/50 bg-surface-2" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 w-4 flex-shrink-0 font-mono text-xs text-fg-muted">
|
||||
{o.checked === null ? (
|
||||
(o.n ?? "↵")
|
||||
) : o.checked ? (
|
||||
<SquareCheck className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">
|
||||
{o.label}
|
||||
{o.hint && <span className="block text-xs text-fg-muted">{o.hint}</span>}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-auto rounded-md border border-border px-2 py-0.5 hover:bg-surface-3"
|
||||
>
|
||||
{t("chat.menu.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneChat({
|
||||
run,
|
||||
sessionId,
|
||||
@@ -147,11 +335,20 @@ export function PaneChat({
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
const [outlineOpen, setOutlineOpen] = useState(panelOpen);
|
||||
// The user's own open/close, per session for this tab: it outlives the chat
|
||||
// leaving for the terminal (an unmount). Unset, the default rules.
|
||||
const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId));
|
||||
// A split or a close changes the pane's shape: its default comes back.
|
||||
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
|
||||
const shape = useRef(panelOpen);
|
||||
useEffect(() => {
|
||||
if (shape.current === panelOpen) return;
|
||||
shape.current = panelOpen;
|
||||
sessionStorage.removeItem(outlineKey(sessionId));
|
||||
setOutlineChoice(null);
|
||||
}, [panelOpen, sessionId]);
|
||||
const openOutline = (open: boolean) => {
|
||||
setOutlineOpen(open);
|
||||
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
|
||||
setOutlineChoice(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
@@ -160,20 +357,42 @@ export function PaneChat({
|
||||
const [dragged, setDragged] = useState(storedSide);
|
||||
const side = dragged ?? (preview ? 0.45 : 0.3);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
// The conversation's height, so a panel laid over a narrow pane stops above
|
||||
// the prompt box instead of covering it.
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const area = areaRef.current;
|
||||
if (!area || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() =>
|
||||
rootRef.current?.style.setProperty("--chat-area", `${area.offsetHeight}px`)
|
||||
);
|
||||
ro.observe(area);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
// Open by default only in a spanning pane, and only once there is an output.
|
||||
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
// The main agent is "working" from a prompt's submit to its Stop hook.
|
||||
// The main agent is "working" from a prompt's submit to its Stop hook, and
|
||||
// waits on the user from a Notification (a permission prompt) to the next hook.
|
||||
const [working, setWorking] = useState(false);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
// Stop presses, for the conversation's live line (issue #74).
|
||||
const [stops, setStops] = useState(0);
|
||||
const readWorking = useCallback(
|
||||
() =>
|
||||
api.agents
|
||||
.list({ session_id: sessionId, status: "working" })
|
||||
// With session_id the server answers every agent of the session, its
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
|
||||
.then((r) => {
|
||||
const main = r.agents.filter((a) => a.type === "main");
|
||||
setWorking(main.some((a) => a.status === "working"));
|
||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||
})
|
||||
.catch(() => {}),
|
||||
[sessionId]
|
||||
);
|
||||
@@ -185,22 +404,83 @@ export function PaneChat({
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working ? 5000 : undefined,
|
||||
intervalMs: working || waiting ? 5000 : undefined,
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
// The menu the terminal underneath shows now. AskUserQuestion fires no
|
||||
// Notification, so this — not `waiting` alone — says the CLI asks.
|
||||
// ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile.
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
useEffect(() => {
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
read();
|
||||
const timer = window.setInterval(read, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId]);
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
if (menu && chooseRunOption(runId, menu, index)) setMenu(null);
|
||||
else toast.error(t("chat.notSent"));
|
||||
};
|
||||
const answer = (reply: string) => {
|
||||
if (menu && answerRunMenu(runId, menu, reply)) {
|
||||
setMenu(null);
|
||||
setFollow((n) => n + 1);
|
||||
} else toast.error(t("chat.notSent"));
|
||||
};
|
||||
|
||||
// A hidden tab says when this session waits on the user or its turn ended.
|
||||
const was = useRef({ working: false, asking: false });
|
||||
useEffect(() => {
|
||||
if (asking) setTabBadge(runId, "waiting");
|
||||
else if (working) setTabBadge(runId, null);
|
||||
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||
was.current = { working, asking };
|
||||
}, [runId, working, asking]);
|
||||
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||
tokens: 0,
|
||||
model: null,
|
||||
});
|
||||
const onContext = useCallback(
|
||||
(tokens: number, model: string | null) => setContext({ tokens, model }),
|
||||
[]
|
||||
);
|
||||
|
||||
// Before the session starts the CLI may be asking in the terminal (trust
|
||||
// this folder? with "No, exit" preselected): an Enter from here answers it.
|
||||
// Asked, not refused: whether the session row appears right after the
|
||||
// answer depends on Claude Code firing a hook then, and a hard lock would
|
||||
// strand the chat if it does not.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
|
||||
// A control is a command typed into the pane, like the user would.
|
||||
const command = (line: string) => {
|
||||
const command = async (line: string) => {
|
||||
if (
|
||||
notStarted &&
|
||||
!(await confirmDialog({
|
||||
title: t("chat.notStartedTitle"),
|
||||
message: t("chat.notStarted"),
|
||||
confirmLabel: t("chat.sendAnyway"),
|
||||
}))
|
||||
)
|
||||
return false;
|
||||
const ok = submitToRun(runId, line);
|
||||
if (!ok) toast.error(t("chat.notSent"));
|
||||
return ok;
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
const send = async () => {
|
||||
if (!text || uploading) return;
|
||||
if (!command(text)) return;
|
||||
if (!(await command(text))) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
@@ -215,12 +495,17 @@ export function PaneChat({
|
||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||
>
|
||||
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
live
|
||||
follow={follow}
|
||||
waiting={asking}
|
||||
stopped={stops}
|
||||
onOpenTerminal={() => showTerminal(runId, sessionId)}
|
||||
onContext={onContext}
|
||||
onNotStarted={setNotStarted}
|
||||
onOpenFile={(path) => {
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview.
|
||||
@@ -244,7 +529,24 @@ export function PaneChat({
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
key={menu.question}
|
||||
menu={menu}
|
||||
onChoose={choose}
|
||||
onAnswer={answer}
|
||||
onCancel={() => {
|
||||
if (interruptRun(runId)) setMenu(null);
|
||||
else toast.error(t("chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* In the card's place while the CLI asks: hidden, never unmounted,
|
||||
so a draft or an upload in flight survives the question. */}
|
||||
<div
|
||||
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
|
||||
>
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
@@ -253,7 +555,7 @@ export function PaneChat({
|
||||
pickFiles={pickFiles}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
onSubmit={() => void send()}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={claude}
|
||||
@@ -271,16 +573,17 @@ export function PaneChat({
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||
{claude && (
|
||||
<ModelMenu
|
||||
value={model}
|
||||
onChange={(v) => {
|
||||
if (command(`/model ${v || "default"}`)) setModel(v);
|
||||
onChange={async (v) => {
|
||||
if (await command(`/model ${v || "default"}`)) setModel(v);
|
||||
}}
|
||||
tool={run.tool}
|
||||
effort={effort}
|
||||
onEffortChange={(e) => {
|
||||
if (command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||
onEffortChange={async (e) => {
|
||||
if (await command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -290,8 +593,10 @@ export function PaneChat({
|
||||
data-testid="pane-chat-stop"
|
||||
onClick={() => {
|
||||
// An interrupted turn fires no Stop hook: clear it here.
|
||||
if (interruptRun(runId)) setWorking(false);
|
||||
else toast.error(t("chat.notStopped"));
|
||||
if (interruptRun(runId)) {
|
||||
setWorking(false);
|
||||
setStops((n) => n + 1);
|
||||
} else toast.error(t("chat.notStopped"));
|
||||
}}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
@@ -303,7 +608,7 @@ export function PaneChat({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
onClick={() => void send()}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
@@ -324,7 +629,7 @@ export function PaneChat({
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when the pane has room, over it when not.
|
||||
className="absolute inset-0 z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
|
||||
@@ -58,6 +58,9 @@ import {
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptWithImages } from "./RunSetup";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -195,7 +198,18 @@ const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||
* the draft (its files as paths) back into the input box, never sent. */
|
||||
/** A session's chat switched to its terminal: the unsent prompt goes into
|
||||
* the CLI's input box, without Enter. */
|
||||
export function showTerminal(runId: string, sessionId: string) {
|
||||
const draft = readChatDraft(sessionId);
|
||||
const text = promptWithImages(draft.prompt, draft.images);
|
||||
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
|
||||
setSessionChat(sessionId, false);
|
||||
}
|
||||
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
@@ -209,7 +223,14 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
aria-pressed={chat}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setSessionChat(sessionId, !chat)}
|
||||
onClick={() => {
|
||||
if (chat) return showTerminal(run.id, sessionId);
|
||||
const draft = readChatDraft(sessionId);
|
||||
const text = takeRunInput(run.id);
|
||||
if (text)
|
||||
writeChatDraft(sessionId, [draft.prompt, text].filter(Boolean).join("\n"), draft.images);
|
||||
setSessionChat(sessionId, true);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{chat ? (
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
@@ -61,6 +63,7 @@ import { Select, menuRoom } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
@@ -224,6 +227,15 @@ export function PromptEditor({
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// With a `/command` or `@path` in it, the textarea's own text turns
|
||||
// transparent and a mirror over it — same box, font and wrapping, scrolled
|
||||
// with it — draws the text with those tokens coloured.
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const tokens = useMemo(() => promptTokens(value), [value]);
|
||||
const marked = tokens.some((tok) => tok.kind !== "text");
|
||||
const box = bare
|
||||
? "block max-h-[40vh] w-full overflow-y-auto px-1 py-1 text-sm"
|
||||
: "w-full rounded-md border px-3 py-2 text-[11px] font-mono";
|
||||
// A composer grows with its text: reset to its rows, then take what the
|
||||
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||
useLayoutEffect(() => {
|
||||
@@ -232,6 +244,10 @@ export function PromptEditor({
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [bare, value]);
|
||||
// A mirror that just appeared starts at the top, whatever the textarea shows.
|
||||
useLayoutEffect(() => {
|
||||
if (mirrorRef.current && taRef.current) mirrorRef.current.scrollTop = taRef.current.scrollTop;
|
||||
}, [marked]);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
@@ -492,7 +508,7 @@ export function PromptEditor({
|
||||
) : (
|
||||
<span
|
||||
title={img.path}
|
||||
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
||||
className="flex h-14 w-40 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{img.name}</span>
|
||||
@@ -545,39 +561,64 @@ export function PromptEditor({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
onScroll={(e) => {
|
||||
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={
|
||||
bare
|
||||
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
}
|
||||
/>
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
|
||||
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
|
||||
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
|
||||
/>
|
||||
{marked && (
|
||||
<div
|
||||
ref={mirrorRef}
|
||||
aria-hidden
|
||||
data-testid="prompt-mirror"
|
||||
// Its own scrollbar keeps the width the textarea's takes, but is
|
||||
// invisible, so the real one shows through.
|
||||
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
|
||||
>
|
||||
{tokens.map((tok, i) =>
|
||||
tok.kind === "text" ? (
|
||||
tok.text
|
||||
) : (
|
||||
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
|
||||
{tok.text}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{/* A trailing newline is a line in a textarea, not in a div. */}
|
||||
{"\u200b"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{upload && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* Files (images or any other) dropped on it are saved on the server
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||
* a file. The pane also registers with
|
||||
@@ -25,6 +25,9 @@
|
||||
* from its terminal — announced by a hint the textarea is described by;
|
||||
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
|
||||
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
|
||||
* An input method that edits the textarea without composition events (macOS
|
||||
* Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone
|
||||
* dropped its capitals and the words it rewrites.
|
||||
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
|
||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
||||
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
|
||||
@@ -37,7 +40,15 @@
|
||||
* here takes the lead back (`resize` with `drive: true`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||
@@ -49,6 +60,7 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
@@ -184,11 +196,33 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||
const ctrlVRef = useRef(false);
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
void attachFiles(Array.from(e.dataTransfer.files));
|
||||
};
|
||||
|
||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||
// text/plain and would paste nothing. Capture phase, because xterm's own
|
||||
// paste listener stops the event. Text is xterm's to paste, Ctrl+V's too;
|
||||
// only a Ctrl+V with nothing on the clipboard is the pane's ^V again
|
||||
// (vim's block select, readline's quoted insert).
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const ctrlV = ctrlVRef.current;
|
||||
ctrlVRef.current = false;
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (files.length > 0) void attachFiles(files);
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
const attachFiles = async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
@@ -327,6 +361,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
requestAnimationFrame(() => findInput.current?.focus());
|
||||
return false;
|
||||
}
|
||||
// Ctrl+V: xterm would send ^V, and claude would then read the
|
||||
// SERVER's clipboard, never this browser's. Let the browser paste
|
||||
// instead (onPaste: a file's path, or the text); a browser that fires
|
||||
// no paste, or one with nothing on the clipboard, still sends ^V.
|
||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
|
||||
ctrlVRef.current = true;
|
||||
setTimeout(() => {
|
||||
if (!ctrlVRef.current) return;
|
||||
ctrlVRef.current = false;
|
||||
term.input("\x16", true);
|
||||
}, 0);
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -486,6 +533,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const reassert = takeLead;
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
|
||||
// textarea with no composition events: a capital arrives while Shift is
|
||||
// held and every later key REPLACES the word — both dropped by xterm
|
||||
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
|
||||
const detachIme = attachImeInput(term);
|
||||
|
||||
const refit = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
const el = containerRef.current;
|
||||
@@ -514,6 +567,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
detachIme();
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
@@ -562,6 +616,24 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||
(data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
||||
},
|
||||
// The rows on screen, dim cells (a TUI's placeholder) left out.
|
||||
() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return null;
|
||||
const buf = term.buffer.active;
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < term.rows; y++) {
|
||||
const line = buf.getLine(buf.baseY + y);
|
||||
let row = "";
|
||||
for (let x = 0; line && x < line.length; x++) {
|
||||
const cell = line.getCell(x);
|
||||
// Width 0: the right half of a wide character.
|
||||
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return { rows, cols: term.cols };
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
@@ -616,7 +688,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
onDrop={onDrop}
|
||||
onPasteCapture={onPaste}
|
||||
onFocus={() => notePaneFocus(runId)}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
|
||||
|
||||
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
|
||||
);
|
||||
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
||||
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
||||
// How far the plan is, and which step is being worked on.
|
||||
expect(screen.getByText("1/2")).toBeTruthy();
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
|
||||
expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||
|
||||
@@ -7,30 +7,52 @@
|
||||
* activity panel, collapsed into a file button with the output count, whose
|
||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane.
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
||||
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
||||
* message sent meanwhile typed as its "Type something" answer.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat } from "../PaneChat";
|
||||
import { PaneChat, contextWindow } from "../PaneChat";
|
||||
import { api } from "../../../lib/api";
|
||||
import { interruptRun, submitToRun } from "../../../lib/panePaste";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
|
||||
ConversationView: ({
|
||||
onOpenFile,
|
||||
onNotStarted,
|
||||
}: {
|
||||
onOpenFile?: (path: string) => void;
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
}) => (
|
||||
<div data-testid="conversation">
|
||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||
{/* The session has no row yet: the CLI has not started it. */}
|
||||
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||
vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
readRunMenu: vi.fn(() => null),
|
||||
chooseRunOption: vi.fn(() => true),
|
||||
answerRunMenu: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -126,6 +148,7 @@ beforeEach(() => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
||||
localStorage.removeItem("agenthub.chat.draft.s1");
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("PaneChat — the + menu", () => {
|
||||
@@ -200,6 +223,44 @@ describe("PaneChat — Stop", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — waiting on the user", () => {
|
||||
it("marks a hidden tab while a permission prompt waits", async () => {
|
||||
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
||||
document.title = "Workspace · AgentHub";
|
||||
try {
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [
|
||||
{
|
||||
type: "main",
|
||||
status: "waiting",
|
||||
awaiting_input_since: "2026-10-12T08:00:00.000Z",
|
||||
awaiting_reason: "notification",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const { unmount } = renderChat();
|
||||
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
|
||||
unmount();
|
||||
expect(document.title).toBe("Workspace · AgentHub");
|
||||
} finally {
|
||||
vis.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextWindow", () => {
|
||||
it("knows Claude's 200K line from the 1M one", () => {
|
||||
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
|
||||
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
|
||||
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
|
||||
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
|
||||
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
|
||||
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
|
||||
expect(contextWindow("gpt-5-codex")).toBeNull();
|
||||
expect(contextWindow(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — model, effort and permission mode", () => {
|
||||
it("types /model into the pane", async () => {
|
||||
renderChat();
|
||||
@@ -253,27 +314,63 @@ describe("PaneChat — the activity panel", () => {
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
|
||||
it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
|
||||
const pane = (panelOpen: boolean) => (
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const { rerender } = render(pane(true));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
await screen.findByTestId("chat-outline");
|
||||
rerender(pane(false));
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resizes from its splitter and remembers the width", () => {
|
||||
it("starts closed while the session has no output, even in a spanning pane", async () => {
|
||||
vi.mocked(api.sessions.outline).mockResolvedValueOnce({
|
||||
progress: [],
|
||||
outputs: [],
|
||||
files: [],
|
||||
uploads: [],
|
||||
memory: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
} as never);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
expect(screen.getByTestId("chat-outline-show").textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("keeps the user's open or close across the chat leaving for the terminal", async () => {
|
||||
const pane = () => (
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const first = render(pane());
|
||||
fireEvent.click(
|
||||
within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" })
|
||||
);
|
||||
first.unmount();
|
||||
render(pane());
|
||||
await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2"));
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
});
|
||||
|
||||
it("resizes from its splitter and remembers the width", async () => {
|
||||
localStorage.removeItem("agenthub.chat.panelWidth");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
|
||||
const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
|
||||
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
||||
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
||||
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
||||
@@ -333,4 +430,82 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||
});
|
||||
|
||||
// Issue #73: before the session starts the CLI may be asking in the terminal
|
||||
// (trust this folder? — "No, exit" preselected); an Enter sent from here
|
||||
// would answer it, so a send asks first.
|
||||
it("asks before sending while the session has not started", async () => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
confirmDialog.mockClear();
|
||||
renderChat();
|
||||
fireEvent.click(screen.getByTestId("not-started"));
|
||||
const box = screen.getByPlaceholderText("Write a message…");
|
||||
fireEvent.change(box, { target: { value: "hello" } });
|
||||
confirmDialog.mockResolvedValueOnce(false);
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
|
||||
expect(submitToRun).not.toHaveBeenCalled();
|
||||
expect(box).toHaveValue("hello");
|
||||
confirmDialog.mockResolvedValueOnce(true);
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
|
||||
});
|
||||
|
||||
it("sends without asking once the session has started", async () => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
confirmDialog.mockClear();
|
||||
renderChat();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — a menu the CLI asks with", () => {
|
||||
const MENU: PaneMenu = {
|
||||
question: "Which color?",
|
||||
options: [
|
||||
{ n: 1, label: "Red", hint: "warm", checked: null },
|
||||
{ n: 2, label: "Type something.", hint: "", checked: null },
|
||||
],
|
||||
cursor: 0,
|
||||
multi: false,
|
||||
typeAt: 1,
|
||||
};
|
||||
|
||||
it("shows the question as buttons in the box's place, and a pick presses its key", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(MENU);
|
||||
try {
|
||||
renderChat();
|
||||
const card = await screen.findByTestId("pane-chat-menu");
|
||||
expect(card).toHaveTextContent("Which color?");
|
||||
expect(card).toHaveTextContent("warm");
|
||||
// The prompt box is hidden (kept mounted) while the CLI asks.
|
||||
expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull();
|
||||
fireEvent.click(screen.getByText("Red"));
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0);
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("types the answer in the card's own field, never pressing that row's key", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(MENU);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
fireEvent.click(await screen.findByText("Type something."));
|
||||
expect(chooseRunOption).not.toHaveBeenCalled();
|
||||
const field = screen.getByTestId("pane-chat-menu-input");
|
||||
expect(field).toHaveFocus();
|
||||
fireEvent.change(field, { target: { value: "purple" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple");
|
||||
expect(submitToRun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||
* chip, a composer
|
||||
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
|
||||
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
@@ -117,6 +117,31 @@ function renderEditor(value: string, slash = true) {
|
||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||
|
||||
describe("PromptEditor — / and @ suggestions", () => {
|
||||
it("colours /command and @path tokens through a mirror, only when there are any", () => {
|
||||
const { unmount } = render(
|
||||
<PromptEditor
|
||||
value={"/review @src/a.ts please"}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={[]}
|
||||
onImagesChange={() => {}}
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
);
|
||||
const mirror = screen.getByTestId("prompt-mirror");
|
||||
expect(mirror).toHaveAttribute("aria-hidden");
|
||||
expect(within(mirror).getByText("/review").className).toContain("text-accent");
|
||||
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
|
||||
expect(box().className).toContain("text-transparent");
|
||||
unmount();
|
||||
|
||||
renderEditor("plain words");
|
||||
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
|
||||
expect(box().className).toContain("text-fg-primary");
|
||||
});
|
||||
|
||||
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const spies = renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file TerminalView.test.tsx
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* mocked terminal, forwards typed input (Safari Telex word replacements
|
||||
* included) as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||
* after `exit` or a policy close,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const inputMock = vi.fn();
|
||||
// The newest terminal's hidden input, where the browser's IME types.
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
@@ -69,6 +73,10 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
input: inputMock,
|
||||
textarea: (textarea = document.createElement("textarea")),
|
||||
// xterm's own element, holding its textarea (lib/imeInput listens on it).
|
||||
element: document.createElement("div").appendChild(textarea).parentElement,
|
||||
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||
})),
|
||||
}));
|
||||
@@ -503,4 +511,83 @@ describe("TerminalView", () => {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("uploads a pasted image and pastes its path, like a drop", async () => {
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [img], getData: () => "" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-image.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||
});
|
||||
|
||||
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||
// pasted like any browser paste: sending ^V for it pasted nothing.
|
||||
it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => {
|
||||
vi.useFakeTimers();
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
|
||||
// Not cancelled: xterm's own paste listener takes the text.
|
||||
expect(
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [], getData: () => "some text" },
|
||||
})
|
||||
).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(inputMock).not.toHaveBeenCalled();
|
||||
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
|
||||
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||
clipboardData: { files: [], getData: () => "" },
|
||||
});
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(inputMock).toHaveBeenCalledTimes(1);
|
||||
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
|
||||
vi.useFakeTimers();
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(inputMock).toHaveBeenCalledTimes(1);
|
||||
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
|
||||
// every later key REPLACES the word — the event order captured on a Mac.
|
||||
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const type = (value: string) => {
|
||||
textarea.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertReplacementText" }));
|
||||
textarea.value = value;
|
||||
textarea.dispatchEvent(new InputEvent("input", { inputType: "insertReplacementText" }));
|
||||
};
|
||||
textarea.value = "x"; // xterm itself sent this one (insertText)
|
||||
type("xi");
|
||||
type("xin");
|
||||
textarea.value = "xin vie";
|
||||
type("xin viet");
|
||||
type("xin viêt");
|
||||
type("xin việt");
|
||||
expect(inputMock.mock.calls).toEqual([
|
||||
["i", true],
|
||||
["n", true],
|
||||
["t", true],
|
||||
["\x7f\x7fêt", true],
|
||||
["\x7f\x7fệt", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file AgentInstructionsSettings — Settings → AI tools → My agent
|
||||
* instructions (issue #67): the account's own rules in markdown, put before
|
||||
* every workspace's own instructions in each new Claude or Codex session of a
|
||||
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
|
||||
* failed load says so with a Retry instead of leaving a dead, empty editor —
|
||||
* the editor stays read-only until the saved text is known, so a save can
|
||||
* never overwrite instructions it did not load. **Choose file** reads a local
|
||||
* Markdown/text file in the browser into the draft (nothing is uploaded until
|
||||
* Save, and Discard brings the saved text back). "Applies to" lists only the
|
||||
* installed, enabled tools whose panes the server says receive them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { FileUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AgentInstructionsView } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function AgentInstructionsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [max, setMax] = useState<number | undefined>();
|
||||
const [toolIds, setToolIds] = useState<string[]>([]);
|
||||
const { tools } = useAiTools();
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const apply = (r: AgentInstructionsView) => {
|
||||
setSaved(r.instructions);
|
||||
setDraft(r.instructions);
|
||||
setMax(r.max);
|
||||
setToolIds(r.tools ?? []);
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
setLoadError(null);
|
||||
api.me
|
||||
.instructions()
|
||||
.then(apply)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
apply(await api.me.saveInstructions(draft));
|
||||
toast.success(t("agentInstructions.saved"));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // the same file can be picked again after a Discard
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
if (max && text.length > max) {
|
||||
toast.error(
|
||||
t("agentInstructions.fileTooBig", {
|
||||
name: file.name,
|
||||
size: text.length.toLocaleString(),
|
||||
max: max.toLocaleString(),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDraft(text);
|
||||
}
|
||||
|
||||
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
|
||||
const ready = saved !== null;
|
||||
const dirty = ready && draft !== saved;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="agent-instructions"
|
||||
title={t("agentInstructions.title")}
|
||||
description={t("agentInstructions.description")}
|
||||
>
|
||||
<div className="pt-1">
|
||||
{appliesTo.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
|
||||
<span>{t("agentInstructions.appliesTo")}</span>
|
||||
{appliesTo.map((x) => (
|
||||
<span
|
||||
key={x.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||
{x.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
aria-label={t("agentInstructions.title")}
|
||||
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
placeholder={t("agentInstructions.placeholder")}
|
||||
value={draft}
|
||||
maxLength={max}
|
||||
disabled={!ready}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
{loadError ? (
|
||||
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
|
||||
<span className="truncate">
|
||||
{t("agentInstructions.loadFailed", { message: loadError })}
|
||||
</span>
|
||||
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-[11px] tabular-nums text-fg-muted">
|
||||
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".md,.markdown,.txt,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
data-testid="agent-instructions-file"
|
||||
onChange={(e) => void pickFile(e)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
|
||||
disabled={!ready || saving}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileUp className="h-3.5 w-3.5" />
|
||||
{t("agentInstructions.chooseFile")}
|
||||
</button>
|
||||
{dirty && !saving && (
|
||||
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
|
||||
{t("agentInstructions.discard")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={saving || !dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import {
|
||||
ArrowRight,
|
||||
DollarSign,
|
||||
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
|
||||
*/
|
||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const href = useSettingsHref();
|
||||
return (
|
||||
<div className="card flex items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0">
|
||||
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||
</div>
|
||||
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
|
||||
<Link to={href(`pricing#pricing-${vendor}`)} className="btn-ghost shrink-0 text-xs">
|
||||
{t("pricingLink.open")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file AgentInstructionsSettings.test.tsx
|
||||
* @description My agent instructions: a failed load shows the error with a
|
||||
* Retry (the editor stays locked, so a save cannot overwrite unloaded text)
|
||||
* and Retry unlocks it; Choose file puts a local file's text into the draft
|
||||
* without saving, and refuses one longer than the limit; "Applies to" names
|
||||
* only the installed, enabled tools the server says receive them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentInstructionsSettings } from "../AgentInstructionsSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { me: { instructions: vi.fn(), saveInstructions: vi.fn() } },
|
||||
};
|
||||
});
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
tools: [] as { id: string; label: string; installed: boolean; enabled: boolean }[],
|
||||
}));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const instructions = vi.mocked(api.me.instructions);
|
||||
const saveInstructions = vi.mocked(api.me.saveInstructions);
|
||||
const editor = () => screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
// jsdom's File has no text(); every browser AgentHub supports does.
|
||||
const pick = (text: string, name = "rules.md") => {
|
||||
const file = Object.assign(new File([text], name, { type: "text/markdown" }), {
|
||||
text: () => Promise.resolve(text),
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("agent-instructions-file"), { target: { files: [file] } });
|
||||
};
|
||||
|
||||
describe("AgentInstructionsSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
aiTools.tools = [];
|
||||
});
|
||||
|
||||
it("names only the installed, enabled tools that receive the instructions", async () => {
|
||||
aiTools.tools = [
|
||||
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||
{ id: "codex", label: "Codex", installed: true, enabled: false },
|
||||
{ id: "cursor", label: "Cursor CLI", installed: true, enabled: true },
|
||||
];
|
||||
instructions.mockResolvedValue({ instructions: "", max: 100, tools: ["claude", "codex"] });
|
||||
render(<AgentInstructionsSettings />);
|
||||
expect(await screen.findByText("Claude Code")).toBeInTheDocument();
|
||||
expect(screen.getByText("Applies to")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Codex")).toBeNull();
|
||||
expect(screen.queryByText("Cursor CLI")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a failed load with Retry, and Retry unlocks the editor", async () => {
|
||||
instructions.mockRejectedValueOnce(new Error("HTTP 404"));
|
||||
render(<AgentInstructionsSettings />);
|
||||
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
|
||||
expect(editor()).toBeDisabled();
|
||||
|
||||
instructions.mockResolvedValueOnce({ instructions: "# saved", max: 100, tools: [] });
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||
expect(editor().value).toBe("# saved");
|
||||
});
|
||||
|
||||
it("Choose file fills the draft without saving; Save sends it", async () => {
|
||||
instructions.mockResolvedValue({ instructions: "", max: 100, tools: [] });
|
||||
saveInstructions.mockResolvedValue({ instructions: "# from file", max: 100, tools: [] });
|
||||
render(<AgentInstructionsSettings />);
|
||||
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||
|
||||
pick("# from file");
|
||||
await waitFor(() => expect(editor().value).toBe("# from file"));
|
||||
expect(saveInstructions).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
await waitFor(() => expect(saveInstructions).toHaveBeenCalledWith("# from file"));
|
||||
});
|
||||
|
||||
it("refuses a file longer than the limit and keeps the draft", async () => {
|
||||
instructions.mockResolvedValue({ instructions: "keep", max: 5, tools: [] });
|
||||
render(<AgentInstructionsSettings />);
|
||||
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||
|
||||
pick("way too long");
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(editor().value).toBe("keep");
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,6 @@
|
||||
"browse.fileTitle": "Browse for a file",
|
||||
"browse.select": "Select this folder",
|
||||
"browse.title": "Browse for a folder",
|
||||
"browse.typePath": "No native picker is available here — type the path instead.",
|
||||
"changes.back": "All files",
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "Binary file — not shown.",
|
||||
@@ -384,6 +383,8 @@
|
||||
"tabs.close": "Close tab (stops its session)",
|
||||
"tabs.full": "All 10 sessions are in use — stop one first",
|
||||
"tabs.agentSettings": "Agent settings…",
|
||||
"tabs.settings": "Settings",
|
||||
"tabs.closeSettings": "Close Settings",
|
||||
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
|
||||
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
|
||||
"panes.pick": "Show another session in this pane",
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
"placeholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"notSent": "The terminal is not connected — the message was not sent.",
|
||||
"notStarted": "Claude Code may be asking something in the terminal — whether to trust this folder, for example. Sending now presses Enter there, which can answer it (its preselected choice is \"No, exit\"). Open the terminal to answer it first.",
|
||||
"notStartedTitle": "The session has not started",
|
||||
"sendAnyway": "Send anyway",
|
||||
"stop": "Stop",
|
||||
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||
"add": "Add files, skills…",
|
||||
@@ -119,6 +122,15 @@
|
||||
"close": "Close",
|
||||
"noPreview": "No preview for this file here",
|
||||
"truncated": "Showing the first part of this file"
|
||||
},
|
||||
"contextTitle": "Context: {{used}} of {{size}} tokens — Claude Code compacts it when it is nearly full",
|
||||
"tab": {
|
||||
"waiting": "Waiting",
|
||||
"done": "Done"
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Type your answer…",
|
||||
"cancel": "Cancel (Esc)"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"loading": "Loading conversation…",
|
||||
"empty": "No conversation records found.",
|
||||
"emptyBody": "This session's metadata was imported, but its transcript file is no longer on disk. Claude Code automatically deletes inactive session transcripts after a retention period (cleanupPeriodDays, default 30 days), so older conversations may already be gone. Sessions imported from now on are snapshotted and kept even after Claude Code prunes the originals.",
|
||||
"notStarted": "The session has not started yet.",
|
||||
"notStartedBody": "Claude Code may be asking you something in the terminal first — whether to trust this folder, for example. Answer it there; the conversation shows up here once the session starts.",
|
||||
"newMessages": "New messages",
|
||||
"systemReminder": "System reminder",
|
||||
"persistedOutput": "Persisted output",
|
||||
@@ -199,7 +201,16 @@
|
||||
},
|
||||
"edited_one": "Edited {{count}} file",
|
||||
"edited_other": "Edited {{count}} files",
|
||||
"working": "Thinking…"
|
||||
"working": "Thinking…",
|
||||
"turnTokens": "↑ {{tokens}} tokens",
|
||||
"waiting": "Waiting for you in the terminal",
|
||||
"waitingFor": "Waiting for your approval: {{step}}",
|
||||
"openTerminal": "Open terminal",
|
||||
"moreSteps_one": "+{{count}} more",
|
||||
"moreSteps_other": "+{{count}} more",
|
||||
"quiet": "no new activity for {{time}}",
|
||||
"askDeclined": "Declined — no answer given",
|
||||
"turnDone": "Done in {{time}}"
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -1032,5 +1032,17 @@
|
||||
"label": "Open new sessions as a chat",
|
||||
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
},
|
||||
"agentInstructions": {
|
||||
"title": "My agent instructions",
|
||||
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
|
||||
"placeholder": "# How I work\n- Answer in Vietnamese\n- Keep diffs small; run the tests before finishing",
|
||||
"saved": "Instructions saved — new sessions will use them",
|
||||
"saveFailed": "Could not save: {{message}}",
|
||||
"loadFailed": "Could not load your instructions: {{message}}",
|
||||
"discard": "Discard",
|
||||
"chooseFile": "Choose file",
|
||||
"fileTooBig": "{{name}} is {{size}} characters — instructions hold at most {{max}}",
|
||||
"appliesTo": "Applies to"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"browse.fileTitle": "Duyệt chọn tệp",
|
||||
"browse.select": "Chọn thư mục này",
|
||||
"browse.title": "Duyệt chọn thư mục",
|
||||
"browse.typePath": "Không mở được hộp thoại chọn của hệ điều hành ở đây — hãy gõ đường dẫn.",
|
||||
"changes.back": "Tất cả file",
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "File nhị phân — không hiển thị.",
|
||||
@@ -384,6 +383,8 @@
|
||||
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
||||
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
|
||||
"tabs.agentSettings": "Cài đặt agent…",
|
||||
"tabs.settings": "Cài đặt",
|
||||
"tabs.closeSettings": "Đóng Cài đặt",
|
||||
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
|
||||
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
|
||||
"panes.pick": "Hiện session khác trong pane này",
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
"placeholder": "Viết tin nhắn…",
|
||||
"send": "Gửi",
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||
"notStarted": "Có thể Claude Code đang hỏi bạn trong terminal — ví dụ có tin tưởng (trust) thư mục này không. Gửi lúc này sẽ nhấn Enter ở đó và có thể trả lời câu hỏi ấy (lựa chọn mặc định là \"No, exit\"). Hãy mở terminal để trả lời trước.",
|
||||
"notStartedTitle": "Phiên chưa bắt đầu",
|
||||
"sendAnyway": "Vẫn gửi",
|
||||
"stop": "Ngưng",
|
||||
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
||||
"add": "Thêm file, skill…",
|
||||
@@ -119,6 +122,15 @@
|
||||
"close": "Đóng",
|
||||
"noPreview": "Không xem trước được tệp này ở đây",
|
||||
"truncated": "Chỉ hiện phần đầu của tệp"
|
||||
},
|
||||
"contextTitle": "Ngữ cảnh: {{used}} / {{size}} token — gần đầy thì Claude Code tự compact",
|
||||
"tab": {
|
||||
"waiting": "Đang chờ",
|
||||
"done": "Xong"
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Nhập câu trả lời của bạn…",
|
||||
"cancel": "Huỷ (Esc)"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"loading": "Đang tải hội thoại…",
|
||||
"empty": "Không tìm thấy bản ghi hội thoại.",
|
||||
"emptyBody": "Siêu dữ liệu của phiên này đã được nhập, nhưng tệp transcript không còn trên đĩa. Claude Code tự động xóa transcript của phiên không hoạt động sau một thời gian lưu giữ (cleanupPeriodDays, mặc định 30 ngày), nên các hội thoại cũ có thể đã mất. Các phiên được nhập từ nay sẽ được chụp lại và giữ nguyên ngay cả khi Claude Code xóa bản gốc.",
|
||||
"notStarted": "Phiên chưa bắt đầu.",
|
||||
"notStartedBody": "Có thể Claude Code đang hỏi bạn trong terminal trước — ví dụ có tin tưởng (trust) thư mục này không. Hãy trả lời ở đó; hội thoại sẽ hiện ở đây khi phiên bắt đầu.",
|
||||
"newMessages": "Tin nhắn mới",
|
||||
"systemReminder": "Nhắc nhở hệ thống",
|
||||
"persistedOutput": "Đầu ra đã lưu",
|
||||
@@ -199,7 +201,16 @@
|
||||
},
|
||||
"edited_one": "Đã sửa {{count}} tệp",
|
||||
"edited_other": "Đã sửa {{count}} tệp",
|
||||
"working": "Đang suy nghĩ…"
|
||||
"working": "Đang suy nghĩ…",
|
||||
"turnTokens": "↑ {{tokens}} token",
|
||||
"waiting": "Đang chờ bạn trong terminal",
|
||||
"waitingFor": "Đang chờ bạn cho phép: {{step}}",
|
||||
"openTerminal": "Mở terminal",
|
||||
"moreSteps_one": "+{{count}} việc nữa",
|
||||
"moreSteps_other": "+{{count}} việc nữa",
|
||||
"quiet": "không có hoạt động mới trong {{time}}",
|
||||
"askDeclined": "Đã từ chối — không trả lời",
|
||||
"turnDone": "Xong trong {{time}}"
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -1032,5 +1032,17 @@
|
||||
"label": "Mở session mới dạng chat",
|
||||
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
},
|
||||
"agentInstructions": {
|
||||
"title": "Hướng dẫn agent của tôi",
|
||||
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
|
||||
"placeholder": "# Cách tôi làm việc\n- Trả lời bằng tiếng Việt\n- Diff nhỏ; chạy test trước khi xong",
|
||||
"saved": "Đã lưu — các session mới sẽ dùng nội dung này",
|
||||
"saveFailed": "Không lưu được: {{message}}",
|
||||
"loadFailed": "Không tải được hướng dẫn: {{message}}",
|
||||
"discard": "Bỏ thay đổi",
|
||||
"chooseFile": "Chọn file",
|
||||
"fileTooBig": "{{name}} dài {{size}} ký tự — hướng dẫn tối đa {{max}}",
|
||||
"appliesTo": "Áp dụng cho"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +534,31 @@
|
||||
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* The chat's live step, like Claude Code's: a light sweeping over the words.
|
||||
Reduced motion stops it on the muted colour. */
|
||||
.text-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(var(--fg-muted)) 40%,
|
||||
rgb(var(--fg-primary)) 50%,
|
||||
rgb(var(--fg-muted)) 60%
|
||||
)
|
||||
0 0 / 300% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: text-shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes text-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rendered markdown (MarkdownPreview.tsx): theme tokens only, so every theme
|
||||
reads it; no typography plugin. */
|
||||
.md-preview {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file imeInput.test.ts
|
||||
* @description Pins `attachImeInput` against a real event trace of macOS's
|
||||
* Vietnamese Simple Telex input method in Chrome (typing "Abc xin chào"): the
|
||||
* IME writes into xterm's textarea with `insertText` / `insertReplacementText`
|
||||
* and no composition events, its keydown (keyCode 229) arriving AFTER the
|
||||
* change — which stock xterm 5.5 turned into " x c". The pane must receive
|
||||
* exactly the text typed, a word the IME rewrites as backspaces plus the new
|
||||
* tail, and keys xterm already sent from `keypress` (Space) never twice.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { attachImeInput, textEdit } from "../imeInput";
|
||||
|
||||
type Step = [type: string, a: string, b: string, keyCode?: number, shift?: boolean];
|
||||
|
||||
// Recorded in the user's browser, one line per event on the textarea.
|
||||
const TELEX: Step[] = [
|
||||
["keydown", "Shift", "ShiftLeft", 16, true],
|
||||
["input", "A", "insertText"],
|
||||
["keydown", "A", "KeyA", 229, true],
|
||||
["keyup", "A", "KeyA", 65, true],
|
||||
["keyup", "Shift", "ShiftLeft", 16],
|
||||
["input", "Ab", "insertReplacementText"],
|
||||
["keydown", "b", "KeyB", 229],
|
||||
["keyup", "b", "KeyB", 66],
|
||||
["input", "Abc", "insertReplacementText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["keyup", "c", "KeyC", 67],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["keyup", " ", "Space", 32],
|
||||
["input", "x", "insertText"],
|
||||
["keydown", "x", "KeyX", 229],
|
||||
["keyup", "x", "KeyX", 88],
|
||||
["input", "xi", "insertReplacementText"],
|
||||
["keydown", "i", "KeyI", 229],
|
||||
["input", "xin", "insertReplacementText"],
|
||||
["keydown", "n", "KeyN", 229],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["input", "c", "insertText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["input", "ch", "insertReplacementText"],
|
||||
["keydown", "h", "KeyH", 229],
|
||||
["input", "cha", "insertReplacementText"],
|
||||
["keydown", "a", "KeyA", 229],
|
||||
["input", "chao", "insertReplacementText"],
|
||||
["keydown", "o", "KeyO", 229],
|
||||
["input", "chào", "insertReplacementText"],
|
||||
["keydown", "f", "KeyF", 229],
|
||||
];
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
/** A terminal stand-in: xterm's element and textarea, `input` collecting what
|
||||
* reaches the pane, and `xterm` what xterm's own textarea listeners saw. */
|
||||
function setup() {
|
||||
const element = document.createElement("div");
|
||||
const textarea = document.createElement("textarea");
|
||||
element.appendChild(textarea);
|
||||
document.body.appendChild(element);
|
||||
let sent = "";
|
||||
const xterm: string[] = [];
|
||||
for (const t of ["keydown", "keypress", "input"])
|
||||
textarea.addEventListener(t, (e) => xterm.push(`${t}:${(e as KeyboardEvent).key ?? ""}`), true);
|
||||
// xterm sends a printable key from its keypress handler (Space, here).
|
||||
textarea.addEventListener("keypress", (e) => (sent += e.key), true);
|
||||
const term = { element, textarea, input: (d: string) => (sent += d) };
|
||||
cleanup = attachImeInput(term);
|
||||
return { textarea, xterm, sent: () => sent };
|
||||
}
|
||||
|
||||
function play(ta: HTMLTextAreaElement, steps: Step[]) {
|
||||
for (const [type, a, b, kc, shift] of steps) {
|
||||
if (type === "input") {
|
||||
const before = ta.value;
|
||||
const next = b === "insertText" ? before + a : before.replace(/\S*$/, "") + a;
|
||||
ta.dispatchEvent(new InputEvent("beforeinput", { data: a, inputType: b, bubbles: true }));
|
||||
ta.value = next;
|
||||
ta.dispatchEvent(new InputEvent("input", { data: a, inputType: b, bubbles: true }));
|
||||
} else {
|
||||
const ev = new KeyboardEvent(type, { key: a, code: b, shiftKey: !!shift, bubbles: true });
|
||||
Object.defineProperty(ev, "keyCode", { get: () => kc });
|
||||
ta.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a line editor holds after `data` (DEL erases one character). */
|
||||
const typed = (data: string) => {
|
||||
const out: string[] = [];
|
||||
for (const ch of data) {
|
||||
if (ch === "\x7f") out.pop();
|
||||
else out.push(ch);
|
||||
}
|
||||
return out.join("");
|
||||
};
|
||||
|
||||
describe("textEdit", () => {
|
||||
it("is the backspaces and new tail that turn one value into the other", () => {
|
||||
expect(textEdit("Abc xin cha", "Abc xin chao")).toBe("o");
|
||||
expect(textEdit("Abc xin chao", "Abc xin chào")).toBe("\x7f\x7fào");
|
||||
expect(textEdit("x", "x")).toBe("");
|
||||
expect(textEdit("ab", "")).toBe("\x7f\x7f");
|
||||
});
|
||||
|
||||
it("counts characters, not UTF-16 units", () => {
|
||||
expect(textEdit("a😀", "a")).toBe("\x7f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachImeInput", () => {
|
||||
it("sends what Telex typed — capitals and rewritten words included", () => {
|
||||
const { textarea, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
expect(typed(sent())).toBe("Abc xin chào");
|
||||
});
|
||||
|
||||
it("leaves xterm the keys it sends itself, and hides the IME's from it", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
// Space is xterm's (its keypress sends it, its input event follows); the
|
||||
// 229 keydowns and the IME's own input events never reach it.
|
||||
expect(xterm.filter((x) => x.startsWith("keypress"))).toHaveLength(2);
|
||||
expect(xterm.filter((x) => x.startsWith("input"))).toHaveLength(2);
|
||||
expect(xterm.some((x) => x === "keydown:A" || x === "keydown:b")).toBe(false);
|
||||
expect(sent().split(" ")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not touch a composition (CJK input) — xterm handles that", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
const ev = new InputEvent("input", {
|
||||
data: "に",
|
||||
inputType: "insertCompositionText",
|
||||
bubbles: true,
|
||||
});
|
||||
textarea.value = "に";
|
||||
textarea.dispatchEvent(ev);
|
||||
expect(sent()).toBe("");
|
||||
expect(xterm).toContain("input:");
|
||||
});
|
||||
|
||||
it("stops after dispose", () => {
|
||||
const { textarea, sent } = setup();
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
play(textarea, TELEX.slice(0, 2));
|
||||
expect(sent()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,44 @@
|
||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||||
* is not on screen (a hidden console behind a full-width Changes panel); and
|
||||
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
|
||||
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane;
|
||||
* and the input box read off a real `claude`/`codex` screen (`parsePromptInput`)
|
||||
* and moved out of it (`takeRunInput`) when the pane switches to Chat; and
|
||||
* the menu a CLI asks with, read off real Claude Code 2.1.296 / Codex 0.161
|
||||
* screens (`parsePaneMenu`) and answered with the keys verified there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
interruptRun,
|
||||
notePaneFocus,
|
||||
parsePromptInput,
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
parsePaneMenu,
|
||||
pasteToLane,
|
||||
readRunMenu,
|
||||
registerPane,
|
||||
takeRunInput,
|
||||
} from "../panePaste";
|
||||
|
||||
const RULE = "─".repeat(60);
|
||||
// Claude Code at 60 columns: a wrapped first line, then a typed newline. Its
|
||||
// box draws `❯` and a NO-BREAK space (U+00A0), never a plain one.
|
||||
const CLAUDE = [
|
||||
"❯ Use the Bash tool to run: sleep 20",
|
||||
"",
|
||||
RULE,
|
||||
"❯\u00a0alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
" lambda mu nu xi omicron pi rho sigma tau",
|
||||
" Tiếng Việt có dấu",
|
||||
RULE,
|
||||
" Model: Haiku 5.5",
|
||||
" ⏵⏵ auto mode on (shift+tab to cycle)",
|
||||
];
|
||||
// Codex at 60 columns (its dim placeholder already left out of an empty box).
|
||||
const CODEX = ["› earlier message", "", "› alpha beta", " indented", "", " GPT-6 · /tmp", ""];
|
||||
|
||||
describe("panePaste", () => {
|
||||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||
@@ -65,4 +97,217 @@ describe("panePaste", () => {
|
||||
expect(keys).toEqual(["\x1b"]);
|
||||
off();
|
||||
});
|
||||
|
||||
it("reads claude's and codex's input box, joining soft-wrapped rows back", () => {
|
||||
expect(parsePromptInput(CLAUDE, 60)).toBe(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau\nTiếng Việt có dấu"
|
||||
);
|
||||
expect(parsePromptInput(CODEX, 60)).toBe("alpha beta\n indented");
|
||||
expect(parsePromptInput([RULE, "❯\u00a0", RULE, " ⏵⏵ auto mode on"], 60)).toBe("");
|
||||
expect(parsePromptInput(["› ", "", " GPT-6 · /tmp", " ← for agents"], 60)).toBe("");
|
||||
// A menu or permission prompt in the box's place: nothing to read.
|
||||
expect(parsePromptInput(["Do you want to proceed?", "❯ 1. Yes", " 2. No"], 60)).toBeNull();
|
||||
});
|
||||
|
||||
it("takeRunInput deletes what it read, and leaves a paste placeholder alone", () => {
|
||||
const keys: string[] = [];
|
||||
let rows = CODEX;
|
||||
const off = registerPane(
|
||||
"agenthub-lane-7",
|
||||
() => {},
|
||||
() => true,
|
||||
(d) => keys.push(d),
|
||||
() => ({ rows, cols: 60 })
|
||||
);
|
||||
expect(takeRunInput("agenthub-lane-7")).toBe("alpha beta\n indented");
|
||||
const n = "alpha beta\n indented".length + 1;
|
||||
expect(keys).toEqual(["\x1b[3~".repeat(n) + "\x7f".repeat(n)]);
|
||||
rows = [RULE, "❯ [Pasted text #1 +40 lines] and more", RULE];
|
||||
expect(takeRunInput("agenthub-lane-7")).toBe("");
|
||||
expect(keys).toHaveLength(1);
|
||||
off();
|
||||
expect(takeRunInput("agenthub-lane-7")).toBe("");
|
||||
});
|
||||
|
||||
it("reads an AskUserQuestion menu: question, options, hints, cursor", () => {
|
||||
const m = parsePaneMenu(ASK);
|
||||
expect(m).toEqual({
|
||||
question: "Which color do you like: red, green, or blue?",
|
||||
options: [
|
||||
{ n: 1, label: "Red", hint: "Crisp red", checked: null },
|
||||
{ n: 2, label: "Green", hint: "", checked: null },
|
||||
{ n: 3, label: "Type something.", hint: "", checked: null },
|
||||
{ n: 4, label: "Chat about this", hint: "", checked: null },
|
||||
],
|
||||
cursor: 0,
|
||||
multi: false,
|
||||
typeAt: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a multiSelect question with its unnumbered Submit row", () => {
|
||||
const m = parsePaneMenu(MULTI)!;
|
||||
expect(m.multi).toBe(true);
|
||||
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
|
||||
[1, "Nuts", true],
|
||||
[2, "Honey", false],
|
||||
[3, "Type something", false],
|
||||
[null, "Submit", null],
|
||||
[4, "Chat about this", null],
|
||||
]);
|
||||
expect(m.cursor).toBe(1);
|
||||
expect(m.typeAt).toBe(2);
|
||||
});
|
||||
|
||||
// The "Type something" row once something is typed in it: its label is the
|
||||
// text. It is still the row right above "Chat about this".
|
||||
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
|
||||
vi.useFakeTimers();
|
||||
const keys: string[] = [];
|
||||
const typed = ASK.map((r) =>
|
||||
r === "❯ 1. Red" ? " 1. Red" : r === " 3. Type something." ? "❯ 3. 44444" : r
|
||||
);
|
||||
const m = parsePaneMenu(typed)!;
|
||||
expect(m.typeAt).toBe(2);
|
||||
expect(m.cursor).toBe(2);
|
||||
const off = registerPane(
|
||||
"agenthub-lane-9",
|
||||
() => {},
|
||||
() => true,
|
||||
(d) => keys.push(d)
|
||||
);
|
||||
expect(answerRunMenu("agenthub-lane-9", m, "teal")).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(keys).toEqual(["\x15", "\x1b[200~teal\x1b[201~", "\r"]);
|
||||
off();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reads Claude's and Codex's permission prompts", () => {
|
||||
const c = parsePaneMenu(PERMISSION)!;
|
||||
expect(c.question).toBe("Do you want to proceed?");
|
||||
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
|
||||
const x = parsePaneMenu(CODEX_APPROVAL)!;
|
||||
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
|
||||
expect(x.options).toHaveLength(3);
|
||||
expect(x.cursor).toBe(0);
|
||||
});
|
||||
|
||||
it("reads a menu with no footer: the login method at first start", () => {
|
||||
const m = parsePaneMenu([
|
||||
" Select login method:",
|
||||
"",
|
||||
" ❯ 1. Claude account with subscription · Pro, Max, Team, or Enterprise",
|
||||
"",
|
||||
" 2. Anthropic Console account · API usage billing",
|
||||
"",
|
||||
])!;
|
||||
expect(m.question).toBe("Select login method:");
|
||||
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
|
||||
// No cursor on the list.
|
||||
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
|
||||
// A numbered list typed into the input box (under its rule), mid-turn.
|
||||
expect(parsePaneMenu([RULE, "❯ 1. fix it", RULE, " esc to interrupt"])).toBeNull();
|
||||
expect(parsePaneMenu([RULE, "❯\u00a01. a", " 2. b", RULE, " esc to interrupt"])).toBeNull();
|
||||
expect(parsePaneMenu(CLAUDE)).toBeNull();
|
||||
});
|
||||
|
||||
it("answers by digit, walks to Submit, and types into Type something", () => {
|
||||
vi.useFakeTimers();
|
||||
const keys: string[] = [];
|
||||
let rows = ASK;
|
||||
const off = registerPane(
|
||||
"agenthub-lane-8",
|
||||
() => {},
|
||||
() => true,
|
||||
(d) => keys.push(d),
|
||||
() => ({ rows, cols: 80 })
|
||||
);
|
||||
const ask = readRunMenu("agenthub-lane-8")!;
|
||||
expect(chooseRunOption("agenthub-lane-8", ask, 1)).toBe(true);
|
||||
expect(keys).toEqual(["2"]);
|
||||
keys.length = 0;
|
||||
// Single select: walk to the row, paste, Enter.
|
||||
expect(answerRunMenu("agenthub-lane-8", ask, "purple")).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\x1b[200~purple\x1b[201~", "\r"]);
|
||||
keys.length = 0;
|
||||
rows = MULTI;
|
||||
const multi = readRunMenu("agenthub-lane-8")!;
|
||||
// From Honey (cursor 1) to Submit (3): two Downs, then Enter.
|
||||
expect(chooseRunOption("agenthub-lane-8", multi, 3)).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\r"]);
|
||||
keys.length = 0;
|
||||
// multiSelect: the typed text ticks the row; no Enter, Submit still to pick.
|
||||
expect(answerRunMenu("agenthub-lane-8", multi, "salt")).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(keys).toEqual(["\x1b[B", "\x1b[200~salt\x1b[201~"]);
|
||||
off();
|
||||
vi.useRealTimers();
|
||||
expect(readRunMenu("agenthub-lane-8")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Real screens (descriptions trimmed), bottom of a 120-column pane.
|
||||
const ASK = [
|
||||
"❯ Use the AskUserQuestion tool to ask me which color I like.",
|
||||
RULE,
|
||||
" ☐ Color",
|
||||
"",
|
||||
"Which color do you like: red, green, or blue?",
|
||||
"",
|
||||
"❯ 1. Red",
|
||||
" Crisp red",
|
||||
" 2. Green",
|
||||
" Green",
|
||||
" 3. Type something.",
|
||||
RULE,
|
||||
" 4. Chat about this",
|
||||
"",
|
||||
"Enter to select · ↑/↓ to navigate · Esc to cancel",
|
||||
"",
|
||||
];
|
||||
const MULTI = [
|
||||
RULE,
|
||||
"← ☒ Fruit ☐ Toppings ✔ Submit →",
|
||||
"",
|
||||
"Which toppings do you want?",
|
||||
"",
|
||||
" 1. [✔] Nuts",
|
||||
" Crunchy, adds texture.",
|
||||
"❯ 2. [ ] Honey",
|
||||
" 3. [ ] Type something",
|
||||
" Submit",
|
||||
RULE,
|
||||
" 4. Chat about this",
|
||||
"",
|
||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||
];
|
||||
const PERMISSION = [
|
||||
" Bash command",
|
||||
" touch probe-file.txt",
|
||||
"╌".repeat(60),
|
||||
" Do you want to proceed?",
|
||||
" ❯ 1. Yes",
|
||||
" 2. Yes, and always allow",
|
||||
" 3. No",
|
||||
"",
|
||||
" Esc to cancel · Tab to amend",
|
||||
];
|
||||
const CODEX_APPROVAL = [
|
||||
"• Running touch probe.txt",
|
||||
"",
|
||||
" Would you like to run the following command?",
|
||||
" $ touch probe.txt",
|
||||
"",
|
||||
"› 1. Yes, proceed (y)",
|
||||
" 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p)",
|
||||
" 3. No, and tell Codex what to do differently",
|
||||
"",
|
||||
// "Press enter to confirm or esc to cancel", its dim cells left out.
|
||||
" enteresc",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @file Tests for promptTokens: `/command` and `@path` at a word start become
|
||||
* tokens; paths, e-mails, mid-word slashes and trailing punctuation do not.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { promptTokens } from "../promptTokens";
|
||||
|
||||
const kinds = (s: string) => promptTokens(s).map((t) => `${t.kind}:${t.text}`);
|
||||
|
||||
describe("promptTokens", () => {
|
||||
it("splits commands and mentions out of the text", () => {
|
||||
expect(kinds("/review @src/a.ts now")).toEqual([
|
||||
"slash:/review",
|
||||
"text: ",
|
||||
"mention:@src/a.ts",
|
||||
"text: now",
|
||||
]);
|
||||
expect(kinds("run /agenthub:file-headers\nok")).toEqual([
|
||||
"text:run ",
|
||||
"slash:/agenthub:file-headers",
|
||||
"text:\nok",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves paths, e-mails and mid-word slashes as text", () => {
|
||||
expect(kinds("see /home/x and/or me@x.io")).toEqual(["text:see /home/x and/or me@x.io"]);
|
||||
});
|
||||
|
||||
it("drops trailing prose punctuation from a mention", () => {
|
||||
expect(kinds("read @a.ts, then")).toEqual(["text:read ", "mention:@a.ts", "text:, then"]);
|
||||
});
|
||||
|
||||
it("keeps the text whole, so it can be laid over the input", () => {
|
||||
const s = " /x @y.\n@z ";
|
||||
expect(
|
||||
promptTokens(s)
|
||||
.map((t) => t.text)
|
||||
.join("")
|
||||
).toBe(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file Tests for settingsHref: the Settings page off a workspace, the
|
||||
* workspace's Settings tab on one (lane and run kept, a replay and a previous
|
||||
* sub-tab dropped, the section's own query and anchor carried).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { settingsHref } from "../settingsLink";
|
||||
|
||||
describe("settingsHref", () => {
|
||||
it("opens the Settings page off a workspace", () => {
|
||||
const at = { pathname: "/sessions", search: "" };
|
||||
expect(settingsHref("", at)).toBe("/settings");
|
||||
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
|
||||
"/settings/agents/claude-code?tab=mcp"
|
||||
);
|
||||
expect(settingsHref("appearance#pet", at)).toBe("/settings/appearance#pet");
|
||||
// The Workspace page with no workspace yet has no tab bar to open it in.
|
||||
expect(settingsHref("agents", { pathname: "/workspace", search: "" })).toBe("/settings/agents");
|
||||
});
|
||||
|
||||
it("opens the workspace's Settings tab on a workspace", () => {
|
||||
const at = { pathname: "/workspace", search: "?lane=3&run=agenthub-lane-3-1&tab=skills" };
|
||||
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
|
||||
"/workspace?lane=3&run=agenthub-lane-3-1&settings=agents%2Fclaude-code&tab=mcp"
|
||||
);
|
||||
expect(settingsHref("", { pathname: "/workspace", search: "?lane=3&session=abc" })).toBe(
|
||||
"/workspace?lane=3&settings="
|
||||
);
|
||||
expect(
|
||||
settingsHref("pricing#pricing-openai", { pathname: "/workspace", search: "?lane=3" })
|
||||
).toBe("/workspace?lane=3&settings=pricing#pricing-openai");
|
||||
});
|
||||
});
|
||||
+25
-4
@@ -692,6 +692,14 @@ export const api = {
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
|
||||
instructions: () => request<AgentInstructionsView>("/me/instructions"),
|
||||
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
|
||||
saveInstructions: (instructions: string) =>
|
||||
request<AgentInstructionsView>("/me/instructions", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ instructions }),
|
||||
}),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
},
|
||||
@@ -2962,14 +2970,20 @@ export const api = {
|
||||
* the Add Lane modal's folder browser. Browsers cannot expose absolute
|
||||
* filesystem paths from a native picker, so this drives a server-backed one.
|
||||
* @param path Absolute path to list; defaults server-side to the home dir.
|
||||
* @param files List files too (`file: true`) — a file field's picker.
|
||||
* @returns `{ path, parent, entries }` — `parent` is null at the root.
|
||||
*/
|
||||
browse: (path?: string) =>
|
||||
request<{
|
||||
browse: (path?: string, files?: boolean) => {
|
||||
const q = new URLSearchParams();
|
||||
if (path) q.set("path", path);
|
||||
if (files) q.set("files", "1");
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: { name: string; path: string; isGitRepo: boolean }[];
|
||||
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
|
||||
entries: { name: string; path: string; isGitRepo: boolean; file?: boolean }[];
|
||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
/**
|
||||
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
|
||||
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
|
||||
@@ -3566,6 +3580,13 @@ export interface GitAuthNeeded {
|
||||
}
|
||||
|
||||
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
|
||||
/** GET/PUT /api/me/instructions — `tools`: the AI tool ids whose new panes receive them. */
|
||||
export interface AgentInstructionsView {
|
||||
instructions: string;
|
||||
max: number;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export interface AiTool {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file imeInput.ts
|
||||
* @description Typing through an input method that edits xterm's textarea
|
||||
* without composition events — macOS's Vietnamese Telex, in Chrome and
|
||||
* Safari, writes each letter with `insertText` and rewrites the whole word
|
||||
* with `insertReplacementText` ("cha" → "chao" → "chào"), its keydown
|
||||
* (keyCode 229) arriving only AFTER the change. xterm 5.5 drops every
|
||||
* replacement (xterm.js #5887) and every `input` that lands while a key is
|
||||
* held (Shift, for a capital), so "Abc xin chào" reached the pane as " x c".
|
||||
* `attachImeInput` turns each such change into what a line editor needs — a
|
||||
* backspace per replaced character, then the new tail — and hides those
|
||||
* events from xterm so nothing is sent twice. A key xterm sends itself (its
|
||||
* `keypress`, e.g. Space) and a real composition (CJK) stay xterm's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** DEL, the byte a terminal's Backspace sends (xterm's too). */
|
||||
const DEL = "\x7f";
|
||||
|
||||
/** The edits this module answers; anything else stays xterm's. */
|
||||
const EDITS = new Set(["insertText", "insertReplacementText", "deleteContentBackward"]);
|
||||
|
||||
/** The backspaces and new tail that turn `before` into `after` — per
|
||||
* character (code point), as the pane's line editor counts them. */
|
||||
export function textEdit(before: string, after: string): string {
|
||||
const a = Array.from(before);
|
||||
const b = Array.from(after);
|
||||
let same = 0;
|
||||
while (same < a.length && same < b.length && a[same] === b[same]) same++;
|
||||
return DEL.repeat(a.length - same) + b.slice(same).join("");
|
||||
}
|
||||
|
||||
/** The parts of an xterm `Terminal` this needs. */
|
||||
interface ImeTerminal {
|
||||
element?: HTMLElement;
|
||||
textarea?: HTMLTextAreaElement;
|
||||
input: (data: string, wasUserInput?: boolean) => void;
|
||||
}
|
||||
|
||||
/** Sends `term`'s non-composition textarea edits to the pane itself; returns
|
||||
* its dispose. Listens in the capture phase on xterm's element, so it runs
|
||||
* before xterm's own listeners on the textarea and can keep them from it. */
|
||||
export function attachImeInput(term: ImeTerminal): () => void {
|
||||
const root = term.element;
|
||||
const ta = term.textarea;
|
||||
if (!root || !ta) return () => {};
|
||||
// The value before the edit in flight (from `beforeinput`).
|
||||
let before: string | null = null;
|
||||
// xterm sent this key from its keypress: the input event that follows is its.
|
||||
let pressed = false;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target !== ta) return;
|
||||
pressed = false;
|
||||
// The IME's own key, its edit already in (or about to come as) an input
|
||||
// event: xterm's fallback would diff the textarea and send it again.
|
||||
if (e.keyCode === 229 && !e.isComposing) e.stopPropagation();
|
||||
};
|
||||
const onKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.target === ta) pressed = true;
|
||||
};
|
||||
const onBeforeInput = (e: Event) => {
|
||||
if (e.target === ta) before = ta.value;
|
||||
};
|
||||
const onInput = (e: Event) => {
|
||||
const ev = e as InputEvent;
|
||||
if (ev.target !== ta) return;
|
||||
const was = before;
|
||||
before = null;
|
||||
if (pressed) {
|
||||
pressed = false;
|
||||
return;
|
||||
}
|
||||
if (ev.isComposing || !EDITS.has(ev.inputType) || was === null) return;
|
||||
e.stopPropagation();
|
||||
const data = textEdit(was, ta.value);
|
||||
if (data) term.input(data, true);
|
||||
};
|
||||
|
||||
root.addEventListener("keydown", onKeyDown, true);
|
||||
root.addEventListener("keypress", onKeyPress, true);
|
||||
root.addEventListener("beforeinput", onBeforeInput, true);
|
||||
root.addEventListener("input", onInput, true);
|
||||
return () => {
|
||||
root.removeEventListener("keydown", onKeyDown, true);
|
||||
root.removeEventListener("keypress", onKeyPress, true);
|
||||
root.removeEventListener("beforeinput", onBeforeInput, true);
|
||||
root.removeEventListener("input", onInput, true);
|
||||
};
|
||||
}
|
||||
+212
-4
@@ -10,27 +10,38 @@
|
||||
* unseen); the caller says where it went, or that it went nowhere. The one
|
||||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||||
* what the user typed there, so it pastes AND presses Enter — and its Stop
|
||||
* button presses Esc through `interruptRun`.
|
||||
* button presses Esc through `interruptRun`. Switching a pane between Chat and
|
||||
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
|
||||
* input box off the screen and empties it; the chat's draft is pasted back
|
||||
* without Enter), so the half-typed text is never left behind on the other side.
|
||||
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
|
||||
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
|
||||
* arrows and Enter, or type into its "Type something" row).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** The terminal's screen: one string per row, dim cells (a placeholder) left out. */
|
||||
export type PaneScreen = { rows: string[]; cols: number };
|
||||
|
||||
interface Pane {
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
visible: () => boolean;
|
||||
key?: (data: string) => void;
|
||||
screen?: () => PaneScreen | null;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||||
* raw input, as a key press); returns its unregister. */
|
||||
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true,
|
||||
key?: (data: string) => void
|
||||
key?: (data: string) => void,
|
||||
screen?: () => PaneScreen | null
|
||||
): () => void {
|
||||
const entry = { paste, visible, key };
|
||||
const entry = { paste, visible, key, screen };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
@@ -84,3 +95,200 @@ export function pasteToLane(laneId: number, text: string): string | null {
|
||||
panes.get(id)!.paste(text);
|
||||
return id;
|
||||
}
|
||||
|
||||
const BORDER = /^─{3,}$/;
|
||||
|
||||
/** The text in a `claude` or `codex` input box on `rows`, or null when no box
|
||||
* is shown (a menu, a permission prompt). Claude's box is the `❯` row under a
|
||||
* `─` rule, up to the next rule; Codex's the last `›` row, up to the blank row
|
||||
* above its footer. Both indent a continued row by two and wrap at word
|
||||
* boundaries, so a row whose first word would have fitted on the one above
|
||||
* starts a new line — a soft-wrapped row is joined back with its space. */
|
||||
export function parsePromptInput(rows: string[], cols: number): string | null {
|
||||
const r = rows.map((l) => l.trimEnd());
|
||||
let p = -1;
|
||||
let end = -1;
|
||||
for (let i = r.length - 1; i >= 0 && p < 0; i--) {
|
||||
const row = r[i] ?? "";
|
||||
// Claude draws `❯` + a no-break space (U+00A0) in its box.
|
||||
if (/^❯([ \u00a0]|$)/.test(row) && BORDER.test(r[i - 1]?.trim() ?? "")) {
|
||||
p = i;
|
||||
end = r.findIndex((l, j) => j > i && BORDER.test(l.trim()));
|
||||
} else if (/^›( |$)/.test(row)) {
|
||||
p = i;
|
||||
let f = r.length - 1;
|
||||
while (f > i && !r[f]) f--;
|
||||
while (f > i && r[f]) f--;
|
||||
end = f > i ? f : -1;
|
||||
}
|
||||
}
|
||||
if (p < 0 || end < 0) return null;
|
||||
const lines = r.slice(p, end).map((l) => l.slice(2));
|
||||
while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
|
||||
const width = cols - 2;
|
||||
let out = "";
|
||||
let prev: string | null = null;
|
||||
for (const line of lines) {
|
||||
const word = line.split(" ")[0] ?? "";
|
||||
// The first row; a word longer than the row, broken mid-word; one that
|
||||
// would not fit after the row above, wrapped; else a typed newline.
|
||||
if (prev !== null)
|
||||
out += prev.length >= width ? "" : word && prev.length + 1 + word.length > width ? " " : "\n";
|
||||
out += line;
|
||||
prev = line;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Moves the text out of `runId`'s input box: returns it and deletes it there
|
||||
* (Delete then Backspace, as many as it has characters — never Ctrl+C or
|
||||
* Esc, which stop a working turn). "" when no box can be read, it is empty,
|
||||
* or it holds what the screen cannot show back (`[Pasted text #1]`,
|
||||
* `[Image #1]`) — then it is left as it is. */
|
||||
export function takeRunInput(runId: string): string {
|
||||
const p = panes.get(runId);
|
||||
const screen = p?.visible() && p.key ? p.screen?.() : null;
|
||||
const text = screen ? parsePromptInput(screen.rows, screen.cols) : null;
|
||||
if (!text?.trim() || /\[(Pasted text|Image) #\d+/.test(text)) return "";
|
||||
const n = text.length + 1;
|
||||
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
|
||||
return text;
|
||||
}
|
||||
|
||||
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
|
||||
* unnumbered Submit row), `checked` a multiSelect box's state. */
|
||||
export type PaneMenuOption = {
|
||||
n: number | null;
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean | null;
|
||||
};
|
||||
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
|
||||
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
|
||||
* "Type something" row (-1: none). */
|
||||
export type PaneMenu = {
|
||||
question: string;
|
||||
options: PaneMenuOption[];
|
||||
cursor: number;
|
||||
multi: boolean;
|
||||
typeAt: number;
|
||||
};
|
||||
|
||||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
|
||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||
const PLACEHOLDER = /^Type something\.?$/;
|
||||
|
||||
/** The numbered menu at the bottom of `rows`, or null. A menu has two
|
||||
* numbered rows or more from 1, the cursor on one of them, and no rule right
|
||||
* above its first row — a reply's numbered list has no cursor, and a list
|
||||
* typed into Claude's input box sits under the box's rule. */
|
||||
export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
const r = rows.map((l) => l.trimEnd());
|
||||
let end = r.length - 1;
|
||||
while (end >= 0 && !r[end]) end--;
|
||||
const options: PaneMenuOption[] = [];
|
||||
let cursor = -1;
|
||||
let hint: string[] = [];
|
||||
let top = -1;
|
||||
// Bottom up: a row under an option that is no option is its hint.
|
||||
for (let i = end; i >= 0 && end - i < 40; i--) {
|
||||
const row = r[i] ?? "";
|
||||
const m = OPTION.exec(row);
|
||||
const s = m ? null : SUBMIT.exec(row);
|
||||
if (m || s) {
|
||||
const text = m ? (m[3] ?? "") : "Submit";
|
||||
const c = m ? CHECK.exec(text) : null;
|
||||
const label = c ? (c[2] ?? "") : text;
|
||||
const h = hint.join(" ");
|
||||
options.unshift({
|
||||
n: m ? Number(m[2]) : null,
|
||||
label,
|
||||
hint: h === label ? "" : h,
|
||||
checked: c ? c[1] === "✔" : null,
|
||||
});
|
||||
if (m?.[1] || s?.[1]) cursor = options.length;
|
||||
hint = [];
|
||||
if (m?.[2] === "1") {
|
||||
top = i;
|
||||
break;
|
||||
}
|
||||
} else if (row && !RULE_ROW.test(row) && options.length) {
|
||||
hint.unshift(row.trim());
|
||||
} else if (row && !RULE_ROW.test(row)) {
|
||||
hint = []; // the footer and anything under the last option
|
||||
}
|
||||
}
|
||||
const numbered = options.filter((o) => o.n !== null);
|
||||
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
|
||||
if (numbered.some((o, i) => o.n !== i + 1)) return null;
|
||||
// The question: the block of text right above the first option.
|
||||
let q = top - 1;
|
||||
while (q >= 0 && !r[q]) q--;
|
||||
const lines: string[] = [];
|
||||
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
|
||||
lines.unshift(r[q]!.trim());
|
||||
return {
|
||||
question: lines.join("\n"),
|
||||
options,
|
||||
cursor: options.length - cursor,
|
||||
multi: options.some((o) => o.checked !== null),
|
||||
typeAt: typeRow(options),
|
||||
};
|
||||
}
|
||||
|
||||
/** The "Type something" row. Text typed into it replaces its label, and it is
|
||||
* still the last numbered row above "Chat about this". */
|
||||
function typeRow(options: PaneMenuOption[]): number {
|
||||
const at = options.findIndex((o) => PLACEHOLDER.test(o.label));
|
||||
if (at >= 0) return at;
|
||||
for (let i = options.findIndex((o) => o.label === "Chat about this") - 1; i >= 0; i--)
|
||||
if (options[i]!.n !== null) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The menu `runId`'s visible pane shows now, or null. */
|
||||
export function readRunMenu(runId: string): PaneMenu | null {
|
||||
const p = panes.get(runId);
|
||||
const screen = p?.visible() && p.key ? p.screen?.() : null;
|
||||
return screen ? parsePaneMenu(screen.rows) : null;
|
||||
}
|
||||
|
||||
// One key per write, a beat apart: a TUI reading two arrows in one chunk may
|
||||
// take them as one key.
|
||||
function pressKeys(key: (data: string) => void, keys: string[]) {
|
||||
keys.forEach((k, i) => setTimeout(() => key(k), i * 40));
|
||||
}
|
||||
|
||||
/** The arrow presses from the menu's cursor to row `to`. */
|
||||
const walk = (menu: PaneMenu, to: number) =>
|
||||
Array<string>(Math.abs(to - menu.cursor)).fill(to > menu.cursor ? "\x1b[B" : "\x1b[A");
|
||||
|
||||
/** Picks `menu`'s option `index` in `runId`'s pane: its digit — which picks
|
||||
* it, or ticks its box in a multiSelect — else (Submit) the arrows to it
|
||||
* and Enter. False when the pane is not visible or not attached. */
|
||||
export function chooseRunOption(runId: string, menu: PaneMenu, index: number): boolean {
|
||||
const p = panes.get(runId);
|
||||
const opt = menu.options[index];
|
||||
if (!p?.visible() || !p.key || !opt) return false;
|
||||
if (opt.n !== null && opt.n < 10) p.key(String(opt.n));
|
||||
else pressKeys(p.key, [...walk(menu, index), "\r"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Answers `menu` with typed text: the arrows to its "Type something" row,
|
||||
* Ctrl+U over what is typed there already (one key clears it, however long
|
||||
* it is), the text as a bracketed
|
||||
* paste, and Enter — none in a multiSelect, where the text only ticks its
|
||||
* row and Submit is still to pick. */
|
||||
export function answerRunMenu(runId: string, menu: PaneMenu, text: string): boolean {
|
||||
const p = panes.get(runId);
|
||||
const row = menu.options[menu.typeAt];
|
||||
if (!p?.visible() || !p.key || !row) return false;
|
||||
const keys = walk(menu, menu.typeAt);
|
||||
if (!PLACEHOLDER.test(row.label)) keys.push("\x15");
|
||||
keys.push(`\x1b[200~${text}\x1b[201~`);
|
||||
pressKeys(p.key, menu.multi ? keys : [...keys, "\r"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file promptTokens — splits a prompt into plain text, `/command` and
|
||||
* `@path` tokens, so the prompt box and a sent message colour them the same
|
||||
* way. A token starts a word (the line start or after whitespace), like the
|
||||
* prompt box's autocomplete, so `a/b`, `me@x.io` and `/home/x` stay text.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type PromptToken = { kind: "text" | "slash" | "mention"; text: string };
|
||||
|
||||
/** A token's colours, shared by the prompt box and a sent message; `@path`
|
||||
* takes the chat's path-link violet. */
|
||||
export const TOKEN_TONE = {
|
||||
slash: "bg-accent/15 text-accent",
|
||||
mention: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
} as const;
|
||||
|
||||
// `/name` (plugin skills are `plugin:name`) ending the word; `@path` minus a
|
||||
// trailing full stop or comma, which is prose.
|
||||
const TOKEN_RE = /(?<=^|\s)(?:\/[\w:.-]+(?=\s|$)|@[^\s]*[^\s.,;:!?)])/g;
|
||||
|
||||
export function promptTokens(text: string): PromptToken[] {
|
||||
const out: PromptToken[] = [];
|
||||
let at = 0;
|
||||
for (const m of text.matchAll(TOKEN_RE)) {
|
||||
if (m.index > at) out.push({ kind: "text", text: text.slice(at, m.index) });
|
||||
out.push({ kind: m[0][0] === "/" ? "slash" : "mention", text: m[0] });
|
||||
at = m.index + m[0].length;
|
||||
}
|
||||
if (at < text.length) out.push({ kind: "text", text: text.slice(at) });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file settingsLink — where a Settings link opens. On a workspace (the
|
||||
* Workspace page with `?lane=`) it is that page's Settings tab
|
||||
* (`?settings=<section>`, keeping `lane` and `run`), so the terminals and chats
|
||||
* stay mounted behind it; anywhere else it is the Settings page
|
||||
* (`/settings/<section>`). A path is what the page takes after `/settings/`:
|
||||
* `<section>[?query][#anchor]`, `""` for the default section.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export function settingsHref(path: string, at: { pathname: string; search: string }): string {
|
||||
const [rest = "", anchor = ""] = path.split("#");
|
||||
const [section = "", query = ""] = rest.split("?");
|
||||
const hash = anchor ? `#${anchor}` : "";
|
||||
const here = new URLSearchParams(at.search);
|
||||
const lane = here.get("lane");
|
||||
if (at.pathname !== "/workspace" || !lane) {
|
||||
return `/settings${section ? `/${section}` : ""}${query ? `?${query}` : ""}${hash}`;
|
||||
}
|
||||
// A section's own query (a sub-tab) and the session replay do not carry over.
|
||||
const next = new URLSearchParams({ lane });
|
||||
const run = here.get("run");
|
||||
if (run) next.set("run", run);
|
||||
next.set("settings", section);
|
||||
for (const [k, v] of new URLSearchParams(query)) next.set(k, v);
|
||||
return `/workspace?${next}${hash}`;
|
||||
}
|
||||
|
||||
/** `settingsHref` from the current location. */
|
||||
export function useSettingsHref(): (path: string) => string {
|
||||
const location = useLocation();
|
||||
return (path) => settingsHref(path, location);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @file tabBadge.ts — the browser tab's title mark for Chat panes while the
|
||||
* page is hidden: "● Waiting" while a pane's CLI asks the user something (a
|
||||
* permission prompt), else "✓ Done" once a turn ended. Shown again, the
|
||||
* page drops the "Done" marks; a "Waiting" one lasts until its question is
|
||||
* answered. Only the mark this module put in front of the title is ever
|
||||
* removed, so a page that sets its own title meanwhile keeps it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import i18n from "../i18n";
|
||||
|
||||
export type TabMark = "waiting" | "done";
|
||||
|
||||
const marks = new Map<string, TabMark>();
|
||||
let applied = "";
|
||||
|
||||
function apply() {
|
||||
const title = document.title;
|
||||
const base = applied && title.startsWith(applied) ? title.slice(applied.length) : title;
|
||||
const all = [...marks.values()];
|
||||
const mark =
|
||||
document.visibilityState !== "hidden"
|
||||
? null
|
||||
: all.includes("waiting")
|
||||
? "waiting"
|
||||
: all.includes("done")
|
||||
? "done"
|
||||
: null;
|
||||
applied = mark ? `${mark === "waiting" ? "●" : "✓"} ${i18n.t(`run:chat.tab.${mark}`)} · ` : "";
|
||||
document.title = applied + base;
|
||||
}
|
||||
|
||||
/** Sets (or, with null, drops) the mark of one pane (`key`). */
|
||||
export function setTabBadge(key: string, mark: TabMark | null): void {
|
||||
// A turn ending in front of the user needs no mark.
|
||||
if (mark === "done" && document.visibilityState !== "hidden") mark = null;
|
||||
if (mark) marks.set(key, mark);
|
||||
else marks.delete(key);
|
||||
apply();
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "hidden") {
|
||||
for (const [key, mark] of marks) if (mark === "done") marks.delete(key);
|
||||
}
|
||||
apply();
|
||||
});
|
||||
}
|
||||
@@ -3213,6 +3213,9 @@ export interface TranscriptContent {
|
||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||
* removed (from Claude Code's `structuredPatch`). */
|
||||
diff?: { added: number; removed: number };
|
||||
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||
* the user picked, or what they typed. */
|
||||
answers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { useEffect, useState, type ComponentType } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
|
||||
import { api, type AiToolConfigFile } from "../lib/api";
|
||||
@@ -235,6 +236,7 @@ function Card({
|
||||
/** The tab for one catalog tool. */
|
||||
export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const href = useSettingsHref();
|
||||
const aiTools = useAiTools();
|
||||
const tool = aiTools.byId(toolId);
|
||||
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
|
||||
@@ -273,7 +275,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
</a>
|
||||
)}
|
||||
{tool.update && (
|
||||
<Link to="/settings/agents" className="btn-ghost text-xs">
|
||||
<Link to={href("agents")} className="btn-ghost text-xs">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("toolTab.update")}
|
||||
</Link>
|
||||
|
||||
@@ -88,6 +88,7 @@ import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
|
||||
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
|
||||
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
|
||||
import type { Session, DashboardEvent } from "../lib/types";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -183,14 +184,17 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
|
||||
// Load remote-source labels so remote-origin rows can show a friendly badge
|
||||
// instead of a raw `src_…` id. Refreshed when a source's status changes.
|
||||
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
|
||||
const { seesAll } = usePermissions();
|
||||
const loadSourceLabels = useCallback(() => {
|
||||
if (!seesAll) return;
|
||||
api.remoteSources
|
||||
.list()
|
||||
.then((res: { sources: RemoteSource[] }) => {
|
||||
setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label])));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
}, [seesAll]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSourceLabels();
|
||||
|
||||
@@ -196,7 +196,7 @@ export function Settings({
|
||||
section?: "data" | "about" | "notifications";
|
||||
} = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin } = usePermissions();
|
||||
const { isAdmin, seesAll } = usePermissions();
|
||||
// Only Data and About show server info; General and Notifications never read it.
|
||||
const needsInfo = section === "data" || section === "about";
|
||||
const [loading, setLoading] = useState(needsInfo);
|
||||
@@ -954,10 +954,12 @@ export function Settings({
|
||||
<ImportHistory />
|
||||
</section>
|
||||
|
||||
{/* ─── REMOTE DATA SOURCES ─── */}
|
||||
<section id="remote-sources" className="scroll-mt-24">
|
||||
<RemoteSources />
|
||||
</section>
|
||||
{/* ─── REMOTE DATA SOURCES (staff only: the server answers 403 to a developer) ─── */}
|
||||
{seesAll && (
|
||||
<section id="remote-sources" className="scroll-mt-24">
|
||||
<RemoteSources />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
|
||||
<PushIngestSettings />
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
* marks AI tools current, the search shows their matches under it), and a
|
||||
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
|
||||
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
|
||||
* With `section` it is a workspace's Settings tab instead (`?settings=` on
|
||||
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
||||
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
||||
* while the tab is hidden behind a session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
@@ -46,6 +50,7 @@ import {
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiToolConfig } from "./AiToolConfig";
|
||||
@@ -55,6 +60,7 @@ import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -143,19 +149,23 @@ const MOVED: Record<string, string> = {
|
||||
// AI Proxy is not here: every user keeps personal profiles (S3).
|
||||
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
/** An unknown section: say so, then show Appearance (`to`). */
|
||||
function UnknownSection({ name, to }: { name: string; to: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
useEffect(() => {
|
||||
toast.info(t("settingsNav.unknownSection", { section: name }));
|
||||
}, [name, t]);
|
||||
return <Navigate to="/settings/appearance" replace />;
|
||||
return <Navigate to={to} replace />;
|
||||
}
|
||||
|
||||
export function SettingsHub() {
|
||||
export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const embedded = section !== undefined;
|
||||
const href = useSettingsHref();
|
||||
const nav = embedded ? { replace: true } : undefined;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const aiTools = useAiTools();
|
||||
const [query, setQuery] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -168,6 +178,8 @@ export function SettingsHub() {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") return;
|
||||
// A workspace's Settings tab hidden behind a session: not on screen.
|
||||
if (rootRef.current?.closest(".hidden")) return;
|
||||
// A config editor's own find keeps the shortcut.
|
||||
const el = e.target;
|
||||
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
|
||||
@@ -257,7 +269,12 @@ export function SettingsHub() {
|
||||
group: "agents",
|
||||
Icon: Bot,
|
||||
...s("agents"),
|
||||
render: () => <AiToolsSettings settingsHref={toolHref} />,
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<AgentInstructionsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(enabled("claude")
|
||||
? [
|
||||
@@ -381,7 +398,7 @@ export function SettingsHub() {
|
||||
const toolTabs = sections.filter((x) => x.tool);
|
||||
function toolHref(id: string) {
|
||||
const page = toolTabs.find((x) => x.tool === id);
|
||||
return page ? `/settings/${page.id}` : undefined;
|
||||
return page ? href(page.id) : undefined;
|
||||
}
|
||||
|
||||
const entries: SettingsEntry[] = sections.flatMap((sec) => [
|
||||
@@ -402,7 +419,9 @@ export function SettingsHub() {
|
||||
})),
|
||||
]);
|
||||
|
||||
const rest = location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
|
||||
const rest = embedded
|
||||
? section
|
||||
: location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
|
||||
if (!rest) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tool = params.get("tool");
|
||||
@@ -412,9 +431,9 @@ export function SettingsHub() {
|
||||
: tool
|
||||
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
||||
: "appearance";
|
||||
return <Navigate to={`/settings/${to}`} replace />;
|
||||
return <Navigate to={href(to)} replace />;
|
||||
}
|
||||
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
|
||||
if (MOVED[rest]) return <Navigate to={href(MOVED[rest])} replace />;
|
||||
const active = sections.find((x) => x.id === rest);
|
||||
if (!active) {
|
||||
// A catalog tool page exists only once the tool list has loaded.
|
||||
@@ -426,7 +445,7 @@ export function SettingsHub() {
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
return <UnknownSection name={rest} />;
|
||||
return <UnknownSection name={rest} to={href("appearance")} />;
|
||||
}
|
||||
|
||||
const ranked = query.trim() ? rankSettings(query, entries) : null;
|
||||
@@ -437,8 +456,8 @@ export function SettingsHub() {
|
||||
const navActive = navId(active.id);
|
||||
const results = ranked ? rankSettingsEntries(query, entries) : [];
|
||||
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
|
||||
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
|
||||
const go = (id: string) => navigate(`/settings/${id}`);
|
||||
const hrefOf = (e: SettingsEntry) => href(`${e.section}${e.to ?? ""}`);
|
||||
const go = (id: string) => navigate(href(id), nav);
|
||||
const back = (e: MouseEvent) => {
|
||||
if (!canGoBack) return;
|
||||
e.preventDefault();
|
||||
@@ -454,7 +473,7 @@ export function SettingsHub() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]), nav);
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
@@ -472,6 +491,7 @@ export function SettingsHub() {
|
||||
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
||||
<Link
|
||||
to={hrefOf(r)}
|
||||
replace={embedded}
|
||||
onClick={() => setQuery("")}
|
||||
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
|
||||
>
|
||||
@@ -486,15 +506,17 @@ export function SettingsHub() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to="/workspace"
|
||||
onClick={back}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
|
||||
</Link>
|
||||
<div ref={rootRef}>
|
||||
{!embedded && (
|
||||
<Link
|
||||
to="/workspace"
|
||||
onClick={back}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex gap-8">
|
||||
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
|
||||
{searchBox}
|
||||
@@ -516,7 +538,8 @@ export function SettingsHub() {
|
||||
{items.map(({ id, title, Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
to={href(id)}
|
||||
replace={embedded}
|
||||
aria-current={id === navActive ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||
* the old one until saved or discarded). Its bottom fold, Workspace context,
|
||||
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
|
||||
* Settings opened on a workspace (`?settings=<section>`, `lib/settingsLink.ts`)
|
||||
* is a tab of its own at the end of the tab bar, kept until its X: the
|
||||
* Settings pages (`SettingsHub`) shown instead of the panes, which stay
|
||||
* mounted behind them.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -116,6 +120,8 @@ import {
|
||||
updateActive,
|
||||
} from "../lib/paneLayout";
|
||||
import { readAllTabs } from "../lib/workspaceTabs";
|
||||
import { settingsHref } from "../lib/settingsLink";
|
||||
import { SettingsHub } from "./SettingsHub";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
|
||||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||||
@@ -204,6 +210,7 @@ function ConsoleArea({
|
||||
onOpenRun,
|
||||
onRunsChanged,
|
||||
allTabs = false,
|
||||
settings = null,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
@@ -226,6 +233,14 @@ function ConsoleArea({
|
||||
onRunsChanged: () => void;
|
||||
/** Tabs from every workspace, not only the selected one's. */
|
||||
allTabs?: boolean;
|
||||
/** The Settings tab while it is open: its section, and whether it is the one
|
||||
* on screen (the panes are then hidden, never unmounted). */
|
||||
settings?: {
|
||||
section: string;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
} | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
@@ -473,7 +488,8 @@ function ConsoleArea({
|
||||
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
onKeys.current = (e) => {
|
||||
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!attachRequest || settings?.active || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
|
||||
return;
|
||||
// Not under a dialog, and not while the console is hidden (the Changes
|
||||
// panel at full width, a replay, the home) — CSS-hidden, so by class.
|
||||
if (document.querySelector('[role="dialog"]')) return;
|
||||
@@ -520,9 +536,10 @@ function ConsoleArea({
|
||||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||||
onClosed={(runId) => ended(keyOf(runId))}
|
||||
onHome={onNewSession}
|
||||
settings={settings}
|
||||
// While one pane is shown, its split menus (a split pane has its own).
|
||||
actions={
|
||||
shown.length === 1 && shown[0] !== undefined ? (
|
||||
!settings?.active && shown.length === 1 && shown[0] !== undefined ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<SplitMenus
|
||||
slot={shown[0]}
|
||||
@@ -540,38 +557,48 @@ function ConsoleArea({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PaneGrid
|
||||
layout={view}
|
||||
mounted={mounted}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
onChange={change}
|
||||
canStart={free !== null}
|
||||
tools={splitTools}
|
||||
toolLabel={aiTools.label}
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
onStop={(slot) => void stopPane(slot)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
// With `allTabs` a pane is bound to its own run's workspace.
|
||||
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||||
slot={slotOfKey(slot)}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
panelOpen={spanning.has(slot)}
|
||||
attachRequest={
|
||||
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{settings && (
|
||||
<div
|
||||
data-testid="workspace-settings"
|
||||
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
|
||||
>
|
||||
<SettingsHub section={settings.section} />
|
||||
</div>
|
||||
)}
|
||||
<div className={settings?.active ? "hidden" : "contents"}>
|
||||
<PaneGrid
|
||||
layout={view}
|
||||
mounted={mounted}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
onChange={change}
|
||||
canStart={free !== null}
|
||||
tools={splitTools}
|
||||
toolLabel={aiTools.label}
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
onStop={(slot) => void stopPane(slot)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
// With `allTabs` a pane is bound to its own run's workspace.
|
||||
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||||
slot={slotOfKey(slot)}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
panelOpen={spanning.has(slot)}
|
||||
attachRequest={
|
||||
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -592,6 +619,14 @@ export function Workspace() {
|
||||
// `?session=` — an ENDED session picked in the sidebar or Recents: its
|
||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||
const sessionParam = runParam ? null : searchParams.get("session");
|
||||
// `?settings=<section>` — the Settings tab is on screen ("" = the section it
|
||||
// was last on). It stays open (`settingsTab`, its last section) until its X.
|
||||
const settingsParam = searchParams.get("settings");
|
||||
const [settingsTab, setSettingsTab] = useState<string | null>(null);
|
||||
const settingsSection = settingsParam || settingsTab || "appearance";
|
||||
useEffect(() => {
|
||||
if (settingsParam !== null) setSettingsTab(settingsSection);
|
||||
}, [settingsParam, settingsSection]);
|
||||
const navigate = useNavigate();
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
|
||||
// it rides on the navigation's state, never in the URL.
|
||||
@@ -1058,8 +1093,9 @@ export function Workspace() {
|
||||
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
|
||||
// the console, panes and Changes panel, which the home only hides (CSS), so
|
||||
// a live terminal never unmounts behind it.
|
||||
const home = !!currentLane && !runParam && !sessionParam;
|
||||
const replaying = !!currentLane && !!sessionParam;
|
||||
const settingsOn = !!currentLane && settingsParam !== null;
|
||||
const home = !!currentLane && !runParam && !sessionParam && !settingsOn;
|
||||
const replaying = !!currentLane && !!sessionParam && !settingsOn;
|
||||
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
||||
// or the one the opened pane holds.
|
||||
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
||||
@@ -1341,6 +1377,31 @@ export function Workspace() {
|
||||
)
|
||||
}
|
||||
onRunsChanged={refreshList}
|
||||
settings={
|
||||
currentLane && (settingsOn || settingsTab !== null)
|
||||
? {
|
||||
section: settingsSection,
|
||||
active: settingsOn,
|
||||
onOpen: () =>
|
||||
navigate(settingsHref(settingsTab ?? "", location), {
|
||||
replace: true,
|
||||
}),
|
||||
onClose: () => {
|
||||
setSettingsTab(null);
|
||||
if (!settingsOn) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("settings");
|
||||
next.delete("tab");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
},
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,9 @@ vi.mock("../../components/settings/ReportSettings", () => ({
|
||||
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
|
||||
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
|
||||
}));
|
||||
@@ -330,3 +333,36 @@ describe("SettingsHub", () => {
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsHub as a workspace's Settings tab", () => {
|
||||
it("shows the section it is given, no Back link, and keeps every link in the tab", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=3&run=agenthub-lane-3"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/workspace"
|
||||
element={
|
||||
<>
|
||||
<SettingsHub section="appearance" />
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Back/)).toBeNull();
|
||||
const notifications = within(nav()).getByRole("link", { name: "Notifications" });
|
||||
expect(notifications).toHaveAttribute(
|
||||
"href",
|
||||
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
|
||||
);
|
||||
fireEvent.click(notifications);
|
||||
expect(screen.getByTestId("where")).toHaveTextContent(
|
||||
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,11 @@ vi.mock("../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
}));
|
||||
// An ended session's replay (its own suite): a stub that names its session.
|
||||
vi.mock("../SettingsHub", () => ({
|
||||
SettingsHub: ({ section }: { section?: string }) => (
|
||||
<div data-testid="settings-hub">{section}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../components/lanes/SessionReplay", () => ({
|
||||
SessionReplay: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="session-replay">{sessionId}</div>
|
||||
@@ -629,6 +634,28 @@ describe("console tabs", () => {
|
||||
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens Settings as a tab of its own, the console only hidden, kept until its X", async () => {
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1&settings=agents");
|
||||
const user = userEvent.setup();
|
||||
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
|
||||
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
|
||||
// A session tab shows its panes again; the Settings tab stays, hidden.
|
||||
await user.click(screen.getByTestId("console-tab-0"));
|
||||
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-settings")).toHaveClass("hidden");
|
||||
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "false");
|
||||
// Back on it: the section it was on.
|
||||
await user.click(screen.getByTestId("console-tab-settings"));
|
||||
expect(screen.getByTestId("workspace-settings")).not.toHaveClass("hidden");
|
||||
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
|
||||
await user.click(screen.getByTestId("console-tab-settings-close"));
|
||||
expect(screen.queryByTestId("console-tab-settings")).toBeNull();
|
||||
expect(screen.queryByTestId("settings-hub")).toBeNull();
|
||||
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a new terminal in the first free slot from the + menu", async () => {
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
|
||||
@@ -5580,30 +5580,34 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="relative min-h-0 flex-1"
|
||||
data-testid="pane-grid"
|
||||
class="contents"
|
||||
>
|
||||
<div
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-menu-bounds="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
class="relative min-h-0 flex-1"
|
||||
data-testid="pane-grid"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-menu-bounds="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
<div
|
||||
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7174,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.7.1 (rebuild client to match)
|
||||
UI build v2.7.5 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+14
-8
@@ -97,6 +97,8 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
|
||||
| --- | --- |
|
||||
| `GET /api/me/prefs` | `{prefs: {<key>: "<value>"}, onboardedAt}`; `401 EAUTH` with no login |
|
||||
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
|
||||
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
|
||||
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
|
||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||
@@ -227,11 +229,13 @@ guarded, and only a socket from loopback carrying no `X-Forwarded-For` /
|
||||
`403 EREMOTE` otherwise, `501 ENODIALOG` with no graphical session, no picker
|
||||
program, or one that could not reach the display, `409 EBUSY` while one is
|
||||
already open, `500 EDIALOG` when the program failed. The dialog is closed when
|
||||
the request goes away (a closed tab, an SSH tunnel's browser leaving). A folder
|
||||
the request goes away (a closed tab, an SSH tunnel's browser leaving). A
|
||||
**Browse** button in the UI (`client/src/components/PathPickButton.tsx`) falls
|
||||
back on ANY of those to the in-app folder browser
|
||||
`GET /api/lanes/browse?path=<abs>`; a file field falls back to typing the path
|
||||
on `EREMOTE`/`ENODIALOG` and shows the error otherwise. In server mode with
|
||||
back on ANY of those to the in-app browser `GET /api/lanes/browse?path=<abs>`.
|
||||
A file field asks it with `files=1`: entries then include the folder's files
|
||||
(`file: true`, listed after the folders), and `path` opens the way the native
|
||||
picker's `start` does (`~` expanded, a file's folder, home when missing) —
|
||||
a click on a file picks it. In server mode with
|
||||
`AGENTHUB_USERS_ROOT` set, a developer's browser opens on their own folder
|
||||
(`parent: null` there) and any path whose real path leaves it is `403 EFOLDER`.
|
||||
|
||||
@@ -355,6 +359,8 @@ Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names
|
||||
|
||||
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
|
||||
|
||||
Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused. Server mode with one Linux user per account: a missing folder inside a member's own home is created instead, as that member (`mkdir -p` through `write-as`) — their home itself can be no workspace, so this is how a new member's first project folder comes to exist.
|
||||
|
||||
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
|
||||
|
||||
#### Preflight a destructive workspace action
|
||||
@@ -1386,7 +1392,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
|
||||
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
|
||||
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
|
||||
|
||||
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
|
||||
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
|
||||
|
||||
#### Projects (workspaces grouped by repository)
|
||||
|
||||
@@ -1597,7 +1603,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
|
||||
GET /api/sessions/:id/image?path=<absolute path>
|
||||
```
|
||||
|
||||
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` in server mode and for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read.
|
||||
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read, and in server mode without S3. With S3 a member's session (its folder under the users root) gets only a file inside that member's home that the member owns (else 404), a host session any file — a member's hook can only report sessions in its own home.
|
||||
|
||||
```bash
|
||||
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
|
||||
@@ -2598,8 +2604,8 @@ GET /api/run/interrupted {items} — sessions whose pane died under
|
||||
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
|
||||
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
|
||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); only a name drop-image/drop-file made → 404 otherwise
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image
|
||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
|
||||
+10
-8
File diff suppressed because one or more lines are too long
+40
-3
@@ -797,8 +797,45 @@ agenthub users provision alice # the Linux account only (a user made before
|
||||
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
|
||||
recorded ones).
|
||||
- Keep every other AgentHub server off the same database (a dev server on
|
||||
another port): an older build cannot see members' tmux servers and marks
|
||||
their runs abandoned.
|
||||
another port). A server without S3 cannot see members' tmux servers, so from
|
||||
v2.7.2 on it leaves any run and workspace under the users root (read from
|
||||
`/etc/agenthub/server.conf`) alone; a build older than v2.7.2 marks their
|
||||
runs interrupted or abandoned within seconds, and the proxy gateway then
|
||||
refuses those panes (`401 not a live pane's token`). A dev server that DOES
|
||||
run in server mode (it read `AGENTHUB_MODE=server` from
|
||||
`~/.config/agenthub/settings.env`) but lacks the unit's
|
||||
`CAP_DAC_READ_SEARCH` cannot stat a member's 0700 `.agenthub/tmux`; from
|
||||
v2.7.3 on that `EACCES` counts as "maybe running" and the member's list is
|
||||
asked through sudo, never read as "no panes". And a run younger than 20 s
|
||||
is never ended by any server: only the server that started a pane writes it
|
||||
into its own cached tmux list, so another one on the same database had not
|
||||
listed it yet and ended a Codex pane seconds after its start.
|
||||
- **Update now** under the system unit restarts through
|
||||
`sudo -n systemctl restart --no-block agenthub.service` (from v2.7.3). An
|
||||
older build started a detached replacement instead, which kept the port
|
||||
while `Restart=always` started the unit every few seconds, each start exiting
|
||||
"already running" — `systemctl status agenthub.service` shows
|
||||
`activating (auto-restart)` with `MainPID` 0. Recover with
|
||||
`kill -TERM <pid of the server on the port>`; the unit then starts on the
|
||||
new code.
|
||||
- A member's tmux list is re-asked at most every 5 s (one `sudo` each, which
|
||||
writes three `auth.log` lines); a pane the dashboard starts or stops shows at
|
||||
once, one that exits on its own within those 5 s.
|
||||
- Claude Code does not carry a folder's trust down from the member's home
|
||||
(which provisioning trusts): a member may be asked "Trust this folder" once
|
||||
per workspace, in the terminal. AgentHub never answers it for them — it is
|
||||
what keeps a cloned repository's own settings and hooks from running unasked.
|
||||
- A member's first project folder: a workspace named on a folder that does not
|
||||
exist yet inside their home is created at its first start, AS the member
|
||||
(from v2.7.4; their home itself is no workspace).
|
||||
- Bypass in a pane's shift+tab cycle (`--allow-dangerously-skip-permissions`)
|
||||
is offered only to an account that already accepted Claude Code's bypass
|
||||
warning (`skipDangerousModePermissionPrompt` in its `~/.claude/settings.json`)
|
||||
or to a run that starts in bypass itself. Before v2.7.4 every new member's
|
||||
first session opened on that warning, which the Chat UI cannot answer.
|
||||
- A developer's System page (`GET /api/settings/info`, `GET /api/updates/status`)
|
||||
carries no path: not the database's, the hooks file's, the install folder,
|
||||
nor the transcript cache's keys — those are every user's transcript paths.
|
||||
|
||||
### Undo and uninstall
|
||||
|
||||
@@ -847,7 +884,7 @@ To remove everything:
|
||||
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
|
||||
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
|
||||
| `403 EFOREIGN` on a file view or save | The workspace's folder is now a link into another account's files. Put the real folder back, or make a workspace on the real one. |
|
||||
| A member's runs show as abandoned | Another, older AgentHub server on the same database (see Upgrade). |
|
||||
| A member's runs show as abandoned or interrupted, or Claude in a member's pane gets `401 not a live pane's token` | Another AgentHub server older than v2.7.2 on the same database (see Upgrade). |
|
||||
| `sudo: a password is required` in the server log | The sudoers file is missing, or a tool moved off its recorded path: re-run `install-system`. |
|
||||
| `[drop-caps] setpriv is missing` | Install util-linux's `setpriv` at `/usr/bin/setpriv`; until then the dashboard's children inherit `CAP_DAC_READ_SEARCH`. |
|
||||
| `[plugin-baseline] <name>: claude is not in the shared tools yet` | `claude` is not published to `/opt/agenthub/tools` yet: `agenthub server tools`, then `agenthub users provision <name>`. |
|
||||
|
||||
+88
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.7.1
|
||||
version: 2.7.5
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -13822,6 +13822,93 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/instructions:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: The account's own agent instructions
|
||||
description: Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.
|
||||
responses:
|
||||
'200':
|
||||
description: Instructions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- instructions
|
||||
- max
|
||||
- tools
|
||||
properties:
|
||||
instructions:
|
||||
type: string
|
||||
max:
|
||||
type: integer
|
||||
tools:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: AI tool ids whose new panes receive the instructions (claude, codex)
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Replace the account's agent instructions (blank or null clears)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
instructions:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 16000
|
||||
responses:
|
||||
'200':
|
||||
description: Saved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- instructions
|
||||
- max
|
||||
- tools
|
||||
properties:
|
||||
instructions:
|
||||
type: string
|
||||
max:
|
||||
type: integer
|
||||
tools:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: AI tool ids whose new panes receive the instructions (claude, codex)
|
||||
'400':
|
||||
description: EBADFIELD — not a string
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'413':
|
||||
description: ETOOBIG — longer than 16 000 characters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/onboarded:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.5",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+10
-2
@@ -16,7 +16,9 @@
|
||||
* setting never writes the developer's real `~/.config/agenthub/settings.env`,
|
||||
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network. The
|
||||
* `GIT_DIR`-family variables a git hook exports are dropped too, so a test's
|
||||
* own `git init`/`commit` can never land in the real repository.
|
||||
* own `git init`/`commit` can never land in the real repository. Every
|
||||
* inherited `AGENTHUB_*` variable is dropped as well: a dashboard pane carries
|
||||
* `AGENTHUB_MODE=server` and friends, which turned the suites login-gated.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -34,8 +36,14 @@ const files = fs
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
|
||||
// A dashboard pane's own AGENTHUB_* (AGENTHUB_MODE=server, AGENTHUB_USERS_ROOT,
|
||||
// its pane token…) would turn the suites into a login-gated server: hundreds
|
||||
// of 401s and EFOLDERs that have nothing to do with the code under test.
|
||||
const inherited = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([k]) => !k.startsWith("AGENTHUB_"))
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
...inherited,
|
||||
TMUX_TMPDIR: tmuxDir,
|
||||
// Never the developer's real ~/.config/agenthub/settings.env: it can pin
|
||||
// DASHBOARD_DATA_DIR at their live database.
|
||||
|
||||
+1
-1
@@ -764,7 +764,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
|
||||
| `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it |
|
||||
| `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image; an admin's GET without `?laneId=` also tries the HOST's drop dir, where their host-folder workspaces drop); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it |
|
||||
| `GET` | `/api/run/:id/permission-mode` | A live **claude** pane's permission mode, read off the mode line Claude Code prints under its prompt (`server/lib/pane-mode.js`): `{ mode }` — `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`, `dontAsk`, or `null` while a menu hides it. 404 for a pane that is not running, 400 `EUNSUPPORTED` for a non-claude pane |
|
||||
| `POST` | `/api/run/:id/permission-mode` | `{ mode }` → steps the pane there with Shift+Tab, the only way a running session changes mode, reading every step back before the next key. `{ mode }` on success; 400 `EBADMODE` outside the five settable modes; 409 `EMODEUNKNOWN` (no mode line — nothing is pressed — or a step did not change it), `EMODEUNAVAILABLE` (not in this session's cycle, e.g. Bypass in a session not started with it; the pane is back on its starting mode), `EBUSY` (a change is already running on that pane) |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
|
||||
@@ -22,8 +22,17 @@ before(() => {
|
||||
});
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
// Long enough ago that no reconcile treats the run as just started.
|
||||
const start = (id, extra = {}) =>
|
||||
runs.recordRun({ id, mode: "interactive", cwd: "/r", status: "running", laneId: 4, ...extra });
|
||||
runs.recordRun({
|
||||
id,
|
||||
mode: "interactive",
|
||||
cwd: "/r",
|
||||
status: "running",
|
||||
laneId: 4,
|
||||
startedAt: Date.parse("2026-01-01T00:00:00Z"),
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("dashboard-runs", () => {
|
||||
it("keeps the previous run of a pane as history when the pane starts again", () => {
|
||||
@@ -77,6 +86,14 @@ describe("dashboard-runs", () => {
|
||||
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
|
||||
});
|
||||
|
||||
it("never ends a run started a moment ago: another process's tmux list may predate it", () => {
|
||||
start("agenthub-lane-30", { laneId: 30, startedAt: Date.now() - 2000 });
|
||||
start("agenthub-lane-31", { laneId: 31, startedAt: Date.now() - 60_000 });
|
||||
runs.reconcileOrphans(() => false);
|
||||
assert.equal(runs.getRun("agenthub-lane-30").status, "running");
|
||||
assert.equal(runs.getRun("agenthub-lane-31").status, "abandoned");
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -143,6 +143,34 @@ describe("collectChanges — work range", () => {
|
||||
assert.equal((await gitFacts(LANE)).untracked, 2);
|
||||
});
|
||||
|
||||
// #72: a change staged and then undone in the working tree is `MM` in
|
||||
// `git status` while the file on disk equals HEAD — JetBrains' Changes view
|
||||
// (GitChangesCollector) drops it after a `git diff HEAD`, and so does this.
|
||||
it("leaves out a staged change the working tree has reverted (#72)", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
||||
g(LANE, "add", "a.txt");
|
||||
write(LANE, "a.txt", "one\ntwo\nthree\n"); // back to HEAD's content
|
||||
write(LANE, "keep.txt", "staged\n");
|
||||
g(LANE, "add", "keep.txt");
|
||||
write(LANE, "keep.txt", "staged, then edited\n"); // MM and still a change
|
||||
assert.deepEqual(porcelain(LANE).sort(), ["MM a.txt", "MM keep.txt"]);
|
||||
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["keep.txt"]
|
||||
);
|
||||
});
|
||||
|
||||
it("still lists a file whose only change is its mode, as JetBrains does", async () => {
|
||||
fs.chmodSync(path.join(LANE, "a.txt"), 0o755);
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => [f.path, f.added, f.deleted]),
|
||||
[["a.txt", 0, 0]]
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hunks for every file in full mode", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||
|
||||
@@ -579,6 +579,25 @@ describe("GET /api/lanes/browse", () => {
|
||||
assert.equal(notADir.status, 400);
|
||||
assert.equal(notADir.body.error.code, "ENOTADIR");
|
||||
});
|
||||
|
||||
it("with files=1 lists files too, opening a file path at its folder", async () => {
|
||||
const filePath = path.join(ROOT, "b-file.txt");
|
||||
fs.writeFileSync(filePath, "hi\n");
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/api/lanes/browse?files=1&path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.path, ROOT);
|
||||
const file = response.body.entries.find((e) => e.name === "b-file.txt");
|
||||
assert.equal(file?.file, true);
|
||||
assert.equal(file.path, filePath);
|
||||
const dir = response.body.entries.find((e) => e.name === "src-repo");
|
||||
assert.ok(dir && !dir.file);
|
||||
|
||||
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||
assert.ok(!dirsOnly.body.entries.some((e) => e.name === "b-file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("destructive lane lifecycle actions", () => {
|
||||
|
||||
@@ -238,6 +238,17 @@ describe("lane-sources instructions and launch", () => {
|
||||
src.setInstructions(codexLane.id, null);
|
||||
});
|
||||
|
||||
it("puts the owner's own agent instructions before the workspace's", () => {
|
||||
src.setUserInstructions("local", "# my style\n- small diffs");
|
||||
const { systemPrompt, systemPromptFile } = src.launchExtras(lane.id, "claude");
|
||||
assert.match(systemPrompt, /^# my style\n- small diffs\n\n- always answer in Vietnamese/);
|
||||
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
|
||||
// A workspace with nothing of its own still gets them.
|
||||
assert.match(src.launchExtras(otherLane.id, "claude").systemPrompt, /^# my style/);
|
||||
src.setUserInstructions("local", null);
|
||||
assert.equal(src.launchExtras(otherLane.id, "claude").systemPrompt, null);
|
||||
});
|
||||
|
||||
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
|
||||
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
|
||||
addDirs: [],
|
||||
|
||||
@@ -427,6 +427,16 @@ describe("lanes changed by another server on the same DB", () => {
|
||||
});
|
||||
|
||||
describe("lane start input", () => {
|
||||
it("refuses to start in a workspace folder that no longer exists (409 ENOCWD)", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-gone-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
// Without the check the pane started in the account's home instead.
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, {});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ENOCWD");
|
||||
});
|
||||
|
||||
it("answers 400 for a model that is not a plain model id", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
|
||||
@@ -74,6 +74,22 @@ describe("local mode: one account", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
||||
let r = await call("GET", "/api/me/instructions");
|
||||
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
||||
r = await call("PUT", "/api/me/instructions", {
|
||||
body: { instructions: "- answer in Vietnamese\r\n" },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.instructions, "- answer in Vietnamese\n");
|
||||
r = await call("PUT", "/api/me/instructions", { body: { instructions: 1 } });
|
||||
assert.equal(r.body.error.code, "EBADFIELD");
|
||||
r = await call("PUT", "/api/me/instructions", { body: { instructions: "x".repeat(16001) } });
|
||||
assert.equal(r.status, 413);
|
||||
r = await call("PUT", "/api/me/instructions", { body: { instructions: " " } });
|
||||
assert.equal(r.body.instructions, "");
|
||||
});
|
||||
|
||||
it("marks the first-run setup done, once", async () => {
|
||||
const r = await call("POST", "/api/me/onboarded");
|
||||
assert.match(r.body.onboardedAt, /^\d{4}-/);
|
||||
@@ -117,6 +133,19 @@ describe("server mode: per account", () => {
|
||||
assert.deepEqual(m, { prefs: { i18nextLng: "en" }, onboardedAt: null });
|
||||
});
|
||||
|
||||
it("keeps each account's own agent instructions", async () => {
|
||||
assert.equal((await call("GET", "/api/me/instructions")).status, 401);
|
||||
await call("PUT", "/api/me/instructions", { cookie: mia, body: { instructions: "mia's" } });
|
||||
assert.equal(
|
||||
(await call("GET", "/api/me/instructions", { cookie: mia })).body.instructions,
|
||||
"mia's"
|
||||
);
|
||||
assert.equal(
|
||||
(await call("GET", "/api/me/instructions", { cookie: max })).body.instructions,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("puts an S3 member's mode into their Claude Code theme, as them", async () => {
|
||||
const sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
|
||||
@@ -44,6 +44,13 @@ describe("native-dialog", () => {
|
||||
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
||||
});
|
||||
|
||||
it("brings osascript forward before the macOS dialog, so it does not open behind the browser", () => {
|
||||
const [[, args]] = nd.dialogCommands("dir", "/x", "darwin");
|
||||
const script = args.filter((_, i) => args[i - 1] === "-e");
|
||||
assert.ok(script.indexOf("activate") > -1);
|
||||
assert.ok(script.indexOf("activate") < script.findIndex((s) => s.includes("choose folder")));
|
||||
});
|
||||
|
||||
it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => {
|
||||
for (const kind of ["dir", "file"]) {
|
||||
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
|
||||
|
||||
@@ -374,7 +374,27 @@ describe("pty-run", () => {
|
||||
if (args[0] === "new-session") newSessionArgv = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
|
||||
const prevHome = process.env.CLAUDE_HOME;
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "bypass-home-"));
|
||||
process.env.CLAUDE_HOME = home;
|
||||
try {
|
||||
// An account that never accepted the bypass warning: no flag, so its
|
||||
// first session does not open on that warning (the Chat UI can't answer).
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
|
||||
assert.ok(!newSessionArgv.includes("--allow-dangerously-skip-permissions"));
|
||||
// ...unless the run asks for bypass itself: then the warning is wanted.
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo", permissionMode: "bypassPermissions" });
|
||||
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
|
||||
fs.writeFileSync(
|
||||
path.join(home, "settings.json"),
|
||||
JSON.stringify({ skipDangerousModePermissionPrompt: true })
|
||||
);
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
|
||||
} finally {
|
||||
if (prevHome === undefined) delete process.env.CLAUDE_HOME;
|
||||
else process.env.CLAUDE_HOME = prevHome;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
|
||||
// ...as an OPTION only: the starting mode stays the requested one, and
|
||||
// `--dangerously-skip-permissions` (which would enable it) is never passed.
|
||||
|
||||
@@ -335,17 +335,15 @@ describe("GET /api/run/drop/:name", () => {
|
||||
});
|
||||
|
||||
it("serves no file that is not a dropped image or text file", async () => {
|
||||
const link = path.join(
|
||||
os.tmpdir(),
|
||||
`agenthub-drops-${os.userInfo().uid}`,
|
||||
"fedcba9876543210.png"
|
||||
);
|
||||
fs.rmSync(link, { force: true });
|
||||
fs.symlinkSync("/etc/passwd", link);
|
||||
const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", {
|
||||
method: "POST",
|
||||
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
|
||||
});
|
||||
// Planted in the real drop dir (the one this drop just made), not a
|
||||
// guessed path: `<data dir>/drops` replaced `/tmp/agenthub-drops-<uid>`.
|
||||
const link = path.join(path.dirname(pdf.body.path), "fedcba9876543210.png");
|
||||
fs.rmSync(link, { force: true });
|
||||
fs.symlinkSync("/etc/passwd", link);
|
||||
for (const name of [
|
||||
path.basename(pdf.body.path), // a drop, but no image
|
||||
"0123456789abcdef.png", // the right shape, never dropped
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file s3-unseen-member-runs.test.js
|
||||
* @description A dashboard WITHOUT S3 (local mode, e.g. a dev server) sharing
|
||||
* the database of an S3 server must never end a member's run or release a
|
||||
* member's workspace: their panes live on the member's own tmux server, which
|
||||
* it cannot ask. Before the fix its heal beat marked every member run
|
||||
* `interrupted` within 10 s, and the proxy gateway then refused the pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "s3-unseen-"));
|
||||
const USERS = path.join(ROOT, "users");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(ROOT, "server.conf");
|
||||
delete process.env.AGENTHUB_MODE; // local mode: this server has no S3
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=host\nHOST_UID=1001\nUSERS_ROOT=${USERS}\n`
|
||||
);
|
||||
|
||||
const runs = require("../lib/dashboard-runs");
|
||||
const { db } = require("../db");
|
||||
const { healAllRunIds } = require("../routes/lanes");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const start = (id, laneId, cwd) =>
|
||||
runs.recordRun({
|
||||
id,
|
||||
mode: "interactive",
|
||||
cwd,
|
||||
status: "running",
|
||||
laneId,
|
||||
sessionId: `s-${id}`,
|
||||
startedAt: Date.parse("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
|
||||
describe("a server without S3 on an S3 machine's database", () => {
|
||||
it("never ends a member's run, and still ends its own", () => {
|
||||
start("agenthub-lane-1", 1, path.join(USERS, "mia", "proj"));
|
||||
start("agenthub-lane-2", 2, "/home/host/proj");
|
||||
runs.reconcileOrphans(() => false);
|
||||
assert.equal(runs.getRun("agenthub-lane-1").status, "running");
|
||||
assert.equal(runs.getRun("agenthub-lane-2").status, "interrupted");
|
||||
});
|
||||
|
||||
it("never releases a member's workspace", () => {
|
||||
const lane = db.prepare(
|
||||
"INSERT INTO lanes (title, cwd, run_id, status) VALUES (?, ?, ?, 'running')"
|
||||
);
|
||||
const member = lane.run(
|
||||
"m",
|
||||
path.join(USERS, "mia", "proj"),
|
||||
"agenthub-lane-1"
|
||||
).lastInsertRowid;
|
||||
const host = lane.run("h", "/home/host/proj", "agenthub-lane-2").lastInsertRowid;
|
||||
healAllRunIds(new Set());
|
||||
const runId = (id) => db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(id).run_id;
|
||||
assert.equal(runId(member), "agenthub-lane-1");
|
||||
assert.equal(runId(host), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file self-restart.test.js
|
||||
* @description After an update, a server our service unit runs is restarted
|
||||
* by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
|
||||
* left the replacement holding the port while `Restart=always` started the
|
||||
* unit every 3 s, each start exiting "already running".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { scheduleRestart, owningUnit } = require("../lib/self-restart");
|
||||
|
||||
function deps(unit, restartAnswer = "restarted") {
|
||||
const calls = { restart: [], kill: 0, spawn: 0 };
|
||||
return {
|
||||
calls,
|
||||
unit,
|
||||
svc: () => ({
|
||||
restartUserService: (name) => {
|
||||
calls.restart.push(name);
|
||||
return restartAnswer;
|
||||
},
|
||||
}),
|
||||
kill: () => calls.kill++,
|
||||
spawnHelper: () => {
|
||||
calls.spawn++;
|
||||
return { unref() {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("self-restart", () => {
|
||||
it("tells which unit owns this process, from its cgroup or launchd label", () => {
|
||||
const linux = (text) => owningUnit({ platform: "linux", cgroup: () => text });
|
||||
assert.equal(linux("0::/system.slice/agenthub.service\n"), "systemd");
|
||||
assert.equal(
|
||||
linux("0::/user.slice/user-1001.slice/user@1001.service/app.slice/agenthub.service\n"),
|
||||
"systemd"
|
||||
);
|
||||
assert.equal(linux("0::/user.slice/user-1001.slice/session-3.scope\n"), null);
|
||||
assert.equal(linux("0::/system.slice/agenthub.service.d/x\n"), null);
|
||||
assert.equal(
|
||||
owningUnit({
|
||||
platform: "linux",
|
||||
cgroup: () => {
|
||||
throw new Error("no /proc");
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
owningUnit({ platform: "darwin", env: { XPC_SERVICE_NAME: "io.smartgift.agenthub" } }),
|
||||
"launchd"
|
||||
);
|
||||
assert.equal(owningUnit({ platform: "darwin", env: {} }), null);
|
||||
});
|
||||
|
||||
it("asks systemd to restart its unit and starts no replacement", () => {
|
||||
const d = deps("systemd");
|
||||
assert.equal(scheduleRestart("/r", d), "service");
|
||||
assert.deepEqual(d.calls.restart, ["agenthub"]);
|
||||
assert.equal(d.calls.spawn, 0);
|
||||
assert.equal(d.calls.kill, 0, "systemd's restart job stops this process");
|
||||
});
|
||||
|
||||
it("exits for Restart=always when systemd cannot be asked — still no replacement", () => {
|
||||
const d = deps("systemd", "failed");
|
||||
assert.equal(scheduleRestart("/r", d), "exit");
|
||||
assert.equal(d.calls.kill, 1);
|
||||
assert.equal(d.calls.spawn, 0);
|
||||
});
|
||||
|
||||
it("exits under launchd, whose KeepAlive starts the new code", () => {
|
||||
const d = deps("launchd");
|
||||
assert.equal(scheduleRestart("/r", d), "exit");
|
||||
assert.equal(d.calls.kill, 1);
|
||||
assert.equal(d.calls.spawn, 0);
|
||||
assert.deepEqual(d.calls.restart, []);
|
||||
});
|
||||
|
||||
it("hands over to the detached helper when no unit owns the server", () => {
|
||||
const d = deps(null);
|
||||
assert.equal(scheduleRestart("/r", d), "helper");
|
||||
assert.equal(d.calls.spawn, 1);
|
||||
assert.equal(d.calls.kill, 1);
|
||||
});
|
||||
});
|
||||
@@ -148,3 +148,23 @@ describe("server mode: a member's skills/commands come from their own workspaces
|
||||
assert.ok(names(r).includes("max-skill"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the System page's facts for a developer", () => {
|
||||
// Every string that looks like an absolute path, anywhere in a body.
|
||||
const paths = (o, out = []) => {
|
||||
if (typeof o === "string" && o.startsWith("/")) out.push(o);
|
||||
else if (o && typeof o === "object") for (const v of Object.values(o)) paths(v, out);
|
||||
return out;
|
||||
};
|
||||
|
||||
it("carries no host or other-user path (DB, hooks file, transcript cache keys)", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const info = await call("/api/settings/info", mia);
|
||||
assert.equal(info.status, 200);
|
||||
assert.deepEqual(paths(info.body), []);
|
||||
assert.equal(typeof info.body.db.size, "number", "the numbers stay");
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const full = await call("/api/settings/info", boss);
|
||||
assert.ok(full.body.db.path, "an admin still sees the host's paths");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,3 +173,22 @@ describe("an analyst's workspace is theirs, like a developer's", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's new project folder", () => {
|
||||
it("is made as the member, and nothing else is made for anyone", () => {
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
const sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
const fresh = path.join(USERS, "mia", "brand-new", "sub");
|
||||
assert.equal(runAs.makeMemberFolder(fresh), true);
|
||||
assert.ok(fs.statSync(fresh).isDirectory());
|
||||
const call = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
||||
assert.ok(call && call.includes("ah-mia") && call.includes(fresh) && call.includes("mkdir"));
|
||||
sudo.calls.length = 0;
|
||||
assert.equal(runAs.makeMemberFolder(path.join(USERS, "mia")), false, "the home itself");
|
||||
assert.equal(runAs.makeMemberFolder(path.join(TMP, "host-new")), false, "not a member's");
|
||||
assert.equal(runAs.makeMemberFolder(path.join(USERS, "nobody", "x")), false, "no account");
|
||||
assert.deepEqual(sudo.calls, []);
|
||||
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,4 +248,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
|
||||
"/opt/agenthub/libexec/write-as"
|
||||
);
|
||||
});
|
||||
it("counts a member's tmux it cannot stat as maybe there, a missing one as none", () => {
|
||||
const home = path.join(TMP, "users", "zoe");
|
||||
const acct = { name: "zoe", login: "ah-zoe", home, uid: 5002 };
|
||||
assert.equal(runAs.mayHaveTmux(acct), false, "no .agenthub at all");
|
||||
const dir = path.join(home, ".agenthub", "tmux");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
assert.equal(runAs.mayHaveTmux(acct), false, "no socket yet");
|
||||
// The member's own 0700 dir, seen by a dashboard without CAP_DAC_READ_SEARCH.
|
||||
fs.chmodSync(dir, 0o000);
|
||||
try {
|
||||
if (process.getuid() !== 0)
|
||||
assert.equal(runAs.mayHaveTmux(acct), true, "EACCES proves nothing");
|
||||
} finally {
|
||||
fs.chmodSync(dir, 0o700);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* non-admin owns no host-folder workspace and a pane's cwd is its
|
||||
* workspace's account's; a pane token works only from its pane's uid; an
|
||||
* analyst's GET path stays in their own folder; a drop goes to the pane's
|
||||
* account. A fake passwd (every member = this test's uid) and server.conf
|
||||
* account; an image a session read is served only from a member session's
|
||||
* own home (a host session's from anywhere). A fake passwd (every member = this test's uid) and server.conf
|
||||
* make the members provisioned; fake sudo runs their commands as this
|
||||
* account — and a fake `sudo` on PATH that never returns stands in for a
|
||||
* stalled member, in a child process.
|
||||
@@ -674,7 +675,54 @@ describe("a dropped file", () => {
|
||||
const host = await drop("boss", hostLane);
|
||||
assert.equal(host.status, 200, JSON.stringify(host.body));
|
||||
assert.equal(path.dirname(host.body.path), path.join(DATA, "drops"));
|
||||
// …and the admin's chat shows it back, though the GET names no workspace.
|
||||
fs.mkdirSync(path.join(USERS, "boss", ".agenthub", "handoff"), { recursive: true });
|
||||
const shown = await fetch(`${BASE}/api/run/drop/${path.basename(host.body.path)}`, {
|
||||
headers: { cookie: cookies.boss },
|
||||
});
|
||||
assert.equal(shown.status, 200);
|
||||
assert.equal(await shown.text(), "hello");
|
||||
// Another member's workspace is not one to name.
|
||||
assert.equal((await drop("bob", miaLane)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an image a session read (the Chat pane's Files read preview)", () => {
|
||||
const readBy = (sid, cwd, file) => {
|
||||
db.prepare("INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)").run(sid, cwd);
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PostToolUse', 'Read', ?)"
|
||||
).run(sid, JSON.stringify({ tool_input: { file_path: file } }));
|
||||
};
|
||||
const get = (sid, file) =>
|
||||
fetch(`${BASE}/api/sessions/${sid}/image?path=${encodeURIComponent(file)}`, {
|
||||
headers: { cookie: cookies.boss },
|
||||
});
|
||||
|
||||
it("is served for the host's own session and a member's file in that member's home", async () => {
|
||||
const hostPng = path.join(TMP, "hostdir", "shot.png");
|
||||
const miaPng = path.join(MIA, "proj", "shot.png");
|
||||
fs.mkdirSync(path.dirname(hostPng), { recursive: true });
|
||||
fs.writeFileSync(hostPng, "HOSTPNG");
|
||||
fs.writeFileSync(miaPng, "MIAPNG");
|
||||
readBy("img-host", path.join(TMP, "hostdir"), hostPng);
|
||||
readBy("img-mia", path.join(MIA, "proj"), miaPng);
|
||||
const host = await get("img-host", hostPng);
|
||||
assert.equal(host.status, 200);
|
||||
assert.equal(await host.text(), "HOSTPNG");
|
||||
const mia = await get("img-mia", miaPng);
|
||||
assert.equal(mia.status, 200);
|
||||
assert.equal(await mia.text(), "MIAPNG");
|
||||
});
|
||||
|
||||
it("is refused when a member's session names a file outside that member's home", async () => {
|
||||
readBy("img-mia-out", path.join(MIA, "proj"), path.join(TMP, "hostdir", "shot.png"));
|
||||
readBy("img-mia-bob", path.join(MIA, "proj"), path.join(BOB, "b.png"));
|
||||
fs.writeFileSync(path.join(BOB, "b.png"), "BOBPNG");
|
||||
for (const f of [path.join(TMP, "hostdir", "shot.png"), path.join(BOB, "b.png")]) {
|
||||
const r = await get(f.includes("bob") ? "img-mia-bob" : "img-mia-out", f);
|
||||
assert.equal(r.status, 404, f);
|
||||
assert.ok(!/PNG/.test(await r.text()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const fs = require("node:fs");
|
||||
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
|
||||
const { classifyTranscriptSender, patchCounts } = require("../routes/sessions");
|
||||
const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions");
|
||||
const { db } = require("../db");
|
||||
|
||||
after(() => {
|
||||
@@ -168,3 +168,22 @@ describe("patchCounts — the +/- lines of a Write/Edit result", () => {
|
||||
assert.equal(patchCounts(undefined), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("askAnswers — what the user picked or typed for an AskUserQuestion", () => {
|
||||
it("returns each question's answer", () => {
|
||||
const r = {
|
||||
questions: [{ question: "Commit?", options: [{ label: "Yes" }, { label: "No" }] }],
|
||||
answers: { "Commit?": "Yes", "Notes?": "typed text" },
|
||||
annotations: {},
|
||||
};
|
||||
assert.deepEqual(askAnswers(r), { "Commit?": "Yes", "Notes?": "typed text" });
|
||||
});
|
||||
|
||||
it("drops a non-string answer and answers null for a result that is no question", () => {
|
||||
assert.deepEqual(askAnswers({ answers: { a: "x", b: 3 } }), { a: "x" });
|
||||
assert.equal(askAnswers({ answers: {} }), null);
|
||||
assert.equal(askAnswers({ answers: ["x"] }), null);
|
||||
assert.equal(askAnswers({ stdout: "ok" }), null);
|
||||
assert.equal(askAnswers("text"), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1778,6 +1778,13 @@ db.exec(`
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
// An account's own agent instructions, put before every workspace's in each
|
||||
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
|
||||
try {
|
||||
db.prepare("SELECT instructions FROM user_prefs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE user_prefs ADD COLUMN instructions TEXT").run();
|
||||
}
|
||||
|
||||
// The `member` role was renamed `developer`: rebuild `users` for the new
|
||||
// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
|
||||
|
||||
@@ -258,8 +258,21 @@ function claimToolSessions(sessionId = null) {
|
||||
}
|
||||
|
||||
const runningStmt = db.prepare(
|
||||
"SELECT id, tool, session_id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||
"SELECT id, tool, session_id, cwd, started_at FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||
);
|
||||
|
||||
/**
|
||||
* How long a new run is never judged dead. A tmux list is a cached answer —
|
||||
* a member's may be up to 15 s old (tmux.js MEMBER_LIST_MAX_AGE_MS) — and only
|
||||
* the process that started a pane writes it into its own cache, so another
|
||||
* dashboard on the same database (a dev server) ended a pane it had simply
|
||||
* not listed yet: the proxy gateway then answered 401 to a live Codex.
|
||||
*/
|
||||
const YOUNG_RUN_MS = 20_000;
|
||||
function isYoungRun(startedAt, now = Date.now()) {
|
||||
const t = Date.parse(startedAt || "");
|
||||
return Number.isFinite(t) && now - t < YOUNG_RUN_MS;
|
||||
}
|
||||
const endStmt = db.prepare(`
|
||||
UPDATE dashboard_runs
|
||||
SET status = @status,
|
||||
@@ -298,8 +311,9 @@ const pruneShellsStmt = db.prepare(`
|
||||
function reconcileOrphans(isAlive = () => false) {
|
||||
try {
|
||||
let changes = 0;
|
||||
const { unseenMemberFolder } = require("./linux-accounts");
|
||||
for (const row of runningStmt.all()) {
|
||||
if (isAlive(row.id)) continue;
|
||||
if (isAlive(row.id) || unseenMemberFolder(row.cwd) || isYoungRun(row.started_at)) continue;
|
||||
const interrupted =
|
||||
row.tool !== "shell" &&
|
||||
!!row.session_id &&
|
||||
@@ -367,6 +381,7 @@ module.exports = {
|
||||
getRun,
|
||||
forgetShellRun,
|
||||
reconcileOrphans,
|
||||
isYoungRun,
|
||||
listInterrupted,
|
||||
dismissInterrupted,
|
||||
proxyForSession,
|
||||
|
||||
@@ -395,13 +395,17 @@ async function summarizeWork(dir) {
|
||||
const [entries, numstat] = await Promise.all([
|
||||
laneStatus(dir),
|
||||
// --relative: cwd-scoped, cwd-relative paths, so they key the status ones.
|
||||
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })),
|
||||
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => null),
|
||||
]);
|
||||
const stats = parseNumstat(numstat.stdout);
|
||||
const stats = parseNumstat(numstat?.stdout ?? "");
|
||||
const files = [];
|
||||
for (const { code, path: file } of entries) {
|
||||
const untracked = code === "??";
|
||||
const stat = stats.get(file);
|
||||
// `MM` with no line against HEAD: a staged change the working tree has
|
||||
// reverted — nothing to commit from the file on disk. JetBrains' Changes
|
||||
// view drops it the same way (#72). Only when the numstat answered.
|
||||
if (code === "MM" && numstat && !stat) continue;
|
||||
// One read per untracked file, not two: its line count and its
|
||||
// binary-ness are the same buffer answered twice.
|
||||
// Sequential on purpose: `normal` folds untracked dirs, so this is short.
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* name it chooses, so a request never names a path on disk. Only Claude
|
||||
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
|
||||
* `-c developer_instructions=`) can take them; every other tool is reported
|
||||
* unsupported and its panes get nothing.
|
||||
* unsupported and its panes get nothing. The workspace OWNER's own agent
|
||||
* instructions (`user_prefs.instructions`, Settings → My agent instructions)
|
||||
* go first, before the workspace's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
@@ -185,8 +187,8 @@ function removeLaneContext(laneId) {
|
||||
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
|
||||
}
|
||||
|
||||
function setInstructions(laneId, text) {
|
||||
laneRow(laneId);
|
||||
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
|
||||
function cleanInstructions(text) {
|
||||
if (text !== null && typeof text !== "string") {
|
||||
throw fail("EBADFIELD", "instructions must be a string or null");
|
||||
}
|
||||
@@ -194,9 +196,37 @@ function setInstructions(laneId, text) {
|
||||
if (value && value.length > MAX_INSTRUCTIONS) {
|
||||
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setInstructions(laneId, text) {
|
||||
laneRow(laneId);
|
||||
const value = cleanInstructions(text);
|
||||
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
|
||||
}
|
||||
|
||||
function setUserInstructions(userKey, text) {
|
||||
const value = cleanInstructions(text);
|
||||
db.prepare(
|
||||
`INSERT INTO user_prefs (user_key, instructions) VALUES (?, ?)
|
||||
ON CONFLICT(user_key) DO UPDATE SET instructions = excluded.instructions,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
||||
).run(userKey, value);
|
||||
}
|
||||
|
||||
function getUserInstructions(userKey) {
|
||||
return (
|
||||
db.prepare("SELECT instructions FROM user_prefs WHERE user_key = ?").get(userKey)
|
||||
?.instructions || null
|
||||
);
|
||||
}
|
||||
|
||||
/** The owner's own instructions: `local` in local mode, `u<owner_id>` in server mode. */
|
||||
function ownerInstructions(ownerId) {
|
||||
if (!require("./auth").isServerMode()) return getUserInstructions("local");
|
||||
return ownerId ? getUserInstructions(`u${ownerId}`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pane of this workspace is started with, read from its rows: the
|
||||
* extra folders and (when any file was uploaded) the context dir to allow, and
|
||||
@@ -207,7 +237,7 @@ function setInstructions(laneId, text) {
|
||||
function launchExtras(laneId, tool) {
|
||||
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
|
||||
if (!supports(tool)) return empty;
|
||||
const lane = db.prepare("SELECT instructions FROM lanes WHERE id = ?").get(laneId);
|
||||
const lane = db.prepare("SELECT instructions, owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
if (!lane) return empty;
|
||||
const rows = db
|
||||
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
|
||||
@@ -238,6 +268,8 @@ function launchExtras(laneId, tool) {
|
||||
}
|
||||
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
|
||||
const parts = [];
|
||||
const mine = ownerInstructions(lane.owner_id);
|
||||
if (mine) parts.push(mine);
|
||||
if (lane.instructions) parts.push(lane.instructions);
|
||||
if (files.length) {
|
||||
parts.push(
|
||||
@@ -366,6 +398,8 @@ module.exports = {
|
||||
removeLaneContext,
|
||||
setInstructions,
|
||||
launchExtras,
|
||||
setUserInstructions,
|
||||
getUserInstructions,
|
||||
codexInstructions,
|
||||
tomlString,
|
||||
INLINE_PROMPT_BYTES,
|
||||
|
||||
@@ -70,6 +70,20 @@ function s3Enabled() {
|
||||
return require("./auth").isServerMode() && serverConf() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's folder (under this machine's S3 users root) while THIS server
|
||||
* runs without S3 — a local-mode or dev dashboard on the same database. Its
|
||||
* panes live on the member's own tmux server, which such a server cannot
|
||||
* ask, so it must never judge them dead.
|
||||
*/
|
||||
function unseenMemberFolder(cwd) {
|
||||
if (typeof cwd !== "string" || !cwd || s3Enabled()) return false;
|
||||
const conf = serverConf();
|
||||
if (!conf) return false;
|
||||
const rel = path.relative(conf.usersRoot, path.resolve(cwd));
|
||||
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
const loginFor = (username) => `ah-${username}`;
|
||||
|
||||
/** The Linux side of an AgentHub user, or null outside S3. */
|
||||
@@ -129,6 +143,7 @@ module.exports = {
|
||||
LINUX_NAME_RE,
|
||||
serverConf,
|
||||
s3Enabled,
|
||||
unseenMemberFolder,
|
||||
accountFor,
|
||||
assertLinuxName,
|
||||
provision,
|
||||
|
||||
@@ -85,6 +85,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
|
||||
const title = TITLES[kind];
|
||||
if (platform === "darwin") {
|
||||
const what = kind === "dir" ? "folder" : "file";
|
||||
// osascript started by a background server is not the frontmost app, so
|
||||
// its dialog opened BEHIND the browser; `activate` brings it forward.
|
||||
return [
|
||||
[
|
||||
"osascript",
|
||||
@@ -92,6 +94,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
|
||||
"-e",
|
||||
"on run argv",
|
||||
"-e",
|
||||
"activate",
|
||||
"-e",
|
||||
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
|
||||
"-e",
|
||||
"end run",
|
||||
|
||||
+29
-1
@@ -165,6 +165,24 @@ function promptFileArgv(id, argv) {
|
||||
return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account a pane runs as has accepted Claude Code's bypass
|
||||
* warning — Claude writes `skipDangerousModePermissionPrompt: true` to its
|
||||
* user settings once it is. A member's (S3) is read from their home, owner-
|
||||
* checked; the host's from CLAUDE_HOME. Unreadable reads as "not yet".
|
||||
*/
|
||||
function bypassAccepted(member) {
|
||||
const file = member
|
||||
? path.join(member.home, ".claude", "settings.json")
|
||||
: path.join(require("./claude-home").getClaudeHome(), "settings.json");
|
||||
try {
|
||||
const text = require("./run-as").readFileSafe(file, "utf8", { max: 1 << 20 });
|
||||
return JSON.parse(text).skipDangerousModePermissionPrompt === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClaudeArgv({
|
||||
model,
|
||||
permissionMode,
|
||||
@@ -177,6 +195,7 @@ function buildClaudeArgv({
|
||||
addDirs = [],
|
||||
systemPromptFile = null,
|
||||
proxyArgv = [],
|
||||
offerBypass = true,
|
||||
platform = process.platform,
|
||||
}) {
|
||||
const argv = ["claude"];
|
||||
@@ -210,7 +229,11 @@ function buildClaudeArgv({
|
||||
// plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could
|
||||
// never reach bypass from inside the pane, while a real terminal could.
|
||||
// The starting mode is still whatever --permission-mode says above.
|
||||
argv.push("--allow-dangerously-skip-permissions");
|
||||
// Only once the account has accepted Claude Code's bypass warning
|
||||
// (`offerBypass`, see bypassAccepted): before that the flag alone puts the
|
||||
// warning in front of a brand-new account's first session — one the Chat UI
|
||||
// cannot answer — although nobody asked for bypass.
|
||||
if (offerBypass) argv.push("--allow-dangerously-skip-permissions");
|
||||
if (model) argv.push("--model", model);
|
||||
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
||||
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
||||
@@ -512,6 +535,11 @@ function spawnRun(args) {
|
||||
resumeSessionId,
|
||||
forkSession: forkSession === true,
|
||||
newSessionId,
|
||||
// Asking for bypass is consent to see its warning; otherwise only an
|
||||
// account that already accepted it gets bypass in its shift+tab cycle.
|
||||
offerBypass:
|
||||
permissionMode === "bypassPermissions" ||
|
||||
(entry.launcher === "claude" && bypassAccepted(require("./run-as").accountForLane(laneId))),
|
||||
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
|
||||
+45
-12
@@ -133,13 +133,22 @@ function memberHomes() {
|
||||
function membersWithTmux() {
|
||||
return memberHomes()
|
||||
.map((home) => accountForPath(home))
|
||||
.filter((acct) => {
|
||||
try {
|
||||
return fs.statSync(tmuxSocket(acct)).isSocket();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
.filter(mayHaveTmux);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member may have a tmux server. Only a missing socket says no:
|
||||
* a member's `.agenthub/tmux` is theirs (0700), and a dashboard without
|
||||
* CAP_DAC_READ_SEARCH (a dev server on the same database) gets EACCES there
|
||||
* — that proves nothing, and reading it as "no server" ended every run of
|
||||
* theirs within seconds. Their list is asked through sudo, which can tell.
|
||||
*/
|
||||
function mayHaveTmux(acct) {
|
||||
try {
|
||||
return fs.statSync(tmuxSocket(acct)).isSocket();
|
||||
} catch (err) {
|
||||
return err.code !== "ENOENT" && err.code !== "ENOTDIR";
|
||||
}
|
||||
}
|
||||
|
||||
// uid by login, from /etc/passwd (world-readable; node has no getpwnam).
|
||||
@@ -357,8 +366,7 @@ function openSafe(file) {
|
||||
// at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and
|
||||
// never past the size `fstat` gave — a file growing under the read is cut.
|
||||
const MEMBER_READ_MAX = 64 * 1024 * 1024;
|
||||
const tooBig = (file) =>
|
||||
Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
|
||||
const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
|
||||
|
||||
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
|
||||
function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
|
||||
@@ -369,7 +377,7 @@ function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
|
||||
if (size > max) throw tooBig(file);
|
||||
const buf = Buffer.alloc(size);
|
||||
let off = 0;
|
||||
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0; ) off += n;
|
||||
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0;) off += n;
|
||||
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
@@ -398,7 +406,7 @@ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
|
||||
if (size > max) throw tooBig(file);
|
||||
const buf = Buffer.alloc(size);
|
||||
let off = 0;
|
||||
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0; )
|
||||
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0;)
|
||||
off += n;
|
||||
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
|
||||
} finally {
|
||||
@@ -438,7 +446,10 @@ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
|
||||
let start = 0;
|
||||
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
|
||||
if (skipping || partLen + (i - start) > maxLine) yield "";
|
||||
else yield text(partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i));
|
||||
else
|
||||
yield text(
|
||||
partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i)
|
||||
);
|
||||
parts = [];
|
||||
partLen = 0;
|
||||
skipping = false;
|
||||
@@ -477,6 +488,26 @@ function sameOwner(...paths) {
|
||||
|
||||
/** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */
|
||||
const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir");
|
||||
|
||||
/**
|
||||
* S3: make a missing workspace folder inside a member's own home, AS them
|
||||
* (`mkdir -p`, so only where they could themselves). A new member has no
|
||||
* other way to their first project: their home is no workspace (EFOLDER).
|
||||
* False — nothing made — for any other folder, the home itself, or a member
|
||||
* with no usable account.
|
||||
*/
|
||||
function makeMemberFolder(dir) {
|
||||
let acct;
|
||||
try {
|
||||
acct = accountForPath(dir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const abs = path.resolve(dir);
|
||||
if (!acct || abs === acct.home) return false;
|
||||
mkdirAs(acct, abs);
|
||||
return true;
|
||||
}
|
||||
const removeAs = (acct, p) => writeAs(acct, p, "remove");
|
||||
|
||||
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
|
||||
@@ -502,6 +533,7 @@ module.exports = {
|
||||
assertOwnerFits,
|
||||
hasLinuxAccount,
|
||||
membersWithTmux,
|
||||
mayHaveTmux,
|
||||
memberHomes,
|
||||
ownerForRead,
|
||||
tmuxSocket,
|
||||
@@ -516,6 +548,7 @@ module.exports = {
|
||||
handoffWrite,
|
||||
writeAs,
|
||||
mkdirAs,
|
||||
makeMemberFolder,
|
||||
removeAs,
|
||||
launchArgv,
|
||||
sameOwner,
|
||||
|
||||
@@ -7,26 +7,85 @@
|
||||
* it, and hands the "wait for the old PID to actually die, then spawn the
|
||||
* replacement" job to a detached helper script — the dying process can't do
|
||||
* that itself without racing its own replacement for the port.
|
||||
*
|
||||
* A server our service unit runs (systemd `agenthub.service`, system or user;
|
||||
* the launchd job) is restarted BY that manager instead: a detached
|
||||
* replacement would hold the port while `Restart=always` starts the unit
|
||||
* again every few seconds, each start exiting "already running" — forever.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const UNIT = "agenthub";
|
||||
|
||||
/**
|
||||
* "systemd" when this process lives in our unit's cgroup, "launchd" when it
|
||||
* is our LaunchAgent job, else null. Pure reads: no process is spawned.
|
||||
*/
|
||||
function owningUnit({
|
||||
platform = process.platform,
|
||||
cgroup = () => fs.readFileSync("/proc/self/cgroup", "utf8"),
|
||||
env = process.env,
|
||||
} = {}) {
|
||||
if (platform === "darwin") {
|
||||
return env.XPC_SERVICE_NAME === `io.smartgift.${UNIT}` ? "launchd" : null;
|
||||
}
|
||||
try {
|
||||
return cgroup()
|
||||
.split("\n")
|
||||
.some((l) => l.trim().endsWith(`/${UNIT}.service`))
|
||||
? "systemd"
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [root] repo root the helper should run from.
|
||||
* @returns {"service"|"exit"|"helper"} how the restart was handed over.
|
||||
*/
|
||||
function scheduleRestart(root = path.join(__dirname, "..", "..")) {
|
||||
function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
|
||||
const {
|
||||
unit = owningUnit(),
|
||||
svc = () => require("../../scripts/service-manager"),
|
||||
kill = () => process.kill(process.pid, "SIGTERM"),
|
||||
spawnHelper = spawn,
|
||||
} = deps;
|
||||
// systemd: one non-blocking restart job (sudo -n for the system unit). If
|
||||
// that cannot be asked, a plain exit still comes back — the unit is
|
||||
// Restart=always — but never with a second server beside it.
|
||||
if (unit === "systemd") {
|
||||
let restarted = false;
|
||||
try {
|
||||
restarted = svc().restartUserService(UNIT) === "restarted";
|
||||
} catch {
|
||||
/* fall through to the exit */
|
||||
}
|
||||
if (restarted) return "service";
|
||||
kill();
|
||||
return "exit";
|
||||
}
|
||||
// launchd: KeepAlive restarts the job on exit; a `bootout` from inside the
|
||||
// job would wait for this very process.
|
||||
if (unit === "launchd") {
|
||||
kill();
|
||||
return "exit";
|
||||
}
|
||||
const helperPath = path.join(root, "scripts", "restart-helper.js");
|
||||
const entry = process.argv[1] || path.join(root, "server", "index.js");
|
||||
const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], {
|
||||
const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
});
|
||||
helper.unref();
|
||||
process.kill(process.pid, "SIGTERM");
|
||||
kill();
|
||||
return "helper";
|
||||
}
|
||||
|
||||
module.exports = { scheduleRestart };
|
||||
module.exports = { scheduleRestart, owningUnit };
|
||||
|
||||
+7
-2
@@ -226,16 +226,21 @@ function listOutput(acct = null) {
|
||||
}
|
||||
|
||||
// A member's list is never waited for: a read answers from `listed` and,
|
||||
// once that is LIST_TTL_MS old, starts one async re-list. Until a first
|
||||
// once that is MEMBER_LIST_TTL_MS old, starts one async re-list. Until a first
|
||||
// answer — or once the last is MEMBER_LIST_MAX_AGE_MS old (nobody polled) —
|
||||
// or while their server fails, it throws: their panes are UNKNOWN. A session
|
||||
// this process creates or kills is written into the answer at once (`noteSession`).
|
||||
const MEMBER_LIST_MAX_AGE_MS = 15_000; // > the 10 s heal beat
|
||||
// A member's list is a `sudo` (fork + PAM session + 3 auth.log lines) — at a
|
||||
// 1 s TTL every open dashboard re-asked each member ~30 times a minute. A pane
|
||||
// this process starts or ends is corrected at once (`noteSession`); only one
|
||||
// that exits on its own reads live for up to these 5 s.
|
||||
const MEMBER_LIST_TTL_MS = 5_000;
|
||||
const refreshing = new Set(); // member logins with a list in flight
|
||||
function memberListOutput(acct) {
|
||||
const hit = listed.get(acct.login);
|
||||
const age = hit ? Date.now() - hit.at : Infinity;
|
||||
if (age >= LIST_TTL_MS) refreshList(acct);
|
||||
if (age >= MEMBER_LIST_TTL_MS) refreshList(acct);
|
||||
if (age >= MEMBER_LIST_MAX_AGE_MS) {
|
||||
throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -90,6 +91,23 @@ const changeResponses = {
|
||||
const prefsBody = {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
|
||||
};
|
||||
const instrBody = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["instructions", "max", "tools"],
|
||||
properties: {
|
||||
instructions: { type: "string" },
|
||||
max: { type: "integer" },
|
||||
tools: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "AI tool ids whose new panes receive the instructions (claude, codex)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/me/prefs": {
|
||||
@@ -127,6 +145,36 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/instructions": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "The account's own agent instructions",
|
||||
description:
|
||||
"Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.",
|
||||
responses: { 200: { description: "Instructions", content: instrBody }, 401: err("EAUTH") },
|
||||
},
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
summary: "Replace the account's agent instructions (blank or null clears)",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { instructions: { type: "string", nullable: true, maxLength: 16000 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Saved", content: instrBody },
|
||||
400: err("EBADFIELD — not a string"),
|
||||
413: err("ETOOBIG — longer than 16 000 characters"),
|
||||
401: err("EAUTH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/onboarded": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
+36
-4
@@ -35,7 +35,7 @@ const { sameOriginGuard } = require("./run");
|
||||
const { isLocalRequest } = require("../lib/same-origin");
|
||||
const scope = require("../lib/scope");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { pickPath } = require("../lib/native-dialog");
|
||||
const { pickPath, startDir } = require("../lib/native-dialog");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
LANES_ROOT,
|
||||
@@ -123,8 +123,15 @@ function lastEventAge(lane) {
|
||||
*/
|
||||
function healRunId(lane, live = null) {
|
||||
if (!lane.run_id) return lane;
|
||||
// A member's pane on a server without S3 (a dev dashboard on the same DB):
|
||||
// its tmux is not ours to ask, so "not listed" proves nothing.
|
||||
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
|
||||
if (live) {
|
||||
if (live.has(lane.run_id)) return lane;
|
||||
// Started a moment ago: a cached list may not show it yet (dashboard-runs.js).
|
||||
const dashboardRuns = require("../lib/dashboard-runs");
|
||||
const row = dashboardRuns.getRun(lane.run_id);
|
||||
if (row && row.status === "running" && dashboardRuns.isYoungRun(row.started_at)) return lane;
|
||||
} else {
|
||||
const run = runs.getRun(lane.run_id);
|
||||
if (run && run.status === "running") return lane;
|
||||
@@ -557,15 +564,20 @@ router.post("/gc", sameOriginGuard, (req, res) => {
|
||||
* expose absolute filesystem paths from a native picker, so path selection is
|
||||
* done by browsing server-side instead. Read-only; registered ahead of
|
||||
* "/:id" so the literal "browse" segment is never captured as a lane id.
|
||||
* `files=1` (a file field's fallback) lists files too, and opens `path` the
|
||||
* way the native picker would (`startDir`: `~`, a file's folder, else home).
|
||||
*/
|
||||
router.get("/browse", (req, res) => {
|
||||
// A developer browses only their own folder (by real path: a link in it
|
||||
// cannot point the listing elsewhere) — the dashboard lists with its own
|
||||
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
|
||||
const own = scope.ownFolder(req);
|
||||
const files = req.query.files === "1";
|
||||
const raw =
|
||||
typeof req.query.path === "string" && req.query.path.trim()
|
||||
? req.query.path
|
||||
? files
|
||||
? startDir(req.query.path)
|
||||
: req.query.path
|
||||
: own || os.homedir();
|
||||
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
|
||||
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
|
||||
@@ -586,12 +598,14 @@ router.get("/browse", (req, res) => {
|
||||
try {
|
||||
entries = fs
|
||||
.readdirSync(resolved, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
||||
.filter((e) => (e.isDirectory() || (files && e.isFile())) && !e.name.startsWith("."))
|
||||
.map((e) => {
|
||||
const full = path.join(resolved, e.name);
|
||||
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true };
|
||||
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// Folders first, as every file picker lists them.
|
||||
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
// An unreadable entry mid-listing is skipped, not a request failure.
|
||||
}
|
||||
@@ -2057,6 +2071,24 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
switch (action) {
|
||||
case "start": {
|
||||
// A folder that is gone (or never was) would start the pane in the
|
||||
// account's home instead, under this workspace's name. EACCES is not
|
||||
// "gone": a member's folder may be unreadable to this process.
|
||||
try {
|
||||
if (!fs.statSync(lane.cwd).isDirectory())
|
||||
throw Object.assign(new Error(), { code: "ENOTDIR" });
|
||||
} catch (err) {
|
||||
// A member's new project folder is made for them, as them.
|
||||
const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd);
|
||||
if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: "ENOCWD",
|
||||
message: `the workspace folder does not exist: ${lane.cwd}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// `"auto"`: the first free tab, picked under the lane lock below — a
|
||||
// client's own pick comes from a list that may already be stale.
|
||||
const auto = body.slot === "auto";
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
|
||||
* string of at most 8 KB, and a PUT merges into the row (`null` removes a
|
||||
* key). Writes are same-origin and touch only the caller's own row.
|
||||
* `/instructions` is the account's own agent instructions (issue #67, at most
|
||||
* 16 000 characters, blank clears), put before the workspace's in every pane
|
||||
* of a workspace the account owns (`launchExtras` in lib/lane-sources.js);
|
||||
* `tools` names the AI tools whose panes receive them.
|
||||
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
|
||||
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
|
||||
*
|
||||
@@ -105,6 +109,32 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
|
||||
res.json(read(key));
|
||||
});
|
||||
|
||||
const sources = require("../lib/lane-sources");
|
||||
const instructionsView = (key) => ({
|
||||
instructions: sources.getUserInstructions(key) || "",
|
||||
max: sources.MAX_INSTRUCTIONS,
|
||||
// The tools a pane of which actually receives them (see launchExtras).
|
||||
tools: [...sources.SUPPORTED_TOOLS],
|
||||
});
|
||||
|
||||
router.get("/instructions", (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
res.json(instructionsView(key));
|
||||
});
|
||||
|
||||
router.put("/instructions", sameOriginGuard, (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
try {
|
||||
sources.setUserInstructions(key, req.body?.instructions ?? null);
|
||||
} catch (err) {
|
||||
const status = err.code === "ETOOBIG" ? 413 : 400; // as PUT /api/lanes/:id/instructions
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json(instructionsView(key));
|
||||
});
|
||||
|
||||
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user