Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a8be55b55 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- 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-<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.
|
- **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.
|
- **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.
|
- **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 workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
|
||||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||||
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), 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.
|
- **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.
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SagiAvatar } from "./SagiAvatar";
|
import { SagiAvatar } from "./SagiAvatar";
|
||||||
import { PetSprite } from "./PetSprite";
|
import { PetSprite } from "./PetSprite";
|
||||||
@@ -215,6 +216,7 @@ function SagiBody() {
|
|||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const reducedMotion = usePrefersReducedMotion();
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const href = useSettingsHref();
|
||||||
const brain = useSagiBrain();
|
const brain = useSagiBrain();
|
||||||
const place = useSagiPosition(size);
|
const place = useSagiPosition(size);
|
||||||
|
|
||||||
@@ -348,7 +350,7 @@ function SagiBody() {
|
|||||||
onClose={() => setMenu(false)}
|
onClose={() => setMenu(false)}
|
||||||
onSettings={() => {
|
onSettings={() => {
|
||||||
setMenu(false);
|
setMenu(false);
|
||||||
navigate("/settings/appearance#pet");
|
navigate(href("appearance#pet"));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sidebar.tsx
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -49,7 +49,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import {
|
import {
|
||||||
@@ -186,6 +187,12 @@ export function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuth();
|
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.
|
// Checking for (and applying) an update acts on the host: an admin's.
|
||||||
const canUpdate = usePermissions().isAdmin;
|
const canUpdate = usePermissions().isAdmin;
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
@@ -533,9 +540,9 @@ export function Sidebar({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to={settingsHref("", location)}
|
||||||
title={collapsed ? t("nav:settings") : undefined}
|
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" />
|
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
* 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
|
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||||
@@ -256,6 +259,22 @@ const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
|||||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
// 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]!);
|
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 }) {
|
function UrlLink({ url }: { url: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -354,7 +373,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||||
if (boldM) {
|
if (boldM) {
|
||||||
push(
|
push(
|
||||||
<strong className="font-semibold text-fg-primary">
|
<strong className="font-bold text-accent">
|
||||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||||
</strong>
|
</strong>
|
||||||
);
|
);
|
||||||
@@ -419,6 +438,24 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
continue;
|
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]!;
|
buf += text[i]!;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
||||||
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
* `[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
|
* 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
|
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||||
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
* 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 { ImageLightbox } from "../ImageLightbox";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
@@ -130,6 +131,7 @@ const SENDER_STYLES: Record<
|
|||||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||||
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
||||||
|
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
|
||||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||||
|
|
||||||
@@ -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" ? (
|
||||||
|
<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 }) {
|
function CommandPill({ display }: { display: string }) {
|
||||||
|
const [name, ...args] = display.split(" ");
|
||||||
return (
|
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">
|
<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="text-status-success/70">›</span>
|
<span className="font-semibold text-accent">{name}</span>
|
||||||
<span className="break-all">{display}</span>
|
{args.length > 0 && (
|
||||||
|
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -792,7 +814,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
|||||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||||
return plain ? (
|
return plain ? (
|
||||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{linkify(stripAnsi(text).trim())}
|
{typedText(stripAnsi(text).trim())}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
renderTextBlock(text, b)
|
renderTextBlock(text, b)
|
||||||
|
|||||||
@@ -66,6 +66,43 @@ describe("<MarkdownContent />", () => {
|
|||||||
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
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", () => {
|
it("handles empty input safely", () => {
|
||||||
const { container } = render(<MarkdownContent text="" />);
|
const { container } = render(<MarkdownContent text="" />);
|
||||||
// Wrapper exists but no block elements
|
// Wrapper exists but no block elements
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
* 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
|
* layout), Main agent / System under their own label — instead of treating
|
||||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
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 { MessageList, workLabel, workingOn } from "../MessageList";
|
||||||
import type { TranscriptMessage } from "../../../lib/types";
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("MessageList — sender attribution", () => {
|
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'", () => {
|
it("labels each row by its sender, not blanket 'User'", () => {
|
||||||
const messages: TranscriptMessage[] = [
|
const messages: TranscriptMessage[] = [
|
||||||
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||||
* a function that moves the cursor to the next / previous changed block.
|
* 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
|
* 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.
|
* which hands the panel the selection's 1-based lines.
|
||||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
highlightActiveLineGutter,
|
highlightActiveLineGutter,
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
|
showTooltip,
|
||||||
|
type Tooltip,
|
||||||
type DecorationSet,
|
type DecorationSet,
|
||||||
type ViewUpdate,
|
type ViewUpdate,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatChip extends WidgetType {
|
/** Add to Chat floating just above the selection's last line, at the column
|
||||||
constructor(
|
* where the selection ends — so a long line never pushes it out of view.
|
||||||
readonly start: number,
|
* `build` makes its DOM from the selection's first and last 1-based line. A
|
||||||
readonly end: number,
|
* selection ending at a line's start (a whole-line drag) does not take that
|
||||||
readonly build: (start: number, end: number) => HTMLElement
|
* line, and the chip then sits at the start of the last line it does take. */
|
||||||
) {
|
|
||||||
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. */
|
|
||||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||||
return StateField.define<DecorationSet>({
|
return StateField.define<Tooltip | null>({
|
||||||
create: () => Decoration.none,
|
create: () => null,
|
||||||
update(deco, tr) {
|
update(tip, tr) {
|
||||||
if (!tr.docChanged && !tr.selection) return deco;
|
if (!tr.docChanged && !tr.selection) return tip;
|
||||||
const { from, to, empty } = tr.state.selection.main;
|
const { from, to, empty } = tr.state.selection.main;
|
||||||
if (empty) return Decoration.none;
|
if (empty) return null;
|
||||||
const doc = tr.state.doc;
|
const doc = tr.state.doc;
|
||||||
const first = doc.lineAt(from);
|
const first = doc.lineAt(from);
|
||||||
const last = doc.lineAt(to);
|
const last = doc.lineAt(to);
|
||||||
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
|
const wholeLine = to === last.from && last.number > first.number;
|
||||||
const widget = new ChatChip(first.number, end, build);
|
const end = wholeLine ? last.number - 1 : last.number;
|
||||||
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
|
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-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
// Doubled classes: drawSelection's base theme scopes its light/dark colours
|
||||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
// 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`]: { backgroundColor: GREEN },
|
||||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||||
[`${M} .cm-changedText`]: { background: "none" },
|
[`${M} .cm-changedText`]: { background: "none" },
|
||||||
@@ -544,7 +549,7 @@ export default function DiffEditor({
|
|||||||
b.dataset.testid = "lane-editor-add-to-chat";
|
b.dataset.testid = "lane-editor-add-to-chat";
|
||||||
b.textContent = cb.current.t("changes.addToChat");
|
b.textContent = cb.current.t("changes.addToChat");
|
||||||
b.className =
|
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.
|
// Keep the editor's focus (and so its selection) through the click.
|
||||||
b.onmousedown = (e) => e.preventDefault();
|
b.onmousedown = (e) => e.preventDefault();
|
||||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||||
|
|||||||
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
|
|||||||
expect(b).not.toBeNull();
|
expect(b).not.toBeNull();
|
||||||
return b!;
|
return b!;
|
||||||
});
|
});
|
||||||
// Inline at the end of the first selected line, never a tooltip over the
|
// A tooltip above the last selected line, never inline at a line's end
|
||||||
// line above it.
|
// (a long line pushed it out of view).
|
||||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
expect(chip.classList.contains("cm-tooltip")).toBe(true);
|
||||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
expect(chip.closest(".cm-line")).toBeNull();
|
||||||
fireEvent.click(chip);
|
fireEvent.click(chip);
|
||||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||||
view.dispatch({ selection: { anchor: 6 } });
|
view.dispatch({ selection: { anchor: 6 } });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Check,
|
Check,
|
||||||
@@ -209,6 +210,7 @@ export function ChatOutline({
|
|||||||
onOpenList: (list: OutlineList) => void;
|
onOpenList: (list: OutlineList) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
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 used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
||||||
const close = (
|
const close = (
|
||||||
<button
|
<button
|
||||||
@@ -329,7 +331,7 @@ export function ChatOutline({
|
|||||||
icon={Plug}
|
icon={Plug}
|
||||||
label={t("chat.outline.connectors")}
|
label={t("chat.outline.connectors")}
|
||||||
names={o.mcp}
|
names={o.mcp}
|
||||||
to="/settings/agents/claude-code?tab=mcp"
|
to={href("agents/claude-code?tab=mcp")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
* one, up to four panes). A right-click on a tab
|
* one, up to four panes). A right-click on a tab
|
||||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -43,8 +45,9 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import type { RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
@@ -152,6 +155,9 @@ interface Tab {
|
|||||||
|
|
||||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
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({
|
export function ConsoleTabs({
|
||||||
lane,
|
lane,
|
||||||
tabs: runs,
|
tabs: runs,
|
||||||
@@ -164,6 +170,7 @@ export function ConsoleTabs({
|
|||||||
sessions,
|
sessions,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
workspaceOf,
|
workspaceOf,
|
||||||
|
settings,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** The workspace's live runs, in slot order. */
|
/** The workspace's live runs, in slot order. */
|
||||||
@@ -187,9 +194,12 @@ export function ConsoleTabs({
|
|||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
/** Right end of the bar (the single pane's split menus). */
|
/** Right end of the bar (the single pane's split menus). */
|
||||||
actions?: ReactNode;
|
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 { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const settingsHref = useSettingsHref();
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
// The tab being renamed (its run id) and the draft name.
|
// 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.
|
// 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 onTabKey = (e: ReactKeyboardEvent, i: number) => {
|
||||||
const last = tabs.length - 1;
|
const last = keys.length - 1;
|
||||||
const to =
|
const to =
|
||||||
e.key === "ArrowRight"
|
e.key === "ArrowRight"
|
||||||
? i === last
|
? i === last
|
||||||
@@ -413,11 +424,14 @@ export function ConsoleTabs({
|
|||||||
: e.key === "End"
|
: e.key === "End"
|
||||||
? last
|
? last
|
||||||
: null;
|
: null;
|
||||||
if (to === null || !tabs[to]) return;
|
const key = to === null ? undefined : keys[to];
|
||||||
|
if (key === undefined) return;
|
||||||
e.preventDefault();
|
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.
|
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||||
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
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"
|
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
||||||
>
|
>
|
||||||
{tabs.map((tab, i) => {
|
{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 single = tab.runs.length === 1 ? tab.focus : null;
|
||||||
const state = tabState(tab);
|
const state = tabState(tab);
|
||||||
if (single && renaming === single.id) {
|
if (single && renaming === single.id) {
|
||||||
@@ -569,6 +583,48 @@ export function ConsoleTabs({
|
|||||||
</div>
|
</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>
|
||||||
<div ref={menuRef} className="relative flex items-center px-1.5">
|
<div ref={menuRef} className="relative flex items-center px-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -618,7 +674,10 @@ export function ConsoleTabs({
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={`${item} !text-fg-muted`}
|
className={`${item} !text-fg-muted`}
|
||||||
onClick={() => navigate("/settings/agents")}
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
navigate(settingsHref("agents"));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4" />
|
<Settings2 className="h-4 w-4" />
|
||||||
{t("tabs.agentSettings")}
|
{t("tabs.agentSettings")}
|
||||||
|
|||||||
@@ -28,8 +28,9 @@
|
|||||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||||
* "Edited N files" card opens in the Workspace's Changes panel
|
* "Edited N files" card opens in the Workspace's Changes panel
|
||||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
* (`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
|
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||||
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
|
* 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
|
* conversation to its end and keeps it there (`follow`), so the message and
|
||||||
* the step running after it come into sight on their own. While the session's
|
* the step running after it come into sight on their own. While the session's
|
||||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ import {
|
|||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
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";
|
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||||
@@ -195,7 +198,9 @@ 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";
|
"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
|
/** 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. */
|
||||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const chat = useSessionChat(run?.sessionId);
|
const chat = useSessionChat(run?.sessionId);
|
||||||
@@ -209,7 +214,22 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
|||||||
aria-pressed={chat}
|
aria-pressed={chat}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setSessionChat(sessionId, !chat)}
|
onClick={() => {
|
||||||
|
const draft = readChatDraft(sessionId);
|
||||||
|
if (chat) {
|
||||||
|
const text = promptWithImages(draft.prompt, draft.images);
|
||||||
|
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []);
|
||||||
|
} else {
|
||||||
|
const text = takeRunInput(run.id);
|
||||||
|
if (text)
|
||||||
|
writeChatDraft(
|
||||||
|
sessionId,
|
||||||
|
[draft.prompt, text].filter(Boolean).join("\n"),
|
||||||
|
draft.images
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSessionChat(sessionId, !chat);
|
||||||
|
}}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
{chat ? (
|
{chat ? (
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
* 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;
|
* 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;
|
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
@@ -61,6 +63,7 @@ import { Select, menuRoom } from "../Select";
|
|||||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { ImageLightbox } from "../ImageLightbox";
|
import { ImageLightbox } from "../ImageLightbox";
|
||||||
|
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||||
import {
|
import {
|
||||||
ATTACH_MIME,
|
ATTACH_MIME,
|
||||||
addAttachments,
|
addAttachments,
|
||||||
@@ -224,6 +227,15 @@ export function PromptEditor({
|
|||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
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
|
// 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.
|
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -232,6 +244,10 @@ export function PromptEditor({
|
|||||||
ta.style.height = "auto";
|
ta.style.height = "auto";
|
||||||
ta.style.height = `${ta.scrollHeight}px`;
|
ta.style.height = `${ta.scrollHeight}px`;
|
||||||
}, [bare, value]);
|
}, [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 [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [files, setFiles] = useState<string[]>([]);
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
@@ -545,39 +561,64 @@ export function PromptEditor({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<div className="relative">
|
||||||
ref={taRef}
|
<textarea
|
||||||
value={value}
|
ref={taRef}
|
||||||
onChange={(e) => {
|
onScroll={(e) => {
|
||||||
onChange(e.target.value);
|
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||||
retrack(e.target);
|
}}
|
||||||
setActive(0);
|
value={value}
|
||||||
}}
|
onChange={(e) => {
|
||||||
onSelect={(e) => retrack(e.currentTarget)}
|
onChange(e.target.value);
|
||||||
onBlur={() => setTrigger(null)}
|
retrack(e.target);
|
||||||
onKeyDown={handleKeyDown}
|
setActive(0);
|
||||||
onPaste={(e) => {
|
}}
|
||||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
onSelect={(e) => retrack(e.currentTarget)}
|
||||||
else {
|
onBlur={() => setTrigger(null)}
|
||||||
// A very long paste goes in as a text file, like Claude Desktop:
|
onKeyDown={handleKeyDown}
|
||||||
// the prompt then carries its path, never the text (#68: a CLI's
|
onPaste={(e) => {
|
||||||
// argument and a tmux message both have a size limit).
|
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
else {
|
||||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
// A very long paste goes in as a text file, like Claude Desktop:
|
||||||
e.preventDefault();
|
// the prompt then carries its path, never the text (#68: a CLI's
|
||||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
// 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}
|
||||||
placeholder={placeholder}
|
rows={rows}
|
||||||
rows={rows}
|
spellCheck={false}
|
||||||
spellCheck={false}
|
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
|
||||||
className={
|
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
|
||||||
bare
|
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
|
||||||
? "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"
|
{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 && (
|
{upload && (
|
||||||
<p
|
<p
|
||||||
role={upload === "failed" ? "alert" : "status"}
|
role={upload === "failed" ? "alert" : "status"}
|
||||||
|
|||||||
@@ -562,6 +562,24 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||||
(data) => {
|
(data) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(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]
|
[runId]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
* 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`
|
* 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
|
* (`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,
|
* 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
|
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
@@ -117,6 +117,31 @@ function renderEditor(value: string, slash = true) {
|
|||||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||||
|
|
||||||
describe("PromptEditor — / and @ suggestions", () => {
|
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 () => {
|
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||||
const spies = renderEditor("");
|
const spies = renderEditor("");
|
||||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||||
|
|||||||
@@ -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 { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
|
|||||||
*/
|
*/
|
||||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
const href = useSettingsHref();
|
||||||
return (
|
return (
|
||||||
<div className="card flex items-center justify-between gap-4 p-5">
|
<div className="card flex items-center justify-between gap-4 p-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||||
</div>
|
</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")}
|
{t("pricingLink.open")}
|
||||||
<ArrowRight className="h-3.5 w-3.5" />
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"tabs.close": "Close tab (stops its session)",
|
"tabs.close": "Close tab (stops its session)",
|
||||||
"tabs.full": "All 10 sessions are in use — stop one first",
|
"tabs.full": "All 10 sessions are in use — stop one first",
|
||||||
"tabs.agentSettings": "Agent settings…",
|
"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",
|
"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.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",
|
"panes.pick": "Show another session in this pane",
|
||||||
|
|||||||
@@ -1032,5 +1032,17 @@
|
|||||||
"label": "Open new sessions as a chat",
|
"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.",
|
"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}}"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,8 @@
|
|||||||
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
"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.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
|
||||||
"tabs.agentSettings": "Cài đặt agent…",
|
"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í",
|
"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.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",
|
"panes.pick": "Hiện session khác trong pane này",
|
||||||
|
|||||||
@@ -1032,5 +1032,17 @@
|
|||||||
"label": "Mở session mới dạng chat",
|
"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.",
|
"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}}"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,38 @@
|
|||||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
* 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
|
* 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
|
* 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.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
import {
|
||||||
|
interruptRun,
|
||||||
|
notePaneFocus,
|
||||||
|
parsePromptInput,
|
||||||
|
pasteToLane,
|
||||||
|
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", () => {
|
describe("panePaste", () => {
|
||||||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||||
@@ -65,4 +91,35 @@ describe("panePaste", () => {
|
|||||||
expect(keys).toEqual(["\x1b"]);
|
expect(keys).toEqual(["\x1b"]);
|
||||||
off();
|
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("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -692,6 +692,14 @@ export const api = {
|
|||||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||||
savePrefs: (prefs: Record<string, string | null>) =>
|
savePrefs: (prefs: Record<string, string | null>) =>
|
||||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
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). */
|
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||||
},
|
},
|
||||||
@@ -3566,6 +3574,13 @@ export interface GitAuthNeeded {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
|
/** 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 {
|
export interface AiTool {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -10,27 +10,35 @@
|
|||||||
* unseen); the caller says where it went, or that it went nowhere. The one
|
* 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
|
* 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
|
* 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.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 {
|
interface Pane {
|
||||||
paste: (text: string, submit?: boolean) => void;
|
paste: (text: string, submit?: boolean) => void;
|
||||||
visible: () => boolean;
|
visible: () => boolean;
|
||||||
key?: (data: string) => void;
|
key?: (data: string) => void;
|
||||||
|
screen?: () => PaneScreen | null;
|
||||||
}
|
}
|
||||||
const panes = new Map<string, Pane>();
|
const panes = new Map<string, Pane>();
|
||||||
let lastFocused: string | null = null;
|
let lastFocused: string | null = null;
|
||||||
|
|
||||||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
/** 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(
|
export function registerPane(
|
||||||
runId: string,
|
runId: string,
|
||||||
paste: (text: string, submit?: boolean) => void,
|
paste: (text: string, submit?: boolean) => void,
|
||||||
visible: () => boolean = () => true,
|
visible: () => boolean = () => true,
|
||||||
key?: (data: string) => void
|
key?: (data: string) => void,
|
||||||
|
screen?: () => PaneScreen | null
|
||||||
): () => void {
|
): () => void {
|
||||||
const entry = { paste, visible, key };
|
const entry = { paste, visible, key, screen };
|
||||||
panes.set(runId, entry);
|
panes.set(runId, entry);
|
||||||
return () => {
|
return () => {
|
||||||
if (panes.get(runId) === entry) panes.delete(runId);
|
if (panes.get(runId) === entry) panes.delete(runId);
|
||||||
@@ -84,3 +92,62 @@ export function pasteToLane(laneId: number, text: string): string | null {
|
|||||||
panes.get(id)!.paste(text);
|
panes.get(id)!.paste(text);
|
||||||
return id;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, type ComponentType } from "react";
|
import { useEffect, useState, type ComponentType } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSettingsHref } from "../lib/settingsLink";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
|
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
|
||||||
import { api, type AiToolConfigFile } from "../lib/api";
|
import { api, type AiToolConfigFile } from "../lib/api";
|
||||||
@@ -235,6 +236,7 @@ function Card({
|
|||||||
/** The tab for one catalog tool. */
|
/** The tab for one catalog tool. */
|
||||||
export function AiToolConfig({ toolId }: { toolId: string }) {
|
export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
const href = useSettingsHref();
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const tool = aiTools.byId(toolId);
|
const tool = aiTools.byId(toolId);
|
||||||
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
|
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
|
||||||
@@ -273,7 +275,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{tool.update && (
|
{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" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
{t("toolTab.update")}
|
{t("toolTab.update")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
* marks AI tools current, the search shows their matches under it), and a
|
* 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.
|
* 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.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +50,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
|
import { useSettingsHref } from "../lib/settingsLink";
|
||||||
import { CcConfig } from "./CcConfig";
|
import { CcConfig } from "./CcConfig";
|
||||||
import { CodexConfig } from "./CodexConfig";
|
import { CodexConfig } from "./CodexConfig";
|
||||||
import { AiToolConfig } from "./AiToolConfig";
|
import { AiToolConfig } from "./AiToolConfig";
|
||||||
@@ -55,6 +60,7 @@ import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
|||||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||||
|
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
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).
|
// AI Proxy is not here: every user keeps personal profiles (S3).
|
||||||
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
|
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
|
||||||
|
|
||||||
/** An unknown section: say so, then show Appearance. */
|
/** An unknown section: say so, then show Appearance (`to`). */
|
||||||
function UnknownSection({ name }: { name: string }) {
|
function UnknownSection({ name, to }: { name: string; to: string }) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toast.info(t("settingsNav.unknownSection", { section: name }));
|
toast.info(t("settingsNav.unknownSection", { section: name }));
|
||||||
}, [name, t]);
|
}, [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 { t } = useTranslation("settings");
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
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 aiTools = useAiTools();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -168,6 +178,8 @@ export function SettingsHub() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") return;
|
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.
|
// A config editor's own find keeps the shortcut.
|
||||||
const el = e.target;
|
const el = e.target;
|
||||||
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
|
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
|
||||||
@@ -257,7 +269,12 @@ export function SettingsHub() {
|
|||||||
group: "agents",
|
group: "agents",
|
||||||
Icon: Bot,
|
Icon: Bot,
|
||||||
...s("agents"),
|
...s("agents"),
|
||||||
render: () => <AiToolsSettings settingsHref={toolHref} />,
|
render: () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AgentInstructionsSettings />
|
||||||
|
<AiToolsSettings settingsHref={toolHref} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
...(enabled("claude")
|
...(enabled("claude")
|
||||||
? [
|
? [
|
||||||
@@ -381,7 +398,7 @@ export function SettingsHub() {
|
|||||||
const toolTabs = sections.filter((x) => x.tool);
|
const toolTabs = sections.filter((x) => x.tool);
|
||||||
function toolHref(id: string) {
|
function toolHref(id: string) {
|
||||||
const page = toolTabs.find((x) => x.tool === id);
|
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) => [
|
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) {
|
if (!rest) {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const tool = params.get("tool");
|
const tool = params.get("tool");
|
||||||
@@ -412,9 +431,9 @@ export function SettingsHub() {
|
|||||||
: tool
|
: tool
|
||||||
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
||||||
: "appearance";
|
: "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);
|
const active = sections.find((x) => x.id === rest);
|
||||||
if (!active) {
|
if (!active) {
|
||||||
// A catalog tool page exists only once the tool list has loaded.
|
// 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" />
|
<Skeleton className="h-40 w-full" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return <UnknownSection name={rest} />;
|
return <UnknownSection name={rest} to={href("appearance")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranked = query.trim() ? rankSettings(query, entries) : null;
|
const ranked = query.trim() ? rankSettings(query, entries) : null;
|
||||||
@@ -437,8 +456,8 @@ export function SettingsHub() {
|
|||||||
const navActive = navId(active.id);
|
const navActive = navId(active.id);
|
||||||
const results = ranked ? rankSettingsEntries(query, entries) : [];
|
const results = ranked ? rankSettingsEntries(query, entries) : [];
|
||||||
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
|
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
|
||||||
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
|
const hrefOf = (e: SettingsEntry) => href(`${e.section}${e.to ?? ""}`);
|
||||||
const go = (id: string) => navigate(`/settings/${id}`);
|
const go = (id: string) => navigate(href(id), nav);
|
||||||
const back = (e: MouseEvent) => {
|
const back = (e: MouseEvent) => {
|
||||||
if (!canGoBack) return;
|
if (!canGoBack) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -454,7 +473,7 @@ export function SettingsHub() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
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("");
|
if (e.key === "Escape") setQuery("");
|
||||||
}}
|
}}
|
||||||
placeholder={t("search.placeholder")}
|
placeholder={t("search.placeholder")}
|
||||||
@@ -472,6 +491,7 @@ export function SettingsHub() {
|
|||||||
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
||||||
<Link
|
<Link
|
||||||
to={hrefOf(r)}
|
to={hrefOf(r)}
|
||||||
|
replace={embedded}
|
||||||
onClick={() => setQuery("")}
|
onClick={() => setQuery("")}
|
||||||
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
|
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 (
|
return (
|
||||||
<div>
|
<div ref={rootRef}>
|
||||||
<Link
|
{!embedded && (
|
||||||
to="/workspace"
|
<Link
|
||||||
onClick={back}
|
to="/workspace"
|
||||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
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")}
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
</Link>
|
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<div className="flex gap-8">
|
<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">
|
<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}
|
{searchBox}
|
||||||
@@ -516,7 +538,8 @@ export function SettingsHub() {
|
|||||||
{items.map(({ id, title, Icon }) => (
|
{items.map(({ id, title, Icon }) => (
|
||||||
<Link
|
<Link
|
||||||
key={id}
|
key={id}
|
||||||
to={`/settings/${id}`}
|
to={href(id)}
|
||||||
|
replace={embedded}
|
||||||
aria-current={id === navActive ? "page" : undefined}
|
aria-current={id === navActive ? "page" : undefined}
|
||||||
data-first-match={id === first ? "true" : 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 ${
|
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
|
* 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,
|
* the old one until saved or discarded). Its bottom fold, Workspace context,
|
||||||
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +120,8 @@ import {
|
|||||||
updateActive,
|
updateActive,
|
||||||
} from "../lib/paneLayout";
|
} from "../lib/paneLayout";
|
||||||
import { readAllTabs } from "../lib/workspaceTabs";
|
import { readAllTabs } from "../lib/workspaceTabs";
|
||||||
|
import { settingsHref } from "../lib/settingsLink";
|
||||||
|
import { SettingsHub } from "./SettingsHub";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
|
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
|
||||||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||||||
@@ -204,6 +210,7 @@ function ConsoleArea({
|
|||||||
onOpenRun,
|
onOpenRun,
|
||||||
onRunsChanged,
|
onRunsChanged,
|
||||||
allTabs = false,
|
allTabs = false,
|
||||||
|
settings = null,
|
||||||
}: {
|
}: {
|
||||||
lanes: Lane[];
|
lanes: Lane[];
|
||||||
selectedLaneId: number | null;
|
selectedLaneId: number | null;
|
||||||
@@ -226,6 +233,14 @@ function ConsoleArea({
|
|||||||
onRunsChanged: () => void;
|
onRunsChanged: () => void;
|
||||||
/** Tabs from every workspace, not only the selected one's. */
|
/** Tabs from every workspace, not only the selected one's. */
|
||||||
allTabs?: boolean;
|
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 { t } = useTranslation("lanes");
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
@@ -473,7 +488,8 @@ function ConsoleArea({
|
|||||||
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
||||||
const areaRef = useRef<HTMLDivElement>(null);
|
const areaRef = useRef<HTMLDivElement>(null);
|
||||||
onKeys.current = (e) => {
|
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
|
// 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.
|
// panel at full width, a replay, the home) — CSS-hidden, so by class.
|
||||||
if (document.querySelector('[role="dialog"]')) return;
|
if (document.querySelector('[role="dialog"]')) return;
|
||||||
@@ -520,9 +536,10 @@ function ConsoleArea({
|
|||||||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||||||
onClosed={(runId) => ended(keyOf(runId))}
|
onClosed={(runId) => ended(keyOf(runId))}
|
||||||
onHome={onNewSession}
|
onHome={onNewSession}
|
||||||
|
settings={settings}
|
||||||
// While one pane is shown, its split menus (a split pane has its own).
|
// While one pane is shown, its split menus (a split pane has its own).
|
||||||
actions={
|
actions={
|
||||||
shown.length === 1 && shown[0] !== undefined ? (
|
!settings?.active && shown.length === 1 && shown[0] !== undefined ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<SplitMenus
|
<SplitMenus
|
||||||
slot={shown[0]}
|
slot={shown[0]}
|
||||||
@@ -540,38 +557,48 @@ function ConsoleArea({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PaneGrid
|
{settings && (
|
||||||
layout={view}
|
<div
|
||||||
mounted={mounted}
|
data-testid="workspace-settings"
|
||||||
runs={tabs}
|
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
|
||||||
label={label}
|
>
|
||||||
onChange={change}
|
<SettingsHub section={settings.section} />
|
||||||
canStart={free !== null}
|
</div>
|
||||||
tools={splitTools}
|
)}
|
||||||
toolLabel={aiTools.label}
|
<div className={settings?.active ? "hidden" : "contents"}>
|
||||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
<PaneGrid
|
||||||
stateOf={stateOf}
|
layout={view}
|
||||||
onStop={(slot) => void stopPane(slot)}
|
mounted={mounted}
|
||||||
renderPane={(slot) => (
|
runs={tabs}
|
||||||
<LaneConsolePane
|
label={label}
|
||||||
lanes={lanes}
|
onChange={change}
|
||||||
// With `allTabs` a pane is bound to its own run's workspace.
|
canStart={free !== null}
|
||||||
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
tools={splitTools}
|
||||||
slot={slotOfKey(slot)}
|
toolLabel={aiTools.label}
|
||||||
binaryStatus={binaryStatus}
|
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||||
activeRuns={activeRuns}
|
stateOf={stateOf}
|
||||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
onStop={(slot) => void stopPane(slot)}
|
||||||
onNewSession={onNewSession}
|
renderPane={(slot) => (
|
||||||
onRunExit={() => ended(slot)}
|
<LaneConsolePane
|
||||||
// Only the focused pane takes the keyboard when it appears.
|
lanes={lanes}
|
||||||
autoFocus={slot === focusSlot}
|
// With `allTabs` a pane is bound to its own run's workspace.
|
||||||
panelOpen={spanning.has(slot)}
|
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||||||
attachRequest={
|
slot={slotOfKey(slot)}
|
||||||
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -592,6 +619,14 @@ export function Workspace() {
|
|||||||
// `?session=` — an ENDED session picked in the sidebar or Recents: its
|
// `?session=` — an ENDED session picked in the sidebar or Recents: its
|
||||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||||
const sessionParam = runParam ? null : searchParams.get("session");
|
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();
|
const navigate = useNavigate();
|
||||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
|
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
|
||||||
// it rides on the navigation's state, never in the URL.
|
// 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 —
|
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
|
||||||
// the console, panes and Changes panel, which the home only hides (CSS), so
|
// the console, panes and Changes panel, which the home only hides (CSS), so
|
||||||
// a live terminal never unmounts behind it.
|
// a live terminal never unmounts behind it.
|
||||||
const home = !!currentLane && !runParam && !sessionParam;
|
const settingsOn = !!currentLane && settingsParam !== null;
|
||||||
const replaying = !!currentLane && !!sessionParam;
|
const home = !!currentLane && !runParam && !sessionParam && !settingsOn;
|
||||||
|
const replaying = !!currentLane && !!sessionParam && !settingsOn;
|
||||||
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
||||||
// or the one the opened pane holds.
|
// or the one the opened pane holds.
|
||||||
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
||||||
@@ -1341,6 +1377,31 @@ export function Workspace() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onRunsChanged={refreshList}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ vi.mock("../../components/settings/ReportSettings", () => ({
|
|||||||
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||||
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
|
||||||
|
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
|
||||||
|
}));
|
||||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||||
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
|
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
|
||||||
}));
|
}));
|
||||||
@@ -330,3 +333,36 @@ describe("SettingsHub", () => {
|
|||||||
off();
|
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),
|
confirmDiscard: vi.fn(async () => true),
|
||||||
}));
|
}));
|
||||||
// An ended session's replay (its own suite): a stub that names its session.
|
// 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", () => ({
|
vi.mock("../../components/lanes/SessionReplay", () => ({
|
||||||
SessionReplay: ({ sessionId }: { sessionId: string }) => (
|
SessionReplay: ({ sessionId }: { sessionId: string }) => (
|
||||||
<div data-testid="session-replay">{sessionId}</div>
|
<div data-testid="session-replay">{sessionId}</div>
|
||||||
@@ -629,6 +634,28 @@ describe("console tabs", () => {
|
|||||||
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
|
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 () => {
|
it("opens a new terminal in the first free slot from the + menu", async () => {
|
||||||
withRuns([live(0)]);
|
withRuns([live(0)]);
|
||||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
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"
|
class="flex min-h-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative min-h-0 flex-1"
|
class="contents"
|
||||||
data-testid="pane-grid"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute flex flex-col"
|
class="relative min-h-0 flex-1"
|
||||||
data-focused="true"
|
data-testid="pane-grid"
|
||||||
data-menu-bounds="true"
|
|
||||||
data-testid="pane-0"
|
|
||||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
class="flex min-h-0 flex-1 flex-col"
|
||||||
data-testid="console-body"
|
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||||
data-testid="pane-no-run"
|
data-testid="console-body"
|
||||||
>
|
>
|
||||||
<p>
|
<div
|
||||||
No session in this pane. Start one from the workspace's prompt box.
|
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"
|
||||||
</p>
|
data-testid="pane-no-run"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
No session in this pane. Start one from the workspace's prompt box.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7174,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.7.1 (rebuild client to match)
|
UI build v2.7.2 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-1
@@ -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 |
|
| `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 |
|
| `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) |
|
| `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.
|
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.
|
||||||
@@ -1386,7 +1388,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.
|
- **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`.
|
- 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)
|
#### Projects (workspaces grouped by repository)
|
||||||
|
|
||||||
|
|||||||
+4
-4
File diff suppressed because one or more lines are too long
+6
-3
@@ -797,8 +797,11 @@ agenthub users provision alice # the Linux account only (a user made before
|
|||||||
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
|
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
|
||||||
recorded ones).
|
recorded ones).
|
||||||
- Keep every other AgentHub server off the same database (a dev server on
|
- 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
|
another port). A server without S3 cannot see members' tmux servers, so from
|
||||||
their runs abandoned.
|
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`).
|
||||||
|
|
||||||
### Undo and uninstall
|
### Undo and uninstall
|
||||||
|
|
||||||
@@ -847,7 +850,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 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 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. |
|
| `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`. |
|
| `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`. |
|
| `[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>`. |
|
| `[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
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.7.1
|
version: 2.7.2
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -13822,6 +13822,93 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$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:
|
/api/me/onboarded:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.1",
|
"version": "2.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.1",
|
"version": "2.7.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.1",
|
"version": "2.7.2",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -238,6 +238,17 @@ describe("lane-sources instructions and launch", () => {
|
|||||||
src.setInstructions(codexLane.id, null);
|
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", () => {
|
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
|
||||||
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
|
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
|
||||||
addDirs: [],
|
addDirs: [],
|
||||||
|
|||||||
@@ -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 () => {
|
it("marks the first-run setup done, once", async () => {
|
||||||
const r = await call("POST", "/api/me/onboarded");
|
const r = await call("POST", "/api/me/onboarded");
|
||||||
assert.match(r.body.onboardedAt, /^\d{4}-/);
|
assert.match(r.body.onboardedAt, /^\d{4}-/);
|
||||||
@@ -117,6 +133,19 @@ describe("server mode: per account", () => {
|
|||||||
assert.deepEqual(m, { prefs: { i18nextLng: "en" }, onboardedAt: null });
|
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 () => {
|
it("puts an S3 member's mode into their Claude Code theme, as them", async () => {
|
||||||
const sudo = fakeSudo();
|
const sudo = fakeSudo();
|
||||||
runAs.__setExecImpl(sudo.exec);
|
runAs.__setExecImpl(sudo.exec);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* @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}` });
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1778,6 +1778,13 @@ db.exec(`
|
|||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
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
|
// The `member` role was renamed `developer`: rebuild `users` for the new
|
||||||
// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
|
// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ function claimToolSessions(sessionId = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runningStmt = db.prepare(
|
const runningStmt = db.prepare(
|
||||||
"SELECT id, tool, session_id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
"SELECT id, tool, session_id, cwd FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||||
);
|
);
|
||||||
const endStmt = db.prepare(`
|
const endStmt = db.prepare(`
|
||||||
UPDATE dashboard_runs
|
UPDATE dashboard_runs
|
||||||
@@ -298,8 +298,9 @@ const pruneShellsStmt = db.prepare(`
|
|||||||
function reconcileOrphans(isAlive = () => false) {
|
function reconcileOrphans(isAlive = () => false) {
|
||||||
try {
|
try {
|
||||||
let changes = 0;
|
let changes = 0;
|
||||||
|
const { unseenMemberFolder } = require("./linux-accounts");
|
||||||
for (const row of runningStmt.all()) {
|
for (const row of runningStmt.all()) {
|
||||||
if (isAlive(row.id)) continue;
|
if (isAlive(row.id) || unseenMemberFolder(row.cwd)) continue;
|
||||||
const interrupted =
|
const interrupted =
|
||||||
row.tool !== "shell" &&
|
row.tool !== "shell" &&
|
||||||
!!row.session_id &&
|
!!row.session_id &&
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
* name it chooses, so a request never names a path on disk. Only Claude
|
* name it chooses, so a request never names a path on disk. Only Claude
|
||||||
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
|
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
|
||||||
* `-c developer_instructions=`) can take them; every other tool is reported
|
* `-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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -185,8 +187,8 @@ function removeLaneContext(laneId) {
|
|||||||
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
|
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInstructions(laneId, text) {
|
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
|
||||||
laneRow(laneId);
|
function cleanInstructions(text) {
|
||||||
if (text !== null && typeof text !== "string") {
|
if (text !== null && typeof text !== "string") {
|
||||||
throw fail("EBADFIELD", "instructions must be a string or null");
|
throw fail("EBADFIELD", "instructions must be a string or null");
|
||||||
}
|
}
|
||||||
@@ -194,9 +196,37 @@ function setInstructions(laneId, text) {
|
|||||||
if (value && value.length > MAX_INSTRUCTIONS) {
|
if (value && value.length > MAX_INSTRUCTIONS) {
|
||||||
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
|
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);
|
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
|
* 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
|
* 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) {
|
function launchExtras(laneId, tool) {
|
||||||
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
|
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
|
||||||
if (!supports(tool)) return empty;
|
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;
|
if (!lane) return empty;
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
|
.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 addDirs = [...folders, ...(files.length ? [ctx] : [])];
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const mine = ownerInstructions(lane.owner_id);
|
||||||
|
if (mine) parts.push(mine);
|
||||||
if (lane.instructions) parts.push(lane.instructions);
|
if (lane.instructions) parts.push(lane.instructions);
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
parts.push(
|
parts.push(
|
||||||
@@ -366,6 +398,8 @@ module.exports = {
|
|||||||
removeLaneContext,
|
removeLaneContext,
|
||||||
setInstructions,
|
setInstructions,
|
||||||
launchExtras,
|
launchExtras,
|
||||||
|
setUserInstructions,
|
||||||
|
getUserInstructions,
|
||||||
codexInstructions,
|
codexInstructions,
|
||||||
tomlString,
|
tomlString,
|
||||||
INLINE_PROMPT_BYTES,
|
INLINE_PROMPT_BYTES,
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ function s3Enabled() {
|
|||||||
return require("./auth").isServerMode() && serverConf() !== null;
|
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}`;
|
const loginFor = (username) => `ah-${username}`;
|
||||||
|
|
||||||
/** The Linux side of an AgentHub user, or null outside S3. */
|
/** The Linux side of an AgentHub user, or null outside S3. */
|
||||||
@@ -129,6 +143,7 @@ module.exports = {
|
|||||||
LINUX_NAME_RE,
|
LINUX_NAME_RE,
|
||||||
serverConf,
|
serverConf,
|
||||||
s3Enabled,
|
s3Enabled,
|
||||||
|
unseenMemberFolder,
|
||||||
accountFor,
|
accountFor,
|
||||||
assertLinuxName,
|
assertLinuxName,
|
||||||
provision,
|
provision,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
* - 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/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
|
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -90,6 +91,23 @@ const changeResponses = {
|
|||||||
const prefsBody = {
|
const prefsBody = {
|
||||||
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
|
"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 = {
|
const paths = {
|
||||||
"/api/me/prefs": {
|
"/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": {
|
"/api/me/onboarded": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ function lastEventAge(lane) {
|
|||||||
*/
|
*/
|
||||||
function healRunId(lane, live = null) {
|
function healRunId(lane, live = null) {
|
||||||
if (!lane.run_id) return lane;
|
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) {
|
||||||
if (live.has(lane.run_id)) return lane;
|
if (live.has(lane.run_id)) return lane;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
|
* 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
|
* 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.
|
* 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`);
|
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
|
||||||
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
|
* 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));
|
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) => {
|
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
||||||
const key = userKey(req);
|
const key = userKey(req);
|
||||||
if (!key) return unauth(res);
|
if (!key) return unauth(res);
|
||||||
|
|||||||
Reference in New Issue
Block a user