Files
AgentHub/client/src/components/run/PaneChat.tsx
T
nntrivi2001 383b9ae231 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
2026-10-21 15:44:17 +07:00

1030 lines
41 KiB
TypeScript

/**
* @file PaneChat.tsx
* @description A live pane's Chat view, the way Claude Desktop shows a session:
* the session's conversation (`ConversationView`, kept current by its own poll)
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
* attached underneath — a message is sent through that terminal
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
* nothing new reaches the server. The box carries Claude Desktop's controls:
* the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
* context files, recent uploads —, skills, plugins' skills), and for a
* Claude pane the Model/Effort chip — a change is typed into the pane as
* `/model <id>` / `/effort <level>` — and the permission mode, which only
* Shift+Tab changes in a running session, so the server steps it and reads
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
* in the box beside `+`. A 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`) — 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,
* outputs, what the session used), open at first in a pane spanning the
* grid's full width or height (`panelOpen`) once the session has an output,
* else collapsed into a file button with the output count; the user's own
* open/close is kept per session for the tab (sessionStorage), so the chat
* leaving for the terminal and back keeps it; it sits beside the
* conversation when the pane is wide and over it when narrow (a CSS container
* query on the pane, not the window), dragged wider or narrower by its
* splitter there (a fraction of the pane, remembered per browser). A file
* clicked there opens its preview
* in the panel's place
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
* the workspace's folder —, a group
* of files or the used skills a list with a preview (`FileListModal`,
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
* conversation to its end and keeps it there (`follow`), so the message and
* the step running after it come into sight on their own. While the session's
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
* Stop in Send's place: Esc pressed in the pane (`interruptRun`). While the
* CLI asks the user something (the main agent awaiting input on a
* Notification — a permission prompt), the chat's live line says so and
* offers the terminal (`showTerminal`); before the session has started (no
* row yet: a trust prompt, say, answered only there) anything typed into the
* pane is confirmed first, so an Enter does not pick that prompt's
* preselected "No, exit" unasked. The box shows how full the context
* is (the newest reply's prompt size against the model's window,
* `contextWindow`), and a hidden browser tab is marked while the session
* waits on the user or once its turn ended (`lib/tabBadge.ts`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowUp,
Check,
ChevronLeft,
ChevronRight,
FileText,
MessageCircleQuestion,
ShieldQuestion,
Square,
SquareCheck,
} from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import {
ModelMenu,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { resolveIn } from "../../lib/resolvePath";
import { Select } from "../Select";
import { Divider, showTerminal } from "./PaneGrid";
import {
answerRunMenu,
chooseRunOption,
notePaneFocus,
onPaneOutput,
stepRunMenu,
interruptRun,
readRunBusy,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format";
import { agentAwaitingReason, 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][] = [
["default", "Default"],
["acceptEdits", "AcceptEdits"],
["plan", "Plan"],
["auto", "Auto"],
["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}`;
const readOutlineChoice = (sessionId: string) => {
const v = sessionStorage.getItem(outlineKey(sessionId));
return v === null ? null : v === "1";
};
const storedSide = () => {
const f = Number(localStorage.getItem(SIDE_KEY));
return f ? clampSide(f) : null;
};
/** The model's context window. Claude's 200K models are the 4.5 line and
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
export function contextWindow(model: string | null): number | null {
// 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
: 1_000_000;
}
/** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */
function ContextMeter({
tokens,
model,
}: {
tokens: number;
model: string | null;
}) {
const { t } = useTranslation("run");
const size = contextWindow(model);
if (!size || !tokens) return null;
const pct = Math.min(100, Math.round((tokens / size) * 100));
const tone =
pct >= 90 ? "text-status-danger" : pct >= 75 ? "text-status-warning" : "text-fg-muted";
const ring = 2 * Math.PI * 6;
return (
<span
data-testid="pane-chat-context"
title={t("chat.contextTitle", { used: fmt(tokens), size: fmt(size) })}
className={`flex items-center gap-1 px-1 text-xs tabular-nums ${tone}`}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 -rotate-90" aria-hidden>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={`${(pct / 100) * ring} ${ring}`}
/>
</svg>
{pct}%
</span>
);
}
/** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
const { t } = useTranslation(["run", "lanes"]);
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
const [busy, setBusy] = useState(false);
const read = useCallback(
() =>
api.run
.permissionMode(runId)
.then((r) => isSettable(r.mode) && setMode(r.mode))
.catch(() => {}),
[runId]
);
useEffect(() => {
void read();
}, [read]);
// Bypass is in a session's cycle only when it was started with it.
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
return (
<div data-testid="pane-chat-mode">
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
disabled={busy}
onChange={(v) => {
setBusy(true);
api.run
.setPermissionMode(runId, v)
.then((r) => setMode(r.mode))
.catch((err: unknown) => {
toast.error(
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
);
void read();
})
.finally(() => setBusy(false));
}}
options={offered.map(([value, key]) => ({
value,
label: t(`lanes:home.permission${key}`),
hint: t(`run:fields.permission${key}`),
}))}
/>
</div>
);
}
/** 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.
* 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 && (
<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) =>
typing && i === menu.typeAt ? (
<div
key={`${i}-type`}
className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1"
>
<span className="w-4 flex-shrink-0 font-mono text-xs text-fg-muted">{o.n}</span>
<input
autoFocus
data-testid="pane-chat-menu-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
submit();
} else if (e.key === "Escape") setTyping(false);
}}
placeholder={t("chat.menu.placeholder")}
aria-label={t("chat.menu.placeholder")}
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
/>
<button
type="button"
data-testid="pane-chat-menu-send"
onClick={submit}
disabled={!draft.trim()}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
key={`${i}-${o.label}`}
type="button"
onClick={() => (i === menu.typeAt ? setTyping(true) : onChoose(i))}
className={`flex items-start gap-2 rounded-lg border px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 ${
i === menu.cursor ? "border-accent/50 bg-surface-2" : "border-border"
}`}
>
<span className="mt-0.5 w-4 flex-shrink-0 font-mono text-xs text-fg-muted">
{o.checked === null ? (
(o.n ?? "↵")
) : o.checked ? (
<SquareCheck className="h-3.5 w-3.5 text-accent" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 break-words">
{o.label}
{o.hint && <span className="block text-xs text-fg-muted">{o.hint}</span>}
</span>
</button>
)
)}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
</div>
);
}
/** Memoized: a lane_update re-renders the Workspace's pane tree, and every
* prop here is a primitive or the pane's own run handle — nothing in a
* frame about another lane reaches the chat. */
export const PaneChat = memo(function PaneChat({
run,
sessionId,
cwd,
laneId = null,
laneCwd = "",
panelOpen = false,
}: {
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
sessionId: string;
cwd: string;
/** The workspace and its folder: a file under `cwd` is previewed through it. */
laneId?: number | null;
laneCwd?: string;
/** The right panel starts open: the pane spans the grid's width or height. */
panelOpen?: boolean;
}) {
const { t } = useTranslation("run");
const runId = run.id;
const claude = run.tool === "claude";
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
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 ?? "");
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
const pickFiles = useRef<(() => void) | null>(null);
// The user's own open/close, per session for this tab: it outlives the chat
// leaving for the terminal (an unmount). Unset, the default rules.
const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId));
// A split or a close changes the pane's shape: its default comes back.
const shape = useRef(panelOpen);
useEffect(() => {
if (shape.current === panelOpen) return;
shape.current = panelOpen;
sessionStorage.removeItem(outlineKey(sessionId));
setOutlineChoice(null);
}, [panelOpen, sessionId]);
const openOutline = (open: boolean) => {
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
setOutlineChoice(open);
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
// The side panel's width as a fraction of the pane: the dragged one, else
// a default per content (a preview wants more room than the panel).
const [dragged, setDragged] = useState(storedSide);
const side = dragged ?? (preview ? 0.45 : 0.3);
const rootRef = useRef<HTMLDivElement>(null);
// The conversation's height, so a panel laid over a narrow pane stops above
// the prompt box instead of covering it.
const areaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const area = areaRef.current;
if (!area || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() =>
rootRef.current?.style.setProperty("--chat-area", `${area.offsetHeight}px`)
);
ro.observe(area);
return () => ro.disconnect();
}, []);
const [list, setList] = useState<OutlineList | null>(null);
// Kept mounted in a hidden workspace or tab: its pollers skip until shown.
const onScreen = useOnScreen(rootRef);
const activity = useSessionOutline(sessionId, onScreen);
// Open by default only in a spanning pane, and only once there is an output.
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images);
// The main agent is "working" from a prompt's submit to its Stop hook, and
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false);
const [waiting, setWaiting] = useState(false);
// Stop presses, for the conversation's live line (issue #74).
const [stops, setStops] = useState(0);
const readWorking = useCallback(
() =>
api.agents
.list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its
// status filter ignored: check the status here.
.then((r) => {
const main = r.agents.filter((a) => a.type === "main");
// A fresh start, resume or `/clear` leaves main "working" at an
// empty prompt (SessionStart's flag) until the first prompt.
setWorking(
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
);
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
[sessionId]
);
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
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working || waiting ? 5000 : undefined,
match: (msg) =>
msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// The menu the terminal underneath shows now. AskUserQuestion fires no
// Notification, so this — not `waiting` alone — says the CLI asks.
// ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile.
const [menu, setMenu] = useState<PaneMenu | null>(null);
useEffect(() => {
const read = () =>
setMenu((prev) => {
const next = readRunMenu(runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
read();
// Nobody can answer a menu in a hidden tab or pane: no screen scan then.
const timer = window.setInterval(() => {
if (isShown(onScreen)) read();
}, 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) => {
if (menu && chooseRunOption(runId, menu, index)) setMenu(null);
else toast.error(t("chat.notSent"));
};
const answer = (reply: string) => {
if (menu && answerRunMenu(runId, menu, reply)) {
setMenu(null);
setFollow((n) => n + 1);
} else toast.error(t("chat.notSent"));
};
// "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 (busy) setTabBadge(runId, null);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working: busy, asking };
}, [runId, busy, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
model: null,
});
const onContext = useCallback(
(tokens: number, model: string | null) =>
setContext((prev) =>
prev.tokens === tokens && prev.model === model ? prev : { tokens, model }
),
[]
);
// Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized).
const openTerminal = useCallback(
() => 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.
const path = resolveIn(cwd, named);
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path);
},
[laneId, cwd]
);
// Before the session starts the CLI may be asking in the terminal (trust
// this folder? with "No, exit" preselected): an Enter from here answers it.
// Asked, not refused: whether the session row appears right after the
// answer depends on Claude Code firing a hook then, and a hard lock would
// strand the chat if it does not.
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would.
const command = async (line: string, files: string[] = []) => {
if (
notStarted &&
!(await confirmDialog({
title: t("chat.notStartedTitle"),
message: t("chat.notStarted"),
confirmLabel: t("chat.sendAnyway"),
}))
)
return false;
const ok = submitToRun(runId, line, files);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = async () => {
if (!text || uploading) return;
const out = promptForPane(prompt, images, claude);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
};
return (
<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}
>
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
<div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
<ConversationView
sessionId={sessionId}
fill
live
follow={follow}
waiting={asking}
stopped={stops}
onOpenTerminal={openTerminal}
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={openFile}
onOpenArtifact={openArtifact}
onMessages={setConvMessages}
/>
{!outlineOpen && !preview && (
<button
type="button"
data-testid="chat-outline-show"
onClick={() => openOutline(true)}
title={t("chat.outline.show")}
aria-label={t("chat.outline.show")}
className="absolute right-3 top-2 flex h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
>
<FileText className="h-3.5 w-3.5" />
{activity.outputs.length}
</button>
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-3xl">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
key={menu.question}
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"));
}}
/>
)}
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
>
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
placeholder={t("chat.placeholder")}
rows={2}
slash={claude}
fileCwd={cwd}
lane={runId}
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={cwd}
laneId={laneId}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
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 && (
<ModelMenu
value={model}
onChange={async (v) => {
if (await command(`/model ${v || "default"}`)) setModel(v);
}}
tool={run.tool}
effort={effort}
onEffortChange={async (e) => {
if (await command(`/effort ${e || "auto"}`)) setEffort(e);
}}
/>
)}
{busy && !text ? (
<button
type="button"
data-testid="pane-chat-stop"
onClick={() => {
// An interrupted turn fires no Stop hook: clear it here.
if (interruptRun(runId)) {
setWorking(false);
setStops((n) => n + 1);
} else toast.error(t("chat.notStopped"));
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
>
{/* A turning ring: the turn is still going. */}
<span
aria-hidden
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
/>
<Square className="h-3 w-3 fill-current" />
</button>
) : (
<button
type="button"
data-testid="pane-chat-send"
onClick={() => void send()}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{preview ? (
<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"
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
)
)}
{(preview || outlineOpen) && (
// Only beside the chat: a panel over a narrow pane has no edge to drag.
<div className="hidden [@container(min-width:40rem)]:contents">
<Divider
gridRef={rootRef}
dir="row"
at={1 - side}
box={{ x: 0, y: 0, w: 1, h: 1 }}
label={t("chat.outline.resize")}
onRatio={(r) => {
const f = clampSide(1 - r);
localStorage.setItem(SIDE_KEY, String(f));
setDragged(f);
}}
/>
</div>
)}
{list === "skills" && (
<SkillListModal
title={t("chat.outline.skills")}
names={activity.skills}
scope={scope}
onClose={() => setList(null)}
draggable={claude}
/>
)}
{list && list !== "skills" && (
<FileListModal
title={t(`chat.outline.${list}`)}
paths={activity[list]}
scope={scope}
onClose={() => setList(null)}
onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])}
/>
)}
</div>
);
});