mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
b63ac10f8f
- Updated the `runAgent` function to improve timeout handling for MCP tools, ensuring shared budget management and better execution limits. - Introduced a countdown mechanism for tool calls, allowing for real-time UI feedback on execution status and tool cancellations. - Enhanced the UI components to display timeout badges and countdowns for running tools, improving user awareness and responsiveness. - Implemented a silent countdown feature for collapsed tool groups, ensuring timely cancellations without UI clutter. - Refactored the `Tool` component to integrate timeout badges, providing clearer visual indicators of tool status.
1664 lines
74 KiB
TypeScript
1664 lines
74 KiB
TypeScript
/*
|
|
* Copyright (c) 2026 Pawan Osman <https://github.com/PawanOsman>
|
|
*
|
|
* This file is part of OpenCursor — AI coding agent chat inside VS Code.
|
|
* https://github.com/PawanOsman/OpenCursor
|
|
*
|
|
* Licensed under the MIT License. See LICENSE file in the project root.
|
|
*/
|
|
|
|
import * as React from "react";
|
|
import { Icon } from "../shared/icons";
|
|
import { renderMarkdown } from "../shared/markdown";
|
|
import { vscode } from "../shared/vscode";
|
|
import { Composer, KIND_SVG, applyFileIconTo } from "./components/Composer";
|
|
import { ToolCard, isReadonlySubagent, TimeoutBadge, ToolTimeoutWatch, isToolCountdownActive } from "./components/Tool";
|
|
import { History } from "./components/History";
|
|
import type { AgentEvent, ApprovalMode, ApprovalRequestInfo, AssistantBlock, AssistantTurn, Attachment, ConversationSummary, ErrorBlock, InMessage, MentionItem, Mode, ModelDef, ModelOption, OutMessage, PendingChangeInfo, PersonaInfo, ThinkingBlock, ToolBlock, Turn, UserTurn } from "./types";
|
|
import { applyEvent, applyToBlocks, closeTrailingThinking, forceSettleOpenWork, parsePartialArgs, renderMentionTokens } from "./types";
|
|
|
|
function post(msg: OutMessage) {
|
|
vscode.postMessage(msg);
|
|
}
|
|
|
|
// Catches render exceptions so a transient error (e.g. opening/closing a subagent
|
|
// tab) shows a recoverable panel instead of blanking the whole webview.
|
|
export class ErrorBoundary extends React.Component<
|
|
{ children: React.ReactNode },
|
|
{ error: Error | null }
|
|
> {
|
|
state = { error: null as Error | null };
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { error };
|
|
}
|
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
post({ type: "logError", message: String(error?.stack || error), info: info?.componentStack || undefined } as any);
|
|
}
|
|
render() {
|
|
if (this.state.error) {
|
|
return (
|
|
<div className="error-boundary">
|
|
<div className="eb-title"><Icon name="close" size={16} /> Something went wrong</div>
|
|
<pre className="eb-msg">{String(this.state.error?.message || this.state.error)}</pre>
|
|
<div className="eb-actions">
|
|
<button className="eb-btn" onClick={() => this.setState({ error: null })}>Recover</button>
|
|
<button className="eb-btn ghost" onClick={() => post({ type: "openLog" })}>View log</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
// Present-tense status verbs aligned to Cursor's tool display names (`tlA`):
|
|
// Read, Grep, Glob, Shell, Edit, LS, SemanticSearch, Delete, WebSearch, Task,
|
|
// CreatePlan, ReadLints, TodoWrite, AskQuestion.
|
|
const TOOL_LABELS: Record<string, string> = {
|
|
// Read
|
|
read_file: "Reading file",
|
|
Read: "Reading file",
|
|
// LS (list directory)
|
|
list_dir: "Listing directory",
|
|
ListDir: "Listing directory",
|
|
// Glob (file name search)
|
|
glob: "Searching files",
|
|
Glob: "Searching files",
|
|
file_search: "Searching files",
|
|
FileSearch: "Searching files",
|
|
// Grep (content search)
|
|
grep: "Grepping",
|
|
Grep: "Grepping",
|
|
// SemanticSearch (codebase)
|
|
SemanticSearch: "Searching codebase",
|
|
// SearchDocs (external docs)
|
|
SearchDocs: "Searching docs",
|
|
// ReadLints
|
|
read_lints: "Reading lints",
|
|
ReadLints: "Reading lints",
|
|
// TodoWrite / read
|
|
todo_read: "Reading todos",
|
|
TodoRead: "Reading todos",
|
|
todo_write: "Updating todos",
|
|
TodoWrite: "Updating todos",
|
|
// WebSearch / WebFetch
|
|
web_search: "Searching the web",
|
|
WebSearch: "Searching the web",
|
|
web_fetch: "Fetching page",
|
|
WebFetch: "Fetching page",
|
|
// Task (subagent)
|
|
task: "Running subagent",
|
|
Task: "Running subagent",
|
|
// AskQuestion
|
|
ask_question: "Waiting for your answer",
|
|
AskQuestion: "Waiting for your answer",
|
|
// Edit
|
|
edit_file: "Editing file",
|
|
StrReplace: "Editing file",
|
|
Write: "Writing file",
|
|
// Delete
|
|
delete_file: "Deleting file",
|
|
Delete: "Deleting file",
|
|
// EditNotebook
|
|
EditNotebook: "Editing notebook",
|
|
// Shell (terminal)
|
|
run_terminal: "Running command",
|
|
Shell: "Running command",
|
|
AwaitShell: "Waiting for shell",
|
|
// CreatePlan
|
|
WritePlan: "Creating plan",
|
|
// SwitchMode
|
|
SwitchMode: "Switching mode",
|
|
// MCP
|
|
CallMcpTool: "Running MCP tool",
|
|
FetchMcpResource: "Fetching MCP resource",
|
|
ListMcpResources: "Listing MCP resources",
|
|
};
|
|
function toolLabel(name: string): string {
|
|
if (name.startsWith("mcp__")) return "Running MCP tool";
|
|
return TOOL_LABELS[name] || name;
|
|
}
|
|
function capitalize(s: string): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
// Read-only "explore the codebase" tools. Consecutive runs of these are folded
|
|
// into a single collapsible "Exploring" section so the chat isn't flooded.
|
|
const EXPLORE_TOOLS = new Set([
|
|
"read_file", "Read",
|
|
"list_dir", "ListDir",
|
|
"glob", "Glob",
|
|
"file_search", "FileSearch",
|
|
"grep", "Grep",
|
|
"SemanticSearch",
|
|
"SearchDocs",
|
|
"read_lints", "ReadLints",
|
|
"todo_read", "TodoRead",
|
|
]);
|
|
function isExploreBlock(b: AssistantBlock): b is ToolBlock {
|
|
return b.kind === "tool" && EXPLORE_TOOLS.has(b.name);
|
|
}
|
|
|
|
type RenderItem = AssistantBlock | { kind: "explore-group"; tools: ToolBlock[] };
|
|
|
|
// Fold consecutive explore tool-calls into explore-group items. A lone explore
|
|
// call (run length 1) is left as a normal block.
|
|
function groupBlocks(blocks: AssistantBlock[]): RenderItem[] {
|
|
const out: RenderItem[] = [];
|
|
let run: ToolBlock[] = [];
|
|
const flush = () => {
|
|
if (run.length >= 1) out.push({ kind: "explore-group", tools: run });
|
|
run = [];
|
|
};
|
|
for (const b of blocks) {
|
|
if (isExploreBlock(b)) run.push(b);
|
|
else {
|
|
flush();
|
|
out.push(b);
|
|
}
|
|
}
|
|
flush();
|
|
return out;
|
|
}
|
|
|
|
// Split turns into groups, each starting at a user turn (so the sticky "You"
|
|
// header sticks only within its own group). Leading assistant turns (e.g. the
|
|
// greeting) form their own initial group.
|
|
function groupTurns(turns: Turn[]): { turn: Turn; index: number }[][] {
|
|
const groups: { turn: Turn; index: number }[][] = [];
|
|
turns.forEach((turn, index) => {
|
|
if (turn.role === "user" || groups.length === 0) groups.push([{ turn, index }]);
|
|
else groups[groups.length - 1].push({ turn, index });
|
|
});
|
|
return groups;
|
|
}
|
|
|
|
// Summarize a finished explore group, e.g. "Explored 3 files · 2 searches".
|
|
function exploreSummary(tools: ToolBlock[]): string {
|
|
let reads = 0;
|
|
let searches = 0;
|
|
let lints = 0;
|
|
for (const t of tools) {
|
|
if (t.name === "Read" || t.name === "read_file" || t.name === "ListDir" || t.name === "list_dir") reads++;
|
|
else if (t.name === "ReadLints" || t.name === "read_lints" || t.name === "TodoRead" || t.name === "todo_read") lints++;
|
|
else searches++;
|
|
}
|
|
const parts: string[] = [];
|
|
if (reads) parts.push(`${reads} ${reads === 1 ? "file" : "files"}`);
|
|
if (searches) parts.push(`${searches} ${searches === 1 ? "search" : "searches"}`);
|
|
if (lints) parts.push(`${lints} ${lints === 1 ? "check" : "checks"}`);
|
|
return "Explored " + (parts.join(" · ") || "codebase");
|
|
}
|
|
|
|
function ExploringSection({
|
|
tools,
|
|
live,
|
|
onImplement,
|
|
onOpenSubagent,
|
|
approvals,
|
|
}: {
|
|
tools: ToolBlock[];
|
|
/** True when this is the trailing group of an in-flight run (keeps the
|
|
* "Exploring" header up between fast tool completions). */
|
|
live?: boolean;
|
|
onImplement?: (path: string) => void;
|
|
onOpenSubagent?: (callId: string) => void;
|
|
/** Pending approval requests keyed by tool callId (rendered on the tool card). */
|
|
approvals?: Record<string, ApprovalRequestInfo>;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
// A pending approval inside must be visible — force the section open.
|
|
const hasApproval = !!approvals && tools.some((t) => t.callId && approvals[t.callId]);
|
|
React.useEffect(() => { if (hasApproval) setOpen(true); }, [hasApproval]);
|
|
const running = tools.some((t) => t.status === "running") || !!live;
|
|
const current = [...tools].reverse().find((t) => t.status === "running") ?? tools[tools.length - 1];
|
|
const subtitle = running ? capitalize(toolLabel(current.name)) : exploreSummary(tools);
|
|
// Keep kill-at-zero active even when the group is collapsed (no ToolCard mount).
|
|
const timed = tools.filter((t) => isToolCountdownActive(t) && t.timeoutMs && t.timeoutMs > 0);
|
|
const headTimed = timed[0] ?? (current && isToolCountdownActive(current) ? current : null);
|
|
|
|
return (
|
|
<div className={"explore-section" + (open ? " open" : "")}>
|
|
{/* Always watch every timed tool so countdown-0 kills even when collapsed. */}
|
|
{timed.map((t) => (
|
|
<ToolTimeoutWatch key={`watch-${t.callId}`} block={t} />
|
|
))}
|
|
<div className="explore-head" onClick={() => setOpen((o) => !o)}>
|
|
<span className={"tchev" + (open ? " open" : "")}>
|
|
<Icon name="chevD" size={12} />
|
|
</span>
|
|
<Icon name="search" size={12} className="explore-icon" />
|
|
<span className="explore-title">{running ? "Exploring" : exploreSummary(tools)}</span>
|
|
{headTimed ? <TimeoutBadge block={headTimed} /> : null}
|
|
{running ? <span className="spinner" /> : <span className="explore-count">{tools.length}</span>}
|
|
</div>
|
|
{!open && running && <div className="explore-subtitle">{subtitle}</div>}
|
|
{open && (
|
|
<div className="explore-body">
|
|
{tools.map((t, i) => (
|
|
<div className="block-group" key={t.callId || i}>
|
|
<ToolCard block={t} onImplement={onImplement} onOpenSubagent={onOpenSubagent} />
|
|
{t.callId && approvals?.[t.callId] && <ApprovalCard request={approvals[t.callId]} inline />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Markdown({ text }: { text: string }) {
|
|
return <div className="markdown-content" dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }} />;
|
|
}
|
|
|
|
// Render <attached /> tags as the SAME pill as the composer editor:
|
|
// [kind icon] name — shares .mention CSS and KIND_SVG icons.
|
|
function renderMentionHtml(text: string): string {
|
|
const esc = (s: string) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
const unesc = (s: string) => s.replace(/"/g, '"').replace(/</g, "<").replace(/&/g, "&");
|
|
return text.replace(/<attached\s+([^>]*?)\/?>/g, (_s, attrs: string) => {
|
|
const a: Record<string, string> = {};
|
|
for (const m of attrs.matchAll(/([\w-]+)\s*=\s*"([^"]*)"/g)) a[m[1]] = unesc(m[2]);
|
|
const kind = a.type || "file";
|
|
const name = a.title || a.content || "";
|
|
// data-path lets the post-render pass swap in the IDE's exact file icon.
|
|
const pathAttr = (kind === "file" || kind === "code") && a.content ? ` data-path="${esc(a.content)}"` : "";
|
|
return `<span class="mention" data-kind="${esc(kind)}"${pathAttr} title="${esc(a.content || "")}"><span class="mention-icon">${KIND_SVG[kind] || KIND_SVG.file}</span><span class="mention-label">${esc(name)}</span></span>`;
|
|
});
|
|
}
|
|
|
|
/** Message text with mention pills; swaps generic SVGs for the IDE's exact
|
|
* file icons after every render (innerHTML is replaced on re-render). */
|
|
function MentionText({ text }: { text: string }) {
|
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
React.useLayoutEffect(() => {
|
|
ref.current?.querySelectorAll<HTMLElement>(".mention[data-path] .mention-icon").forEach((icon) => {
|
|
const path = icon.parentElement?.dataset.path;
|
|
if (path) applyFileIconTo(icon, path);
|
|
});
|
|
});
|
|
return (
|
|
<div ref={ref}>
|
|
<Markdown text={renderMentionHtml(text)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Run paused at the step limit: Continue button with an "always auto continue" dropdown. */
|
|
function MaxStepsCard({ block, running }: { block: import("./types").AssistantBlock & { kind: "max-steps" }; running: boolean }) {
|
|
const [menu, setMenu] = React.useState(false);
|
|
const [resumed, setResumed] = React.useState(false);
|
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
React.useEffect(() => {
|
|
if (!menu) return;
|
|
const close = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) setMenu(false); };
|
|
window.addEventListener("mousedown", close);
|
|
return () => window.removeEventListener("mousedown", close);
|
|
}, [menu]);
|
|
const go = (always?: boolean) => { setMenu(false); setResumed(true); post({ type: "continueRun", always }); };
|
|
return (
|
|
<div className="approval-card inline">
|
|
<div className="ap-head">
|
|
<Icon name="clock" size={14} />
|
|
<span className="ap-title">Paused after {block.steps} steps</span>
|
|
</div>
|
|
{!resumed && !running && (
|
|
<div className="ap-actions">
|
|
<div className="ap-approve-group" ref={menuRef}>
|
|
<button className="ap-btn allow" onClick={() => go()}>Continue</button>
|
|
<button className="ap-btn allow ap-arrow" title="Continue options" onClick={() => setMenu((v) => !v)}>
|
|
<Icon name="chevD" size={11} />
|
|
</button>
|
|
{menu && (
|
|
<div className="ap-menu">
|
|
<button
|
|
className="ap-menu-item"
|
|
title="Continue and always auto continue from now on (updates General settings)"
|
|
onClick={() => go(true)}
|
|
>
|
|
Always auto continue
|
|
</button>
|
|
<button className="ap-menu-item" onClick={() => post({ type: "openSettings", section: "general" })}>
|
|
<Icon name="settings" size={12} /> General settings…
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** In-chat marker: earlier conversation auto-summarized to free context. */
|
|
function CompactionCard({ block }: { block: import("./types").AssistantBlock & { kind: "compaction" } }) {
|
|
const [open, setOpen] = React.useState(false);
|
|
if (block.status === "running") {
|
|
return (
|
|
<div className="compaction-card running">
|
|
<span className="spinner" /> Summarizing earlier conversation to free context…
|
|
</div>
|
|
);
|
|
}
|
|
if (block.status === "failed") {
|
|
return <div className="compaction-card failed">Context summarization failed — older messages were trimmed instead.</div>;
|
|
}
|
|
return (
|
|
<div className={"compaction-card done" + (open ? " open" : "")}>
|
|
<div className="compaction-head" onClick={() => setOpen((o) => !o)}>
|
|
<Icon name={open ? "chevD" : "chevR"} size={12} />
|
|
<span>Earlier conversation summarized to free context</span>
|
|
</div>
|
|
{open && block.summary && <div className="compaction-body"><Markdown text={block.summary} /></div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ThinkingCard({ block }: { block: ThinkingBlock }) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const live = !block.endedAt;
|
|
const secs = block.endedAt && block.startedAt ? Math.max(1, Math.round((block.endedAt - block.startedAt) / 1000)) : 0;
|
|
const title = live ? "Thinking" : secs ? `Thought for ${secs}s` : "Thought";
|
|
return (
|
|
<div className={"thinking-card" + (open ? " open" : "") + (live ? " live" : "")}>
|
|
<div className="thinking-head" onClick={() => setOpen((o) => !o)}>
|
|
<Icon name="brain" size={12} className="thinking-spark" />
|
|
<span className="thinking-title">{title}</span>
|
|
<Icon name={open ? "chevD" : "chevR"} size={12} className="thinking-chev" />
|
|
</div>
|
|
{open && <div className="thinking-body"><Markdown text={block.text} /></div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorCard({ block }: { block: ErrorBlock }) {
|
|
if (block.retrying) {
|
|
return (
|
|
<div className="error-card retrying">
|
|
<Icon name="brain" size={12} className="error-spark" />
|
|
<span>Request failed, retrying ({block.retrying.attempt}/{block.retrying.max})… </span>
|
|
<span className="error-detail">{block.message}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="error-card">
|
|
<div className="error-card-head">Request failed</div>
|
|
<div className="error-card-body">{block.message}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PersonaSelect({
|
|
personas,
|
|
personaId,
|
|
onSelect,
|
|
}: {
|
|
personas: PersonaInfo[];
|
|
personaId: string;
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
if (!personas.length) return null;
|
|
return (
|
|
<div className="persona-select">
|
|
<div className="persona-select-label">Persona</div>
|
|
<div className="persona-cards">
|
|
{personas.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
className={"persona-card" + (p.id === personaId ? " active" : "")}
|
|
onClick={() => onSelect(p.id)}
|
|
>
|
|
<span className="pc-top">
|
|
<Icon name="agent" size={14} />
|
|
<span className="pc-name">{p.name}</span>
|
|
{p.id === personaId && <Icon name="check" size={13} className="pc-check" />}
|
|
</span>
|
|
<span className="pc-desc">{p.description}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubagentChat({ block, onBack }: { block: import("./types").ToolBlock; onBack: () => void }) {
|
|
const subDone = block.subStatus === "finished" || block.subStatus === "cancelled" || block.subStatus === "error";
|
|
const running = !subDone && (block.status === "running" || !!block.subStatus || (block.subBlocks?.length ?? 0) > 0);
|
|
const sub = block.subBlocks ?? [];
|
|
return (
|
|
<div className="subagent-view">
|
|
<div className="subagent-view-head">
|
|
<button className="sub-back" onClick={onBack}>
|
|
<Icon name="chevD" size={12} /> Back to chat
|
|
</button>
|
|
<span className="sub-readonly">{isReadonlySubagent(block.input) ? "read-only" : "agent"}</span>
|
|
<TimeoutBadge block={block} />
|
|
{running && (
|
|
<button className="sub-stop" onClick={() => post({ type: "cancelSubagent", callId: block.callId, reason: "user" })}>
|
|
<Icon name="close" size={12} /> Stop
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="msg user">
|
|
<div className="role"><Icon name="task" /> Task</div>
|
|
<div className="bubble">
|
|
<div className="subagent-meta">{block.input?.description || "Subagent"} · {isReadonlySubagent(block.input) ? "Explore" : "Agent"}</div>
|
|
{block.input?.prompt && <Markdown text={String(block.input.prompt)} />}
|
|
</div>
|
|
</div>
|
|
<div className="msg assistant">
|
|
<div className="role"><Icon name="bot" /> Subagent</div>
|
|
<div className="bubble">
|
|
{sub.length === 0 ? (
|
|
<div className="sub-empty">{running ? "Starting…" : "No activity"}</div>
|
|
) : (
|
|
groupBlocks(sub).map((b, bi) =>
|
|
b.kind === "explore-group" ? (
|
|
<ExploringSection key={bi} tools={b.tools} />
|
|
) : b.kind === "text" ? (
|
|
<div className="block-group" key={bi}><Markdown text={b.text} /></div>
|
|
) : b.kind === "thinking" ? (
|
|
<ThinkingCard key={bi} block={b} />
|
|
) : b.kind === "error" ? (
|
|
<div className="block-group" key={bi}><ErrorCard block={b} /></div>
|
|
) : b.kind === "compaction" ? (
|
|
<div className="block-group" key={bi}><CompactionCard block={b} /></div>
|
|
) : b.kind === "max-steps" ? (
|
|
<div className="block-group" key={bi}><MaxStepsCard block={b} running={false} /></div>
|
|
) : (
|
|
<div className="block-group" key={bi}><ToolCard block={b} /></div>
|
|
)
|
|
)
|
|
)}
|
|
{running && (
|
|
<div className="phase-row"><span className="phase-shimmer">Working</span></div>
|
|
)}
|
|
{!running && block.result && (
|
|
<div className="sub-summary">
|
|
<div className="sub-summary-label">Summary</div>
|
|
<Markdown text={block.result} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ChatSession {
|
|
turns: Turn[];
|
|
running: boolean;
|
|
status: { text: string; error?: boolean };
|
|
/** Tokens used in the last request (context consumption). */
|
|
usedTokens?: number;
|
|
}
|
|
const ACTION_LABEL: Record<ApprovalRequestInfo["actionType"], string> = {
|
|
shell: "Terminal command",
|
|
edits: "File edit",
|
|
delete: "File delete",
|
|
outside: "Outside-workspace access",
|
|
mcp: "MCP tool",
|
|
web: "Web access",
|
|
};
|
|
|
|
/** In-chat approval prompt rendered on the tool/action card. Approve has a
|
|
* dropdown mirroring the Behavior settings for this action type (options
|
|
* update the global policy too). The agent stays blocked until resolved. */
|
|
function ApprovalCard({ request, inline }: { request: ApprovalRequestInfo; inline?: boolean }) {
|
|
const [menu, setMenu] = React.useState(false);
|
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
React.useEffect(() => {
|
|
if (!menu) return;
|
|
const close = (e: MouseEvent) => {
|
|
if (!menuRef.current?.contains(e.target as Node)) setMenu(false);
|
|
};
|
|
window.addEventListener("mousedown", close);
|
|
return () => window.removeEventListener("mousedown", close);
|
|
}, [menu]);
|
|
const resolve = (msg: Omit<Extract<OutMessage, { type: "resolveApproval" }>, "type" | "requestId">) =>
|
|
post({ type: "resolveApproval", requestId: request.requestId, ...msg });
|
|
const label = ACTION_LABEL[request.actionType].toLowerCase();
|
|
return (
|
|
<div className={"approval-card" + (inline ? " inline" : "")}>
|
|
<div className="ap-head">
|
|
<Icon name="tools" size={14} />
|
|
<span className="ap-title">{ACTION_LABEL[request.actionType]} needs approval</span>
|
|
<span className="ap-tool">{request.toolName}</span>
|
|
</div>
|
|
{!inline && <div className="ap-detail"><code>{request.detail}</code></div>}
|
|
<div className="ap-actions">
|
|
<div className="ap-approve-group" ref={menuRef}>
|
|
<button className="ap-btn allow" onClick={() => resolve({ approve: true })}>Approve</button>
|
|
<button className="ap-btn allow ap-arrow" title="Approve options (these also update Behavior settings)" onClick={() => setMenu((v) => !v)}>
|
|
<Icon name="chevD" size={11} />
|
|
</button>
|
|
{menu && (
|
|
<div className="ap-menu">
|
|
{request.suggestion && (
|
|
<button
|
|
className="ap-menu-item"
|
|
title={`Approve and add "${request.suggestion}" to the ${label} allow list`}
|
|
onClick={() => resolve({ approve: true, pattern: request.suggestion, addPattern: "allow" })}
|
|
>
|
|
Always allow <code>{request.suggestion}</code>
|
|
</button>
|
|
)}
|
|
<button
|
|
className="ap-menu-item"
|
|
title={`Approve and only ask for risky-looking ${label}s from now on`}
|
|
onClick={() => resolve({ approve: true, setMode: "review" })}
|
|
>
|
|
Auto review {label}s
|
|
</button>
|
|
<button
|
|
className="ap-menu-item"
|
|
title={`Approve and run every ${label} without asking from now on`}
|
|
onClick={() => resolve({ approve: true, setMode: "allow" })}
|
|
>
|
|
Run everything ({label}s)
|
|
</button>
|
|
{request.suggestion && (
|
|
<button
|
|
className="ap-menu-item danger"
|
|
title={`Reject and add "${request.suggestion}" to the deny list`}
|
|
onClick={() => resolve({ approve: false, pattern: request.suggestion, addPattern: "deny" })}
|
|
>
|
|
Always deny <code>{request.suggestion}</code>
|
|
</button>
|
|
)}
|
|
<button className="ap-menu-item" onClick={() => post({ type: "openSettings", section: "behavior" })}>
|
|
<Icon name="settings" size={12} /> Behavior settings…
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button className="ap-btn deny" onClick={() => resolve({ approve: false })}>Reject</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Short two-tone chime via WebAudio (no asset files needed). */
|
|
function playCompletionSound() {
|
|
try {
|
|
const ctx = new AudioContext();
|
|
const play = (freq: number, at: number) => {
|
|
const o = ctx.createOscillator();
|
|
const g = ctx.createGain();
|
|
o.frequency.value = freq;
|
|
o.type = "sine";
|
|
g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
|
|
g.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + at + 0.02);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + 0.25);
|
|
o.connect(g).connect(ctx.destination);
|
|
o.start(ctx.currentTime + at);
|
|
o.stop(ctx.currentTime + at + 0.3);
|
|
};
|
|
play(660, 0);
|
|
play(880, 0.12);
|
|
setTimeout(() => ctx.close(), 800);
|
|
} catch { /* audio unavailable */ }
|
|
}
|
|
|
|
export function App() {
|
|
const [mode, setMode] = React.useState<Mode>("agent");
|
|
const [models, setModels] = React.useState<string[]>([]);
|
|
const [modelList, setModelList] = React.useState<ModelDef[]>([]);
|
|
const [selectedModel, setSelectedModel] = React.useState("");
|
|
const [conversations, setConversations] = React.useState<ConversationSummary[]>([]);
|
|
const [activeId, setActiveId] = React.useState<string | undefined>(undefined);
|
|
const [openTabs, setOpenTabs] = React.useState<string[]>([]); // IDs of tabs visible in tab bar
|
|
const [historyOpen, setHistoryOpen] = React.useState(false);
|
|
const [moreOpen, setMoreOpen] = React.useState(false);
|
|
const moreRef = React.useRef<HTMLDivElement>(null);
|
|
React.useEffect(() => {
|
|
if (!moreOpen) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!moreRef.current?.contains(e.target as Node)) setMoreOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onDown);
|
|
return () => document.removeEventListener("mousedown", onDown);
|
|
}, [moreOpen]);
|
|
const [personas, setPersonas] = React.useState<PersonaInfo[]>([]);
|
|
const [personaId, setPersonaId] = React.useState("default");
|
|
const [hasProviders, setHasProviders] = React.useState(true);
|
|
const [uiPrefs, setUiPrefs] = React.useState<{ chatTextSize: string; submitWithCtrlEnter: boolean; maxTabCount: number; completionSound: boolean }>({ chatTextSize: "default", submitWithCtrlEnter: false, maxTabCount: 0, completionSound: false });
|
|
const uiPrefsRef = React.useRef(uiPrefs);
|
|
React.useEffect(() => { uiPrefsRef.current = uiPrefs; }, [uiPrefs]);
|
|
const [pendingChanges, setPendingChanges] = React.useState<PendingChangeInfo[]>([]);
|
|
// Pending in-chat approval requests, keyed by conversation id.
|
|
const [approvals, setApprovals] = React.useState<Record<string, ApprovalRequestInfo[]>>({});
|
|
const [reviewOpen, setReviewOpen] = React.useState(false);
|
|
// Editing an earlier user message: index of that turn. The edit composer
|
|
// shares the global model/mode selection (one selection for all composers).
|
|
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
|
|
// Pending edit awaiting the revert-confirm dialog. `restore` = return the
|
|
// message to the bottom composer instead of resending it.
|
|
const [revertPrompt, setRevertPrompt] = React.useState<{ index: number; text: string; attachments: Attachment[]; restore?: boolean } | null>(null);
|
|
// Message restored into the bottom composer (as if not yet sent).
|
|
const [draft, setDraft] = React.useState<{ text: string; attachments?: Attachment[] } | null>(null);
|
|
// callId of the subagent currently opened as its own tab (null = parent chat).
|
|
const [subTab, setSubTab] = React.useState<string | null>(null);
|
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
// Single source of truth: one independent session per conversation id. The
|
|
// visible chat just renders the active session. Avoids any snapshot races.
|
|
const sessionsRef = React.useRef<Map<string, ChatSession>>(new Map());
|
|
const [, force] = React.useReducer((n) => n + 1, 0);
|
|
// Mirror of activeId readable inside the stable message handler.
|
|
const activeIdRef = React.useRef<string | undefined>(undefined);
|
|
React.useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
|
|
// Enforce Max Tab Count (0 = unlimited): drop oldest non-active tabs when over.
|
|
React.useEffect(() => {
|
|
const max = uiPrefs.maxTabCount;
|
|
if (!max || max < 1 || openTabs.length <= max) return;
|
|
setOpenTabs((tabs) => {
|
|
const keep = [...tabs];
|
|
while (keep.length > max) {
|
|
const idx = keep.findIndex((id) => id !== activeIdRef.current);
|
|
if (idx === -1) break;
|
|
keep.splice(idx, 1);
|
|
}
|
|
return keep;
|
|
});
|
|
}, [openTabs, uiPrefs.maxTabCount]);
|
|
|
|
// Scroll the active tab into view when it changes (new/opened from history).
|
|
const tabBarRef = React.useRef<HTMLDivElement>(null);
|
|
React.useEffect(() => {
|
|
tabBarRef.current?.querySelector(".tab.active")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}, [activeId, openTabs]);
|
|
|
|
// Brand-new (id-less) chats use the "" key until the backend assigns an id.
|
|
const sessionFor = (id: string | undefined): ChatSession => {
|
|
const key = id ?? "";
|
|
let s = sessionsRef.current.get(key);
|
|
if (!s) { s = { turns: [], running: false, status: { text: "" } }; sessionsRef.current.set(key, s); }
|
|
return s;
|
|
};
|
|
const active = sessionFor(activeId);
|
|
const turns = active.turns;
|
|
const isRunning = active.running;
|
|
const status = active.status;
|
|
// Whether the view is pinned to the bottom. Starts true; flips off ONLY on an
|
|
// explicit user gesture scrolling up (wheel/touch/scrollbar drag), back on
|
|
// when the user returns to the bottom (gesture or the jump button). Mirrored
|
|
// in state so the "scroll to bottom" button can render.
|
|
const stickRef = React.useRef(true);
|
|
const [following, setFollowing] = React.useState(true);
|
|
const setStick = React.useCallback((v: boolean) => {
|
|
stickRef.current = v;
|
|
setFollowing(v);
|
|
}, []);
|
|
// When the user sends a message, pin that new user bubble to the top of the
|
|
// viewport (fresh-chat feel) instead of the default stick-to-bottom.
|
|
const pinTopRef = React.useRef(false);
|
|
// True while we're performing a programmatic scroll, so onScroll ignores the
|
|
// resulting events (otherwise stick-state flip-flops → back-and-forth jank).
|
|
const selfScrollRef = React.useRef(false);
|
|
// Detects conversation switches so we can reset to the bottom on switch.
|
|
const prevActiveIdRef = React.useRef<string | undefined>(activeId);
|
|
// Per-conversation queue of messages typed while a run was in flight. Sent
|
|
// automatically (FIFO) when the current run settles.
|
|
type QueuedMsg = { text: string; attachments?: Attachment[]; model?: string; mode?: Mode };
|
|
const queueRef = React.useRef<Map<string, QueuedMsg[]>>(new Map());
|
|
// Conversations whose next settle must NOT auto-flush the queue (a "send now"
|
|
// replaced the run: the abort's settle event belongs to the replaced run).
|
|
const suppressFlushRef = React.useRef<Set<string>>(new Set());
|
|
// The last group gets a min-height = viewport so it can be pinned to the top
|
|
// without any real spacer element (purely visual "virtual" space that grows no
|
|
// extra scrollable height beyond one viewport). Set imperatively so it tracks
|
|
// the scroll area size. Never applied to the first group (already at top).
|
|
const lastGroupRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// Give the last group a min-height of one viewport so its user message can be
|
|
// scrolled to the top ("virtual space") without adding real extra scroll beyond
|
|
// one screen. Not applied to a lone first group (it already sits at the top).
|
|
const sizeSpacer = React.useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
// Clear stale virtual space from groups that are no longer last.
|
|
el.querySelectorAll<HTMLElement>(".chat-turn-group").forEach((g) => {
|
|
if (g !== lastGroupRef.current && g.style.minHeight) g.style.minHeight = "";
|
|
});
|
|
const group = lastGroupRef.current;
|
|
if (!group) return;
|
|
// Grow the last group so the max scroll position lands exactly with the
|
|
// group's top at the viewport top — no more (no overscroll under the sticky
|
|
// header) and no less. `trailing` = space after the group (container bottom
|
|
// padding) and is invariant to the group's own height, so this is stable.
|
|
const gTop = group.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
|
|
const trailing = el.scrollHeight - gTop - group.offsetHeight;
|
|
const minH = Math.max(0, Math.round(el.clientHeight - trailing));
|
|
const target = `${minH}px`;
|
|
if (group.style.minHeight !== target) group.style.minHeight = target;
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
window.addEventListener("resize", sizeSpacer);
|
|
return () => window.removeEventListener("resize", sizeSpacer);
|
|
}, [sizeSpacer]);
|
|
|
|
// Stick-to-bottom may ONLY be re-armed by an explicit user gesture that lands
|
|
// at the bottom (wheel down / touch / scrollbar drag). Scroll *events* alone
|
|
// are never trusted: after a send the pinned-to-top position IS the scroll
|
|
// bottom (the spacer sizes it that way), and layout shifts (tool cards
|
|
// collapsing, scrollTop clamping when content shrinks, trailing smooth-scroll
|
|
// frames) fire bottom-position scroll events that used to flip follow mode
|
|
// back on and drag the pinned message up as the run streamed.
|
|
const draggingRef = React.useRef(false);
|
|
// Whether the user scrolled at all since the last send (any gesture). Gates
|
|
// the end-of-run "reveal": we only auto-scroll to the tail if they never moved.
|
|
const userScrolledRef = React.useRef(false);
|
|
React.useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const atBottom = () => el.scrollHeight - el.scrollTop - el.clientHeight < 8;
|
|
// Evaluate after the browser applies the gesture's scroll (next frame).
|
|
const evalStick = () => requestAnimationFrame(() => { setStick(atBottom()); });
|
|
const onWheel = (e: WheelEvent) => {
|
|
userScrolledRef.current = true;
|
|
if (e.deltaY < 0) setStick(false); // scrolling up always unsticks
|
|
else evalStick(); // scrolling down re-arms only if it lands at the bottom
|
|
};
|
|
const onDown = () => { draggingRef.current = true; }; // possible scrollbar drag
|
|
const onUp = () => { draggingRef.current = false; };
|
|
const onTouch = () => { userScrolledRef.current = true; evalStick(); };
|
|
el.addEventListener("wheel", onWheel, { passive: true });
|
|
el.addEventListener("touchmove", onTouch, { passive: true });
|
|
el.addEventListener("pointerdown", onDown);
|
|
window.addEventListener("pointerup", onUp);
|
|
return () => {
|
|
el.removeEventListener("wheel", onWheel);
|
|
el.removeEventListener("touchmove", onTouch);
|
|
el.removeEventListener("pointerdown", onDown);
|
|
window.removeEventListener("pointerup", onUp);
|
|
};
|
|
}, [setStick]);
|
|
|
|
const onScroll = React.useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
// Ignore scroll events we triggered ourselves (prevents feedback loops that
|
|
// make the stick-to-bottom state flip-flop → visible back-and-forth jank).
|
|
if (selfScrollRef.current) return;
|
|
// Scroll events alone never change follow state — layout shifts during
|
|
// streaming (cards collapsing, clamping) fire them constantly. Only a
|
|
// scrollbar drag (pointer held) is treated as user-driven here; wheel and
|
|
// touch are handled by their own listeners above.
|
|
if (!draggingRef.current) return;
|
|
userScrolledRef.current = true;
|
|
setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 8);
|
|
}, [setStick]);
|
|
|
|
// Jump-to-bottom button: scroll to the end and re-arm follow mode.
|
|
const scrollToBottom = React.useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
setStick(true);
|
|
selfScrollRef.current = true;
|
|
el.scrollTo({ top: el.scrollHeight - el.clientHeight, behavior: "smooth" });
|
|
window.setTimeout(() => { selfScrollRef.current = false; }, 450);
|
|
}, [setStick]);
|
|
|
|
// Keep the view anchored as content changes: pin a freshly sent message to the
|
|
// top once, otherwise follow the bottom only while the user is already there.
|
|
// Runs after every render (streaming deltas); all scrolls are instant/idempotent
|
|
// so re-running is cheap and never fights itself.
|
|
React.useLayoutEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
// Switching conversations: cancel any pending pin and land at the bottom.
|
|
if (prevActiveIdRef.current !== activeId) {
|
|
prevActiveIdRef.current = activeId;
|
|
pinTopRef.current = false;
|
|
setStick(true);
|
|
}
|
|
sizeSpacer();
|
|
if (pinTopRef.current) {
|
|
pinTopRef.current = false;
|
|
// Autoscroll stays ON by default after a send: the pinned-to-top position
|
|
// IS the scroll bottom (the spacer sizes it that way), so follow mode and
|
|
// the pin agree. Only a manual scroll-up turns follow off.
|
|
setStick(true);
|
|
userScrolledRef.current = false;
|
|
const users = el.querySelectorAll<HTMLElement>(".msg.user");
|
|
const last = users[users.length - 1];
|
|
if (last) {
|
|
const top = el.scrollTop + (last.getBoundingClientRect().top - el.getBoundingClientRect().top);
|
|
selfScrollRef.current = true;
|
|
el.scrollTo({ top, behavior: "smooth" });
|
|
// Release the self-scroll guard after the smooth animation settles.
|
|
window.setTimeout(() => { selfScrollRef.current = false; }, 450);
|
|
}
|
|
return;
|
|
}
|
|
if (stickRef.current) {
|
|
selfScrollRef.current = true;
|
|
el.scrollTop = el.scrollHeight; // instant; no-op when already at the bottom
|
|
requestAnimationFrame(() => { selfScrollRef.current = false; });
|
|
}
|
|
});
|
|
|
|
|
|
// Auto-scroll already handled below; persistence happens on settle per session.
|
|
|
|
// Throttled per-conversation persistence of live turns (max ~1/sec each) so the
|
|
// host store stays close to the on-screen state during a run.
|
|
const persistTimers = React.useRef(new Map<string, number>());
|
|
const schedulePersist = (id: string, s: ChatSession) => {
|
|
if (!id || persistTimers.current.has(id)) return;
|
|
const t = window.setTimeout(() => {
|
|
persistTimers.current.delete(id);
|
|
post({ type: "persistTurns", convId: id, turns: s.turns });
|
|
}, 800);
|
|
persistTimers.current.set(id, t);
|
|
};
|
|
|
|
// Reconcile each session's `running` flag with the host's authoritative set of
|
|
// in-flight runs (after a webview reload the agent may still be working).
|
|
const markRunning = (ids?: string[]) => {
|
|
if (!ids) return;
|
|
const set = new Set(ids);
|
|
for (const [id, s] of sessionsRef.current) {
|
|
if (!id) continue;
|
|
const live = set.has(id);
|
|
if (live && !s.running) {
|
|
s.running = true;
|
|
if (!s.status.text) s.status = { text: "Working" };
|
|
} else if (!live && s.running) {
|
|
// Host no longer has this run (stop, crash, IDE reopen) — clear Working.
|
|
s.running = false;
|
|
s.status = { text: "" };
|
|
s.turns = forceSettleOpenWork(closeTrailingThinking(s.turns), "cancelled");
|
|
}
|
|
}
|
|
};
|
|
|
|
// Seed a session's turns from persisted data without clobbering a live run.
|
|
const seedSession = (id: string | undefined, persisted: Turn[], usedTokens?: number) => {
|
|
if (!id) return;
|
|
// Stale "running" tools left on disk after IDE close → settle them.
|
|
const clean = forceSettleOpenWork(closeTrailingThinking(persisted), "cancelled");
|
|
const s = sessionsRef.current.get(id);
|
|
if (!s) {
|
|
sessionsRef.current.set(id, { turns: clean, running: false, status: { text: "" }, usedTokens });
|
|
} else if (!s.running) {
|
|
// Only refresh from disk when not running (live turns are authoritative).
|
|
s.turns = clean;
|
|
if (usedTokens !== undefined) s.usedTokens = usedTokens;
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
// rAF-batch stream-driven re-renders so tool/args/text deltas don't thrash React.
|
|
let raf = 0;
|
|
const scheduleForce = () => {
|
|
if (raf) return;
|
|
raf = requestAnimationFrame(() => {
|
|
raf = 0;
|
|
force();
|
|
});
|
|
};
|
|
const handler = (event: MessageEvent<InMessage>) => {
|
|
const msg = event.data;
|
|
switch (msg.type) {
|
|
case "initialState":
|
|
setMode(msg.mode);
|
|
setSelectedModel(msg.selectedModel || "");
|
|
seedSession(msg.activeId, msg.turns || [], msg.usedTokens);
|
|
markRunning(msg.runningConvIds);
|
|
setActiveId(msg.activeId);
|
|
setPersonas(msg.personas || []);
|
|
setPersonaId(msg.activePersonaId || "default");
|
|
setHasProviders(!!msg.hasProviders);
|
|
if (msg.uiPrefs) setUiPrefs(msg.uiPrefs);
|
|
if (msg.activeId) setOpenTabs((t) => t.includes(msg.activeId!) ? t : [...t, msg.activeId!]);
|
|
force();
|
|
break;
|
|
case "modelSelected":
|
|
setSelectedModel(msg.model || ""); // auto hidden for now
|
|
break;
|
|
case "configState":
|
|
setPersonas(msg.personas || []);
|
|
setHasProviders(!!msg.hasProviders);
|
|
if (msg.uiPrefs) setUiPrefs(msg.uiPrefs);
|
|
// Only follow the global default persona for brand-new (empty) chats.
|
|
if (sessionFor(activeIdRef.current).turns.length === 0) setPersonaId(msg.activePersonaId || "default");
|
|
break;
|
|
case "modelsFetched":
|
|
setModels(msg.models || []);
|
|
if (msg.modelList) setModelList(msg.modelList);
|
|
break;
|
|
case "pendingChanges":
|
|
setPendingChanges(msg.changes || []);
|
|
break;
|
|
case "conversations":
|
|
setConversations(msg.list || []);
|
|
{ const ids = new Set((msg.list || []).map((c: ConversationSummary) => c.id));
|
|
setOpenTabs((t) => t.filter((id) => ids.has(id)));
|
|
// Keep the "" pending session (brand-new chat awaiting its id).
|
|
for (const k of [...sessionsRef.current.keys()]) if (k !== "" && !ids.has(k)) sessionsRef.current.delete(k);
|
|
}
|
|
if (msg.activeId) setOpenTabs((t) => t.includes(msg.activeId!) ? t : [...t, msg.activeId!]);
|
|
markRunning(msg.runningConvIds);
|
|
break;
|
|
case "loadConversation":
|
|
if (!msg.activeId) sessionsRef.current.set("", { turns: [], running: false, status: { text: "" } });
|
|
else seedSession(msg.activeId, msg.turns || [], msg.usedTokens);
|
|
setActiveId(msg.activeId);
|
|
setHistoryOpen(false);
|
|
if (msg.personaId) setPersonaId(msg.personaId);
|
|
if (msg.activeId) setOpenTabs((t) => t.includes(msg.activeId!) ? t : [...t, msg.activeId!]);
|
|
force();
|
|
break;
|
|
case "runStarted": {
|
|
// First message in a brand-new chat: migrate the pending (id-less) session.
|
|
if (!activeIdRef.current) {
|
|
const pending = sessionsRef.current.get("") ;
|
|
if (pending) { sessionsRef.current.set(msg.convId, pending); sessionsRef.current.delete(""); }
|
|
activeIdRef.current = msg.convId;
|
|
setActiveId(msg.convId);
|
|
setOpenTabs((t) => t.includes(msg.convId) ? t : [...t, msg.convId]);
|
|
}
|
|
const s = sessionFor(msg.convId);
|
|
s.running = true;
|
|
s.status = { text: "Generating…" };
|
|
force();
|
|
break;
|
|
}
|
|
case "error": {
|
|
const s = sessionFor(activeIdRef.current);
|
|
s.running = false;
|
|
s.status = { text: "Error: " + msg.message, error: true };
|
|
force();
|
|
break;
|
|
}
|
|
case "approvalRequest":
|
|
setApprovals((a) => {
|
|
const list = a[msg.convId] || [];
|
|
if (list.some((r) => r.requestId === msg.request.requestId)) return a;
|
|
return { ...a, [msg.convId]: [...list, msg.request] };
|
|
});
|
|
break;
|
|
case "approvalResolved":
|
|
setApprovals((a) => ({ ...a, [msg.convId]: (a[msg.convId] || []).filter((r) => r.requestId !== msg.requestId) }));
|
|
break;
|
|
case "agentEvent": {
|
|
const ev = msg.event;
|
|
const s = sessionFor(msg.convId);
|
|
const settled = ev.type === "run-status" && (ev.status === "finished" || ev.status === "cancelled" || ev.status === "error");
|
|
if (ev.type === "run-status") {
|
|
s.status = { text: ev.status === "running" ? "Planning next moves" : ev.status === "finished" ? "" : ev.status };
|
|
if (settled) {
|
|
const wasRunning = s.running;
|
|
s.running = false;
|
|
if (wasRunning && ev.status === "finished" && uiPrefsRef.current.completionSound) playCompletionSound();
|
|
// Close open thinking + cancel any still-spinning tools/subagents.
|
|
s.turns = forceSettleOpenWork(
|
|
closeTrailingThinking(s.turns),
|
|
ev.status === "error" ? "error" : "cancelled",
|
|
);
|
|
post({ type: "persistTurns", convId: msg.convId, turns: s.turns });
|
|
// Auto-start the next queued message once (duplicate settle from host finally must not double-flush).
|
|
if (wasRunning) {
|
|
if (suppressFlushRef.current.has(msg.convId)) suppressFlushRef.current.delete(msg.convId);
|
|
else window.setTimeout(() => flushQueueRef.current(msg.convId), 0);
|
|
}
|
|
}
|
|
} else {
|
|
s.turns = applyEvent(s.turns, ev);
|
|
// Throttle-persist live turns so a pane move / remount (which destroys
|
|
// the webview without a reliable pagehide) restores the in-flight chat.
|
|
schedulePersist(msg.convId, s);
|
|
if (ev.type === "thinking-delta") s.status = { text: "Thinking" };
|
|
else if (ev.type === "text-delta") s.status = { text: "Generating" };
|
|
else if (ev.type === "tool-call-started") s.status = { text: capitalize(toolLabel(ev.name)) };
|
|
else if (ev.type === "tool-call-args") {/* keep current tool label while args stream */}
|
|
else if (ev.type === "tool-call-completed") s.status = { text: "Planning next moves" };
|
|
else if (ev.type === "retry") s.status = { text: `Retrying (${ev.attempt}/${ev.max})…` };
|
|
else if (ev.type === "usage") s.usedTokens = ev.totalTokens;
|
|
else if (ev.type === "compaction") s.status = { text: ev.status === "running" ? "Summarizing conversation" : "Planning next moves" };
|
|
else if (ev.type === "shell-notify") s.status = { text: ev.message };
|
|
else if (ev.type === "error") {
|
|
// Keep the rendered error block; persist so the chat survives reloads.
|
|
s.status = { text: "Error: " + ev.message, error: true };
|
|
post({ type: "persistTurns", convId: msg.convId, turns: s.turns });
|
|
} else if (ev.type === "mode-changed") {
|
|
setMode(ev.mode);
|
|
post({ type: "setMode", mode: ev.mode });
|
|
}
|
|
}
|
|
// Immediate paint on settle / tool complete; coalesce stream deltas.
|
|
if (
|
|
settled ||
|
|
ev.type === "tool-call-completed" ||
|
|
ev.type === "tool-call-started" ||
|
|
ev.type === "error" ||
|
|
ev.type === "run-result"
|
|
) {
|
|
force();
|
|
} else {
|
|
scheduleForce();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("message", handler);
|
|
// Last-chance flush: if the webview is torn down (window close / extension
|
|
// reload) mid-run, persist every session's current turns so reopening shows
|
|
// the live state instead of a stale/blank chat.
|
|
const flush = () => {
|
|
for (const [id, s] of sessionsRef.current) {
|
|
if (id && s.turns.length) post({ type: "persistTurns", convId: id, turns: s.turns });
|
|
}
|
|
};
|
|
window.addEventListener("pagehide", flush);
|
|
window.addEventListener("beforeunload", flush);
|
|
post({ type: "ready" });
|
|
return () => {
|
|
if (raf) cancelAnimationFrame(raf);
|
|
flush();
|
|
window.removeEventListener("message", handler);
|
|
window.removeEventListener("pagehide", flush);
|
|
window.removeEventListener("beforeunload", flush);
|
|
};
|
|
}, []);
|
|
|
|
const sendNow = React.useCallback((text: string, attachments?: Attachment[], model?: string, mode2?: Mode) => {
|
|
const s = sessionFor(activeIdRef.current);
|
|
s.turns = [...s.turns, { role: "user", text, attachments, model, mode: mode2 }];
|
|
pinTopRef.current = true;
|
|
force();
|
|
post({ type: "sendMessage", text, attachments });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Send the next queued message for a conversation (FIFO). Returns true if sent.
|
|
const flushQueue = React.useCallback((convId: string): boolean => {
|
|
const q = queueRef.current.get(convId);
|
|
if (!q?.length) return false;
|
|
const [next, ...rest] = q;
|
|
if (rest.length) queueRef.current.set(convId, rest);
|
|
else queueRef.current.delete(convId);
|
|
sendNow(next.text, next.attachments, next.model, next.mode);
|
|
return true;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
const flushQueueRef = React.useRef(flushQueue);
|
|
flushQueueRef.current = flushQueue;
|
|
|
|
const onSubmit = (text: string, attachments: Attachment[]) => {
|
|
const s = sessionFor(activeIdRef.current);
|
|
const id = activeIdRef.current ?? "";
|
|
// A run is in flight, or other messages are already waiting (e.g. saving an
|
|
// edited queue item): append to the END of the queue — never jump ahead.
|
|
if (s.running || (queueRef.current.get(id)?.length ?? 0) > 0) {
|
|
const q = queueRef.current.get(id) ?? [];
|
|
queueRef.current.set(id, [...q, { text, attachments: attachments.length ? attachments : undefined, model: selectedModel, mode }]);
|
|
force();
|
|
if (!s.running) window.setTimeout(() => flushQueueRef.current(id), 0);
|
|
return;
|
|
}
|
|
sendNow(text, attachments.length ? attachments : undefined, selectedModel, mode);
|
|
};
|
|
|
|
// Queue item actions.
|
|
const queued = queueRef.current.get(activeId ?? "") ?? [];
|
|
const removeQueued = (i: number) => {
|
|
const id = activeId ?? "";
|
|
const q = [...(queueRef.current.get(id) ?? [])];
|
|
q.splice(i, 1);
|
|
if (q.length) queueRef.current.set(id, q);
|
|
else queueRef.current.delete(id);
|
|
force();
|
|
};
|
|
const editQueued = (i: number) => {
|
|
const q = queueRef.current.get(activeId ?? "") ?? [];
|
|
const item = q[i];
|
|
if (!item) return;
|
|
removeQueued(i);
|
|
setDraft({ text: item.text, attachments: item.attachments });
|
|
};
|
|
// Promote a queued item to run immediately. The host aborts any in-flight run
|
|
// for this conversation and starts the new one; suppress the settle-time
|
|
// auto-flush so the cancelled run doesn't also fire the next queued item.
|
|
const runQueuedNow = (i: number) => {
|
|
const id = activeId ?? "";
|
|
const q = [...(queueRef.current.get(id) ?? [])];
|
|
const [item] = q.splice(i, 1);
|
|
if (!item) return;
|
|
if (q.length) queueRef.current.set(id, q);
|
|
else queueRef.current.delete(id);
|
|
if (sessionFor(id).running) suppressFlushRef.current.add(id);
|
|
sendNow(item.text, item.attachments, item.model, item.mode);
|
|
};
|
|
|
|
// Clicking outside the inline edit composer cancels the edit.
|
|
React.useEffect(() => {
|
|
if (editingIndex === null) return;
|
|
const h = (e: MouseEvent) => {
|
|
// Portaled dropdowns (model picker / mode menu) live in document.body.
|
|
if (!(e.target as HTMLElement).closest(".msg.user.editing, .modal-overlay, .model-picker, .mode-dropdown")) setEditingIndex(null);
|
|
};
|
|
document.addEventListener("mousedown", h);
|
|
return () => document.removeEventListener("mousedown", h);
|
|
}, [editingIndex]);
|
|
|
|
const startEdit = (index: number, _turn: UserTurn) => {
|
|
setEditingIndex(index);
|
|
};
|
|
|
|
// Resend an edited earlier message. If there are file changes below it, ask the
|
|
// user whether to revert them first; otherwise resend straight away.
|
|
const requestEditSubmit = (index: number, text: string, attachments: Attachment[]) => {
|
|
if (pendingChanges.length > 0) {
|
|
setRevertPrompt({ index, text, attachments });
|
|
} else {
|
|
commitEdit(index, text, attachments, false);
|
|
}
|
|
};
|
|
|
|
// Revert to a message: drop it + everything after, put its text back into the
|
|
// bottom composer as an unsent draft.
|
|
const requestRevert = (index: number, turn: UserTurn) => {
|
|
if (pendingChanges.length > 0) {
|
|
setRevertPrompt({ index, text: turn.text, attachments: turn.attachments ?? [], restore: true });
|
|
} else {
|
|
restoreMessage(index, turn.text, turn.attachments ?? [], false);
|
|
}
|
|
};
|
|
|
|
const restoreMessage = (index: number, text: string, attachments: Attachment[], revertFiles: boolean) => {
|
|
const s = sessionFor(activeIdRef.current);
|
|
s.turns = s.turns.slice(0, index);
|
|
setDraft({ text, attachments: attachments.length ? attachments : undefined });
|
|
setRevertPrompt(null);
|
|
setEditingIndex(null);
|
|
force();
|
|
post({ type: "revertToMessage", index, revertFiles });
|
|
};
|
|
|
|
const commitEdit = (index: number, text: string, attachments: Attachment[], revertFiles: boolean) => {
|
|
const s = sessionFor(activeIdRef.current);
|
|
// Drop this turn and everything after it, then append the edited message.
|
|
s.turns = [...s.turns.slice(0, index), { role: "user", text, attachments: attachments.length ? attachments : undefined, model: selectedModel, mode }];
|
|
setEditingIndex(null);
|
|
setRevertPrompt(null);
|
|
pinTopRef.current = true;
|
|
force();
|
|
post({ type: "sendMessage", text, attachments: attachments.length ? attachments : undefined, fromIndex: index, model: selectedModel, mode, revertFiles });
|
|
};
|
|
|
|
// Switch to agent mode and kick off implementation of a written plan.
|
|
const onImplement = (planPath: string) => {
|
|
setMode("agent");
|
|
post({ type: "setMode", mode: "agent" });
|
|
const text = planPath
|
|
? `Implement the plan in \`${planPath}\`. Read it first, then execute every step. Keep going until it is fully done.`
|
|
: "Implement the plan you just wrote. Execute every step until it is fully done.";
|
|
const s = sessionFor(activeIdRef.current);
|
|
s.turns = [...s.turns, { role: "user", text }];
|
|
pinTopRef.current = true;
|
|
force();
|
|
post({ type: "sendMessage", text });
|
|
};
|
|
|
|
// Locate the subagent (task) ToolBlock for the open sub-tab.
|
|
const findSub = (callId: string): import("./types").ToolBlock | undefined => {
|
|
for (const t of turns) {
|
|
if (t.role !== "assistant") continue;
|
|
for (const b of t.blocks) {
|
|
if (b.kind === "tool" && b.callId === callId) return b;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
const subBlock = subTab ? findSub(subTab) : undefined;
|
|
// Pending approvals for the active conversation, keyed by tool callId so
|
|
// the prompt renders directly on its tool card. Requests without a callId
|
|
// (e.g. beforeSubmit) fall back to the bottom stack.
|
|
const activeApprovals = approvals[activeId ?? ""] || [];
|
|
const approvalsByCall = React.useMemo(() => {
|
|
const m: Record<string, ApprovalRequestInfo> = {};
|
|
for (const r of activeApprovals) if (r.callId) m[r.callId] = r;
|
|
return m;
|
|
}, [activeApprovals]);
|
|
const orphanApprovals = activeApprovals.filter((r) => !r.callId);
|
|
// If the sub-tab's block vanished (new conversation loaded), drop back to parent.
|
|
React.useEffect(() => {
|
|
if (subTab && !subBlock) setSubTab(null);
|
|
}, [subTab, subBlock]);
|
|
|
|
const closeTab = (id: string) => {
|
|
setOpenTabs((tabs) => {
|
|
const next = tabs.filter((t) => t !== id);
|
|
// If we closed the active tab, switch to another or start fresh
|
|
if (id === activeId) {
|
|
const idx = tabs.indexOf(id);
|
|
const fallback = next[Math.min(idx, next.length - 1)];
|
|
if (fallback) {
|
|
post({ type: "selectConversation", id: fallback });
|
|
} else {
|
|
// No tabs left → new conversation
|
|
post({ type: "newConversation" });
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className={"app" + (uiPrefs.chatTextSize !== "default" ? ` text-${uiPrefs.chatTextSize}` : "")}>
|
|
<div className="chat-header">
|
|
<div
|
|
className="tab-bar"
|
|
ref={tabBarRef}
|
|
onWheel={(e) => {
|
|
if (e.deltaY === 0) return;
|
|
e.currentTarget.scrollLeft += e.deltaY;
|
|
}}
|
|
>
|
|
{openTabs.map((tabId) => {
|
|
const c = conversations.find((x) => x.id === tabId);
|
|
const title = c ? c.title : "New Chat";
|
|
return (
|
|
<div
|
|
key={tabId}
|
|
className={"tab" + (tabId === activeId && !subTab ? " active" : "")}
|
|
onClick={() => {
|
|
if (subTab) setSubTab(null);
|
|
if (tabId !== activeId) post({ type: "selectConversation", id: tabId });
|
|
}}
|
|
onMouseDown={(e) => {
|
|
// middle-click closes tab (not delete)
|
|
if (e.button === 1) {
|
|
e.preventDefault();
|
|
closeTab(tabId);
|
|
}
|
|
}}
|
|
>
|
|
{tabId !== activeId && sessionsRef.current.get(tabId)?.running && <span className="status-spinner tab-spin" />}
|
|
<span className="tab-title">{title}</span>
|
|
<span
|
|
className="tab-close"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
closeTab(tabId);
|
|
}}
|
|
>
|
|
<Icon name="close" size={12} />
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{/* Show a "New Chat" tab when no tabs are open or activeId has no tab */}
|
|
{(!activeId || !openTabs.includes(activeId)) && (
|
|
<div className={"tab" + (!subTab ? " active" : "")}>
|
|
<span className="tab-title">New Chat</span>
|
|
</div>
|
|
)}
|
|
{/* Virtual tab for an opened subagent run. */}
|
|
{subBlock && (
|
|
<div className="tab subagent-tab active" title="Subagent">
|
|
<span className="tab-icon"><Icon name="task" size={12} /></span>
|
|
<span className="tab-title">{subBlock.input?.description || "Subagent"}</span>
|
|
<span className="tab-close" onClick={(e) => { e.stopPropagation(); setSubTab(null); }}>
|
|
<Icon name="close" size={12} />
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="actions">
|
|
<button className="hicon" title="New Chat" onClick={() => post({ type: "newConversation" })}>
|
|
<Icon name="plus" size={14} />
|
|
</button>
|
|
<button className="hicon" title="History" onClick={() => setHistoryOpen(true)}>
|
|
<Icon name="history" size={14} />
|
|
</button>
|
|
<button className="hicon" title="Settings" onClick={() => post({ type: "openSettings" })}>
|
|
<Icon name="settings" size={14} />
|
|
</button>
|
|
<div className="more-menu-wrap" ref={moreRef}>
|
|
<button className="hicon" title="More" onClick={() => setMoreOpen((v) => !v)}>
|
|
<Icon name="more" size={14} />
|
|
</button>
|
|
{moreOpen && (
|
|
<div className="more-menu">
|
|
<button onClick={() => { setMoreOpen(false); post({ type: "openBrowserTab" }); }}>
|
|
<Icon name="globe" size={13} /> Open Browser Tab
|
|
</button>
|
|
<button
|
|
disabled={!activeId}
|
|
onClick={() => { setMoreOpen(false); post({ type: "exportConversation", convId: activeId }); }}
|
|
>
|
|
<Icon name="download" size={13} /> Export Conversation
|
|
</button>
|
|
<button
|
|
disabled={openTabs.length === 0}
|
|
onClick={() => { setMoreOpen(false); setOpenTabs([]); post({ type: "newConversation" }); }}
|
|
>
|
|
<Icon name="close" size={13} /> Close All Tabs
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{historyOpen && (
|
|
<History
|
|
list={conversations}
|
|
activeId={activeId}
|
|
onSelect={(id) => post({ type: "selectConversation", id })}
|
|
onDelete={(id) => post({ type: "deleteConversation", id })}
|
|
onClose={() => setHistoryOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<div className="chat-messages" ref={scrollRef} onScroll={onScroll}>
|
|
{subBlock ? (
|
|
<SubagentChat block={subBlock} onBack={() => setSubTab(null)} />
|
|
) : !hasProviders ? (
|
|
<div className="setup-screen">
|
|
<img className="app-logo" src={document.getElementById("root")?.dataset.icon} alt="OpenCursor" />
|
|
<div className="setup-title">Set up a provider to start</div>
|
|
<div className="setup-desc">OpenCursor needs an AI provider before you can chat.</div>
|
|
<ol className="setup-steps">
|
|
<li>Open <b>Settings → Providers</b>.</li>
|
|
<li>Add a provider (OpenAI, Anthropic, OpenRouter, Ollama or llama.cpp).</li>
|
|
<li>Enter its base URL and API key, then set it active.</li>
|
|
</ol>
|
|
<button className="setup-btn" onClick={() => post({ type: "openSettings", section: "providers" })}>
|
|
<Icon name="settings" size={14} /> Add a provider
|
|
</button>
|
|
</div>
|
|
) : turns.length === 0 ? (
|
|
<div className="chat-empty">
|
|
<img className="app-logo" src={document.getElementById("root")?.dataset.icon} alt="OpenCursor" />
|
|
<div className="empty-hint">Ask the agent to build or explain something.</div>
|
|
<PersonaSelect
|
|
personas={personas}
|
|
personaId={personaId}
|
|
onSelect={(p) => {
|
|
setPersonaId(p);
|
|
post({ type: "setPersona", personaId: p });
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
// Group each user turn with the assistant turns that follow it, so the
|
|
// sticky "You" header only sticks within its own group and the next
|
|
// user message pushes it up on scroll (instead of overlapping).
|
|
groupTurns(turns).map((group, gi, all) => (
|
|
// Pin-to-top space only on the last group when it isn't the first one.
|
|
<div className="chat-turn-group" key={gi} ref={gi === all.length - 1 && all.length > 1 ? lastGroupRef : undefined}>
|
|
{group.map(({ turn, index }) =>
|
|
turn.role === "user" ? (
|
|
editingIndex === index ? (
|
|
<div className="msg user editing" key={index}>
|
|
<Composer
|
|
editing
|
|
initialText={turn.text}
|
|
initialAttachments={turn.attachments}
|
|
focusKey={`edit-${index}`}
|
|
mode={mode}
|
|
onMode={(m) => {
|
|
setMode(m);
|
|
post({ type: "setMode", mode: m });
|
|
}}
|
|
models={models}
|
|
modelList={modelList}
|
|
selectedModel={selectedModel}
|
|
onSelectModel={(m) => {
|
|
setSelectedModel(m);
|
|
post({ type: "selectModel", model: m });
|
|
}}
|
|
onSaveModelOptions={(modelId, options) => {
|
|
setModelList((prev) => prev.map((m) => (m.id === modelId ? { ...m, options } : m)));
|
|
post({ type: "saveModelOptions", modelId, options });
|
|
}}
|
|
onResetModelOptions={(modelId) => post({ type: "resetModelOptions", modelId })}
|
|
isRunning={isRunning}
|
|
onSubmit={(text, attachments) => requestEditSubmit(index, text, attachments)}
|
|
onCancel={() => setEditingIndex(null)}
|
|
onCancelEdit={() => setEditingIndex(null)}
|
|
submitWithCtrlEnter={uiPrefs.submitWithCtrlEnter}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="msg user" key={index}>
|
|
<button
|
|
className="msg-revert-btn"
|
|
title="Revert to here — returns this message to the composer"
|
|
onClick={(e) => { e.stopPropagation(); requestRevert(index, turn); }}
|
|
>
|
|
<Icon name="reset" size={12} />
|
|
</button>
|
|
<div
|
|
className="bubble"
|
|
onClick={() => startEdit(index, turn)}
|
|
title="Click to edit & resend"
|
|
ref={(el) => { if (el) el.classList.toggle("clamped", el.scrollHeight > el.clientHeight + 1); }}
|
|
>
|
|
{turn.attachments && turn.attachments.length > 0 && (
|
|
<div className="msg-attachments">
|
|
{turn.attachments.map((a) =>
|
|
a.kind === "image" ? (
|
|
<img key={a.id} className="msg-attach-img" src={a.data} alt={a.name} title={a.name} />
|
|
) : (
|
|
<span key={a.id} className="msg-attach-file">
|
|
<Icon name="file" /> {a.name}
|
|
</span>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
{turn.text && <MentionText text={turn.text} />}
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="msg assistant" key={index}>
|
|
<div className="role">
|
|
<Icon name="bot" /> Agent
|
|
</div>
|
|
<div className="bubble">
|
|
{(() => { const items = groupBlocks((turn as AssistantTurn).blocks); const lastTurn = turn === turns[turns.length - 1]; return items.map((b, bi) =>
|
|
b.kind === "explore-group" ? (
|
|
<ExploringSection
|
|
key={bi}
|
|
tools={b.tools}
|
|
live={isRunning && lastTurn && bi === items.length - 1}
|
|
onImplement={onImplement}
|
|
onOpenSubagent={(id) => setSubTab(id)}
|
|
approvals={approvalsByCall}
|
|
/>
|
|
) : b.kind === "text" ? (
|
|
<div className="block-group" key={bi}>
|
|
<Markdown text={b.text} />
|
|
</div>
|
|
) : b.kind === "thinking" ? (
|
|
<ThinkingCard key={bi} block={b} />
|
|
) : b.kind === "error" ? (
|
|
<div className="block-group" key={bi}><ErrorCard block={b} /></div>
|
|
) : b.kind === "compaction" ? (
|
|
<div className="block-group" key={bi}><CompactionCard block={b} /></div>
|
|
) : b.kind === "max-steps" ? (
|
|
<div className="block-group" key={bi}><MaxStepsCard block={b} running={isRunning} /></div>
|
|
) : (
|
|
<div className="block-group" key={bi}>
|
|
<ToolCard block={b} onImplement={onImplement} onOpenSubagent={(id) => setSubTab(id)} />
|
|
{b.callId && approvalsByCall[b.callId] && <ApprovalCard request={approvalsByCall[b.callId]} inline />}
|
|
</div>
|
|
)
|
|
); })()}
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
{/* Live status belongs to the last group so it reads as part of the
|
|
conversation and sits inside the virtual space (no extra scroll). */}
|
|
{gi === all.length - 1 && !subBlock && isRunning && status.text && !status.error && (
|
|
<div className="phase-row">
|
|
<span className="phase-shimmer">{status.text}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="bottom-stack" style={hasProviders && !subBlock ? undefined : { display: "none" }}>
|
|
{!following && turns.length > 0 && (
|
|
<button className="scroll-bottom-btn" title="Scroll to bottom" onClick={scrollToBottom}>
|
|
<Icon name="chevD" size={14} />
|
|
</button>
|
|
)}
|
|
{pendingChanges.length > 0 && (
|
|
<div className="review-bar">
|
|
<div className="review-head">
|
|
<span className="review-title" onClick={() => setReviewOpen((o) => !o)}>
|
|
<Icon name={reviewOpen ? "chevD" : "chevR"} size={12} className="rv-chev" />
|
|
{pendingChanges.length} File{pendingChanges.length > 1 ? "s" : ""}
|
|
</span>
|
|
<div className="review-actions">
|
|
<button className="rv-link" onClick={() => post({ type: "rejectAllChanges" })}>
|
|
Undo All
|
|
</button>
|
|
<button className="rv-link" onClick={() => post({ type: "acceptAllChanges" })}>
|
|
Keep All
|
|
</button>
|
|
{/* <button className="rv-review" onClick={() => setReviewOpen((o) => !o)}>
|
|
Review
|
|
</button> */}
|
|
</div>
|
|
</div>
|
|
{reviewOpen && (
|
|
<div className="review-list">
|
|
{pendingChanges.map((c) => {
|
|
const name = c.path.split(/[\\/]/).pop() || c.path;
|
|
return (
|
|
<div className="review-item" key={c.path}>
|
|
<span className="rv-file" title={c.path} onClick={() => post({ type: "diffChange", path: c.path })}>
|
|
<Icon name="file" size={13} />
|
|
<span className="rv-name">{name}</span>
|
|
{!c.existedBefore && <span className="rv-tag">new</span>}
|
|
<span className="rv-stats">
|
|
{(c.added ?? 0) > 0 && <span className="rv-add">+{c.added}</span>}
|
|
{(c.removed ?? 0) > 0 && <span className="rv-del">-{c.removed}</span>}
|
|
</span>
|
|
</span>
|
|
<span className="rv-item-actions">
|
|
<button className="rv-icon reject" title="Undo" onClick={() => post({ type: "rejectChange", path: c.path })}>
|
|
<Icon name="close" size={13} />
|
|
</button>
|
|
<button className="rv-icon accept" title="Keep" onClick={() => post({ type: "acceptChange", path: c.path })}>
|
|
<Icon name="check" size={13} />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{orphanApprovals.map((r) => (
|
|
<ApprovalCard key={r.requestId} request={r} />
|
|
))}
|
|
{status.error && (
|
|
<div className="status" style={{ padding: "0 14px" }}>
|
|
{status.error ? (
|
|
<span className="error">{status.text}</span>
|
|
) : (
|
|
<span className="status-live">
|
|
{isRunning && <span className="status-spinner" />}
|
|
<span className="status-text">{status.text}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{queued.length > 0 && (
|
|
<div className="queue-bar">
|
|
{queued.map((q, i) => (
|
|
<div className="queue-item" key={i}>
|
|
<Icon name="clock" size={12} />
|
|
<span className="queue-text" title={renderMentionTokens(q.text)}>{renderMentionTokens(q.text)}</span>
|
|
<span className="queue-actions">
|
|
<button className="q-btn" title="Send now (stops current run)" onClick={() => runQueuedNow(i)}>
|
|
<Icon name="play" size={12} />
|
|
</button>
|
|
<button className="q-btn" title="Edit (back to composer)" onClick={() => editQueued(i)}>
|
|
<Icon name="edit" size={12} />
|
|
</button>
|
|
<button className="q-btn" title="Remove" onClick={() => removeQueued(i)}>
|
|
<Icon name="close" size={12} />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{turns.length > 0 && (() => {
|
|
const p = personas.find((x) => x.id === personaId);
|
|
return p ? (
|
|
<div className="persona-badge-bar">
|
|
<span className="persona-badge" title={p.description}>
|
|
<Icon name="agent" size={12} />
|
|
{p.name}
|
|
</span>
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
<Composer
|
|
focusKey={activeId ?? "new"}
|
|
mode={mode}
|
|
onMode={(m) => {
|
|
setMode(m);
|
|
post({ type: "setMode", mode: m });
|
|
}}
|
|
models={models}
|
|
modelList={modelList}
|
|
selectedModel={selectedModel}
|
|
onSelectModel={(m) => {
|
|
setSelectedModel(m);
|
|
post({ type: "selectModel", model: m });
|
|
}}
|
|
onSaveModelOptions={(modelId, options) => {
|
|
setModelList((prev) => prev.map((m) => (m.id === modelId ? { ...m, options } : m)));
|
|
post({ type: "saveModelOptions", modelId, options });
|
|
}}
|
|
onResetModelOptions={(modelId) => post({ type: "resetModelOptions", modelId })}
|
|
isRunning={isRunning}
|
|
isFirst={turns.length === 0}
|
|
usedTokens={active.usedTokens}
|
|
queuedCount={queued.length}
|
|
onRunNextQueued={() => runQueuedNow(0)}
|
|
draft={draft}
|
|
onSubmit={(text, attachments) => { setDraft(null); onSubmit(text, attachments); }}
|
|
onCancel={() => post({ type: "cancelRun", convId: activeId })}
|
|
submitWithCtrlEnter={uiPrefs.submitWithCtrlEnter}
|
|
/>
|
|
</div>
|
|
|
|
{revertPrompt && (
|
|
<div className="modal-overlay" onClick={() => setRevertPrompt(null)}>
|
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-title">Revert file changes?</div>
|
|
<div className="modal-body">
|
|
Resending from an earlier message will remove the messages after it. You have {pendingChanges.length} pending file change{pendingChanges.length > 1 ? "s" : ""} — revert them too?
|
|
</div>
|
|
<div className="modal-actions">
|
|
<button className="btn-ghost" onClick={() => setRevertPrompt(null)}>Cancel</button>
|
|
<button className="btn-ghost" onClick={() => (revertPrompt.restore ? restoreMessage : commitEdit)(revertPrompt.index, revertPrompt.text, revertPrompt.attachments, false)}>Don't revert</button>
|
|
<button className="btn-primary" onClick={() => (revertPrompt.restore ? restoreMessage : commitEdit)(revertPrompt.index, revertPrompt.text, revertPrompt.attachments, true)}>Revert</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|