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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface inside
|
||||
// BooChat's multi-pane workspace.
|
||||
// BooCoder pane — chat + diff inside BooChat's multi-pane workspace.
|
||||
//
|
||||
// Architecture:
|
||||
// - REST calls go through /api/coder/* which BooChat's server proxies to
|
||||
// the boocoder container at http://boocoder:3000/api/*
|
||||
// - WS connects directly to the boocoder container at :9502 (same Tailscale
|
||||
// network, no CORS for WebSocket). In dev, the Vite proxy handles it.
|
||||
// REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502).
|
||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||
import { ProviderPicker } from '@/components/ProviderPicker';
|
||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||
import { PermissionCard } from '@/components/PermissionCard';
|
||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||
import { useSkills } from '@/hooks/useSkills';
|
||||
import { toast } from 'sonner';
|
||||
import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { mergeWireToolCall } from '@/lib/coder-tools';
|
||||
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,16 +27,26 @@ interface CoderMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
reasoning_text?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
tool_results?: {
|
||||
}
|
||||
|
||||
interface CoderToolMessage {
|
||||
id: string;
|
||||
role: 'tool';
|
||||
tool_results: {
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
|
||||
|
||||
interface PendingChange {
|
||||
id: string;
|
||||
file_path: string;
|
||||
@@ -43,24 +58,106 @@ interface PendingChange {
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
chatId?: string;
|
||||
chatPending?: boolean;
|
||||
projectPath?: string;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
interface WsHandlers {
|
||||
onPermissionRequested?: (prompt: PermissionPrompt) => void;
|
||||
onPermissionResolved?: (taskId: string) => void;
|
||||
onAssistantComplete?: () => void;
|
||||
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
function useCoderMessages(sessionId: string) {
|
||||
const [messages, setMessages] = useState<CoderMessage[]>([]);
|
||||
type RawCoderMessage = {
|
||||
id: string;
|
||||
role: string;
|
||||
chat_id?: string;
|
||||
content?: string | null;
|
||||
status?: string | null;
|
||||
reasoning_text?: string;
|
||||
reasoning_parts?: Array<{ text?: string }> | null;
|
||||
tool_results?: {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
} | null;
|
||||
tool_calls?: Array<
|
||||
| { id: string; name: string; args?: Record<string, unknown> }
|
||||
| { id: string; function: { name: string; arguments: string } }
|
||||
> | null;
|
||||
};
|
||||
|
||||
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
||||
if (raw.role === 'tool') {
|
||||
if (!raw.tool_results?.tool_call_id) return null;
|
||||
return {
|
||||
id: raw.id,
|
||||
role: 'tool',
|
||||
tool_results: raw.tool_results,
|
||||
};
|
||||
}
|
||||
if (raw.role !== 'user' && raw.role !== 'assistant' && raw.role !== 'system') return null;
|
||||
const tool_calls = raw.tool_calls?.map((tc) => {
|
||||
if ('function' in tc) {
|
||||
return { id: tc.id, function: tc.function };
|
||||
}
|
||||
return {
|
||||
id: tc.id,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.args ?? {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const reasoning_text =
|
||||
raw.reasoning_text ??
|
||||
raw.reasoning_parts?.map((p) => p.text ?? '').join('') ??
|
||||
'';
|
||||
return {
|
||||
id: raw.id,
|
||||
role: raw.role as CoderMessage['role'],
|
||||
content: raw.content ?? '',
|
||||
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
||||
...(reasoning_text ? { reasoning_text } : {}),
|
||||
...(tool_calls?.length ? { tool_calls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function useCoderMessages(sessionId: string, chatId: string | undefined, handlers: WsHandlers) {
|
||||
const [messages, setMessages] = useState<CoderTimelineMessage[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
const chatIdRef = useRef(chatId);
|
||||
chatIdRef.current = chatId;
|
||||
|
||||
const loadMessages = useCallback(() => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return api.coder
|
||||
.listMessages(sessionId, chatId)
|
||||
.then((rows) =>
|
||||
setMessages(
|
||||
rows
|
||||
.map(mapCoderTimelineRow)
|
||||
.filter((m): m is CoderTimelineMessage => m !== null),
|
||||
),
|
||||
)
|
||||
.catch(() => {/* boocoder may be down */});
|
||||
}, [sessionId, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch existing messages on mount
|
||||
fetch(`/api/coder/sessions/${sessionId}/messages`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: CoderMessage[]) => setMessages(data))
|
||||
.catch(() => {/* noop — coder backend may not be running */});
|
||||
}, [sessionId]);
|
||||
void loadMessages();
|
||||
}, [loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
// WS connects to the coder backend. In production, this goes through the
|
||||
@@ -77,38 +174,137 @@ function useCoderMessages(sessionId: string) {
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const frame = JSON.parse(ev.data as string);
|
||||
if (frame.type === 'message_started') {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: frame.message_id, role: frame.role ?? 'assistant', content: '', status: 'streaming' },
|
||||
]);
|
||||
const scopedChatId = chatIdRef.current;
|
||||
if (
|
||||
scopedChatId &&
|
||||
frame.chat_id &&
|
||||
frame.chat_id !== scopedChatId &&
|
||||
frame.type !== 'snapshot'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'snapshot' && Array.isArray(frame.messages)) {
|
||||
const rawMessages = (frame.messages as RawCoderMessage[]).filter(
|
||||
(m) => !scopedChatId || m.chat_id === scopedChatId,
|
||||
);
|
||||
setMessages(
|
||||
rawMessages
|
||||
.map(mapCoderTimelineRow)
|
||||
.filter((m): m is CoderTimelineMessage => m !== null),
|
||||
);
|
||||
} else if (frame.type === 'message_started') {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === frame.message_id)) return prev;
|
||||
const role = frame.role ?? 'assistant';
|
||||
const tempIdx =
|
||||
role === 'user'
|
||||
? prev.findIndex((m) => m.id.startsWith('temp-') && m.role === 'user')
|
||||
: -1;
|
||||
if (tempIdx >= 0) {
|
||||
return prev.map((m, i) =>
|
||||
i === tempIdx ? { ...m, id: frame.message_id, status: 'streaming' } : m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: frame.message_id, role, content: '', status: 'streaming' },
|
||||
];
|
||||
});
|
||||
} else if (frame.type === 'delta') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? { ...m, content: m.content + (frame.content ?? '') }
|
||||
: m
|
||||
)
|
||||
prev.map((m) => {
|
||||
if (m.id !== frame.message_id || m.role === 'tool') return m;
|
||||
const chunk = frame.content ?? '';
|
||||
if (m.role === 'user') {
|
||||
return { ...m, content: chunk || m.content };
|
||||
}
|
||||
return { ...m, content: m.content + chunk };
|
||||
}),
|
||||
);
|
||||
} else if (frame.type === 'message_complete') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id ? { ...m, status: 'complete' } : m
|
||||
)
|
||||
);
|
||||
setMessages((prev) => {
|
||||
const completed = prev.find(
|
||||
(m): m is CoderMessage => m.id === frame.message_id && m.role === 'assistant',
|
||||
);
|
||||
const next = prev.map((m) =>
|
||||
m.id === frame.message_id && m.role !== 'tool'
|
||||
? { ...m, status: 'complete' as const }
|
||||
: m,
|
||||
);
|
||||
if (completed) {
|
||||
queueMicrotask(() => handlersRef.current.onAssistantComplete?.());
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else if (frame.type === 'tool_call') {
|
||||
const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> } | undefined;
|
||||
if (tc?.id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role !== 'assistant' || m.id !== frame.message_id
|
||||
? m
|
||||
: { ...m, tool_calls: mergeWireToolCall(m.tool_calls, { ...tc, args: tc.args ?? {} }) },
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (frame.type === 'tool_result') {
|
||||
setMessages((prev) => {
|
||||
const exists = prev.some((m) => m.id === frame.tool_message_id);
|
||||
if (exists) {
|
||||
return prev.map((m) =>
|
||||
m.role === 'tool' && m.id === frame.tool_message_id
|
||||
? {
|
||||
...m,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
}
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: frame.tool_message_id,
|
||||
role: 'tool' as const,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
} else if (frame.type === 'reasoning_delta') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? {
|
||||
...m,
|
||||
tool_calls: [
|
||||
...(m.tool_calls ?? []),
|
||||
{ id: frame.tool_call_id, function: { name: frame.name, arguments: frame.arguments ?? '' } },
|
||||
],
|
||||
}
|
||||
: m
|
||||
)
|
||||
m.id === frame.message_id && m.role === 'assistant'
|
||||
? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} else if (frame.type === 'permission_requested') {
|
||||
handlersRef.current.onPermissionRequested?.({
|
||||
taskId: frame.task_id,
|
||||
toolTitle: frame.tool_title,
|
||||
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
|
||||
optionId: o.option_id,
|
||||
label: o.label,
|
||||
})),
|
||||
});
|
||||
} else if (frame.type === 'permission_resolved') {
|
||||
handlersRef.current.onPermissionResolved?.(frame.task_id);
|
||||
} else if (frame.type === 'agent_commands') {
|
||||
handlersRef.current.onAgentCommands?.(
|
||||
frame.task_id,
|
||||
(frame.commands ?? []).map((c: { name: string; description?: string }) => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -122,7 +318,11 @@ function useCoderMessages(sessionId: string) {
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return { messages, setMessages, connected };
|
||||
useEffect(() => {
|
||||
handlersRef.current.onConnectedChange?.(connected);
|
||||
}, [connected]);
|
||||
|
||||
return { messages, setMessages, connected, loadMessages };
|
||||
}
|
||||
|
||||
function usePendingChanges(sessionId: string) {
|
||||
@@ -165,48 +365,6 @@ function usePendingChanges(sessionId: string) {
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CoderMessageBubble({ message }: { message: CoderMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1 px-3 py-2', isUser ? 'items-end' : 'items-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 max-w-[85%] text-sm',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownRenderer content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||
<div className="mt-2 border-t border-border/50 pt-2 space-y-1">
|
||||
{message.tool_calls.map((tc) => (
|
||||
<div key={tc.id} className="text-xs font-mono text-muted-foreground">
|
||||
<span className="text-primary/70">{tc.function.name}</span>
|
||||
{tc.function.arguments && (
|
||||
<span className="ml-1 opacity-60">
|
||||
({tc.function.arguments.slice(0, 80)}
|
||||
{tc.function.arguments.length > 80 ? '...' : ''})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.status === 'streaming' && (
|
||||
<span className="inline-block w-2 h-4 bg-current opacity-60 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffPanel({
|
||||
changes,
|
||||
loading,
|
||||
@@ -296,115 +454,272 @@ function DiffPanel({
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CoderPane({ sessionId }: Props) {
|
||||
const { messages, setMessages, connected } = useCoderMessages(sessionId);
|
||||
export function CoderPane({
|
||||
sessionId,
|
||||
paneId,
|
||||
chatId,
|
||||
chatPending = false,
|
||||
projectPath,
|
||||
onConnectedChange,
|
||||
}: Props) {
|
||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||
provider: 'boocode',
|
||||
model: '',
|
||||
modeId: null,
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||
const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
|
||||
const [liveTaskCommands, setLiveTaskCommands] = useState<AgentCommand[]>([]);
|
||||
const { skills } = useSkills();
|
||||
const [slashState, setSlashState] = useState<{ query: string } | null>(null);
|
||||
|
||||
const displayedCommands = useMemo(() => {
|
||||
const base =
|
||||
agentConfig.provider === 'boocode'
|
||||
? skills.map((s) => ({ name: s.name, description: s.description }))
|
||||
: providerCommands;
|
||||
return mergeCommandsByName(base, liveTaskCommands);
|
||||
}, [agentConfig.provider, skills, providerCommands, liveTaskCommands]);
|
||||
|
||||
const skillsByName = useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
|
||||
const commandsByName = useMemo(
|
||||
() => new Set(displayedCommands.map((c) => c.name)),
|
||||
[displayedCommands],
|
||||
);
|
||||
|
||||
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||
onConnectedChange,
|
||||
onPermissionRequested: (prompt) => {
|
||||
setActiveTaskId(prompt.taskId);
|
||||
setPermissionPrompt(prompt);
|
||||
},
|
||||
onPermissionResolved: (taskId) => {
|
||||
if (activeTaskId === taskId || permissionPrompt?.taskId === taskId) {
|
||||
setPermissionPrompt(null);
|
||||
}
|
||||
},
|
||||
onAssistantComplete: () => {
|
||||
setActiveTaskId(null);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
},
|
||||
onAgentCommands: (_taskId, commands) => {
|
||||
setLiveTaskCommands(commands);
|
||||
},
|
||||
});
|
||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [provider, setProvider] = useState('boocode');
|
||||
const [model, setModel] = useState('qwen3.6-35b-a3b-mxfp4');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Refresh pending changes when a message_complete arrives
|
||||
useEffect(() => {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role === 'assistant' && lastMsg.status === 'complete') {
|
||||
const lastAssistant = [...messages].reverse().find(
|
||||
(m): m is CoderMessage => m.role === 'assistant',
|
||||
);
|
||||
if (lastAssistant?.status === 'complete') {
|
||||
refresh();
|
||||
}
|
||||
}, [messages, refresh]);
|
||||
|
||||
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
|
||||
useEffect(() => {
|
||||
if (!activeTaskId || connected) return;
|
||||
const interval = setInterval(() => {
|
||||
if (!permissionPrompt) {
|
||||
void api.coder
|
||||
.getTaskPermission(activeTaskId)
|
||||
.then((prompt) => {
|
||||
setPermissionPrompt({
|
||||
taskId: prompt.taskId,
|
||||
toolTitle: prompt.toolTitle,
|
||||
options: prompt.options,
|
||||
});
|
||||
})
|
||||
.catch(() => {/* no pending permission */});
|
||||
}
|
||||
void api.coder
|
||||
.getTaskCommands(activeTaskId)
|
||||
.then((res) => setLiveTaskCommands(res.commands))
|
||||
.catch(() => {/* not cached yet */});
|
||||
void api.coder
|
||||
.getTask(activeTaskId)
|
||||
.then((task) => {
|
||||
if (task.state === 'running' || task.state === 'pending' || task.state === 'blocked') {
|
||||
return;
|
||||
}
|
||||
setActiveTaskId(null);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
void loadMessages();
|
||||
})
|
||||
.catch(() => {/* task gone */});
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeTaskId, connected, permissionPrompt, loadMessages]);
|
||||
|
||||
const handleProviderCommandsChange = useCallback((commands: AgentCommand[]) => {
|
||||
setProviderCommands(commands);
|
||||
}, []);
|
||||
|
||||
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
|
||||
if (!permissionPrompt) return;
|
||||
setPermissionBusy(true);
|
||||
try {
|
||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
|
||||
setPermissionPrompt(null);
|
||||
} finally {
|
||||
setPermissionBusy(false);
|
||||
}
|
||||
}, [permissionPrompt]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
if (!text || sending || !chatId) return;
|
||||
|
||||
if (text.startsWith('/')) {
|
||||
const parsed = parseSlashInput(text);
|
||||
if (parsed) {
|
||||
const { cmdName, args } = parsed;
|
||||
if (agentConfig.provider === 'boocode' && skillsByName.has(cmdName)) {
|
||||
setInput('');
|
||||
setSlashState(null);
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
try {
|
||||
await api.coder.skillInvoke(
|
||||
sessionId,
|
||||
paneId,
|
||||
cmdName,
|
||||
args.length > 0 ? args : null,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!commandsByName.has(cmdName)) {
|
||||
// Unknown slash — fall through and send as literal text.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInput('');
|
||||
setSlashState(null);
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
|
||||
// Optimistic user message
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
provider: provider !== 'boocode' ? provider : undefined,
|
||||
model: model || undefined,
|
||||
}),
|
||||
const data = await api.coder.sendMessage(sessionId, {
|
||||
content: text,
|
||||
pane_id: paneId,
|
||||
chat_id: chatId,
|
||||
provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
|
||||
model: agentConfig.model || undefined,
|
||||
mode_id: agentConfig.modeId ?? undefined,
|
||||
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Replace temp message with real one if server returned it
|
||||
if (data.user_message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => m.id === tempId ? { ...m, id: data.user_message_id } : m)
|
||||
);
|
||||
}
|
||||
if (data.user_message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// The WS will bring the real messages; optimistic is good enough
|
||||
if (data.task_id) {
|
||||
setActiveTaskId(data.task_id);
|
||||
} else {
|
||||
setActiveTaskId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to send');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [input, sending, sessionId, provider, model, setMessages]);
|
||||
}, [
|
||||
input,
|
||||
sending,
|
||||
sessionId,
|
||||
paneId,
|
||||
chatId,
|
||||
agentConfig,
|
||||
skillsByName,
|
||||
commandsByName,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
const handleSlashSelect = useCallback((name: string) => {
|
||||
const next = `/${name} `;
|
||||
setInput(next);
|
||||
setSlashState(null);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = inputRef.current;
|
||||
if (ta) {
|
||||
ta.selectionStart = ta.selectionEnd = next.length;
|
||||
ta.focus();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInput(newValue);
|
||||
if (isSlashCommandToken(newValue)) {
|
||||
setSlashState({ query: slashQuery(newValue) });
|
||||
} else {
|
||||
setSlashState(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (slashState) return;
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
[handleSend, slashState]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0">
|
||||
<Code size={14} className="text-muted-foreground shrink-0" />
|
||||
<ProviderPicker
|
||||
provider={provider}
|
||||
model={model}
|
||||
onChange={(prov, mod) => {
|
||||
setProvider(prov);
|
||||
setModel(mod);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full ml-auto shrink-0',
|
||||
connected ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-sm text-muted-foreground gap-2">
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-sm text-muted-foreground gap-2">
|
||||
<Code size={32} className="opacity-40" />
|
||||
<p>Send a message to start coding</p>
|
||||
<p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{messages.map((msg) => (
|
||||
<CoderMessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<CoderMessageList
|
||||
messages={messages as CoderTimelineWire[]}
|
||||
footer={
|
||||
activeTaskId && !permissionPrompt && sending === false ? (
|
||||
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{permissionPrompt && (
|
||||
<PermissionCard
|
||||
prompt={permissionPrompt}
|
||||
onRespond={(id) => void handlePermissionRespond(id)}
|
||||
busy={permissionBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Diff panel — only shows when there are pending changes */}
|
||||
{changes.filter((c) => c.status === 'pending').length > 0 && (
|
||||
<div className="h-48 shrink-0">
|
||||
@@ -418,28 +733,46 @@ export function CoderPane({ sessionId }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 border-t border-border p-2">
|
||||
{/* Composer + input */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
|
||||
<AgentComposerBar
|
||||
projectPath={projectPath}
|
||||
value={agentConfig}
|
||||
onChange={setAgentConfig}
|
||||
onProviderCommandsChange={handleProviderCommandsChange}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask BooCoder to write code..."
|
||||
placeholder="Type / for commands…"
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!input.trim() || sending}
|
||||
disabled={!input.trim() || sending || !chatId || chatPending}
|
||||
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{slashState && (
|
||||
<SlashCommandPicker
|
||||
query={slashState.query}
|
||||
items={displayedCommands}
|
||||
inputRef={inputRef}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user