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:
228
apps/web/src/components/panes/CoderMessageList.tsx
Normal file
228
apps/web/src/components/panes/CoderMessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user