From 3a8be55b55f4f9e46d9a93bfe924d97a53d826b3 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 12 Oct 2026 13:06:04 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.7.2=20=E2=80=94=20your=20own=20agent?= =?UTF-8?q?=20instructions=20(#67),=20Settings=20as=20a=20workspace=20tab,?= =?UTF-8?q?=20Chat=20=E2=87=84=20terminal=20keeps=20the=20unsent=20prompt,?= =?UTF-8?q?=20a=20dev=20server=20no=20longer=20ends=20members'=20panes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand. --- CLAUDE.md | 4 +- client/src/components/Sagi/Sagi.tsx | 4 +- client/src/components/Sidebar.tsx | 15 +- .../conversation/MarkdownContent.tsx | 41 ++++- .../components/conversation/MessageList.tsx | 36 +++- .../__tests__/MarkdownContent.test.tsx | 37 ++++ .../__tests__/MessageList.sender.test.tsx | 20 +- client/src/components/lanes/DiffEditor.tsx | 71 ++++---- .../lanes/__tests__/DiffEditor.test.tsx | 8 +- client/src/components/run/ChatOutline.tsx | 4 +- client/src/components/run/ConsoleTabs.tsx | 75 +++++++- client/src/components/run/PaneChat.tsx | 5 +- client/src/components/run/PaneGrid.tsx | 24 ++- client/src/components/run/RunSetup.tsx | 105 +++++++---- client/src/components/run/TerminalView.tsx | 18 ++ .../run/__tests__/RunSetup.test.tsx | 29 ++- .../settings/AgentInstructionsSettings.tsx | 171 ++++++++++++++++++ .../components/settings/PricingSettings.tsx | 4 +- .../AgentInstructionsSettings.test.tsx | 96 ++++++++++ client/src/i18n/locales/en/lanes.json | 2 + client/src/i18n/locales/en/settings.json | 12 ++ client/src/i18n/locales/vi/lanes.json | 2 + client/src/i18n/locales/vi/settings.json | 12 ++ client/src/lib/__tests__/panePaste.test.ts | 61 ++++++- client/src/lib/__tests__/promptTokens.test.ts | 43 +++++ client/src/lib/__tests__/settingsLink.test.ts | 35 ++++ client/src/lib/api.ts | 15 ++ client/src/lib/panePaste.ts | 75 +++++++- client/src/lib/promptTokens.ts | 32 ++++ client/src/lib/settingsLink.ts | 35 ++++ client/src/pages/AiToolConfig.tsx | 4 +- client/src/pages/SettingsHub.tsx | 69 ++++--- client/src/pages/Workspace.tsx | 133 ++++++++++---- .../src/pages/__tests__/SettingsHub.test.tsx | 36 ++++ client/src/pages/__tests__/Workspace.test.tsx | 27 +++ .../screens.snapshot.test.tsx.snap | 36 ++-- docs/API.md | 4 +- docs/LANES.md | 8 +- docs/SERVER-MODE.md | 9 +- openapi.yaml | 89 ++++++++- package-lock.json | 4 +- package.json | 2 +- server/__tests__/lane-sources.test.js | 11 ++ server/__tests__/me-prefs.test.js | 29 +++ .../__tests__/s3-unseen-member-runs.test.js | 60 ++++++ server/db.js | 7 + server/lib/dashboard-runs.js | 5 +- server/lib/lane-sources.js | 42 ++++- server/lib/linux-accounts.js | 15 ++ server/openapi-extra/auth.js | 48 +++++ server/routes/lanes.js | 3 + server/routes/me.js | 30 +++ 52 files changed, 1559 insertions(+), 203 deletions(-) create mode 100644 client/src/components/settings/AgentInstructionsSettings.tsx create mode 100644 client/src/components/settings/__tests__/AgentInstructionsSettings.test.tsx create mode 100644 client/src/lib/__tests__/promptTokens.test.ts create mode 100644 client/src/lib/__tests__/settingsLink.test.ts create mode 100644 client/src/lib/promptTokens.ts create mode 100644 client/src/lib/settingsLink.ts create mode 100644 server/__tests__/s3-unseen-member-runs.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 911eef8d..beda257e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.2 (2026-10-12) — an account's own agent instructions go before every workspace's (#67); Chat ⇄ terminal keeps the unsent prompt; a server without S3 never ends a member's run._ 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. @@ -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-` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane--<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale. - **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-` 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 `/workspace-context//` 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 `/workspace-prompts/.md` at every spawn and passed as `--append-system-prompt-file=` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=` — 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 `/workspace-context//` 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 `/workspace-prompts/.md` at every spawn and passed as `--append-system-prompt-file=` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=` — 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 ` (`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=` — 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=` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `/.claude/worktrees/`; 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=&run=`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=&session=`, `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/` 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. diff --git a/client/src/components/Sagi/Sagi.tsx b/client/src/components/Sagi/Sagi.tsx index 3a7a2dd6..adfc0439 100644 --- a/client/src/components/Sagi/Sagi.tsx +++ b/client/src/components/Sagi/Sagi.tsx @@ -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")); }} /> diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index ad8bdef7..7b8ea4f9 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -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ĩ */ /* ============================================================================= @@ -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(null); @@ -533,9 +540,9 @@ export function Sidebar({ }`} > `${navClass(p)} ${collapsed ? "" : "flex-1"}`} + className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`} > {!collapsed && {t("nav:settings")}} diff --git a/client/src/components/conversation/MarkdownContent.tsx b/client/src/components/conversation/MarkdownContent.tsx index 7604ad7a..69d08607 100644 --- a/client/src/components/conversation/MarkdownContent.tsx +++ b/client/src/components/conversation/MarkdownContent.tsx @@ -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 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( - + {renderInline(boldM[2]!, `${baseKey}-b${n}`)} ); @@ -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({labelM[0]}); + i += labelM[0].length; + continue; + } + + const numM = numStart(text, i) ? NUM_RE.exec(rest) : null; + if (numM) { + push( + + {numM[0]} + + ); + i += numM[0].length; + continue; + } + buf += text[i]!; i++; } diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index d7437b68..9b1f222b 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -9,7 +9,8 @@ * image instead (`GET /api/run/drop/:name`), and Claude Code's own * `[Image: source: ]` 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 @@ -66,7 +67,7 @@ * * ----------------------------------------------------------------------------- */ -import { memo, useEffect, useState, useMemo } from "react"; +import { Fragment, memo, useEffect, useState, useMemo } from "react"; import { ImageLightbox } from "../ImageLightbox"; import { useTranslation } from "react-i18next"; import i18n from "../../i18n"; @@ -130,6 +131,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"; @@ -205,12 +207,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" ? ( + {linkify(tok.text)} + ) : ( + + {tok.text} + + ) + ); +} + +/** 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 ( -
- › - {display} +
+ {name} + {args.length > 0 && ( + {typedText(args.join(" "))} + )}
); } @@ -792,7 +814,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage }) const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text); return plain ? (

- {linkify(stripAnsi(text).trim())} + {typedText(stripAnsi(text).trim())}

) : ( renderTextBlock(text, b) diff --git a/client/src/components/conversation/__tests__/MarkdownContent.test.tsx b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx index 1a23eb03..597ccc9a 100644 --- a/client/src/components/conversation/__tests__/MarkdownContent.test.tsx +++ b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx @@ -66,6 +66,43 @@ describe("", () => { expect(screen.getByText("just a normal sentence.")).toBeInTheDocument(); }); + it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => { + const { container } = render( + + ); + 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( + + ); + 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(); + expect(container.querySelector("span.tabular-nums")).toBeNull(); + }); + + it("colours a flag label by how serious it is", () => { + render(); + 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(); // Wrapper exists but no block elements diff --git a/client/src/components/conversation/__tests__/MessageList.sender.test.tsx b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx index ea22fa2e..44a8c1fe 100644 --- a/client/src/components/conversation/__tests__/MessageList.sender.test.tsx +++ b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx @@ -4,11 +4,12 @@ * 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ĩ */ import { describe, it, expect } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { MessageList, workLabel, workingOn } from "../MessageList"; import type { TranscriptMessage } from "../../../lib/types"; @@ -22,6 +23,21 @@ function msg(partial: Partial): TranscriptMessage { } describe("MessageList — sender attribution", () => { + it("shows a typed /command and @path as chips in the user's bubble", () => { + render( + + ); + 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" }] }), diff --git a/client/src/components/lanes/DiffEditor.tsx b/client/src/components/lanes/DiffEditor.tsx index a1a9041e..be56d9d3 100644 --- a/client/src/components/lanes/DiffEditor.tsx +++ b/client/src/components/lanes/DiffEditor.tsx @@ -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({ - create: () => Decoration.none, - update(deco, tr) { - if (!tr.docChanged && !tr.selection) return deco; + return StateField.define({ + 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); diff --git a/client/src/components/lanes/__tests__/DiffEditor.test.tsx b/client/src/components/lanes/__tests__/DiffEditor.test.tsx index 95b4670c..757a00f9 100644 --- a/client/src/components/lanes/__tests__/DiffEditor.test.tsx +++ b/client/src/components/lanes/__tests__/DiffEditor.test.tsx @@ -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 } }); diff --git a/client/src/components/run/ChatOutline.tsx b/client/src/components/run/ChatOutline.tsx index 0dbcce55..be02fe8e 100644 --- a/client/src/components/run/ChatOutline.tsx +++ b/client/src/components/run/ChatOutline.tsx @@ -18,6 +18,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, @@ -209,6 +210,7 @@ export function ChatOutline({ onOpenList: (list: OutlineList) => void; }) { const { t } = useTranslation("run"); + const href = useSettingsHref(); const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length; const close = (
); })} + {settings && ( +
+ {settings.active && ( + + )} + + +
+ )}