feat(chat): terminal chat parity for every tool; Desktop-like artifacts

- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
This commit is contained in:
2026-10-21 15:44:17 +07:00
parent aee4fc190e
commit 383b9ae231
41 changed files with 2582 additions and 426 deletions
+3 -2
View File
@@ -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.13 (2026-10-21) — realtime chat (bridge, `/ws-chat`) removed; the terminal chat covers omp/Cursor/agy (agy's step log as a transcript), permission prompts as cards, Stop from the pane's busy line, Codex `/model`·`/permissions` pickers, `transcript_changed` push; artifacts kept._ 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.
@@ -64,6 +64,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **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.
+4
View File
@@ -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"]);
});
});
+295 -39
View File
@@ -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[];
+321 -15
View File
@@ -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"
+3 -3
View File
@@ -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 (
+18 -1
View File
@@ -65,7 +65,12 @@ 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 {
MENU_CURSOR_ROW,
notePaneFocus,
notePaneOutput,
registerPane,
} from "../../lib/panePaste";
import {
claudePastes,
droppedFilePaths,
@@ -617,6 +622,7 @@ export const TerminalView = memo(function TerminalView({
}
}
term.write(data);
notePaneOutput(runId);
}
};
sock.onclose = (e?: CloseEvent) => {
@@ -779,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();
});
});
+23 -1
View File
@@ -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": {
+9
View File
@@ -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": {
+2 -2
View File
@@ -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": {
+23 -1
View File
@@ -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": {
+9
View File
@@ -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": {
+2 -2
View File
@@ -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--&gt;B&lt;img&gt;"
);
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"],
]);
});
});
+227
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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;
}
+16 -2
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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 |
+2 -1
View File
File diff suppressed because one or more lines are too long
+10 -3
View File
@@ -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);
});
});
+72 -4
View File
@@ -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);
});
});
+3
View File
@@ -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/);
+3
View File
@@ -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", () => {
+9
View File
@@ -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
View File
@@ -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();
}
+18 -1
View File
@@ -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,
};
+16 -1
View File
@@ -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,
};
+4 -2
View File
@@ -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"),
};
+5
View File
@@ -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
+1
View File
@@ -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":
+324
View File
@@ -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,
};
+178 -295
View File
@@ -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.