Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a6dc6a29e | |||
| c042519bc9 | |||
| 383b9ae231 | |||
| aee4fc190e |
@@ -1,13 +1,13 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.13 (2026-10-20) — Windows paths in chat open as files; AI Tools npm install falls back to `--prefix ~/.local`; editing a skill artifact writes a new snapshot, a shared row its owner deletes stays in the Hub; Files uploads small files in batches (a member's: one write-as `extract`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.14 (2026-10-21) — Artifacts: Uninstall for an installed skill (moved aside, never deleted) or plugin (`claude plugin uninstall --keep-data`) beside Reinstall._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
|
||||
|
||||
## Repo map
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
|
||||
- `client/`: React + Vite UI.
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||||
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
|
||||
@@ -59,11 +59,12 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live 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. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
|
||||
- **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.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill writes a NEW snapshot and drops the old one — never a write into the one being read) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill writes a NEW snapshot and drops the old one — never a write into the one being read) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
|
||||
- **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 at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
||||
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
|
||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
|
||||
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
|
||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||
|
||||
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
stepRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
@@ -505,6 +506,9 @@ export function PetChat({
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
|
||||
@@ -96,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
// The server saw this session's transcript grow (`transcript_changed`, itself
|
||||
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
|
||||
const PUSH_FETCH_MS = 300;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
@@ -108,6 +111,10 @@ interface ConversationViewProps {
|
||||
compact?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The messages on screen (for an artifact's versions), as they change. */
|
||||
onMessages?: (messages: TranscriptMessage[]) => void;
|
||||
/** The session runs in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
@@ -133,6 +140,8 @@ function ConversationViewPane({
|
||||
fill = false,
|
||||
compact = false,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
onMessages,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
@@ -144,19 +153,21 @@ function ConversationViewPane({
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const shown = messages;
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => onMessages?.(shown), [shown, onMessages]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
for (let i = shown.length - 1; i >= 0; i--) {
|
||||
const m = shown[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
}, [shown, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -209,12 +220,13 @@ function ConversationViewPane({
|
||||
const agentLinks = useMemo<AgentLinks>(() => {
|
||||
const ids = new Map<string, string>();
|
||||
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
|
||||
return { ids, notices: agentNotices(messages), live, render: renderAgent };
|
||||
}, [transcripts, messages, live, renderAgent]);
|
||||
return { ids, notices: agentNotices(shown), live, render: renderAgent };
|
||||
}, [transcripts, shown, live, renderAgent]);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
async function loadTranscripts() {
|
||||
try {
|
||||
@@ -254,6 +266,12 @@ function ConversationViewPane({
|
||||
firstLineRef.current = 0;
|
||||
|
||||
async function load() {
|
||||
// No session yet: no history.
|
||||
if (!sessionId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
@@ -297,6 +315,7 @@ function ConversationViewPane({
|
||||
// parsed message we've seen. The server already de-overlaps via
|
||||
// afterLine, so we can safely append.
|
||||
const fetchNewMessages = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
if (fetchingRef.current) {
|
||||
// Coalesce: remember a trigger arrived during this fetch and re-run
|
||||
// exactly once when the in-flight request settles.
|
||||
@@ -358,7 +377,12 @@ function ConversationViewPane({
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
const fetchPushed = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
@@ -367,13 +391,14 @@ function ConversationViewPane({
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchSoon();
|
||||
if (msg.type === "transcript_changed") fetchPushed();
|
||||
else fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
@@ -535,7 +560,7 @@ function ConversationViewPane({
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
if (!loading && shown.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -626,7 +651,7 @@ function ConversationViewPane({
|
||||
>
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
) : shown.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -642,7 +667,7 @@ function ConversationViewPane({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
) : shown.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -651,9 +676,10 @@ function ConversationViewPane({
|
||||
</div>
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
messages={shown}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
|
||||
@@ -112,7 +112,11 @@ import {
|
||||
Search,
|
||||
Globe,
|
||||
Wrench,
|
||||
Image as ImageIcon,
|
||||
Code2,
|
||||
Table2,
|
||||
} from "lucide-react";
|
||||
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -148,6 +152,8 @@ interface MessageListProps {
|
||||
loading: boolean;
|
||||
/** Opens a file named in the "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The session is running in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** The CLI is asking the user something (a permission prompt): the live
|
||||
@@ -604,30 +610,91 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
|
||||
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
|
||||
* the lines added and removed. A call still running or failed is left out. */
|
||||
const ARTIFACT_ICON: Partial<Record<ArtifactKind, typeof FileText>> = {
|
||||
html: Globe,
|
||||
svg: ImageIcon,
|
||||
markdown: FileText,
|
||||
mermaid: Workflow,
|
||||
react: Code2,
|
||||
csv: Table2,
|
||||
};
|
||||
|
||||
/** Claude Desktop's artifact card: what the model made, opened beside the chat. */
|
||||
function ArtifactCard({ path, onOpen }: { path: string; onOpen?: (path: string) => void }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const kind = artifactKind(path);
|
||||
const Icon = ARTIFACT_ICON[kind] ?? FileText;
|
||||
const name = path.split(/[\\/]/).pop() || path;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-card"
|
||||
title={path}
|
||||
disabled={!onOpen}
|
||||
onClick={() => onOpen?.(path)}
|
||||
draggable
|
||||
onDragStart={(e) => dragAttachments(e, [projectAttachment(path)])}
|
||||
className="flex w-full max-w-md items-center gap-3 rounded-xl border border-border bg-surface-1 px-3 py-2.5 text-left transition-colors enabled:hover:border-border-light enabled:hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2">
|
||||
<Icon className="h-4 w-4 text-fg-secondary" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{name.replace(/\.[A-Za-z0-9]+$/, "")}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-fg-muted">
|
||||
{t(`conversation.artifact.${kind}`, { defaultValue: kind })}
|
||||
{onOpen ? ` · ${t("conversation.artifact.open")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EditedFilesCard({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
pending = false,
|
||||
}: {
|
||||
blocks: TranscriptContent[];
|
||||
toolResultMap: Map<string, TranscriptContent>;
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact (a page, document, diagram…) in the chat's own panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The turn is still working: only its artifact cards so far. */
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
// `counted`: a result carried its +/- lines (an older server sends none).
|
||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||
const created = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
const p = input.file_path ?? input.notebook_path;
|
||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||
if (b.name === "Write") created.add(p);
|
||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += r.diff?.added ?? 0;
|
||||
f.removed += r.diff?.removed ?? 0;
|
||||
f.counted ||= !!r.diff;
|
||||
files.set(p, f);
|
||||
}
|
||||
if (files.size === 0) return null;
|
||||
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
|
||||
const cards = artifacts.length > 0 && (
|
||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||
{artifacts.map((p) => (
|
||||
<ArtifactCard key={p} path={p} onOpen={onOpenArtifact ?? onOpenFile} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (pending) return cards || null;
|
||||
for (const p of artifacts) files.delete(p);
|
||||
if (files.size === 0) return cards || null;
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let counted = false;
|
||||
@@ -643,7 +710,7 @@ function EditedFilesCard({
|
||||
<span className="text-status-danger">−{r}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
const list = (
|
||||
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
||||
<div
|
||||
draggable
|
||||
@@ -673,11 +740,21 @@ function EditedFilesCard({
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return cards ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{cards}
|
||||
{list}
|
||||
</div>
|
||||
) : (
|
||||
list
|
||||
);
|
||||
}
|
||||
const EditedFiles = memo(
|
||||
EditedFilesCard,
|
||||
(p, n) =>
|
||||
p.onOpenFile === n.onOpenFile &&
|
||||
p.onOpenArtifact === n.onOpenArtifact &&
|
||||
p.pending === n.pending &&
|
||||
sameBlocks(p.blocks, n.blocks) &&
|
||||
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
@@ -688,6 +765,7 @@ function MessageListView({
|
||||
messages,
|
||||
loading,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
live = false,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
@@ -780,14 +858,16 @@ function MessageListView({
|
||||
/>
|
||||
);
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
// The card closes a finished turn; one still working shows
|
||||
// only the artifacts it made so far, as Claude Desktop does.
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
pending={!!pending && item === items[items.length - 1]}
|
||||
/>
|
||||
);
|
||||
case "done":
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file ConversationView.pushed.test.tsx
|
||||
* @description The server says a session's transcript grew
|
||||
* (`transcript_changed`): the chat showing that session reads the new lines
|
||||
* at once, and again within a short window after a burst — not at the hook
|
||||
* throttle's 1.5 s pace — while another session's notice fetches nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-21T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const pushed = (session_id: string) =>
|
||||
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView on transcript_changed", () => {
|
||||
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
pushed("s1");
|
||||
expect(calls()).toBe(before + 1);
|
||||
pushed("s1");
|
||||
pushed("s1");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
|
||||
it("ignores another session's transcript", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
pushed("s2");
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 3, removed: 1 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 10, removed: 0 }),
|
||||
call("3", "Edit", "/w/a.ts"),
|
||||
result("3", { added: 2, removed: 2 }),
|
||||
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
|
||||
expect(within(card).getByText("+15")).toBeTruthy();
|
||||
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
fireEvent.click(within(card).getByTitle("/w/b.json"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
|
||||
});
|
||||
|
||||
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenArtifact = vi.fn();
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
messages={[
|
||||
user("make a page"),
|
||||
call("1", "Write", "/w/page.html"),
|
||||
result("1", { added: 20, removed: 0 }),
|
||||
call("2", "Edit", "/w/App.tsx"),
|
||||
result("2", { added: 1, removed: 1 }),
|
||||
user("thanks"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const cards = screen.getAllByTestId("artifact-card");
|
||||
// A .tsx it only edited is project code, not an artifact.
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]!.textContent).toContain("page");
|
||||
fireEvent.click(cards[0]!);
|
||||
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
|
||||
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
const one = drag(within(card).getByTitle("/w/b.json"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
{ path: "@/w/b.json", name: "b.json" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
expect(one["text/plain"]).toBe("/w/b.json");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1422,6 +1422,27 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
@@ -2756,6 +2777,17 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -770,6 +770,24 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -18,15 +18,40 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Share2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { toast } from "../../lib/toast";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import {
|
||||
RENDERED,
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactLang,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
parseCsv,
|
||||
type ArtifactKind,
|
||||
} from "../../lib/artifactView";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
export interface PreviewScope {
|
||||
@@ -146,33 +171,89 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
return content;
|
||||
}
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
|
||||
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
|
||||
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
|
||||
const [head, ...body] = rows;
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
{head && (
|
||||
<thead className="sticky top-0 bg-surface-2">
|
||||
<tr>
|
||||
{head.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{body.map((r, i) => (
|
||||
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
|
||||
* scripts (none for an SVG) but never the dashboard's origin. */
|
||||
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
|
||||
const { theme } = useTheme();
|
||||
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid="artifact-frame"
|
||||
title={title}
|
||||
srcDoc={doc ?? ""}
|
||||
sandbox={frameSandbox(kind)}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type View = "preview" | "code";
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
const { t } = useTranslation("run");
|
||||
const loaded = useContent(path, scope);
|
||||
const kind = artifactKind(path);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const content: Content =
|
||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||
if (content.kind === "loading") {
|
||||
const shown: Content =
|
||||
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
|
||||
if (shown.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "image") {
|
||||
if (shown.kind === "image") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={content.src}
|
||||
src={shown.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
onError={() => setBroken(shown.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "none") {
|
||||
if (shown.kind === "none") {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
@@ -181,69 +262,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{isMarkdown(path) ? (
|
||||
<MarkdownPreview text={content.text} className="p-4" />
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
||||
{content.text}
|
||||
</pre>
|
||||
)}
|
||||
{content.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
const note = shown.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
);
|
||||
if (view === "code" || !RENDERED.has(kind)) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "markdown") {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<MarkdownPreview text={shown.text} className="p-4" />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||
}
|
||||
|
||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
/** Saves text (or an image's URL) as a file through the browser. */
|
||||
function download(name: string, content: Content) {
|
||||
const a = document.createElement("a");
|
||||
if (content.kind === "image") a.href = content.src;
|
||||
else if (content.kind === "text")
|
||||
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
|
||||
else return;
|
||||
a.download = name;
|
||||
a.click();
|
||||
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
|
||||
* versions the conversation made of it, Copy, Download, Share to the
|
||||
* Artifacts Hub, full screen, Close. */
|
||||
export function FilePreview({
|
||||
path,
|
||||
scope,
|
||||
onClose,
|
||||
className = "",
|
||||
messages,
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
/** The conversation, for the versions its Write/Edit calls left. */
|
||||
messages?: TranscriptMessage[];
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
return (
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
|
||||
const [full, setFull] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
() => (messages ? artifactVersions(messages, path) : []),
|
||||
[messages, path]
|
||||
);
|
||||
// null: the newest — the file as it is now.
|
||||
const [picked, setPicked] = useState<number | null>(null);
|
||||
// Another file opens on its own default tab, at its newest version.
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
setView(RENDERED.has(artifactKind(path)) ? "preview" : "code");
|
||||
}, [path]);
|
||||
const last = Math.max(versions.length - 1, 0);
|
||||
const at = picked ?? last;
|
||||
const older = picked !== null && picked < last ? versions[picked] : null;
|
||||
const content: Content =
|
||||
older && older.content !== null
|
||||
? { kind: "text", text: older.content, truncated: false }
|
||||
: loaded;
|
||||
const known = (i: number) => i === last || versions[i]?.content != null;
|
||||
const step = (dir: -1 | 1) => {
|
||||
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
|
||||
if (known(i)) return setPicked(i === last ? null : i);
|
||||
}
|
||||
};
|
||||
const share = async () => {
|
||||
if (sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
|
||||
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
|
||||
await api.artifacts.pinFile({
|
||||
laneId: scope.laneId,
|
||||
path: file.slice(base.length + 1),
|
||||
title: displayName(path),
|
||||
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
|
||||
});
|
||||
} else if (content.kind === "text") {
|
||||
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
|
||||
title: displayName(path),
|
||||
});
|
||||
} else throw new Error(t("chat.artifact.cannotShare"));
|
||||
toast.success(t("chat.artifact.shared"));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
const panel = (
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||
className={
|
||||
full
|
||||
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
|
||||
}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
|
||||
>
|
||||
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
</button>
|
||||
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
|
||||
{versions.length > 1 && (
|
||||
<span
|
||||
data-testid="artifact-versions"
|
||||
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
disabled={![...Array(at).keys()].some(known)}
|
||||
aria-label={t("chat.artifact.older")}
|
||||
title={t("chat.artifact.older")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
disabled={at >= last}
|
||||
aria-label={t("chat.artifact.newer")}
|
||||
title={t("chat.artifact.newer")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
|
||||
{RENDERED.has(kind) && kind !== "image" && (
|
||||
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
|
||||
{(["preview", "code"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === v}
|
||||
data-testid={`artifact-tab-${v}`}
|
||||
onClick={() => setView(v)}
|
||||
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
|
||||
>
|
||||
{t(`chat.artifact.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
aria-label={t("chat.outline.copyPath", { path })}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
data-testid="artifact-copy"
|
||||
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
|
||||
title={t("chat.artifact.copy")}
|
||||
aria-label={t("chat.artifact.copy")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(displayName(path), content)}
|
||||
disabled={content.kind !== "text" && content.kind !== "image"}
|
||||
title={t("chat.artifact.download")}
|
||||
aria-label={t("chat.artifact.download")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-share"
|
||||
onClick={() => void share()}
|
||||
disabled={sharing || content.kind === "loading" || content.kind === "none"}
|
||||
title={t("chat.artifact.share")}
|
||||
aria-label={t("chat.artifact.share")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{sharing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("chat.outline.close")}
|
||||
aria-label={t("chat.outline.close")}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} scope={scope} />
|
||||
<Body path={path} content={content} view={view} />
|
||||
</section>
|
||||
);
|
||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||
return full ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
|
||||
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* new lane's own live session for its slot, or an empty pane — never the
|
||||
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||
* A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) also
|
||||
* has a Chat view (`PaneChat`): the session's
|
||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
||||
* permission or trust prompt, Codex's approval — is read off the terminal
|
||||
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
||||
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
|
||||
* that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
|
||||
* (`PermissionMenuCard`); its "Type something" row opens
|
||||
* a field in the card, whose text is typed there as the answer
|
||||
* (`answerRunMenu`).
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
@@ -56,7 +57,17 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
MessageCircleQuestion,
|
||||
ShieldQuestion,
|
||||
Square,
|
||||
SquareCheck,
|
||||
} from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import {
|
||||
@@ -77,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
notePaneFocus,
|
||||
onPaneOutput,
|
||||
stepRunMenu,
|
||||
interruptRun,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
@@ -89,7 +104,9 @@ import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { setTabBadge } from "../../lib/tabBadge";
|
||||
import { fmt } from "../../lib/format";
|
||||
import { agentAwaitingReason } from "../../lib/types";
|
||||
import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
@@ -100,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
// Pane output re-reads the transcript at most this often: a TUI spinner
|
||||
// redraws several times a second, and each read walks the transcript file.
|
||||
const OUTPUT_FETCH_MS = 1000;
|
||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||
const OUTPUT_BUSY_MS = 2000;
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
@@ -117,6 +139,7 @@ const storedSide = () => {
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
export function contextWindow(model: string | null): number | null {
|
||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||
if (!model?.startsWith("claude-")) return null;
|
||||
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
||||
? 200_000
|
||||
@@ -125,7 +148,13 @@ export function contextWindow(model: string | null): number | null {
|
||||
|
||||
/** How full the context is: a ring and a percentage, amber then red as
|
||||
* Claude Code's auto-compact comes near. */
|
||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||
function ContextMeter({
|
||||
tokens,
|
||||
model,
|
||||
}: {
|
||||
tokens: number;
|
||||
model: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const size = contextWindow(model);
|
||||
if (!size || !tokens) return null;
|
||||
@@ -212,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
||||
);
|
||||
}
|
||||
|
||||
/** A permission prompt (Claude's tool approval, Codex's command approval,
|
||||
* the folder-trust question): it offers a Yes and a No. */
|
||||
export function isPermissionMenu(menu: PaneMenu): boolean {
|
||||
if (menu.multi || menu.tabs) return false;
|
||||
const labels = menu.options.map((o) => o.label);
|
||||
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
|
||||
}
|
||||
|
||||
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
|
||||
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
|
||||
|
||||
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
|
||||
* answers as buttons — each still presses its key in the pane. "No, and
|
||||
* tell … differently" also takes a note, sent as the next message once the
|
||||
* CLI is back at its prompt. */
|
||||
function PermissionMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onDenyNote,
|
||||
onCancel,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onDenyNote: (index: number, note: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [noting, setNoting] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const q = menu.question.split("\n");
|
||||
const title = menu.detail?.[0] ?? q[0] ?? "";
|
||||
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
|
||||
const ask = menu.detail ? menu.question : "";
|
||||
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
|
||||
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
|
||||
const sendNote = () => {
|
||||
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
|
||||
};
|
||||
return (
|
||||
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{title}
|
||||
</p>
|
||||
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{body && (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
|
||||
{body}
|
||||
</pre>
|
||||
)}
|
||||
{noting ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="pane-chat-permission-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
sendNote();
|
||||
} else if (e.key === "Escape") setNoting(false);
|
||||
}}
|
||||
placeholder={t("chat.request.notePlaceholder")}
|
||||
aria-label={t("chat.request.notePlaceholder")}
|
||||
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendNote}
|
||||
disabled={!note.trim()}
|
||||
className="btn-secondary text-xs disabled:opacity-40"
|
||||
>
|
||||
{t("chat.request.send")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{menu.options.map((o, i) => (
|
||||
<button
|
||||
key={`${i}-${o.label}`}
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-option"
|
||||
autoFocus={i === yes}
|
||||
onClick={() => onChoose(i)}
|
||||
title={o.hint || undefined}
|
||||
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
|
||||
>
|
||||
{bareLabel(o.label)}
|
||||
</button>
|
||||
))}
|
||||
{tell >= 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-tell"
|
||||
onClick={() => setNoting(true)}
|
||||
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{t("chat.request.denyWithNote")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
{t("chat.menu.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||
* pane; its "Type something" row opens a field right there instead —
|
||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
||||
* pressing that row's digit again would type the digit into the CLI's own.
|
||||
* A permission prompt takes its own layout (`PermissionMenuCard`), any
|
||||
* other menu the question layout. */
|
||||
export function PaneMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onAnswer,
|
||||
onCancel,
|
||||
onStep,
|
||||
onDenyNote,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onAnswer: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
|
||||
onStep?: (dir: -1 | 1) => void;
|
||||
/** A permission prompt's "No, and tell …", with the note to send after it. */
|
||||
onDenyNote?: (index: number, note: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const submit = () => draft.trim() && onAnswer(draft);
|
||||
if (isPermissionMenu(menu))
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
<PermissionMenuCard
|
||||
menu={menu}
|
||||
onChoose={onChoose}
|
||||
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
{menu.tabs && (
|
||||
<div
|
||||
data-testid="pane-chat-menu-tabs"
|
||||
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
|
||||
>
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(-1)}
|
||||
aria-label={t("chat.menu.prev")}
|
||||
title={t("chat.menu.prev")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{menu.tabs.map((tab, i) => (
|
||||
<span
|
||||
key={`${i}-${tab.label}`}
|
||||
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
|
||||
i === menu.tab
|
||||
? "border-accent/50 bg-surface-2 text-fg-primary"
|
||||
: "border-border text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{tab.done ? (
|
||||
<Check className="h-3 w-3 text-accent" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
{tab.label}
|
||||
</span>
|
||||
))}
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(1)}
|
||||
aria-label={t("chat.menu.next")}
|
||||
title={t("chat.menu.next")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{menu.question && (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{menu.options.map((o, i) =>
|
||||
@@ -338,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||
const draftKey = sessionId;
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
|
||||
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
@@ -416,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
// The terminal underneath drew something: the CLI is at work, so its
|
||||
// transcript may have grown — read it now, for any tool, hooks or not. A
|
||||
// spinner redraws all the time, hence the throttle.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const poke = throttle(
|
||||
() =>
|
||||
eventBus.publish({
|
||||
type: "transcript_changed",
|
||||
data: { session_id: sessionId },
|
||||
} as unknown as WSMessage),
|
||||
OUTPUT_FETCH_MS
|
||||
);
|
||||
const off = onPaneOutput(runId, poke);
|
||||
return () => {
|
||||
off();
|
||||
poke.cancel();
|
||||
};
|
||||
}, [runId, sessionId]);
|
||||
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
|
||||
// shows, else — a tool whose line we do not know — output in the last
|
||||
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
|
||||
// without its opt-in hooks), and arrive late on a dashboard they skip.
|
||||
const [screenBusy, setScreenBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let last = 0;
|
||||
const off = onPaneOutput(runId, () => (last = Date.now()));
|
||||
const read = () => {
|
||||
const known = readRunBusy(runId, run.tool);
|
||||
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
|
||||
};
|
||||
read();
|
||||
const timer = window.setInterval(() => {
|
||||
if (isShown(onScreen)) read();
|
||||
}, 700);
|
||||
return () => {
|
||||
off();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runId, run.tool, onScreen]);
|
||||
useLiveRefresh(readWorking, {
|
||||
throttleMs: 500,
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
@@ -444,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId, onScreen]);
|
||||
const busy = working || screenBusy;
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
@@ -456,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
|
||||
setFollow((n) => n + 1);
|
||||
} else toast.error(t("chat.notSent"));
|
||||
};
|
||||
// "No, and tell … differently": the CLI drops back to its prompt, where
|
||||
// the note is what the user would type next.
|
||||
const denyWithNote = (index: number, note: string) => {
|
||||
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
|
||||
setMenu(null);
|
||||
window.setTimeout(() => void command(note), 800);
|
||||
};
|
||||
|
||||
// A hidden tab says when this session waits on the user or its turn ended.
|
||||
const was = useRef({ working: false, asking: false });
|
||||
useEffect(() => {
|
||||
if (asking) setTabBadge(runId, "waiting");
|
||||
else if (working) setTabBadge(runId, null);
|
||||
else if (busy) setTabBadge(runId, null);
|
||||
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||
was.current = { working, asking };
|
||||
}, [runId, working, asking]);
|
||||
was.current = { working: busy, asking };
|
||||
}, [runId, busy, asking]);
|
||||
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||
tokens: 0,
|
||||
@@ -484,6 +751,16 @@ export const PaneChat = memo(function PaneChat({
|
||||
() => showTerminal(runId, sessionId, run.tool),
|
||||
[runId, sessionId, run.tool]
|
||||
);
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it.
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// An artifact card opens in this chat's own panel, like Claude Desktop's —
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
(named: string) => {
|
||||
setPreview(resolveIn(cwd, named));
|
||||
},
|
||||
[cwd]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
@@ -533,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-prompt-drop
|
||||
// The last chat focused is where the Changes panel's comments go.
|
||||
onFocusCapture={() => notePaneFocus(runId)}
|
||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||
>
|
||||
@@ -549,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
onContext={onContext}
|
||||
onNotStarted={setNotStarted}
|
||||
onOpenFile={openFile}
|
||||
onOpenArtifact={openArtifact}
|
||||
onMessages={setConvMessages}
|
||||
/>
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
@@ -573,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
menu={menu}
|
||||
onChoose={choose}
|
||||
onAnswer={answer}
|
||||
onDenyNote={denyWithNote}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(runId)) setMenu(null);
|
||||
else toast.error(t("chat.notSent"));
|
||||
@@ -609,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
{run.tool === "codex" &&
|
||||
// Codex's `/model` (then its effort) and `/permissions` are
|
||||
// numbered pickers: typed here, answered on the menu card.
|
||||
(
|
||||
[
|
||||
["/permissions", t("fields.permissionMode")],
|
||||
["/model", t("fields.model")],
|
||||
] as const
|
||||
).map(([line, label]) => (
|
||||
<button
|
||||
key={line}
|
||||
type="button"
|
||||
data-testid={`pane-chat-codex-${line.slice(1)}`}
|
||||
onClick={() => void command(line)}
|
||||
title={t("chat.codexPicker")}
|
||||
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||
{claude && (
|
||||
@@ -624,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{working && !text ? (
|
||||
{busy && !text ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-stop"
|
||||
@@ -669,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
messages={convMessages}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when the pane has room, over it when not.
|
||||
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptForPane } from "./RunSetup";
|
||||
@@ -238,7 +238,7 @@ function PaneMenu({
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
/** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
|
||||
* (`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. */
|
||||
@@ -254,7 +254,7 @@ export function showTerminal(runId: string, sessionId: string, tool?: string) {
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
||||
if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
|
||||
const sessionId = run.sessionId;
|
||||
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||
return (
|
||||
|
||||
@@ -308,7 +308,7 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
|
||||
]).then(([c, sk, pl]) => {
|
||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||
// installed at both user and project scope is listed once.
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||
* reads them (an image as an image) when the prompt is sent; a browser on the
|
||||
* dashboard's own machine pastes a file manager's files by their own paths
|
||||
* instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -64,9 +65,15 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import {
|
||||
claudeMention,
|
||||
MENU_CURSOR_ROW,
|
||||
notePaneFocus,
|
||||
notePaneOutput,
|
||||
registerPane,
|
||||
} from "../../lib/panePaste";
|
||||
import {
|
||||
claudePastes,
|
||||
droppedFilePaths,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
@@ -153,6 +160,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
/** The `file://` paths a paste carries as text — a file manager's copy — or
|
||||
* null: its uri-list, else its plain text when every line is one. */
|
||||
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
|
||||
if (!/^file:\/\//.test(text.trim())) return null;
|
||||
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
@@ -168,8 +184,9 @@ interface TerminalViewProps {
|
||||
* so it is moved out of the viewport with a transform, which keeps its
|
||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||
covered?: boolean;
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
|
||||
* as an `@` mention (`claudeMention`), any other one its plain path. */
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
|
||||
* as its bare path and any other file as an `@` mention (`claudePastes`),
|
||||
* any other tool its plain path. */
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
@@ -241,8 +258,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDrop(null);
|
||||
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
|
||||
const text = e.dataTransfer.getData("text/plain");
|
||||
if (text) termRef.current?.paste(text + " ");
|
||||
if (local) pastePaths(local);
|
||||
else if (text) termRef.current?.paste(text + " ");
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
@@ -262,6 +281,14 @@ export const TerminalView = memo(function TerminalView({
|
||||
const ctrlV = ctrlVRef.current;
|
||||
ctrlVRef.current = false;
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
// A file manager's copied files reach a browser as `file://` text only.
|
||||
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
|
||||
if (local) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pastePaths(local);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -269,6 +296,19 @@ export const TerminalView = memo(function TerminalView({
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
/** Pastes files' paths as a local terminal hands them over — paste(), not
|
||||
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
|
||||
* rather than typed keystrokes. A `claude` pane gets its images as bare
|
||||
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
|
||||
* (`claudePastes`). */
|
||||
const pastePaths = (paths: string[]) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
|
||||
term.paste(text + " ");
|
||||
term.focus();
|
||||
};
|
||||
|
||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||
* server finds the same files there — nothing uploaded —, else the paths
|
||||
@@ -286,13 +326,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (paths.length) {
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
const asText = tool === "claude" ? claudeMention : (p: string) => p;
|
||||
termRef.current?.paste(paths.map(asText).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
}
|
||||
if (paths.length) pastePaths(paths);
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
@@ -588,6 +622,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
}
|
||||
}
|
||||
term.write(data);
|
||||
notePaneOutput(runId);
|
||||
}
|
||||
};
|
||||
sock.onclose = (e?: CloseEvent) => {
|
||||
@@ -750,6 +785,17 @@ export const TerminalView = memo(function TerminalView({
|
||||
rows.push(row);
|
||||
}
|
||||
return { rows, cols: term.cols };
|
||||
},
|
||||
// Plain text of the rows on screen, dim cells kept (a status line's
|
||||
// `esc to interrupt` is dim): the chat's busy read.
|
||||
() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return [];
|
||||
const buf = term.buffer.active;
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < term.rows; y++)
|
||||
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
|
||||
return rows;
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
|
||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
artifacts: {
|
||||
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
|
||||
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
|
||||
},
|
||||
sessions: {
|
||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||
readFile: vi.fn((_id: string, p: string) =>
|
||||
@@ -75,7 +79,11 @@ const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
||||
describe("FilePreview", () => {
|
||||
it("reads a workspace file through the lane", async () => {
|
||||
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
});
|
||||
|
||||
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
|
||||
it("reads Windows paths the same way", async () => {
|
||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
cleanup();
|
||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
|
||||
);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the artifact view", () => {
|
||||
const html = (lines: string[]) =>
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
|
||||
available: true,
|
||||
path: "page.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines,
|
||||
} as never);
|
||||
|
||||
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
|
||||
html(["<button>go</button>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
|
||||
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
|
||||
fireEvent.click(screen.getByTestId("artifact-tab-code"));
|
||||
expect(screen.queryByTestId("artifact-frame")).toBeNull();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
|
||||
)
|
||||
).toBeTruthy();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("steps back to an older version the conversation wrote", async () => {
|
||||
html(["<p>two</p>"]);
|
||||
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
|
||||
type: "assistant" as const,
|
||||
timestamp: null,
|
||||
content: [{ type: "tool_use" as const, id, name, input }],
|
||||
});
|
||||
const messages = [
|
||||
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
|
||||
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
|
||||
];
|
||||
render(
|
||||
<FilePreview
|
||||
path="/w/page.html"
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
messages={messages as never}
|
||||
/>
|
||||
);
|
||||
const versions = await screen.findByTestId("artifact-versions");
|
||||
expect(versions.textContent).toContain("2");
|
||||
expect(
|
||||
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||
).toContain("two");
|
||||
fireEvent.click(versions.querySelector("button")!);
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("saves a workspace file to the Artifacts library by its path", async () => {
|
||||
html(["<p>x</p>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
await screen.findByTestId("artifact-frame");
|
||||
fireEvent.click(screen.getByTestId("artifact-share"));
|
||||
await vi.waitFor(() =>
|
||||
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
|
||||
laneId: 4,
|
||||
path: "page.html",
|
||||
title: "page.html",
|
||||
sessionId: "s1",
|
||||
})
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
* activity panel, collapsed into a file button with the output count, whose
|
||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
||||
* Stop in Send's place while the main agent works (its hooks, or the pane's
|
||||
* own busy line / fresh output), pressing Esc in the pane;
|
||||
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
||||
* message sent meanwhile typed as its "Type something" answer.
|
||||
* message sent meanwhile typed as its "Type something" answer; a Codex
|
||||
* terminal's Model / Permission mode buttons typing its own pickers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,11 +24,14 @@ import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
onPaneOutput,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
readRunMenu: vi.fn(() => null),
|
||||
readRunBusy: vi.fn(() => false),
|
||||
chooseRunOption: vi.fn(() => true),
|
||||
answerRunMenu: vi.fn(() => true),
|
||||
stepRunMenu: vi.fn(() => true),
|
||||
notePaneFocus: vi.fn(),
|
||||
onPaneOutput: vi.fn(() => () => {}),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
|
||||
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||
const fns: (() => void)[] = [];
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
fns.forEach((fn) => fn());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Send for a session at rest", async () => {
|
||||
// With session_id the server answers EVERY agent of the session (its
|
||||
// status filter does not apply): the main agent is there, waiting.
|
||||
@@ -553,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-reads its transcript when the pane draws, at most once a second", async () => {
|
||||
// Every listener the chat put on the pane (the re-read, the busy clock).
|
||||
const fns: (() => void)[] = [];
|
||||
const drew = () => fns.forEach((fn) => fn());
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const off = eventBus.subscribe((m) => {
|
||||
if (m.type === "transcript_changed") seen.push(m.data);
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
vi.useFakeTimers();
|
||||
drew();
|
||||
drew();
|
||||
drew();
|
||||
expect(seen).toEqual([{ session_id: "s1" }]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
});
|
||||
expect(seen).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
off();
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
const PERMISSION: PaneMenu = {
|
||||
question: "Do you want to proceed?",
|
||||
detail: ["Bash command", "npm test", "Run the tests"],
|
||||
options: [
|
||||
{ n: 1, label: "Yes", hint: "", checked: null },
|
||||
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
|
||||
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
|
||||
],
|
||||
cursor: 0,
|
||||
multi: false,
|
||||
typeAt: -1,
|
||||
};
|
||||
|
||||
it("lays a permission prompt out as a card, each answer its own key", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
const card = await screen.findByTestId("pane-chat-permission");
|
||||
expect(card).toHaveTextContent("Bash command");
|
||||
expect(card).toHaveTextContent("npm test");
|
||||
expect(card).toHaveTextContent("Do you want to proceed?");
|
||||
const options = screen.getAllByTestId("pane-chat-permission-option");
|
||||
// The key hint is dropped from the label.
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
"Yes",
|
||||
"Yes, and don't ask again for npm test",
|
||||
"No, and tell Claude what to do differently",
|
||||
]);
|
||||
fireEvent.click(options[1]!);
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
|
||||
const field = screen.getByTestId("pane-chat-permission-note");
|
||||
fireEvent.change(field, { target: { value: "use pnpm" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — a Codex terminal", () => {
|
||||
it("types /model and /permissions, whose pickers come back as menu cards", () => {
|
||||
renderChat({ tool: "codex" });
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
|
||||
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
|
||||
});
|
||||
|
||||
it("offers neither to a Claude pane", () => {
|
||||
renderChat();
|
||||
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Add",
|
||||
"form.cancel": "Cancel",
|
||||
"added": "Added",
|
||||
"files": "{{count}} files",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} files",
|
||||
"close": "Close",
|
||||
"fromSession": "From a session",
|
||||
"form.saveEdit": "Save",
|
||||
@@ -129,6 +130,12 @@
|
||||
"reinstallHint": "Write this version over your installed copy",
|
||||
"reinstallTitle": "Reinstall “{{title}}”?",
|
||||
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallHint": "Remove it from your account",
|
||||
"uninstallTitle": "Uninstall “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
|
||||
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
|
||||
"uninstalled": "Uninstalled {{name}}",
|
||||
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
|
||||
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
|
||||
"deleteKept": "Removed from your library — still in the Hub",
|
||||
@@ -143,5 +150,29 @@
|
||||
"preview.frontmatter": "Frontmatter (SKILL.md header)",
|
||||
"preview.whenUsed": "When Claude uses it",
|
||||
"preview.noDescription": "No description",
|
||||
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/"
|
||||
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Reusable instructions Claude follows",
|
||||
"blurb.plugin": "Bundles of skills, commands and hooks",
|
||||
"blurb.page": "Pages your sessions published on claude.ai",
|
||||
"blurb.file": "Documents and outputs worth keeping",
|
||||
"clearSearch": "Clear search",
|
||||
"categories": "Categories",
|
||||
"seeAll": "See all",
|
||||
"results_one": "{{count}} result",
|
||||
"results_other": "{{count}} results",
|
||||
"empty.title": "Your library is empty",
|
||||
"empty.hubTitle": "The Hub is quiet",
|
||||
"empty.searchTitle": "No matches",
|
||||
"empty.search": "Nothing matches this search or category — try other words, or pick All.",
|
||||
"details": "Details",
|
||||
"more": "More actions",
|
||||
"about": "About",
|
||||
"stat.kind": "Type",
|
||||
"stat.installs": "Installs",
|
||||
"stat.files": "Files",
|
||||
"stat.size": "Size",
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
}
|
||||
|
||||
@@ -531,6 +531,9 @@
|
||||
"changes.unversionedGroup": "Unversioned Files",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit and Push",
|
||||
"changes.discardAll": "Discard all",
|
||||
"changes.discardAllTitle": "Discard all changes?",
|
||||
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"findClose": "Close find"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Opens Codex's own picker — answer it in the chat",
|
||||
"placeholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"notSent": "The terminal is not connected — the message was not sent.",
|
||||
@@ -139,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Type your answer…",
|
||||
"cancel": "Cancel (Esc)"
|
||||
"cancel": "Cancel (Esc)",
|
||||
"prev": "Previous question",
|
||||
"next": "Next question"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Tell the model what to do instead…",
|
||||
"send": "Send",
|
||||
"denyWithNote": "Deny with a note"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Preview",
|
||||
"code": "Code",
|
||||
"copy": "Copy",
|
||||
"download": "Download",
|
||||
"share": "Save to Artifacts",
|
||||
"shared": "Saved to your Artifacts library — share it from the Artifacts page.",
|
||||
"cannotShare": "This file cannot be saved to Artifacts.",
|
||||
"expand": "Full screen",
|
||||
"collapse": "Exit full screen",
|
||||
"older": "Older version",
|
||||
"newer": "Newer version",
|
||||
"version": "Version {{n}} of {{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -230,6 +230,15 @@
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"hide": "Hide conversation"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Web page",
|
||||
"svg": "Image · SVG",
|
||||
"markdown": "Document",
|
||||
"mermaid": "Diagram",
|
||||
"react": "React component",
|
||||
"csv": "Table",
|
||||
"open": "Click to open"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -1302,9 +1302,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Chat UI",
|
||||
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||
"description": "How a new Claude, Codex, omp, Cursor or Antigravity session opens in its workspace. Saved for your account only.",
|
||||
"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 new session first shows its conversation like Claude Desktop, with a message box, instead of its terminal (the terminal stays underneath). Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and questions are answered from cards in the chat.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Thêm",
|
||||
"form.cancel": "Huỷ",
|
||||
"added": "Đã thêm",
|
||||
"files": "{{count}} file",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} file",
|
||||
"close": "Đóng",
|
||||
"fromSession": "Từ một phiên",
|
||||
"form.saveEdit": "Lưu",
|
||||
@@ -129,6 +130,12 @@
|
||||
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
|
||||
"reinstallTitle": "Cài lại “{{title}}”?",
|
||||
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||
"uninstall": "Gỡ cài đặt",
|
||||
"uninstallHint": "Gỡ khỏi tài khoản của bạn",
|
||||
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
|
||||
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
|
||||
"uninstalled": "Đã gỡ {{name}}",
|
||||
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
|
||||
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
|
||||
@@ -143,5 +150,29 @@
|
||||
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
|
||||
"preview.whenUsed": "Khi nào Claude dùng",
|
||||
"preview.noDescription": "Chưa có mô tả",
|
||||
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/"
|
||||
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Bộ hướng dẫn tái sử dụng cho Claude",
|
||||
"blurb.plugin": "Gói skill, lệnh và hook",
|
||||
"blurb.page": "Trang mà phiên đã publish lên claude.ai",
|
||||
"blurb.file": "Tài liệu và kết quả đáng giữ lại",
|
||||
"clearSearch": "Xoá tìm kiếm",
|
||||
"categories": "Danh mục",
|
||||
"seeAll": "Xem tất cả",
|
||||
"results_one": "{{count}} kết quả",
|
||||
"results_other": "{{count}} kết quả",
|
||||
"empty.title": "Thư viện đang trống",
|
||||
"empty.hubTitle": "Hub chưa có gì",
|
||||
"empty.searchTitle": "Không tìm thấy",
|
||||
"empty.search": "Không có mục nào khớp tìm kiếm hoặc danh mục này — thử từ khác, hoặc chọn Tất cả.",
|
||||
"details": "Chi tiết",
|
||||
"more": "Thao tác khác",
|
||||
"about": "Giới thiệu",
|
||||
"stat.kind": "Loại",
|
||||
"stat.installs": "Lượt cài",
|
||||
"stat.files": "File",
|
||||
"stat.size": "Dung lượng",
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
}
|
||||
|
||||
@@ -531,6 +531,9 @@
|
||||
"changes.unversionedGroup": "File chưa theo dõi",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit và Push",
|
||||
"changes.discardAll": "Bỏ tất cả",
|
||||
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
||||
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"findClose": "Đóng tìm kiếm"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Mở bộ chọn của chính Codex — trả lời ngay trong chat",
|
||||
"placeholder": "Viết tin nhắn…",
|
||||
"send": "Gửi",
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||
@@ -139,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Nhập câu trả lời của bạn…",
|
||||
"cancel": "Huỷ (Esc)"
|
||||
"cancel": "Huỷ (Esc)",
|
||||
"prev": "Câu trước",
|
||||
"next": "Câu tiếp"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Nói cho model biết nên làm gì thay vào đó…",
|
||||
"send": "Gửi",
|
||||
"denyWithNote": "Từ chối kèm lời nhắn"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Xem trước",
|
||||
"code": "Mã",
|
||||
"copy": "Sao chép",
|
||||
"download": "Tải xuống",
|
||||
"share": "Lưu vào Artifacts",
|
||||
"shared": "Đã lưu vào thư viện Artifacts của bạn — chia sẻ từ trang Artifacts.",
|
||||
"cannotShare": "Không lưu được file này vào Artifacts.",
|
||||
"expand": "Toàn màn hình",
|
||||
"collapse": "Thoát toàn màn hình",
|
||||
"older": "Phiên bản cũ hơn",
|
||||
"newer": "Phiên bản mới hơn",
|
||||
"version": "Phiên bản {{n}}/{{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -230,6 +230,15 @@
|
||||
"stopped": "đã dừng"
|
||||
},
|
||||
"hide": "Ẩn hội thoại"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Trang web",
|
||||
"svg": "Ảnh · SVG",
|
||||
"markdown": "Tài liệu",
|
||||
"mermaid": "Sơ đồ",
|
||||
"react": "Component React",
|
||||
"csv": "Bảng",
|
||||
"open": "Bấm để mở"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -1302,9 +1302,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Giao diện chat",
|
||||
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"description": "Cách một session Claude, Codex, omp, Cursor hoặc Antigravity mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"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 mới sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal (terminal vẫn chạy bên dưới). 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à câu hỏi được trả lời bằng thẻ ngay trong chat.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file artifactView.test.ts
|
||||
* @description What counts as an artifact and how it is shown: kinds by
|
||||
* name, a React file only when written whole, a file's versions rebuilt from
|
||||
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
|
||||
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
isArtifact,
|
||||
parseCsv,
|
||||
} from "../artifactView";
|
||||
import type { TranscriptMessage } from "../types";
|
||||
|
||||
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
|
||||
type: "assistant",
|
||||
timestamp: `2026-10-21T00:00:0${id}.000Z`,
|
||||
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
|
||||
});
|
||||
const result = (id: string, isError = false): TranscriptMessage => ({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: null,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id,
|
||||
output: "",
|
||||
is_error: isError,
|
||||
} as TranscriptMessage["content"][number],
|
||||
],
|
||||
});
|
||||
|
||||
describe("artifactView", () => {
|
||||
it("knows an artifact by its name", () => {
|
||||
expect(artifactKind("/a/page.HTML")).toBe("html");
|
||||
expect(artifactKind("d.mmd")).toBe("mermaid");
|
||||
expect(artifactKind("App.tsx")).toBe("react");
|
||||
expect(artifactKind("x.go")).toBe("code");
|
||||
expect(isArtifact("report.md", false)).toBe(true);
|
||||
expect(isArtifact("App.tsx", false)).toBe(false);
|
||||
expect(isArtifact("App.tsx", true)).toBe(true);
|
||||
expect(isArtifact("server.js", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||
const msgs = [
|
||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||
result("1"),
|
||||
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
|
||||
result("2"),
|
||||
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
|
||||
result("3", true),
|
||||
use("4", "MultiEdit", {
|
||||
file_path: "/w/a.md",
|
||||
edits: [{ old_string: "# A", new_string: "# B" }],
|
||||
}),
|
||||
result("4"),
|
||||
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
|
||||
];
|
||||
const v = artifactVersions(msgs, "/w/a.md");
|
||||
expect(v.map((x) => x.content)).toEqual([
|
||||
"# A\nold line\n",
|
||||
"# A\nnew line\n",
|
||||
"# B\nnew line\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("says an edit to text it never saw is of unknown content", () => {
|
||||
const v = artifactVersions(
|
||||
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
|
||||
"a.md"
|
||||
);
|
||||
expect(v).toHaveLength(1);
|
||||
expect(v[0]!.content).toBeNull();
|
||||
});
|
||||
|
||||
it("reads CSV with quotes and newlines inside them", () => {
|
||||
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
|
||||
["a", "b"],
|
||||
["x, y", 'say "hi"\nthere'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds sandboxed documents that keep the source inert inside a script", () => {
|
||||
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
|
||||
expect(doc).toContain("babel");
|
||||
expect(doc).not.toContain("a</script>b");
|
||||
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
|
||||
"A-->B<img>"
|
||||
);
|
||||
expect(artifactDocument("code", "x", false)).toBeNull();
|
||||
expect(frameSandbox("svg")).toBe("");
|
||||
expect(frameSandbox("html")).not.toContain("allow-same-origin");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
chooseRunOption,
|
||||
parsePaneMenu,
|
||||
pasteToLane,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
registerPane,
|
||||
takeRunInput,
|
||||
@@ -160,6 +161,33 @@ describe("panePaste", () => {
|
||||
expect(m.typeAt).toBe(2);
|
||||
});
|
||||
|
||||
// Several questions: the unnumbered row is Next until the last one, and the
|
||||
// tab bar says which questions have an answer.
|
||||
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
|
||||
const m = parsePaneMenu(MULTI_Q)!;
|
||||
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
|
||||
[1, "JWT HS256 + default secret", true],
|
||||
[2, "Product code issue 2 lần", true],
|
||||
[3, "Type something", false],
|
||||
[null, "Next", null],
|
||||
[4, "Chat about this", null],
|
||||
]);
|
||||
expect(m.options[2]!.hint).toBe("");
|
||||
expect(m.cursor).toBe(0);
|
||||
expect(m.tabs).toEqual([
|
||||
{ label: "Critical", done: true },
|
||||
{ label: "High 1/3", done: false },
|
||||
{ label: "High 2/3", done: false },
|
||||
{ label: "High 3/3", done: false },
|
||||
]);
|
||||
expect(m.tab).toBe(0);
|
||||
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
|
||||
});
|
||||
|
||||
it("reads no tabs for a single question", () => {
|
||||
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
|
||||
});
|
||||
|
||||
// The "Type something" row once something is typed in it: its label is the
|
||||
// text. It is still the row right above "Chat about this".
|
||||
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
|
||||
@@ -188,8 +216,11 @@ describe("panePaste", () => {
|
||||
const c = parsePaneMenu(PERMISSION)!;
|
||||
expect(c.question).toBe("Do you want to proceed?");
|
||||
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
|
||||
// The tool and its command, drawn over the dashed rule.
|
||||
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
|
||||
const x = parsePaneMenu(CODEX_APPROVAL)!;
|
||||
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
|
||||
expect(x.detail).toBeUndefined();
|
||||
expect(x.options).toHaveLength(3);
|
||||
expect(x.cursor).toBe(0);
|
||||
});
|
||||
@@ -317,6 +348,23 @@ const MULTI = [
|
||||
"",
|
||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||
];
|
||||
const MULTI_Q = [
|
||||
RULE,
|
||||
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
|
||||
"",
|
||||
"Critical (đã verify trong code). Chọn issue để log BUG:",
|
||||
"",
|
||||
"❯ 1. [✔] JWT HS256 + default secret",
|
||||
" pkg/middleware/auth.go:156,184 — HMAC only",
|
||||
" 2. [✔] Product code issue 2 lần",
|
||||
" gift product_code_repository.go:57 — không FOR UPDATE",
|
||||
" 3. [ ] Type something",
|
||||
" Next",
|
||||
RULE,
|
||||
" 4. Chat about this",
|
||||
"",
|
||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||
];
|
||||
const PERMISSION = [
|
||||
" Bash command",
|
||||
" touch probe-file.txt",
|
||||
@@ -378,3 +426,51 @@ describe("MENU_CURSOR_ROW", () => {
|
||||
expect(has(CODEX)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRunBusy", () => {
|
||||
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
|
||||
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||||
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
// Claude's permission footer is not a turn at work.
|
||||
rows = ["Esc to cancel · Tab to amend"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
|
||||
rows = ["Thinking… (3s · esc to cancel)"];
|
||||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
|
||||
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
|
||||
off();
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex's pickers", () => {
|
||||
it("reads /permissions with each row's description column as its hint", () => {
|
||||
const menu = parsePaneMenu([
|
||||
" >_ OpenAI Codex (v0.163.0)",
|
||||
"",
|
||||
" Update Model Permissions",
|
||||
"",
|
||||
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
|
||||
" access or edits outside the workspace",
|
||||
" 2. Approve for me Only ask for actions detected as potentially unsafe",
|
||||
" 3. Full Access Use with caution: Codex can edit files",
|
||||
" without approval",
|
||||
" 4. Read Only Read workspace files",
|
||||
"",
|
||||
" enter select · esc back",
|
||||
]);
|
||||
expect(menu?.question).toBe("Update Model Permissions");
|
||||
expect(menu?.cursor).toBe(0);
|
||||
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
|
||||
[
|
||||
1,
|
||||
"Ask for approval (current)",
|
||||
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
|
||||
],
|
||||
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
|
||||
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
|
||||
[4, "Read Only", "Read workspace files"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudePastes } from "../promptAttach";
|
||||
|
||||
describe("claudePastes", () => {
|
||||
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
|
||||
expect(
|
||||
claudePastes(["/d/drops/a.png", "/d/drops/b-report.pdf", "/d/x.JPG", "/d/c.log"])
|
||||
).toEqual(["/d/drops/a.png", "/d/x.JPG", "@/d/drops/b-report.pdf @/d/c.log"]);
|
||||
});
|
||||
|
||||
it("keeps a Windows or spaced image path a mention", () => {
|
||||
expect(claudePastes(["C:\\u\\drops\\a.png", "/home/u/my shot.png"])).toEqual([
|
||||
'@C:/u/drops/a.png @"/home/u/my shot.png"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2210,7 +2210,8 @@ export const api = {
|
||||
* each one contributes (skills/agents/commands/hooks counts).
|
||||
* @returns {@link CcPluginsResponse} — the manifest path/status plus plugins.
|
||||
*/
|
||||
plugins: () => request<CcPluginsResponse>("/cc-config/plugins"),
|
||||
plugins: (cwd?: string) =>
|
||||
request<CcPluginsResponse>(`/cc-config/plugins${ccQuery(undefined, cwd)}`),
|
||||
/**
|
||||
* GET /api/cc-config/mcp - configured MCP servers, user and project-scoped.
|
||||
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
|
||||
@@ -3006,6 +3007,11 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
|
||||
uninstall: (id: number) =>
|
||||
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
|
||||
method: "POST",
|
||||
}),
|
||||
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
|
||||
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
|
||||
/** One of the snapshot's own files, served sandboxed. */
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @file artifactView.ts
|
||||
* @description What makes a file an artifact, Claude Desktop's way, and how
|
||||
* one is shown: its kind from its name (a page, an image, a diagram, a React
|
||||
* component, a document, a table, code), the sandboxed document an HTML,
|
||||
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
|
||||
* same-origin access, its libraries from a pinned CDN build, as Claude's own
|
||||
* artifacts load theirs), a CSV's rows, and the versions a conversation's
|
||||
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
|
||||
* server keeps copies.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { TranscriptMessage } from "./types";
|
||||
|
||||
export type ArtifactKind =
|
||||
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
|
||||
|
||||
const EXT = (p: string) =>
|
||||
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
|
||||
|
||||
/** The kind a file shows as. */
|
||||
export function artifactKind(path: string): ArtifactKind {
|
||||
const e = EXT(path);
|
||||
if (e === "html" || e === "htm") return "html";
|
||||
if (e === "svg") return "svg";
|
||||
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
|
||||
if (e === "mmd" || e === "mermaid") return "mermaid";
|
||||
if (e === "jsx" || e === "tsx") return "react";
|
||||
if (e === "csv" || e === "tsv") return "csv";
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
|
||||
return "code";
|
||||
}
|
||||
|
||||
/** Kinds with a rendered Preview (the rest show their code only). */
|
||||
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
||||
"html",
|
||||
"svg",
|
||||
"markdown",
|
||||
"mermaid",
|
||||
"react",
|
||||
"csv",
|
||||
"image",
|
||||
]);
|
||||
|
||||
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
||||
* table — and a React component the model wrote whole (a `.tsx` it merely
|
||||
* edited is project code, not an artifact). */
|
||||
export function isArtifact(path: string, created: boolean): boolean {
|
||||
const k = artifactKind(path);
|
||||
if (k === "react") return created;
|
||||
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||
}
|
||||
|
||||
/** A code fence language for the Code tab. */
|
||||
export function artifactLang(path: string): string {
|
||||
const e = EXT(path);
|
||||
const map: Record<string, string> = {
|
||||
htm: "html",
|
||||
mmd: "text",
|
||||
mermaid: "text",
|
||||
md: "markdown",
|
||||
mdx: "markdown",
|
||||
jsx: "jsx",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
ts: "typescript",
|
||||
py: "python",
|
||||
sh: "bash",
|
||||
yml: "yaml",
|
||||
};
|
||||
return map[e] || e;
|
||||
}
|
||||
|
||||
// Pinned builds (all on npm for well over two weeks), as Claude's own
|
||||
// artifacts load React, Tailwind and Mermaid from a CDN.
|
||||
const CDN = {
|
||||
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
|
||||
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
|
||||
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
|
||||
tailwind: "https://cdn.tailwindcss.com/3.4.16",
|
||||
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
|
||||
};
|
||||
|
||||
/** Text safe inside a `<script>` element. */
|
||||
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
|
||||
const htmlEscape = (s: string) =>
|
||||
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
|
||||
/** Shown inside the frame when the artifact itself fails. */
|
||||
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
|
||||
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
|
||||
|
||||
/** The document a rendered artifact runs in (null: it is not a frame kind). */
|
||||
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
|
||||
switch (kind) {
|
||||
case "html":
|
||||
return /<html[\s>]|<!doctype/i.test(source)
|
||||
? source
|
||||
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
|
||||
case "svg":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
|
||||
case "mermaid":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
|
||||
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
|
||||
case "react":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
|
||||
<body><div id="root"></div>${ERROR_BOX}
|
||||
<script>
|
||||
(function(){
|
||||
var src = ${JSON.stringify(scriptSafe(source))};
|
||||
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
|
||||
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
|
||||
try {
|
||||
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
|
||||
var module = { exports: {} };
|
||||
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
|
||||
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
|
||||
if (!C) throw new Error("No component exported: export a default React component.");
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
|
||||
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
|
||||
})();
|
||||
</script></body></html>`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
|
||||
* (no `allow-same-origin`), and none at all for an SVG. */
|
||||
export const frameSandbox = (kind: ArtifactKind) =>
|
||||
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
|
||||
|
||||
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
|
||||
export function parseCsv(text: string, sep = ","): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if (quoted) {
|
||||
if (c === '"' && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else field += c;
|
||||
} else if (c === '"' && field === "") quoted = true;
|
||||
else if (c === sep) {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (c === "\n" || c === "\r") {
|
||||
if (c === "\r" && text[i + 1] === "\n") i++;
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
} else field += c;
|
||||
}
|
||||
if (field !== "" || row.length) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** One version of a file, as a Write/Edit call left it. */
|
||||
export interface ArtifactVersion {
|
||||
/** The call that made it. */
|
||||
toolUseId: string | null;
|
||||
timestamp: string | null;
|
||||
/** null: the call is known, its result text is not (an edit to a file
|
||||
* whose earlier text the conversation never showed). */
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
const samePath = (a: unknown, b: string) =>
|
||||
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
|
||||
|
||||
/** Applies one Edit (old → new, once or everywhere) to known text. */
|
||||
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
|
||||
if (typeof oldS !== "string" || typeof newS !== "string") return null;
|
||||
if (!text.includes(oldS)) return null;
|
||||
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
|
||||
}
|
||||
|
||||
/**
|
||||
* The versions of `path` in a conversation, oldest first: each successful
|
||||
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
|
||||
* that text with its edits applied. A call whose input the transcript cut, or
|
||||
* an edit to text never shown, is a version of unknown content (the current
|
||||
* file on disk stands in for the newest one).
|
||||
*/
|
||||
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
|
||||
const failed = new Set<string>();
|
||||
for (const m of messages)
|
||||
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
|
||||
const out: ArtifactVersion[] = [];
|
||||
let text: string | null = null;
|
||||
for (const m of messages) {
|
||||
if (m.type !== "assistant") continue;
|
||||
for (const c of m.content) {
|
||||
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
|
||||
const input = (c.input || {}) as Record<string, unknown>;
|
||||
if (!samePath(input.file_path, path)) continue;
|
||||
if (c.name === "Write") {
|
||||
text = typeof input.content === "string" ? input.content : null;
|
||||
} else if (c.name === "Edit") {
|
||||
text =
|
||||
text === null
|
||||
? null
|
||||
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
|
||||
} else if (c.name === "MultiEdit") {
|
||||
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
|
||||
for (const e of edits) {
|
||||
if (text === null) break;
|
||||
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
|
||||
}
|
||||
} else continue;
|
||||
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
|
||||
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
|
||||
* @file chatUi.ts — which tools' terminal panes have a Chat view
|
||||
* (`CHAT_TOOLS`), and whether such a session shows as a chat (`PaneChat`,
|
||||
* like Claude Desktop) or as its terminal. Each SESSION has its
|
||||
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
|
||||
* chat/terminal button), kept in this browser's localStorage
|
||||
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
|
||||
@@ -18,6 +19,19 @@ import { api } from "./api";
|
||||
|
||||
export const CHAT_UI_KEY = "agenthub.chatUi";
|
||||
|
||||
/** The tools whose terminal pane can show a chat: each one's pane learns its
|
||||
* session id (Claude's `--session-id`, the others' pollers through
|
||||
* `claimToolSessions`) and the server reads that session's conversation
|
||||
* (`GET /api/sessions/:id/transcript`). omp and Cursor send no hooks, so
|
||||
* their chat has no Stop button and follows at the pollers' pace. */
|
||||
export const CHAT_TOOLS: ReadonlySet<string> = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"omp",
|
||||
"cursor",
|
||||
"antigravity",
|
||||
]);
|
||||
|
||||
export const VIEWS_KEY = "agenthub.chatUi.sessions";
|
||||
const MAX_VIEWS = 200;
|
||||
|
||||
|
||||
+115
-11
@@ -34,20 +34,24 @@ interface Pane {
|
||||
key?: (data: string) => void;
|
||||
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null;
|
||||
/** The rows on screen as plain text, dim cells kept. */
|
||||
lines?: () => string[];
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||||
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
|
||||
* raw input, as a key press; `screen`: its rows now; `lines`: the same as
|
||||
* plain text); returns its unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true,
|
||||
key?: (data: string) => void,
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null,
|
||||
lines?: () => string[]
|
||||
): () => void {
|
||||
const entry = { paste, visible, key, screen };
|
||||
const entry = { paste, visible, key, screen, lines };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
@@ -59,6 +63,26 @@ export function notePaneFocus(runId: string): void {
|
||||
lastFocused = runId;
|
||||
}
|
||||
|
||||
/** Who wants to hear that a pane drew something (its chat: the CLI is doing
|
||||
* something, so its transcript may have grown). */
|
||||
const outputListeners = new Map<string, Set<() => void>>();
|
||||
|
||||
/** A terminal received output for `runId` (TerminalView, every PTY frame). */
|
||||
export function notePaneOutput(runId: string): void {
|
||||
outputListeners.get(runId)?.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
/** Calls `fn` on every output `runId`'s terminal receives; returns its unregister. */
|
||||
export function onPaneOutput(runId: string, fn: () => void): () => void {
|
||||
let set = outputListeners.get(runId);
|
||||
if (!set) outputListeners.set(runId, (set = new Set()));
|
||||
set.add(fn);
|
||||
return () => {
|
||||
set!.delete(fn);
|
||||
if (!set!.size) outputListeners.delete(runId);
|
||||
};
|
||||
}
|
||||
|
||||
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
|
||||
function ofLane(runId: string, laneId: number): boolean {
|
||||
const base = `agenthub-lane-${laneId}`;
|
||||
@@ -162,32 +186,47 @@ export function takeRunInput(runId: string): string {
|
||||
}
|
||||
|
||||
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
|
||||
* unnumbered Submit row), `checked` a multiSelect box's state. */
|
||||
* unnumbered Submit / Next row), `checked` a multiSelect box's state. */
|
||||
export type PaneMenuOption = {
|
||||
n: number | null;
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean | null;
|
||||
};
|
||||
/** One question tab of an AskUserQuestion that asks several: `done` once it
|
||||
* has an answer (`☒`). */
|
||||
export type PaneMenuTab = { label: string; done: boolean };
|
||||
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
|
||||
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
|
||||
* "Type something" row (-1: none). */
|
||||
* "Type something" row (-1: none). `tabs` only when it asks several
|
||||
* questions (`← ☒ A □ B ✔ Submit →`), `tab` the one shown (-1: unknown).
|
||||
* `detail` what a permission prompt asks about (its tool, then the command),
|
||||
* when the screen shows it apart from the question. */
|
||||
export type PaneMenu = {
|
||||
question: string;
|
||||
options: PaneMenuOption[];
|
||||
cursor: number;
|
||||
multi: boolean;
|
||||
typeAt: number;
|
||||
tabs?: PaneMenuTab[];
|
||||
tab?: number;
|
||||
detail?: string[];
|
||||
};
|
||||
|
||||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
||||
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
|
||||
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
|
||||
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
|
||||
export const MENU_CURSOR_ROW =
|
||||
/^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |(?:Submit|Next)\s*$)|Enter to confirm)/;
|
||||
// The unnumbered button under a multiSelect's rows: Next while more questions
|
||||
// follow, Submit on the last one.
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*(Submit|Next)$/;
|
||||
const TAB_BAR = /^ *← +(.+?) +→ *$/;
|
||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||
const DASHED_ROW = /^ *╌{3,} *$/;
|
||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||
const PLACEHOLDER = /^Type something\.?$/;
|
||||
const COLUMN = /^(\S.*?\S) {2,}(\S.*)$/;
|
||||
|
||||
/** The numbered menu at the bottom of `rows`, or null. A menu has two
|
||||
* numbered rows or more from 1, the cursor on one of them, and no rule right
|
||||
@@ -207,10 +246,13 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
const m = OPTION.exec(row);
|
||||
const s = m ? null : SUBMIT.exec(row);
|
||||
if (m || s) {
|
||||
const text = m ? (m[3] ?? "") : "Submit";
|
||||
const text = m ? (m[3] ?? "") : (s?.[2] ?? "Submit");
|
||||
const c = m ? CHECK.exec(text) : null;
|
||||
const label = c ? (c[2] ?? "") : text;
|
||||
const h = hint.join(" ");
|
||||
// Codex's pickers (`/model`, `/permissions`) draw each row's
|
||||
// description in a column after the name: split it off as the hint.
|
||||
const col = COLUMN.exec(c ? (c[2] ?? "") : text);
|
||||
const label = col ? col[1]! : c ? (c[2] ?? "") : text;
|
||||
const h = [col?.[2], ...hint].filter(Boolean).join(" ");
|
||||
options.unshift({
|
||||
n: m ? Number(m[2]) : null,
|
||||
label,
|
||||
@@ -239,15 +281,47 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
const lines: string[] = [];
|
||||
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
|
||||
lines.unshift(r[q]!.trim());
|
||||
const question = lines.join("\n");
|
||||
const tabs = tabBar(r.slice(Math.max(0, q - 4), Math.max(0, q + 1)));
|
||||
const low = question.toLowerCase();
|
||||
// What a permission prompt asks about: Claude draws the tool and its
|
||||
// command over a dashed rule above the question, up to the dialog's top rule.
|
||||
const detail: string[] = [];
|
||||
let d = q;
|
||||
while (d >= 0 && !r[d]) d--;
|
||||
if (d >= 0 && DASHED_ROW.test(r[d]!))
|
||||
for (d--; d >= 0 && !RULE_ROW.test(r[d]!) && detail.length < 12; d--)
|
||||
if (r[d]) detail.unshift(r[d]!.trim());
|
||||
return {
|
||||
question: lines.join("\n"),
|
||||
question,
|
||||
options,
|
||||
cursor: options.length - cursor,
|
||||
multi: options.some((o) => o.checked !== null),
|
||||
typeAt: typeRow(options),
|
||||
...(tabs.length > 1
|
||||
? { tabs, tab: tabs.findIndex((t) => low.startsWith(t.label.toLowerCase())) }
|
||||
: {}),
|
||||
...(detail.length ? { detail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The question tabs of a multi-question AskUserQuestion, read off its bar
|
||||
* (`← ☒ Critical □ High 1/3 ✔ Submit →`) among `rows`; the Submit tab is
|
||||
* no question. [] when there is no bar. */
|
||||
function tabBar(rows: string[]): PaneMenuTab[] {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const m = TAB_BAR.exec(rows[i] ?? "");
|
||||
if (!m) continue;
|
||||
return (m[1] ?? "")
|
||||
.split(/(?=[☒☐□✔☑■] )/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => ({ label: x.slice(1).trim(), done: /^[☒✔☑■]/.test(x) }))
|
||||
.filter((t) => t.label && t.label !== "Submit");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
|
||||
|
||||
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
|
||||
@@ -291,6 +365,26 @@ function typeRow(options: PaneMenuOption[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The status line a tool's TUI shows only while a turn runs, read off its
|
||||
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
||||
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
|
||||
* tool missing here (omp, Cursor) has no line we know. */
|
||||
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
||||
claude: /\besc to interrupt\b/i,
|
||||
codex: /\besc to interrupt\b/i,
|
||||
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
||||
};
|
||||
|
||||
/** Whether `runId`'s pane shows `tool` at work now (its BUSY_LINE), or null
|
||||
* when the tool's line is unknown or the pane is not mounted. */
|
||||
export function readRunBusy(runId: string, tool: string): boolean | null {
|
||||
const re = BUSY_LINE[tool];
|
||||
const p = panes.get(runId);
|
||||
if (!re || !p?.lines) return null;
|
||||
return p.lines().some((l) => re.test(l));
|
||||
}
|
||||
|
||||
/** The menu `runId`'s visible pane shows now, or null. */
|
||||
export function readRunMenu(runId: string): PaneMenu | null {
|
||||
const p = panes.get(runId);
|
||||
@@ -322,6 +416,16 @@ export function chooseRunOption(runId: string, menu: PaneMenu, index: number): b
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Moves a multi-question AskUserQuestion to its previous (-1) or next (1)
|
||||
* question tab, the arrows Claude's footer names. False when the pane is not
|
||||
* visible or not attached. */
|
||||
export function stepRunMenu(runId: string, dir: -1 | 1): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible() || !p.key) return false;
|
||||
p.key(dir < 0 ? "\x1b[D" : "\x1b[C");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Answers `menu` with typed text: the arrows to its "Type something" row,
|
||||
* Ctrl+U over what is typed there already (one key clears it, however long
|
||||
* it is), the text as a bracketed
|
||||
|
||||
@@ -169,6 +169,22 @@ export function claudeMention(path: string): string {
|
||||
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
|
||||
}
|
||||
|
||||
const IMAGE_PATH = /\.(png|jpe?g|gif|webp)$/i;
|
||||
|
||||
/** What a terminal pastes for files dropped or pasted on a `claude` pane, one
|
||||
* paste per entry, as a local terminal hands them over: the images as their
|
||||
* bare paths, each a paste of its own — the shape Claude Code turns into
|
||||
* `[Image #n]`, as it does a native drop — and every other file as an `@`
|
||||
* mention, together in one. A Windows path, or
|
||||
* one with a space, stays a mention — the bare path stayed plain text there
|
||||
* (see `claudeMention`). */
|
||||
export function claudePastes(paths: string[]): string[] {
|
||||
const bare = (p: string) => IMAGE_PATH.test(p) && p.startsWith("/") && !/\s/.test(p);
|
||||
const images = paths.filter(bare);
|
||||
const others = paths.filter((p) => !bare(p)).map(claudeMention);
|
||||
return [...images, others.join(" ")].filter(Boolean);
|
||||
}
|
||||
|
||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||
|
||||
@@ -1930,7 +1930,10 @@ export interface WSMessage {
|
||||
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||
| "runs_interrupted"
|
||||
/** An artifact was added, changed, shared or deleted; refetch the list. */
|
||||
| "artifact_changed";
|
||||
| "artifact_changed"
|
||||
/** A session's transcript grew on disk (`{session_id}`); a chat showing
|
||||
* it reads the new lines now. */
|
||||
| "transcript_changed";
|
||||
/** The message body, whose concrete shape is selected by `type` above. */
|
||||
data:
|
||||
| Session
|
||||
|
||||
+812
-191
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,9 @@
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
* A card's right-click menu runs the same actions (Preview, Delete). A file
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
|
||||
* Add menu closes on Escape or a click outside it.
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
|
||||
* Add menu closes on Escape or a click outside it, the front page is shelved
|
||||
* by kind until a category is picked, and a card opens its detail sheet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -32,6 +33,7 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
installs: vi.fn(),
|
||||
setInstallsEnabled: vi.fn(),
|
||||
skills: vi.fn(),
|
||||
@@ -294,6 +296,30 @@ describe("Artifacts", () => {
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||
});
|
||||
|
||||
it("uninstalls an installed plugin only after a confirmation", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 4, kind: "plugin", title: "tool", source: "tool@market", installed: true }),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.uninstall).mockReset();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-4");
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.uninstall).mockResolvedValueOnce({
|
||||
uninstalled: "tool@market",
|
||||
paths: [],
|
||||
});
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
|
||||
});
|
||||
|
||||
it("installs a skill only after a confirmation, and asks before replacing", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -315,8 +341,8 @@ describe("Artifacts", () => {
|
||||
it("previews a file in a sandboxed iframe that may run nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("report.html");
|
||||
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
|
||||
const card = await screen.findByTestId("artifact-1");
|
||||
await user.click(within(card).getByRole("button", { name: "Preview" }));
|
||||
const frame = await screen.findByTestId("artifact-preview");
|
||||
expect(frame.getAttribute("sandbox")).toBe("");
|
||||
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||
@@ -425,4 +451,22 @@ describe("Artifacts", () => {
|
||||
await user.click(screen.getByText("report.html"));
|
||||
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shelves the front page by kind, and a card opens its detail sheet", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("deploy");
|
||||
const shelves = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
|
||||
expect(shelves).toEqual(["Skills1", "Files1"]);
|
||||
await user.click(screen.getAllByRole("button", { name: "See all" })[0]!);
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ kind: "skill" })
|
||||
)
|
||||
);
|
||||
await user.click(screen.getByText("deploy"));
|
||||
const sheet = await screen.findByTestId("artifact-detail");
|
||||
expect(within(sheet).getByText("/deploy")).toBeInTheDocument();
|
||||
expect(within(sheet).getByRole("button", { name: "Install" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.13 (rebuild client to match)
|
||||
UI build v2.8.14 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+6
-4
@@ -2787,7 +2787,7 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
|
||||
POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||||
```
|
||||
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||
|
||||
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
|
||||
|
||||
@@ -2884,8 +2884,8 @@ GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narr
|
||||
GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50
|
||||
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
|
||||
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over 20 MB before reading the body, and close the connection; of several files only the refused ones are lost
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
|
||||
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||
@@ -3050,6 +3050,7 @@ PATCH /api/artifacts/:id {title?, description?, shared?, s
|
||||
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
|
||||
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||
```
|
||||
|
||||
@@ -3063,7 +3064,8 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page shows **Uninstall** (card icon, ⋯ menu, detail sheet) beside **Installed**.
|
||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
||||
|
||||
### Query Explorer
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ CREATE TABLE sessions (
|
||||
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
|
||||
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
||||
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from, or `push:<name>` for a session another machine pushed to `POST /api/hooks/ingest-batch` (a pushed hook may only extend a session its own source created). Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none), `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none); its conversation is read from the file `cursor_transcripts` recorded for it, `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
|
||||
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
|
||||
| `pinned_at` | TEXT | YES | When the user pinned the session to the top of its workspace's sidebar list (`PATCH /api/sessions/:id {pinned}`; `GET /api/sessions?pinned_first=1` orders by it). Order only; NULL = not pinned |
|
||||
|
||||
+6
-3
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.13",
|
||||
"version": "2.8.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.13",
|
||||
"version": "2.8.14",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.13",
|
||||
"version": "2.8.14",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+10
-3
@@ -632,12 +632,19 @@ An `antigravity` workspace carries no pipeline either.
|
||||
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
|
||||
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
|
||||
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
|
||||
`antigravity_conversations` holds the mtime read for each file.
|
||||
`antigravity_conversations` holds the mtime read for each file. The
|
||||
session's conversation (`GET /api/sessions/:id/transcript`) is read from its
|
||||
step log (`brain/<id>/.system_generated/logs/transcript_full.jsonl`,
|
||||
`transcriptPath`): prompts, the model's text, thinking, tool calls and
|
||||
tokens, and each tool's result tied to its call by step.
|
||||
- **Cursor sessions:** `server/lib/cursor-usage.js` reads Cursor's agent
|
||||
transcripts (`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`) every
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
and `cursor_transcripts` holds the mtime read for each file. The session's
|
||||
conversation (`GET /api/sessions/:id/transcript`) is read from that recorded
|
||||
file: prompts, replies, thinking, tool calls and results, with no timestamps
|
||||
or model (Cursor writes none).
|
||||
- **Oh My Pi sessions:** `server/lib/omp-usage.js` reads omp's session journals
|
||||
(`~/.omp/agent/sessions/<encoded cwd>/<timestamp>_<id>.jsonl`, plus every
|
||||
subagent journal under `<timestamp>_<id>/`) every `DASHBOARD_OMP_POLL_MS` ms
|
||||
@@ -803,7 +810,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
|
||||
|
||||
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the workspace action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
|
||||
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher), and `transcript_changed` `{ session_id }` — the session sync's `fs.watch` on the projects tree saw a Claude transcript grow (`pushTranscript` in `server/index.js`, at most every 250 ms per session; nothing is parsed for it), so a chat showing that session reads its new lines at once instead of at its next 3 s poll. It reaches whoever the session's own `new_event` reaches (`broadcastAudience`).
|
||||
|
||||
### Import History
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file antigravity-transcript.test.js
|
||||
* @description An Antigravity (agy) session's conversation view: its
|
||||
* `transcript_full.jsonl` step log mapped to the Claude transcript shape —
|
||||
* the prompt out of `<USER_REQUEST>`, the model's thinking, text, tool calls
|
||||
* and tokens, each tool result tied to its call by step and an invalid call
|
||||
* marked an error — one "main" transcript, no subagent files, and a session
|
||||
* id that is no UUID finding nothing. The agy dir and the DB are temp dirs;
|
||||
* the steps are synthetic, in the shape agy 1.4 writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-transcript-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const agy = require("../lib/antigravity-usage");
|
||||
|
||||
let server;
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ID = "57154e1a-65e5-4b51-a116-1890f5ca831f";
|
||||
const AT = "2026-10-21T05:08:24Z";
|
||||
|
||||
function steps(id) {
|
||||
const dir = path.join(
|
||||
process.env.AGENTHUB_GEMINI_DIR,
|
||||
"antigravity-cli",
|
||||
"brain",
|
||||
id,
|
||||
".system_generated",
|
||||
"logs"
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "transcript_full.jsonl");
|
||||
const lines = [
|
||||
{
|
||||
step_index: 0,
|
||||
source: "USER_EXPLICIT",
|
||||
type: "USER_INPUT",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content:
|
||||
"<USER_REQUEST>\nCreate c.txt\n</USER_REQUEST>\n<ADDITIONAL_METADATA>\nThe current local time is: x.\n</ADDITIONAL_METADATA>",
|
||||
},
|
||||
{
|
||||
step_index: 1,
|
||||
source: "MODEL",
|
||||
type: "PLANNER_RESPONSE",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
input_tokens: 3889,
|
||||
cache_read_tokens: 768,
|
||||
output_tokens: 12,
|
||||
thinking: "Two commands.",
|
||||
content: "Running them now.",
|
||||
tool_calls: [
|
||||
{ name: "run_command", args: { CommandLine: "echo ok > c.txt" } },
|
||||
{ name: "read", args: { path: "c.txt" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
step_index: 2,
|
||||
source: "MODEL",
|
||||
type: "GENERIC",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content: "Created At: x\nCompleted At: y\n\nThe command exited with code 0.\n",
|
||||
},
|
||||
{
|
||||
step_index: 3,
|
||||
source: "MODEL",
|
||||
type: "GENERIC",
|
||||
status: "INVALID",
|
||||
error: 'unknown tool: "read"',
|
||||
created_at: AT,
|
||||
content: 'Created At: x\nCompleted At: y\nError invalid tool call: unknown tool: "read"',
|
||||
},
|
||||
{
|
||||
step_index: 4,
|
||||
source: "MODEL",
|
||||
type: "PLANNER_RESPONSE",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content: "Done.",
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("an Antigravity session's conversation", () => {
|
||||
it("reads agy's step log in the Claude transcript shape", async () => {
|
||||
const file = steps(ID);
|
||||
assert.equal(agy.transcriptPath(ID), file);
|
||||
assert.equal(agy.transcriptPath("../../etc"), null);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, tool) VALUES (?, 'c', 'completed', ?, 'antigravity')"
|
||||
).run(ID, AT);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
server ||= await startServer(createApp(), 0);
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||
assert.deepEqual(
|
||||
list.transcripts.map((t) => [t.id, t.type]),
|
||||
[["main", "main"]]
|
||||
);
|
||||
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||
assert.deepEqual(
|
||||
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
|
||||
[
|
||||
["user", ["text"]],
|
||||
["assistant", ["thinking", "text", "tool_use", "tool_use"]],
|
||||
["tool", ["tool_result"]],
|
||||
["tool", ["tool_result"]],
|
||||
["assistant", ["text"]],
|
||||
]
|
||||
);
|
||||
const [user, plan, ok, bad] = body.messages;
|
||||
assert.equal(user.content[0].text, "Create c.txt");
|
||||
assert.equal(user.timestamp, AT);
|
||||
// Each result names the call it answers: call i of step s lands at step s+1+i.
|
||||
assert.deepEqual(
|
||||
plan.content.filter((c) => c.type === "tool_use").map((c) => c.id),
|
||||
[ok.content[0].id, bad.content[0].id]
|
||||
);
|
||||
assert.equal(ok.content[0].output, "The command exited with code 0.\n");
|
||||
assert.equal(ok.content[0].is_error, false);
|
||||
assert.equal(bad.content[0].is_error, true);
|
||||
assert.equal(plan.usage.input_tokens, 3889);
|
||||
assert.equal(plan.usage.cache_read_input_tokens, 768);
|
||||
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
|
||||
assert.equal(sub.messages.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
|
||||
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
|
||||
* symlink/secret refusal, install into the caller's ~/.claude and ~/.agents skills with
|
||||
* replace moving the old one aside), plugins (marketplace allowlist, install
|
||||
* replace and uninstall moving the old one aside), plugins (marketplace allowlist, install
|
||||
* argv; a marketplace link read into its plugin list, the same plugin added
|
||||
* twice kept as one row, key/market edited), skills uploaded (one Markdown
|
||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||
@@ -200,6 +200,18 @@ describe("skills", () => {
|
||||
const old = fs.readdirSync(aside).find((n) => !n.includes("-agents-"));
|
||||
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
|
||||
assert.ok(fs.readdirSync(aside).some((n) => n.startsWith("deploy-agents-")));
|
||||
|
||||
// Uninstall moves both copies aside too, never deletes them.
|
||||
const gone = await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
assert.equal(gone.status, 200, gone.text);
|
||||
assert.deepEqual(gone.body.paths, [target, shared]);
|
||||
assert.ok(!fs.existsSync(target) && !fs.existsSync(shared));
|
||||
assert.equal((await listed()).installed, false);
|
||||
const kept = fs.readdirSync(aside).filter((n) => n.includes("-uninstalled-"));
|
||||
assert.equal(kept.length, 2);
|
||||
const none = await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
assert.equal(none.status, 409);
|
||||
assert.equal(none.body.error.code, "ENOTINSTALLED");
|
||||
});
|
||||
|
||||
it("edits a skill's name, description and instructions into a new snapshot", async () => {
|
||||
@@ -271,6 +283,12 @@ describe("plugins", () => {
|
||||
["claude", "plugin", "marketplace", "add", "o/r"],
|
||||
["claude", "plugin", "install", "--scope", "user", "tool@market"],
|
||||
]);
|
||||
calls.length = 0;
|
||||
const un = await request("POST", `/api/artifacts/${r.body.item.id}/uninstall`, {});
|
||||
assert.equal(un.status, 200, un.text);
|
||||
assert.deepEqual(calls, [
|
||||
["claude", "plugin", "uninstall", "--scope", "user", "--keep-data", "tool@market"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* project slug walked back onto the filesystem) and no tokens, an unchanged
|
||||
* file never re-read, a quiet one settled to completed, a session the user
|
||||
* deleted staying deleted, and a non-cursor row with the same id never
|
||||
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
|
||||
* synthetic, in the shape Cursor writes.
|
||||
* touched, a session claimed by the Cursor pane it started in, and the
|
||||
* transcript mapped to the Claude transcript shape by the
|
||||
* transcript routes. The Cursor dir and the DB are temp dirs; the transcripts
|
||||
* are synthetic, in the shape Cursor writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,13 +18,18 @@ const path = require("node:path");
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const cursor = require("../lib/cursor-usage");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
let server;
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
|
||||
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
|
||||
@@ -65,7 +72,8 @@ const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
|
||||
db.exec(
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;" +
|
||||
" DELETE FROM dashboard_runs; DELETE FROM lanes;"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -175,4 +183,64 @@ describe("cursor usage", () => {
|
||||
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
|
||||
assert.equal(session(ID).cwd, null);
|
||||
});
|
||||
|
||||
it("maps the transcript to the transcript shape", async () => {
|
||||
const file = transcript(ID);
|
||||
const more = [
|
||||
{
|
||||
role: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Reading it now." },
|
||||
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "x" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
message: { content: [{ type: "tool_result", tool_use_id: "t1", content: "no match" }] },
|
||||
},
|
||||
{ role: "assistant", message: { content: [{ type: "text", text: "Done." }] } },
|
||||
];
|
||||
fs.appendFileSync(file, more.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(cursor.transcriptPath(ID), file);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
server ||= await startServer(createApp(), 0);
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||
assert.deepEqual(
|
||||
list.transcripts.map((t) => [t.id, t.type]),
|
||||
[["main", "main"]]
|
||||
);
|
||||
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||
assert.deepEqual(
|
||||
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
|
||||
[
|
||||
["user", ["text"]],
|
||||
["assistant", ["tool_use"]],
|
||||
["assistant", ["text", "tool_use"]],
|
||||
["tool", ["tool_result"]],
|
||||
["assistant", ["text"]],
|
||||
]
|
||||
);
|
||||
assert.equal(body.messages[0].content[0].text, "Review the campaign code\nplease");
|
||||
assert.equal(body.messages[3].content[0].output, "no match");
|
||||
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
|
||||
assert.equal(sub.messages.length, 0);
|
||||
});
|
||||
|
||||
it("gives the session to the Cursor pane it started in", async () => {
|
||||
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (5, 'w', ?)").run(PROJECT);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, lane_id, cwd, status, started_at, tool)
|
||||
VALUES ('agenthub-lane-5', 5, ?, 'running', ?, 'cursor')`
|
||||
).run(PROJECT, new Date(Date.now() - 60_000).toISOString());
|
||||
transcript(ID);
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).lane_id, 5);
|
||||
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-5'");
|
||||
assert.equal(run.get().session_id, ID);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +205,9 @@ describe("lane-sources instructions and launch", () => {
|
||||
assert.equal(argv[5], "--permission-mode");
|
||||
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
|
||||
// Claude Code hides its task tools from newer models without this, and the
|
||||
// chat Progress panel is drawn from those calls.
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
|
||||
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
|
||||
@@ -354,7 +354,19 @@ describe("POST /api/run/drop-image", () => {
|
||||
});
|
||||
assert.equal(status, 413);
|
||||
assert.equal(body.error.code, "ETOOBIG");
|
||||
assert.equal(body.error.max_bytes, 20 * 1024 * 1024); // the UI shows the server's limit
|
||||
// The UI shows the server's limit: a loopback browser's, here.
|
||||
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it("streams a loopback drop over the remote limit to disk", async () => {
|
||||
const buf = Buffer.alloc(21 * 1024 * 1024, 7);
|
||||
const r = await fetchJson("/api/run/drop-file?name=big.bin", {
|
||||
method: "POST",
|
||||
raw: { type: "application/octet-stream", buf },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.body.path, /[\\/]drops[\\/][0-9a-f]{16}-big\.bin$/);
|
||||
assert.equal(fs.statSync(r.body.path).size, buf.length);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -227,6 +227,9 @@ describe("broadcastAudience", () => {
|
||||
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
|
||||
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
|
||||
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
|
||||
// A transcript's growth notice goes where the session's events go.
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m2), true);
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
|
||||
});
|
||||
|
||||
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
||||
|
||||
@@ -437,6 +437,82 @@ describe("the top of a member's home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the run form's `/` list", () => {
|
||||
it("reads the user skills, commands and plugins of the account the pane runs as", async () => {
|
||||
const skill = (claude, name) => {
|
||||
fs.mkdirSync(path.join(claude, "skills", name), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(claude, "skills", name, "SKILL.md"),
|
||||
`---\nname: ${name}\n---\nx\n`
|
||||
);
|
||||
};
|
||||
const hostClaude = process.env.CLAUDE_HOME;
|
||||
skill(hostClaude, "host-only");
|
||||
skill(path.join(MIA, ".claude"), "mia-skill");
|
||||
skill(path.join(BOB, ".claude"), "bob-skill");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "commands"), { recursive: true });
|
||||
fs.writeFileSync(path.join(MIA, ".claude", "commands", "mia-cmd.md"), "do it\n");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "plugins"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(MIA, ".claude", "plugins", "installed_plugins.json"),
|
||||
JSON.stringify({ plugins: { "mp@market": [{ scope: "user" }] } })
|
||||
);
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
|
||||
const mia = await call("mia", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(mia), ["mia-skill"], JSON.stringify(mia.body));
|
||||
const cmds = await call("mia", "GET", "/api/cc-config/commands?scope=user");
|
||||
assert.deepEqual(names(cmds), ["mia-cmd"]);
|
||||
// Plugins stay staff-only; an admin's run form in a member folder lists that member's.
|
||||
const plugins = await call(
|
||||
"boss",
|
||||
"GET",
|
||||
`/api/cc-config/plugins?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
plugins.body.plugins.map((p) => p.key),
|
||||
["mp@market"],
|
||||
JSON.stringify(plugins.body)
|
||||
);
|
||||
// A folder picks its own account's home.
|
||||
const bob = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(BOB)}`
|
||||
);
|
||||
assert.deepEqual(names(bob), ["bob-skill"], JSON.stringify(bob.body));
|
||||
// An admin's Config Explorer view, no folder named, stays the host's.
|
||||
const boss = await call("boss", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(boss), ["host-only"]);
|
||||
});
|
||||
|
||||
it("never lists another member's or the host's skills as someone's own", async () => {
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
const bob = await call("bob", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(bob), ["bob-skill"]);
|
||||
const intoMia = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.equal(intoMia.status, 403);
|
||||
assert.equal(intoMia.body.error.code, "EFOLDER");
|
||||
// A developer with no Linux account of their own gets nothing, not the host's.
|
||||
await auth.createUser("eve", "eve-pass-123", "developer");
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "eve", password: "eve-pass-123" }),
|
||||
});
|
||||
cookies.eve = login.headers.get("set-cookie").split(";")[0];
|
||||
for (const kind of ["skills", "commands"]) {
|
||||
const r = await call("eve", "GET", `/api/cc-config/${kind}`);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body.items, [], kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled member's session", () => {
|
||||
it("is still read from their home, and does not stop the liveness reaper", () => {
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
|
||||
|
||||
@@ -1827,6 +1827,15 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN pane_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// A realtime chat run's bridge secret (`AGENTHUB_CHAT_TOKEN`), SHA-256: only the
|
||||
// pane's own bridge may stream that run's events (server/routes/chat.js).
|
||||
// Never listed.
|
||||
try {
|
||||
db.prepare("SELECT chat_token_hash FROM dashboard_runs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// Server mode: whose browser a push subscription is (`users.id`); NULL in
|
||||
// local mode. A user's notifications go only to their own subscriptions.
|
||||
try {
|
||||
|
||||
+33
-5
@@ -1032,7 +1032,9 @@ function startWorkflowPoll(broadcast) {
|
||||
* sessions show up immediately rather than on the next poll. Events for
|
||||
* files already in `mtimeCache` (active transcripts being appended to) are
|
||||
* ignored, so a busy session never thrashes the importer — the poll picks
|
||||
* up its growth. Recursive watching is used only on macOS/Windows (native,
|
||||
* up its growth; such an append only broadcasts `transcript_changed`
|
||||
* `{session_id}` (at most every 250 ms per session), so an open chat reads
|
||||
* the new lines at once. Recursive watching is used only on macOS/Windows (native,
|
||||
* stable); on Linux, where Node's userland recursive watcher trips on the
|
||||
* high-churn projects tree (see lib/cc-watcher.js), we watch the root plus
|
||||
* each immediate child folder non-recursively instead.
|
||||
@@ -1148,11 +1150,37 @@ function startSessionSync(broadcast) {
|
||||
}, DEBOUNCE_MS);
|
||||
if (debounce.unref) debounce.unref();
|
||||
}
|
||||
// Only a path we don't already track is interesting (a new session file or a
|
||||
// new project folder). Appends to a known active transcript are left to the
|
||||
// poll, so the watcher never re-parses a busy session every write.
|
||||
// An append to a session's own transcript: a chat showing that session reads
|
||||
// it now instead of at its next poll. One `transcript_changed` per session
|
||||
// per PUSH_MS — a notice only, nothing is parsed here.
|
||||
const PUSH_MS = 250;
|
||||
const pushing = new Map(); // session id → pending timer
|
||||
function pushTranscript(fullPath) {
|
||||
const m =
|
||||
/(?:^|[\\/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(
|
||||
fullPath
|
||||
);
|
||||
if (!m || pushing.has(m[1])) return;
|
||||
const sessionId = m[1];
|
||||
const timer = setTimeout(() => {
|
||||
pushing.delete(sessionId);
|
||||
try {
|
||||
broadcast("transcript_changed", { session_id: sessionId });
|
||||
} catch {
|
||||
/* best-effort — the chat's poll still catches up */
|
||||
}
|
||||
}, PUSH_MS);
|
||||
if (timer.unref) timer.unref();
|
||||
pushing.set(sessionId, timer);
|
||||
}
|
||||
|
||||
// Only a path we don't already track is interesting to the importer (a new
|
||||
// session file or a new project folder). Appends to a known active
|
||||
// transcript are left to the poll, so the watcher never re-parses a busy
|
||||
// session every write — it only tells the chats (`pushTranscript`).
|
||||
function onFsEvent(fullPath) {
|
||||
if (fullPath && mtimeCache.has(fullPath)) return;
|
||||
if (fullPath && mtimeCache.has(fullPath)) return void pushTranscript(fullPath);
|
||||
if (fullPath) pushTranscript(fullPath);
|
||||
scheduleSweep();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* 5 cache read.
|
||||
* The session's name and start come from its transcript
|
||||
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
|
||||
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `USER_INPUT`), and the same file is the conversation view's
|
||||
* (`transcriptPath`, read by `GET /api/sessions/:id/transcript`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `-wal`'s) changes — they are small — and the mtime per file lives in
|
||||
* `antigravity_conversations`, so a session the user deleted stays deleted
|
||||
* until agy writes to it again. Opening read-only still lets SQLite leave an
|
||||
@@ -157,6 +158,21 @@ const transcriptFiles = (id) =>
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
/** The transcript of conversation `id` (this account's or a member's) the
|
||||
* conversation view reads, or null. Only a UUID names one. */
|
||||
function transcriptPath(id) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(id || "")))
|
||||
return null;
|
||||
for (const file of transcriptFiles(id)) {
|
||||
try {
|
||||
if (fs.statSync(file).isFile()) return file;
|
||||
} catch {
|
||||
/* try the next name */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The transcript's last write, 0 when it has none. */
|
||||
function transcriptMtime(id) {
|
||||
for (const file of transcriptFiles(id)) {
|
||||
@@ -531,4 +547,5 @@ module.exports = {
|
||||
readConversation,
|
||||
ingestAntigravityUsage,
|
||||
startAntigravityUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
+102
-30
@@ -33,8 +33,10 @@
|
||||
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
|
||||
* installed without `-y`, so one whose marketplace declares an install
|
||||
* command fails rather than runs unseen. An admin can turn installs off
|
||||
* for everyone (`setInstallsEnabled`). Share, unshare and install are
|
||||
* written to `audit_log`.
|
||||
* for everyone (`setInstallsEnabled`). Uninstall (`uninstall`) takes it out
|
||||
* of the same accounts again — a skill moved aside the same way, a plugin
|
||||
* `claude plugin uninstall --keep-data` — and is allowed with installs off.
|
||||
* Share, unshare, install and uninstall are written to `audit_log`.
|
||||
* - Editing a skill's name, description or instructions (`editSkill`) writes
|
||||
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
|
||||
* copies already installed stay as they were (Reinstall picks it up).
|
||||
@@ -1409,6 +1411,31 @@ function installedCheck(req) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move installed skill folders (`{acct, claudeHome, target}`) aside to
|
||||
* `~/.claude/agenthub-replaced-skills/<name>[-agents][-uninstalled]-<stamp>`,
|
||||
* as their account — a replace or an uninstall never deletes a user's copy.
|
||||
*/
|
||||
async function moveAside(taken, row, label = "") {
|
||||
const runAs = require("./run-as");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
for (const t of taken) {
|
||||
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
|
||||
const aside = path.join(
|
||||
t.claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}${tag}${label}-${stamp}`
|
||||
);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||
fs.renameSync(t.target, aside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill snapshot as `<name>` into every skills folder of every
|
||||
* account the viewer's sessions run as (`installAccounts` × `skillDirsOf`).
|
||||
@@ -1428,22 +1455,7 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
for (const t of taken) {
|
||||
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
|
||||
const aside = path.join(
|
||||
t.claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}${tag}-${stamp}`
|
||||
);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||
fs.renameSync(t.target, aside);
|
||||
}
|
||||
}
|
||||
await moveAside(taken, row);
|
||||
const files = row.files ? JSON.parse(row.files) : [];
|
||||
const contents = files.map((f) => ({
|
||||
rel: f.path.split("/"),
|
||||
@@ -1470,14 +1482,9 @@ function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||
async function installPlugin(req, row) {
|
||||
/** `args => Promise`: runs `claude <args>` as the viewer's own account. */
|
||||
function claudeAsViewer(req) {
|
||||
const { acct } = ownAccount(req);
|
||||
const steps = [
|
||||
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||
["plugin", "install", "--scope", "user", row.source],
|
||||
];
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
let run;
|
||||
if (acct) {
|
||||
const runAs = require("./run-as");
|
||||
@@ -1493,11 +1500,23 @@ async function installPlugin(req, row) {
|
||||
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
||||
run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env });
|
||||
}
|
||||
const lastLine = (e) =>
|
||||
String(e?.stderr || e?.stdout || e?.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
return run;
|
||||
}
|
||||
|
||||
const lastLine = (e) =>
|
||||
String(e?.stderr || e?.stdout || e?.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
|
||||
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||
async function installPlugin(req, row) {
|
||||
const steps = [
|
||||
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||
["plugin", "install", "--scope", "user", row.source],
|
||||
];
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
const run = claudeAsViewer(req);
|
||||
try {
|
||||
await run(steps[0]);
|
||||
} catch (e) {
|
||||
@@ -1527,6 +1546,58 @@ async function install(req, id, opts = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an installed skill or plugin out of the viewer's own accounts again
|
||||
* (`installAccounts`). A skill's folders are moved aside like a replace
|
||||
* (`moveAside`, `-uninstalled`), never deleted; a plugin is `claude plugin
|
||||
* uninstall --scope user --keep-data`, as the viewer, so its data dir stays.
|
||||
* Allowed while installs are off — it only removes. `ENOTINSTALLED` when
|
||||
* there is nothing of it to remove.
|
||||
*/
|
||||
async function uninstall(req, id) {
|
||||
const row = load(req, id);
|
||||
if (row.kind !== "skill" && row.kind !== "plugin") {
|
||||
throw fail("EBADKIND", "only a skill or a plugin is uninstalled");
|
||||
}
|
||||
let result;
|
||||
if (row.kind === "skill") {
|
||||
const runAs = require("./run-as");
|
||||
const taken = installAccounts(req)
|
||||
.flatMap((a) => skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) })))
|
||||
.filter((t) => exists(t.target));
|
||||
if (!taken.length) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"uninstalling needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
await moveAside(taken, row, "-uninstalled");
|
||||
result = { uninstalled: row.source, paths: taken.map((t) => t.target) };
|
||||
} else {
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
try {
|
||||
await claudeAsViewer(req)([
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"--scope",
|
||||
"user",
|
||||
"--keep-data",
|
||||
row.source,
|
||||
]);
|
||||
} catch (e) {
|
||||
const msg = lastLine(e);
|
||||
if (/not (found|installed)/i.test(msg)) {
|
||||
throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
}
|
||||
throw fail("EPLUGIN", `uninstall: ${msg}`);
|
||||
}
|
||||
result = { uninstalled: row.source, paths: [] };
|
||||
}
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "uninstalled");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who installed artifact `id`, newest first: `{items: [{user, mine, at,
|
||||
* replaced, paths?}], unrecorded}`. The owner, staff and local mode see every
|
||||
@@ -1593,6 +1664,7 @@ module.exports = {
|
||||
remove,
|
||||
snapshotFile,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
__setExecImpl,
|
||||
|
||||
@@ -215,10 +215,15 @@ function readSkillsAt(scope, claudeDir) {
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{scope?: string, cwd?: string, userClaudeDir?: string}} opts
|
||||
* `userClaudeDir` = whose user scope to read (a member's `~/.claude` under
|
||||
* S3); the host's Claude home when absent.
|
||||
*/
|
||||
function readSkills(opts = {}) {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readSkillsAt("user", getClaudeHome()));
|
||||
out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
|
||||
@@ -257,7 +262,7 @@ function readSimpleMdSurface(subdir) {
|
||||
return (opts = {}) => {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readMdFilesAt("user", getClaudeHome(), subdir));
|
||||
out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
|
||||
@@ -423,18 +428,18 @@ function readPluginContributions(installPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function readEnabledPluginsMap() {
|
||||
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
|
||||
function readEnabledPluginsMap(home = getClaudeHome()) {
|
||||
const userSettings = readJson(path.join(home, "settings.json"));
|
||||
if (!userSettings.ok || !userSettings.data) return {};
|
||||
const ep = userSettings.data.enabledPlugins;
|
||||
return ep && typeof ep === "object" ? ep : {};
|
||||
}
|
||||
|
||||
function readPlugins() {
|
||||
const home = getClaudeHome();
|
||||
/** @param {string} [home] whose Claude home (a member's under S3); the host's by default. */
|
||||
function readPlugins(home = getClaudeHome()) {
|
||||
const manifestPath = path.join(home, "plugins", "installed_plugins.json");
|
||||
const manifest = readJson(manifestPath);
|
||||
const enabledMap = readEnabledPluginsMap();
|
||||
const enabledMap = readEnabledPluginsMap(home);
|
||||
const plugins = [];
|
||||
if (manifest.ok && manifest.data && manifest.data.plugins) {
|
||||
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
|
||||
@@ -847,7 +852,11 @@ function isSecretConfigPath(resolved) {
|
||||
const base = path.basename(resolved);
|
||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||
const rel = path.relative(getClaudeHome(), resolved);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-"))
|
||||
if (
|
||||
!rel.startsWith("..") &&
|
||||
!path.isAbsolute(rel) &&
|
||||
rel.split(path.sep)[0].startsWith("agenthub-")
|
||||
)
|
||||
return true;
|
||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||
// keys and every gateway pane's settings file WITH its live pane token.
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
* is resolved only when exactly such a path still exists, else left empty.
|
||||
* A file is re-read when its mtime changes, and the mtime per file lives in
|
||||
* `cursor_transcripts`, so a session the user deleted stays deleted until
|
||||
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
|
||||
* Cursor writes to it again. A session a dashboard pane started is claimed
|
||||
* for that pane's workspace and run (`claimToolSessions`), which gives the
|
||||
* pane its session id and so its Chat view. No transcript is ever stored as
|
||||
* `transcript_path`; the conversation view finds the file through
|
||||
* `transcriptPath` instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +30,7 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
|
||||
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||
const FUTURE_SLACK_MS = 1000;
|
||||
@@ -148,6 +153,14 @@ function transcriptFilesIn(cursorDir) {
|
||||
}
|
||||
|
||||
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
|
||||
const getPathOf = db.prepare(
|
||||
"SELECT path FROM cursor_transcripts WHERE session_id = ? ORDER BY mtime_ms DESC LIMIT 1"
|
||||
);
|
||||
|
||||
/** The transcript a Cursor session was read from (recorded by the poller), or null. */
|
||||
function transcriptPath(sessionId) {
|
||||
return getPathOf.get(sessionId)?.path || null;
|
||||
}
|
||||
const upsertSeen = db.prepare(
|
||||
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
@@ -235,6 +248,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||
upsertSeen.run(f.path, f.id, f.mtime);
|
||||
if (type) {
|
||||
written += 1;
|
||||
claimToolSessions(f.id); // its workspace and live pane, before the broadcast
|
||||
broadcast(type, stmts.getSession.get(f.id));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -273,4 +287,5 @@ module.exports = {
|
||||
firstPrompt,
|
||||
ingestCursorUsage,
|
||||
startCursorUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ function bindable(v) {
|
||||
*/
|
||||
/** A run row as it may leave this machine: never a pane credential's hash. */
|
||||
function exportableRun(row) {
|
||||
const { pane_token_hash: _secret, ...rest } = row;
|
||||
const { pane_token_hash: _secret, chat_token_hash: _chat, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@ function importExportBundle(db, bundle) {
|
||||
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
|
||||
token_usage: makeInserter(db, "token_usage"),
|
||||
workflows: makeInserter(db, "workflows"),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", {
|
||||
omit: ["pane_token_hash", "chat_token_hash"],
|
||||
}),
|
||||
alert_rules: makeInserter(db, "alert_rules"),
|
||||
model_pricing: makeInserter(db, "model_pricing"),
|
||||
};
|
||||
|
||||
@@ -640,6 +640,11 @@ function spawnRun(args) {
|
||||
...(entry.launcher === "claude" && systemPromptFile
|
||||
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
|
||||
: {}),
|
||||
// Claude Code hides TaskCreate/TaskUpdate from its newer models unless
|
||||
// this is set, and those calls are all the chat Progress panel draws
|
||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||
// wins, so `"0"` there turns them back off.
|
||||
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
|
||||
@@ -288,6 +288,7 @@ function broadcastAudience(type, data) {
|
||||
case "agent_updated":
|
||||
case "new_event":
|
||||
case "workflow_upserted":
|
||||
case "transcript_changed":
|
||||
return { owners: sessionOwners(d.session_id) };
|
||||
case "alert_triggered":
|
||||
case "alert_updated":
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @file Turns one Claude Code transcript line (or a stream-json event shaped
|
||||
* like one) into the conversation message the chat renders: sender, text,
|
||||
* thinking, tool calls and results (with an edit's +/- counts, an
|
||||
* AskUserQuestion's answers, a subagent's facts), model, usage. Shared by the
|
||||
* transcript route and the realtime chat runtime so both draw the same thing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
||||
* not always the human: it also carries tool results, harness-injected
|
||||
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
||||
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
||||
* of those to "User" is wrong; the UI styles each sender distinctly.
|
||||
*
|
||||
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
||||
* user — a real message typed by the human
|
||||
* assistant — the agent's own turn
|
||||
* orchestrator — a subagent's task, assigned by its parent/main agent
|
||||
* system — harness/tooling injection (task-notification, /loop meta, …)
|
||||
* tool — a tool_result echoed back on a `user` line
|
||||
*/
|
||||
function classifyTranscriptSender(entry, isSubagentFile) {
|
||||
if (entry.type === "assistant") return "assistant";
|
||||
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
||||
if (entry.type !== "user") return "user";
|
||||
|
||||
const content = entry.message ? entry.message.content : undefined;
|
||||
const onlyToolResults =
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every((b) => b && b.type === "tool_result");
|
||||
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
||||
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
||||
: "";
|
||||
const lead = text.replace(/^\s+/, "");
|
||||
|
||||
// Harness injections that masquerade as a user line.
|
||||
if (entry.isMeta === true) return "system";
|
||||
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
||||
return "system";
|
||||
}
|
||||
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
||||
// that newer harness builds prefix ahead of the <task-notification> payload.
|
||||
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
||||
|
||||
// In a subagent transcript, a user line with no human prompt provenance is the
|
||||
// task injected by the Task/Agent tool. A real human message to the subagent
|
||||
// (rare, but allowed) carries promptSource/origin and stays "user".
|
||||
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
||||
return "orchestrator";
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
function patchCounts(r) {
|
||||
if (!r || typeof r !== "object") return null;
|
||||
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of r.structuredPatch) {
|
||||
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
|
||||
if (typeof l !== "string") continue;
|
||||
if (l.startsWith("+")) added++;
|
||||
else if (l.startsWith("-")) removed++;
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
if (r.type === "create" && typeof r.content === "string") {
|
||||
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
|
||||
* text → the option picked, or what the user typed. Null for any other result. */
|
||||
function askAnswers(r) {
|
||||
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
|
||||
if (Array.isArray(r.answers)) return null;
|
||||
const out = {};
|
||||
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
|
||||
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
|
||||
* finished in the foreground, its time, tokens and tool calls. Null for any
|
||||
* other result. */
|
||||
function agentInfo(r) {
|
||||
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
|
||||
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
return {
|
||||
id: r.agentId,
|
||||
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
|
||||
status: typeof r.status === "string" ? r.status : null,
|
||||
duration_ms: num(r.totalDurationMs),
|
||||
tokens: num(r.totalTokens),
|
||||
tool_uses: num(r.totalToolUseCount),
|
||||
};
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
// against a pathological line, not a long answer.
|
||||
const TEXT_MAX = 1024 * 1024;
|
||||
// The tools whose input carries a file's text (artifactVersions on the client).
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
|
||||
const EDIT_INPUT_MAX = 256 * 1024;
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
}
|
||||
|
||||
function truncateObj(obj, maxLen) {
|
||||
if (!obj) return obj;
|
||||
const json = JSON.stringify(obj);
|
||||
if (json.length <= maxLen) return obj;
|
||||
return { _truncated: truncate(json, maxLen) };
|
||||
}
|
||||
|
||||
/** A parser for one transcript: it remembers the last rename it surfaced, so
|
||||
* a repeated `custom-title` line shows once. `isSubagentFile` tells a
|
||||
* subagent's task (orchestrator) from a human prompt. */
|
||||
function createClaudeLineParser(isSubagentFile = false) {
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
let lastRenameTitle = null;
|
||||
|
||||
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
||||
function parseMessage(entry, num) {
|
||||
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
||||
// commands produce no user/assistant turn, so without this they'd be
|
||||
// invisible. Surface a compact "renamed" marker instead.
|
||||
if (entry.type === "custom-title") {
|
||||
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
||||
if (!title || title === lastRenameTitle) return null;
|
||||
lastRenameTitle = title;
|
||||
return {
|
||||
type: "session_event",
|
||||
event_kind: "rename",
|
||||
title,
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Local slash-command I/O. Newer Claude Code builds write the command
|
||||
// invocation and its captured output as `system`/`local_command` lines
|
||||
// with the TUI markup in a top-level `content` string (older builds used
|
||||
// `user` messages, handled below). Surface those as user-side text so the
|
||||
// client's tuiSegments parser renders the command pill + stdout/stderr
|
||||
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
||||
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
||||
// empty local_command lines (e.g. /clear writes a content-less one).
|
||||
if (entry.type === "system") {
|
||||
if (entry.subtype !== "local_command") return null;
|
||||
const sysText = typeof entry.content === "string" ? entry.content : "";
|
||||
if (!sysText.trim()) return null;
|
||||
return {
|
||||
type: "user",
|
||||
sender: "user", // local slash-command I/O is the human's own action
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Mid-turn queued message: journaled as a `queued_command` attachment
|
||||
// (never as a `user` line). Surface it at the position the model actually
|
||||
// received it. NOT everything in the queue is the human, though — the
|
||||
// harness delivers its own injections (task-notifications from background
|
||||
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
||||
// those attachments carry NO `origin` field, while a genuinely typed
|
||||
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
||||
// non-human origin → "system"; everything else → "user". Other attachment
|
||||
// subtypes are harness noise → dropped.
|
||||
if (entry.type === "attachment") {
|
||||
const att = entry.attachment;
|
||||
if (!att || att.type !== "queued_command") return null;
|
||||
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
||||
if (!prompt.trim()) return null;
|
||||
const lead = prompt.replace(/^\s+/, "");
|
||||
// Same harness markers classifyTranscriptSender strips off user lines.
|
||||
const isHarnessText =
|
||||
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
||||
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
||||
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
||||
return {
|
||||
type: "user",
|
||||
sender: isSystem ? "system" : "user",
|
||||
timestamp: entry.timestamp || att.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
||||
const content = [];
|
||||
|
||||
if (entry.type === "user") {
|
||||
const msgContent = entry.message?.content;
|
||||
if (typeof msgContent === "string") {
|
||||
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
||||
} else if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "tool_result") {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
const agent = agentInfo(entry.toolUseResult);
|
||||
// A subagent's report is a reply, read as prose: its text blocks
|
||||
// whole, not a 10 KB slice of their JSON.
|
||||
const report =
|
||||
agent && Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n\n")
|
||||
: null;
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output:
|
||||
report !== null
|
||||
? truncate(report, TEXT_MAX)
|
||||
: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msgContent === undefined || msgContent === null) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const msgContent = msg.content || [];
|
||||
if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||
} else if (block.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: block.name || "unknown",
|
||||
id: block.id || null,
|
||||
// A file a Write/Edit made is an artifact the chat can show in
|
||||
// versions, rebuilt from these inputs: they keep up to 256 KB.
|
||||
input: truncateObj(block.input, EDIT_TOOLS.has(block.name) ? EDIT_INPUT_MAX : 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
const message = {
|
||||
type: entry.type,
|
||||
sender: classifyTranscriptSender(entry, isSubagentFile),
|
||||
timestamp: entry.timestamp
|
||||
? typeof entry.timestamp === "number"
|
||||
? new Date(entry.timestamp).toISOString()
|
||||
: entry.timestamp
|
||||
: null,
|
||||
content,
|
||||
line: num,
|
||||
};
|
||||
|
||||
if (entry.type === "assistant") {
|
||||
if (msg.model) message.model = msg.model;
|
||||
// "end_turn" closes the turn; "tool_use" means more work follows.
|
||||
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
|
||||
if (msg.usage) {
|
||||
message.usage = {
|
||||
input_tokens: msg.usage.input_tokens || 0,
|
||||
output_tokens: msg.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
return parseMessage;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
truncate,
|
||||
truncateObj,
|
||||
TEXT_MAX,
|
||||
createClaudeLineParser,
|
||||
};
|
||||
@@ -7,7 +7,7 @@
|
||||
* rename/share/unshare, delete, read a snapshot file (preview/download, under
|
||||
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
|
||||
* origin), and install a shared skill or plugin into the caller's own
|
||||
* account. Every write is same-origin. A path in a request only ever names a
|
||||
* account (or uninstall it from there again). Every write is same-origin. A path in a request only ever names a
|
||||
* file inside a workspace (pin, under the read path's guards) or one of a
|
||||
* snapshot's own recorded files.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -30,6 +30,7 @@ const STATUS = {
|
||||
EFOREIGN: 403,
|
||||
EINSTALLSOFF: 403,
|
||||
EEXISTS: 409,
|
||||
ENOTINSTALLED: 409,
|
||||
ENOACCOUNT: 409,
|
||||
EUPGRADE: 409,
|
||||
ENOTSHARED: 409,
|
||||
@@ -361,4 +362,10 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/uninstall",
|
||||
sameOriginGuard,
|
||||
wrap(async (req, res) => res.json(await artifacts.uninstall(req, req.params.id)))
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -95,16 +95,45 @@ function cwdOf(req) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `{scope, cwd}` for the two lists a member may read (the run form's `/`
|
||||
* The Claude home whose USER skills, commands and plugins a request's sessions
|
||||
* load — so the run form's `/` list matches what its pane's Claude can call.
|
||||
* Under S3 a pane runs as its FOLDER's account: `?cwd=` picks that account; a
|
||||
* non-admin with no `?cwd=` reads their own. Undefined = the host's (local
|
||||
* mode, or an admin in a host folder / the Config Explorer); null = none, for
|
||||
* a non-admin with no account of their own — another user's or the host's
|
||||
* skills are never listed as theirs.
|
||||
*/
|
||||
function userClaudeDir(req) {
|
||||
if (!req.user) return undefined;
|
||||
const runAs = require("../lib/run-as");
|
||||
const cwd = typeof req.query.cwd === "string" && req.query.cwd;
|
||||
let acct = null;
|
||||
try {
|
||||
if (cwd && path.isAbsolute(cwd)) acct = runAs.ownerForRead(path.resolve(cwd));
|
||||
else if (req.user.role !== "admin") acct = runAs.accountForUser(req.user.username);
|
||||
} catch {
|
||||
acct = null;
|
||||
}
|
||||
if (acct) return path.join(acct.home, ".claude");
|
||||
return req.user.role === "admin" ? undefined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{scope, cwd, userClaudeDir}` for the two lists a member may read (the run form's `/`
|
||||
* autocomplete). Server mode keeps a member's project items to their own
|
||||
* workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is
|
||||
* no project at all (never the dashboard's own working directory). Returns null
|
||||
* once it has answered; `none` = nothing to list.
|
||||
*/
|
||||
function listOpts(req, res) {
|
||||
const opts = { scope: scopeOf(req), cwd: cwdOf(req) };
|
||||
if (scope.memberId(req) == null) return opts;
|
||||
const opts = { scope: scopeOf(req), cwd: cwdOf(req), userClaudeDir: userClaudeDir(req) };
|
||||
const asked = typeof req.query.cwd === "string" && req.query.cwd;
|
||||
// No account of their own: only a named workspace's project items, if any.
|
||||
if (opts.userClaudeDir === null) {
|
||||
if (opts.scope === "user" || !asked) return { none: true };
|
||||
opts.scope = "project";
|
||||
}
|
||||
if (scope.memberId(req) == null) return opts;
|
||||
if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" };
|
||||
if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) {
|
||||
res.status(403).json({
|
||||
@@ -137,8 +166,10 @@ router.get("/output-styles", (req, res) => {
|
||||
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/plugins", (_req, res) => {
|
||||
res.json(cc.readPlugins());
|
||||
router.get("/plugins", (req, res) => {
|
||||
const home = userClaudeDir(req);
|
||||
if (home === null) return res.json({ manifestPath: null, manifestExists: false, plugins: [] });
|
||||
res.json(cc.readPlugins(home));
|
||||
});
|
||||
|
||||
router.get("/mcp", (req, res) => {
|
||||
|
||||
+94
-31
@@ -114,21 +114,31 @@ const DROP_TYPES = {
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
// A browser on this machine (loopback, never server mode) crosses no network:
|
||||
// its drop is streamed to disk, so a video or a dump is no reason to refuse.
|
||||
// Chrome hands a dropped file no `file://` path, so it cannot attach by path.
|
||||
const DROP_LOCAL_MAX_BYTES = 2 * 1024 ** 3;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
/** A body declared over the limit is refused before a byte of it is read, and
|
||||
* the connection is closed: express.raw would read (and discard) all of it
|
||||
* first, so a 4 GB drop crossed the network in full just to be told 413. */
|
||||
function refuseOversize(req, res, next) {
|
||||
if (!(Number(req.headers["content-length"]) > DROP_MAX_BYTES)) return next();
|
||||
const localDrop = (req) => !require("../lib/auth").isServerMode() && isLocalRequest(req);
|
||||
const dropLimit = (req) => (localDrop(req) ? DROP_LOCAL_MAX_BYTES : DROP_MAX_BYTES);
|
||||
function tooBig(res, max) {
|
||||
res.set("Connection", "close");
|
||||
res.status(413).json({
|
||||
error: {
|
||||
code: "ETOOBIG",
|
||||
message: `a dropped file may be at most ${DROP_MAX_BYTES / 1024 / 1024} MB`,
|
||||
max_bytes: DROP_MAX_BYTES,
|
||||
message: `a dropped file may be at most ${max / 1024 / 1024} MB`,
|
||||
max_bytes: max,
|
||||
},
|
||||
});
|
||||
}
|
||||
/** A body declared over the limit is refused before a byte of it is read, and
|
||||
* the connection is closed: express.raw would read (and discard) all of it
|
||||
* first, so a 4 GB drop crossed the network in full just to be told 413. */
|
||||
function refuseOversize(req, res, next) {
|
||||
const max = dropLimit(req);
|
||||
if (!(Number(req.headers["content-length"]) > max)) return next();
|
||||
tooBig(res, max);
|
||||
}
|
||||
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
|
||||
* workspace `?laneId=` names (scopeGuard already refused one this user may
|
||||
* not see), else the requester's own — written AS that member under S3, so
|
||||
@@ -147,28 +157,33 @@ function dropDir(req) {
|
||||
: { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null };
|
||||
}
|
||||
|
||||
// Saves one dropped body under the drop dir and answers its path. Pruned on
|
||||
// write: files older than a day go.
|
||||
// The drop dir, made and pruned: files older than a day go.
|
||||
function prepareDropDir(req) {
|
||||
const { dir, member } = dropDir(req);
|
||||
const runAs = require("../lib/run-as");
|
||||
if (member) runAs.mkdirAs(member, dir);
|
||||
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, entry);
|
||||
try {
|
||||
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
|
||||
if (member) runAs.removeAs(member, f);
|
||||
else fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
return { dir, member };
|
||||
}
|
||||
|
||||
// Saves one dropped body under the drop dir and answers its path.
|
||||
function saveDrop(req, res, name, body) {
|
||||
try {
|
||||
const { dir, member } = dropDir(req);
|
||||
const runAs = require("../lib/run-as");
|
||||
if (member) runAs.mkdirAs(member, dir);
|
||||
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, entry);
|
||||
try {
|
||||
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
|
||||
if (member) runAs.removeAs(member, f);
|
||||
else fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const { dir, member } = prepareDropDir(req);
|
||||
const file = path.join(dir, name);
|
||||
if (member) runAs.handoffWrite(member, path.join("drops", name), body);
|
||||
if (member) require("../lib/run-as").handoffWrite(member, path.join("drops", name), body);
|
||||
else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
@@ -198,16 +213,64 @@ router.post(
|
||||
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
|
||||
// random prefix, so it can neither leave the drop dir nor collide, and the
|
||||
// space-joined paste of several paths stays parseable.
|
||||
const dropName = (req) =>
|
||||
path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
|
||||
/** A local browser's drop is piped to its file as it arrives, never held in
|
||||
* memory whole; a body that runs past the limit (or is empty) leaves no file. */
|
||||
function streamLocalDrop(req, res, next) {
|
||||
if (!localDrop(req)) return next();
|
||||
const safe = dropName(req);
|
||||
if (!safe) return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
let file;
|
||||
let out;
|
||||
try {
|
||||
const { dir, member } = prepareDropDir(req);
|
||||
if (member) return next(); // a member's file is written as them, by saveDrop
|
||||
file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safe}`);
|
||||
out = fs.createWriteStream(file, { mode: 0o600, flags: "wx" });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
let bytes = 0;
|
||||
let failed = null;
|
||||
const fail = (status, send) => {
|
||||
if (failed) return;
|
||||
failed = status;
|
||||
req.unpipe(out);
|
||||
out.destroy();
|
||||
fs.rm(file, { force: true }, () => {});
|
||||
if (!res.headersSent) send();
|
||||
};
|
||||
req.on("data", (c) => {
|
||||
bytes += c.length;
|
||||
if (bytes > DROP_LOCAL_MAX_BYTES) fail(413, () => tooBig(res, DROP_LOCAL_MAX_BYTES));
|
||||
});
|
||||
req.on("aborted", () => fail(400, () => {}));
|
||||
out.on("error", (err) => fail(500, () => res.status(500).json({ error: err.message })));
|
||||
out.on("finish", () => {
|
||||
if (failed) return;
|
||||
if (bytes === 0) {
|
||||
return fail(400, () =>
|
||||
res.status(400).json({ error: "?name= and a non-empty file body required" })
|
||||
);
|
||||
}
|
||||
res.json({ path: file });
|
||||
});
|
||||
req.pipe(out);
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/drop-file",
|
||||
refuseOversize,
|
||||
streamLocalDrop,
|
||||
express.raw({ type: () => true, limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const safe = path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
const safe = dropName(req);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
}
|
||||
|
||||
+178
-295
@@ -22,6 +22,16 @@ const {
|
||||
findTranscriptPath,
|
||||
findSubagentTranscriptPath,
|
||||
} = require("../lib/claude-home");
|
||||
const {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
truncate,
|
||||
truncateObj,
|
||||
TEXT_MAX,
|
||||
createClaudeLineParser,
|
||||
} = require("../lib/transcript-message");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -52,59 +62,6 @@ const TRANSCRIPT_RENDER_TYPES = new Set([
|
||||
"attachment",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
||||
* not always the human: it also carries tool results, harness-injected
|
||||
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
||||
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
||||
* of those to "User" is wrong; the UI styles each sender distinctly.
|
||||
*
|
||||
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
||||
* user — a real message typed by the human
|
||||
* assistant — the agent's own turn
|
||||
* orchestrator — a subagent's task, assigned by its parent/main agent
|
||||
* system — harness/tooling injection (task-notification, /loop meta, …)
|
||||
* tool — a tool_result echoed back on a `user` line
|
||||
*/
|
||||
function classifyTranscriptSender(entry, isSubagentFile) {
|
||||
if (entry.type === "assistant") return "assistant";
|
||||
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
||||
if (entry.type !== "user") return "user";
|
||||
|
||||
const content = entry.message ? entry.message.content : undefined;
|
||||
const onlyToolResults =
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every((b) => b && b.type === "tool_result");
|
||||
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
||||
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
||||
: "";
|
||||
const lead = text.replace(/^\s+/, "");
|
||||
|
||||
// Harness injections that masquerade as a user line.
|
||||
if (entry.isMeta === true) return "system";
|
||||
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
||||
return "system";
|
||||
}
|
||||
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
||||
// that newer harness builds prefix ahead of the <task-notification> payload.
|
||||
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
||||
|
||||
// In a subagent transcript, a user line with no human prompt provenance is the
|
||||
// task injected by the Task/Agent tool. A real human message to the subagent
|
||||
// (rare, but allowed) carries promptSource/origin and stays "user".
|
||||
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
||||
return "orchestrator";
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only the first non-empty line from a JSONL file using streaming.
|
||||
* Avoids loading the entire file into memory.
|
||||
@@ -866,6 +823,26 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
// Cursor and Antigravity: one transcript per session, no subagent files.
|
||||
if (session.tool === "cursor" || session.tool === "antigravity") {
|
||||
const file =
|
||||
session.tool === "cursor"
|
||||
? require("../lib/cursor-usage").transcriptPath(req.params.id)
|
||||
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
|
||||
const list =
|
||||
file && fs.existsSync(file)
|
||||
? [
|
||||
{
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
has_transcript: true,
|
||||
db_agent_id: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
// OpenCode: a subagent is a child session of the store, tied to the agent
|
||||
// row its poller made.
|
||||
if (session.tool === "opencode") {
|
||||
@@ -1120,6 +1097,10 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
const isCodex = session.tool === "codex";
|
||||
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
|
||||
const isOmp = session.tool === "omp";
|
||||
// A Cursor session's agent transcript, recorded by cursor-usage.js.
|
||||
const isCursor = session.tool === "cursor";
|
||||
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
|
||||
const isAgy = session.tool === "antigravity";
|
||||
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
|
||||
// whole (one session's rows), then paged like the files below.
|
||||
if (session.tool === "opencode") {
|
||||
@@ -1136,6 +1117,14 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (isOmp) {
|
||||
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
|
||||
} else if (isCursor) {
|
||||
jsonlPath = isSubagentFile
|
||||
? null
|
||||
: require("../lib/cursor-usage").transcriptPath(req.params.id);
|
||||
} else if (isAgy) {
|
||||
jsonlPath = isSubagentFile
|
||||
? null
|
||||
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
|
||||
} else if (isCodex) {
|
||||
jsonlPath = db
|
||||
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
||||
@@ -1172,184 +1161,22 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
? (e) => e.type === "response_item"
|
||||
: isOmp
|
||||
? (e) => e.type === "message"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
: isCursor
|
||||
? (e) => e.role === "user" || e.role === "assistant"
|
||||
: isAgy
|
||||
? (e) => AGY_RENDER_TYPES.has(e.type)
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
const toMessage = isCodex
|
||||
? codexMessage
|
||||
: isOmp
|
||||
? (e, n) => ompMessage(e, n, isSubagentFile)
|
||||
: (e, n) => parseMessage(e, n);
|
||||
: isCursor
|
||||
? cursorMessage
|
||||
: isAgy
|
||||
? agyMessage
|
||||
: (e, n) => parseMessage(e, n);
|
||||
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
let lastRenameTitle = null;
|
||||
|
||||
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
||||
function parseMessage(entry, num) {
|
||||
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
||||
// commands produce no user/assistant turn, so without this they'd be
|
||||
// invisible. Surface a compact "renamed" marker instead.
|
||||
if (entry.type === "custom-title") {
|
||||
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
||||
if (!title || title === lastRenameTitle) return null;
|
||||
lastRenameTitle = title;
|
||||
return {
|
||||
type: "session_event",
|
||||
event_kind: "rename",
|
||||
title,
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Local slash-command I/O. Newer Claude Code builds write the command
|
||||
// invocation and its captured output as `system`/`local_command` lines
|
||||
// with the TUI markup in a top-level `content` string (older builds used
|
||||
// `user` messages, handled below). Surface those as user-side text so the
|
||||
// client's tuiSegments parser renders the command pill + stdout/stderr
|
||||
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
||||
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
||||
// empty local_command lines (e.g. /clear writes a content-less one).
|
||||
if (entry.type === "system") {
|
||||
if (entry.subtype !== "local_command") return null;
|
||||
const sysText = typeof entry.content === "string" ? entry.content : "";
|
||||
if (!sysText.trim()) return null;
|
||||
return {
|
||||
type: "user",
|
||||
sender: "user", // local slash-command I/O is the human's own action
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Mid-turn queued message: journaled as a `queued_command` attachment
|
||||
// (never as a `user` line). Surface it at the position the model actually
|
||||
// received it. NOT everything in the queue is the human, though — the
|
||||
// harness delivers its own injections (task-notifications from background
|
||||
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
||||
// those attachments carry NO `origin` field, while a genuinely typed
|
||||
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
||||
// non-human origin → "system"; everything else → "user". Other attachment
|
||||
// subtypes are harness noise → dropped.
|
||||
if (entry.type === "attachment") {
|
||||
const att = entry.attachment;
|
||||
if (!att || att.type !== "queued_command") return null;
|
||||
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
||||
if (!prompt.trim()) return null;
|
||||
const lead = prompt.replace(/^\s+/, "");
|
||||
// Same harness markers classifyTranscriptSender strips off user lines.
|
||||
const isHarnessText =
|
||||
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
||||
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
||||
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
||||
return {
|
||||
type: "user",
|
||||
sender: isSystem ? "system" : "user",
|
||||
timestamp: entry.timestamp || att.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
||||
const content = [];
|
||||
|
||||
if (entry.type === "user") {
|
||||
const msgContent = entry.message?.content;
|
||||
if (typeof msgContent === "string") {
|
||||
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
||||
} else if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "tool_result") {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
const agent = agentInfo(entry.toolUseResult);
|
||||
// A subagent's report is a reply, read as prose: its text blocks
|
||||
// whole, not a 10 KB slice of their JSON.
|
||||
const report =
|
||||
agent && Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n\n")
|
||||
: null;
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output:
|
||||
report !== null
|
||||
? truncate(report, TEXT_MAX)
|
||||
: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msgContent === undefined || msgContent === null) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const msgContent = msg.content || [];
|
||||
if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||
} else if (block.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: block.name || "unknown",
|
||||
id: block.id || null,
|
||||
input: truncateObj(block.input, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
const message = {
|
||||
type: entry.type,
|
||||
sender: classifyTranscriptSender(entry, isSubagentFile),
|
||||
timestamp: entry.timestamp
|
||||
? typeof entry.timestamp === "number"
|
||||
? new Date(entry.timestamp).toISOString()
|
||||
: entry.timestamp
|
||||
: null,
|
||||
content,
|
||||
line: num,
|
||||
};
|
||||
|
||||
if (entry.type === "assistant") {
|
||||
if (msg.model) message.model = msg.model;
|
||||
// "end_turn" closes the turn; "tool_use" means more work follows.
|
||||
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
|
||||
if (msg.usage) {
|
||||
message.usage = {
|
||||
input_tokens: msg.usage.input_tokens || 0,
|
||||
output_tokens: msg.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
const parseMessage = createClaudeLineParser(isSubagentFile);
|
||||
|
||||
if (afterLine !== null) {
|
||||
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
|
||||
@@ -1626,6 +1453,131 @@ function ompMessage(entry, num, isSubagent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of a Cursor agent transcript (`{role, message:{content}}`, see
|
||||
* cursor-usage.js) as a transcript message. Cursor writes no timestamps and no
|
||||
* model, so neither is set. A user line wraps the typed prompt in
|
||||
* `<user_query>` after a `<timestamp>` block; only the prompt is shown.
|
||||
*/
|
||||
function cursorMessage(entry, num) {
|
||||
const m = entry.message || {};
|
||||
const parts = Array.isArray(m.content)
|
||||
? m.content
|
||||
: typeof m.content === "string"
|
||||
? [{ type: "text", text: m.content }]
|
||||
: [];
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: null, content, line: num });
|
||||
const content = [];
|
||||
if (entry.role === "user") {
|
||||
for (const c of parts) {
|
||||
if (c && c.type === "text" && typeof c.text === "string") {
|
||||
const q = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(c.text);
|
||||
const text = (q ? q[1] : c.text.replace(/<timestamp>[\s\S]*?<\/timestamp>/, "")).trim();
|
||||
if (text) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
|
||||
} else if (c && c.type === "tool_result") {
|
||||
const out = Array.isArray(c.content)
|
||||
? c.content.map((x) => (x && typeof x.text === "string" ? x.text : "")).join("")
|
||||
: typeof c.content === "string"
|
||||
? c.content
|
||||
: "";
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: c.tool_use_id || null,
|
||||
output: truncate(out, 10240),
|
||||
is_error: c.is_error === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!content.length) return null;
|
||||
const onlyResults = content.every((c) => c.type === "tool_result");
|
||||
return msg("user", onlyResults ? "tool" : "user", content);
|
||||
}
|
||||
for (const c of parts) {
|
||||
if (!c) continue;
|
||||
if (c.type === "text" && typeof c.text === "string" && c.text.trim()) {
|
||||
content.push({ type: "text", text: truncate(c.text, TEXT_MAX) });
|
||||
} else if (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
|
||||
content.push({ type: "thinking", text: truncate(c.thinking, 10240) });
|
||||
} else if (c.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.input && typeof c.input === "object" ? c.input : {}, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
return content.length ? msg("assistant", "assistant", content) : null;
|
||||
}
|
||||
|
||||
/** The agy steps a conversation view shows (see `agyMessage`). */
|
||||
const AGY_RENDER_TYPES = new Set(["USER_INPUT", "PLANNER_RESPONSE", "GENERIC"]);
|
||||
|
||||
/**
|
||||
* One step of an agy conversation (`brain/<id>/.system_generated/logs/
|
||||
* transcript_full.jsonl`, see antigravity-usage.js) as a transcript message:
|
||||
* `USER_INPUT` the prompt (inside `<USER_REQUEST>`), `PLANNER_RESPONSE` the
|
||||
* model's text, thinking, tool calls and tokens, `GENERIC` a tool's result
|
||||
* (`status: "INVALID"` + `error` an error). agy gives a call no id: its result
|
||||
* is the step right after it (call `i` of step `s` lands at step `s + 1 + i`),
|
||||
* so both are named by that step — the same on every page of the file.
|
||||
*/
|
||||
function agyMessage(entry, num) {
|
||||
const at = typeof entry.created_at === "string" ? entry.created_at : null;
|
||||
const step = Number.isInteger(entry.step_index) ? entry.step_index : num;
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
|
||||
const text = typeof entry.content === "string" ? entry.content : "";
|
||||
switch (entry.type) {
|
||||
case "USER_INPUT": {
|
||||
const req = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/.exec(text);
|
||||
const prompt = (req ? req[1] : text).trim();
|
||||
if (!prompt) return null;
|
||||
return msg("user", entry.source === "USER_EXPLICIT" ? "user" : "system", [
|
||||
{ type: "text", text: truncate(prompt, TEXT_MAX) },
|
||||
]);
|
||||
}
|
||||
case "PLANNER_RESPONSE": {
|
||||
const content = [];
|
||||
if (typeof entry.thinking === "string" && entry.thinking.trim())
|
||||
content.push({ type: "thinking", text: truncate(entry.thinking, 10240) });
|
||||
if (text.trim()) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
|
||||
(Array.isArray(entry.tool_calls) ? entry.tool_calls : []).forEach((c, i) => {
|
||||
if (!c) return;
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: `agy-${step + 1 + i}`,
|
||||
input: truncateObj(c.args && typeof c.args === "object" ? c.args : {}, 10240),
|
||||
});
|
||||
});
|
||||
if (!content.length) return null;
|
||||
const out = msg("assistant", "assistant", content);
|
||||
if (Number.isFinite(entry.input_tokens) || Number.isFinite(entry.output_tokens))
|
||||
out.usage = {
|
||||
input_tokens: entry.input_tokens || 0,
|
||||
output_tokens: entry.output_tokens || 0,
|
||||
cache_read_input_tokens: entry.cache_read_tokens || 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
};
|
||||
return out;
|
||||
}
|
||||
case "GENERIC": {
|
||||
// Its first lines say when the call ran: not part of what it returned.
|
||||
const output = text.replace(/^Created At: [^\n]*\nCompleted At: [^\n]*\n\n?/, "");
|
||||
return msg("user", "tool", [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: `agy-${step}`,
|
||||
output: truncate(output, 10240),
|
||||
is_error: entry.status === "INVALID" || entry.status === "ERROR" || !!entry.error,
|
||||
},
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript route's paging over a conversation already in memory (an
|
||||
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
|
||||
@@ -1662,75 +1614,6 @@ function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
function patchCounts(r) {
|
||||
if (!r || typeof r !== "object") return null;
|
||||
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of r.structuredPatch) {
|
||||
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
|
||||
if (typeof l !== "string") continue;
|
||||
if (l.startsWith("+")) added++;
|
||||
else if (l.startsWith("-")) removed++;
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
if (r.type === "create" && typeof r.content === "string") {
|
||||
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
|
||||
* text → the option picked, or what the user typed. Null for any other result. */
|
||||
function askAnswers(r) {
|
||||
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
|
||||
if (Array.isArray(r.answers)) return null;
|
||||
const out = {};
|
||||
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
|
||||
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
|
||||
* finished in the foreground, its time, tokens and tool calls. Null for any
|
||||
* other result. */
|
||||
function agentInfo(r) {
|
||||
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
|
||||
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
return {
|
||||
id: r.agentId,
|
||||
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
|
||||
status: typeof r.status === "string" ? r.status : null,
|
||||
duration_ms: num(r.totalDurationMs),
|
||||
tokens: num(r.totalTokens),
|
||||
tool_uses: num(r.totalToolUseCount),
|
||||
};
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
// against a pathological line, not a long answer.
|
||||
const TEXT_MAX = 1024 * 1024;
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
}
|
||||
|
||||
function truncateObj(obj, maxLen) {
|
||||
if (!obj) return obj;
|
||||
const json = JSON.stringify(obj);
|
||||
if (json.length <= maxLen) return obj;
|
||||
return { _truncated: truncate(json, maxLen) };
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.sessionStats = sessionStats;
|
||||
// Exported for unit tests — sender attribution is correctness-critical.
|
||||
|
||||
Reference in New Issue
Block a user