383b9ae231
- 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
1030 lines
41 KiB
TypeScript
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>
|
|
);
|
|
});
|