/** * @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 ` / `/effort ` — 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ĩ */ 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 ( {pct}% ); } /** 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(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 (
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}`), }))} />
); } /** 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 (

{title}

{ask &&

{ask}

}
{body && (
          {body}
        
)} {noting ? (
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" />
) : (
{menu.options.map((o, i) => ( ))} {tell >= 0 && ( )}
)}
); } /** 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 (
onChoose(i))} onCancel={onCancel} />
); return (
{menu.tabs && (
{onStep && ( )} {menu.tabs.map((tab, i) => ( {tab.done ? ( ) : ( )} {tab.label} ))} {onStep && ( )}
)} {menu.question && (

{menu.question}

)}
{menu.options.map((o, i) => typing && i === menu.typeAt ? (
{o.n} 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" />
) : ( ) )}
); } /** 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; 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(() => 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(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(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(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(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(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(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([]); // 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 (
notePaneFocus(runId)} className="relative flex min-h-0 flex-1 [container-type:inline-size]" style={{ "--chat-side": `${side * 100}%` } as CSSProperties} >
{!outlineOpen && !preview && ( )}
{menu && ( { 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. */}
void send()} placeholder={t("chat.placeholder")} rows={2} slash={claude} fileCwd={cwd} lane={runId} />
pickFiles.current?.()} onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)} onAttach={(items) => setImages((prev) => addAttachments(prev, items))} /> {claude && } {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]) => ( ))}
{claude && ( { 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 ? ( ) : ( )}
{preview ? ( 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 && ( openOutline(false)} onOpenFile={setPreview} onOpenList={setList} /> ) )} {(preview || outlineOpen) && ( // Only beside the chat: a panel over a narrow pane has no edge to drag.
{ const f = clampSide(1 - r); localStorage.setItem(SIDE_KEY, String(f)); setDragged(f); }} />
)} {list === "skills" && ( setList(null)} draggable={claude} /> )} {list && list !== "skills" && ( setList(null)} onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])} /> )}
); });