v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes

Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch
rewrite with streaming/persist, permission prompts, and agent commands.
Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline,
WS user-delta replace, and inference orphan tool_call stripping.
Archive openspec v2-2; update CHANGELOG and CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 15:18:31 +00:00
parent 04673eaf59
commit 93d3f86c2b
96 changed files with 6694 additions and 1329 deletions

View File

@@ -0,0 +1,228 @@
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { ToolCallGroup } from '@/components/ToolCallGroup';
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
export interface CoderMessageWire {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
reasoning_text?: string;
tool_calls?: CoderToolCallWire[];
}
export interface CoderToolMessageWire {
id: string;
role: 'tool';
tool_results: {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: string;
};
}
export type CoderTimelineWire = CoderMessageWire | CoderToolMessageWire;
function isToolMessage(m: CoderTimelineWire): m is CoderToolMessageWire {
return m.role === 'tool';
}
type RenderItem =
| { kind: 'message'; message: CoderMessageWire }
| { kind: 'tool_run'; run: ToolRun; key: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
const SCROLL_THRESHOLD_PX = 150;
function flattenCoderMessages(messages: CoderTimelineWire[]): RenderItem[] {
const items: RenderItem[] = [];
const runsByCallId = new Map<string, ToolRun>();
for (const m of messages) {
if (isToolMessage(m)) {
const run = runsByCallId.get(m.tool_results.tool_call_id);
if (run) {
run.result = {
tool_call_id: m.tool_results.tool_call_id,
output: m.tool_results.output,
truncated: m.tool_results.truncated ?? false,
...(m.tool_results.error ? { error: m.tool_results.error } : {}),
};
}
continue;
}
if (m.role === 'user' || m.role === 'system') {
items.push({ kind: 'message', message: m });
continue;
}
const hasToolCalls = (m.tool_calls?.length ?? 0) > 0;
const hasText = m.content.trim().length > 0;
const hasReasoning = (m.reasoning_text?.trim().length ?? 0) > 0;
// External agents persist tool calls + final answer on one row. Render tools
// before the answer text so the timeline matches BooChat (tools, then reply).
const externalCombined = hasToolCalls && (hasText || hasReasoning);
if (externalCombined) {
if (hasReasoning) {
items.push({
kind: 'message',
message: { ...m, content: '', reasoning_text: m.reasoning_text },
});
}
for (const tc of m.tool_calls!) {
const run = wireToolCallToRun(tc);
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
}
if (hasText || m.status === 'streaming') {
items.push({
kind: 'message',
message: { ...m, reasoning_text: undefined },
});
}
continue;
}
// Native inference: separate assistant rows per step — mirror MessageList.
if (hasText || hasReasoning || m.status === 'streaming') {
items.push({ kind: 'message', message: m });
}
if (hasToolCalls) {
for (const tc of m.tool_calls!) {
const run = wireToolCallToRun(tc);
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
}
}
}
return items;
}
function groupToolRuns(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
while (i < items.length) {
const item = items[i]!;
if (item.kind !== 'tool_run') {
out.push(item);
i += 1;
continue;
}
const name = item.run.call.name;
let j = i + 1;
while (
j < items.length &&
items[j]!.kind === 'tool_run' &&
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
) {
j += 1;
}
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
if (run.length >= GROUP_THRESHOLD) {
out.push({ kind: 'tool_group', runs: run.map((r) => r.run), key: `group-${run[0]!.key}` });
} else {
for (const r of run) out.push(r);
}
i = j;
}
return out;
}
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const hasText = message.content.trim().length > 0;
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
if (isUser) {
return (
<div className="flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{hasReasoning && (
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
{message.reasoning_text}
</pre>
</details>
)}
{(hasText || (isStreaming && !hasReasoning)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasText ? <MarkdownRenderer content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
)}
{message.status === 'failed' && (
<div className="text-xs text-destructive">message failed</div>
)}
</div>
);
}
interface Props {
messages: CoderTimelineWire[];
footer?: ReactNode;
}
export function CoderMessageList({ messages, footer }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const renderItems = useMemo(
() => groupToolRuns(flattenCoderMessages(messages)),
[messages],
);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
}, []);
useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
}
}, [messages]);
if (messages.length === 0) {
return null;
}
return (
<div className="flex-1 overflow-y-auto" ref={scrollRef} onScroll={handleScroll}>
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => {
if (item.kind === 'message') {
return <CoderTextBubble key={item.message.id} message={item.message} />;
}
if (item.kind === 'tool_run') {
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
{footer}
<div ref={endRef} />
</div>
</div>
);
}