Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
822 lines
28 KiB
TypeScript
822 lines
28 KiB
TypeScript
// BooCoder pane — chat + diff inside BooChat's multi-pane workspace.
|
|
//
|
|
// 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, useMemo, useRef, useState } from 'react';
|
|
import { Code, Check, X, RefreshCw } from 'lucide-react';
|
|
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
|
import { PermissionCard } from '@/components/PermissionCard';
|
|
import { ChatInput } from '@/components/ChatInput';
|
|
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';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CoderMessage {
|
|
id: string;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
status?: 'streaming' | 'complete' | 'failed';
|
|
reasoning_text?: string;
|
|
tool_calls?: Array<{
|
|
id: string;
|
|
function: { name: string; arguments: string };
|
|
}>;
|
|
ctx_used?: number | null;
|
|
ctx_max?: number | null;
|
|
}
|
|
|
|
interface CoderToolMessage {
|
|
id: string;
|
|
role: 'tool';
|
|
tool_results: {
|
|
tool_call_id: string;
|
|
output: unknown;
|
|
truncated?: boolean;
|
|
error?: string;
|
|
};
|
|
}
|
|
|
|
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
|
|
|
|
interface PendingChange {
|
|
id: string;
|
|
file_path: string;
|
|
operation: 'create' | 'modify' | 'delete';
|
|
diff?: string;
|
|
new_content?: string;
|
|
status: 'pending' | 'approved' | 'rejected';
|
|
}
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
paneId: string;
|
|
chatId?: string;
|
|
chatPending?: boolean;
|
|
projectPath?: string;
|
|
onConnectedChange?: (connected: boolean) => void;
|
|
onAgentLabelChange?: (label: string) => void;
|
|
}
|
|
|
|
interface WsHandlers {
|
|
onPermissionRequested?: (prompt: PermissionPrompt) => void;
|
|
onPermissionResolved?: (taskId: string) => void;
|
|
onAssistantComplete?: () => void;
|
|
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
|
onConnectedChange?: (connected: boolean) => void;
|
|
}
|
|
|
|
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;
|
|
ctx_used?: number | null;
|
|
ctx_max?: number | 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 } : {}),
|
|
ctx_used: raw.ctx_used ?? null,
|
|
ctx_max: raw.ctx_max ?? null,
|
|
};
|
|
}
|
|
|
|
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(() => {
|
|
void loadMessages();
|
|
}, [loadMessages]);
|
|
|
|
useEffect(() => {
|
|
// WS connects to the coder backend. In production, this goes through the
|
|
// same host (BooChat serves the SPA and proxies). In dev, Vite proxy
|
|
// handles /api/coder/ws/* -> boocoder:9502.
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${proto}//${window.location.host}/api/coder/ws/sessions/${sessionId}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => setConnected(true);
|
|
ws.onclose = () => setConnected(false);
|
|
|
|
ws.onmessage = (ev) => {
|
|
try {
|
|
const frame = JSON.parse(ev.data as string);
|
|
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) => {
|
|
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) => {
|
|
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,
|
|
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
|
|
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
|
|
}
|
|
: 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.role === 'assistant'
|
|
? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') }
|
|
: m,
|
|
),
|
|
);
|
|
} else if (frame.type === 'permission_requested') {
|
|
handlersRef.current.onPermissionRequested?.({
|
|
taskId: frame.task_id,
|
|
kind: frame.kind,
|
|
toolTitle: frame.tool_title,
|
|
...(frame.input ? { input: frame.input as Record<string, unknown> } : {}),
|
|
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 {
|
|
// ignore unparseable frames
|
|
}
|
|
};
|
|
|
|
return () => {
|
|
ws.close();
|
|
wsRef.current = null;
|
|
};
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
handlersRef.current.onConnectedChange?.(connected);
|
|
}, [connected]);
|
|
|
|
return { messages, setMessages, connected, loadMessages };
|
|
}
|
|
|
|
function usePendingChanges(sessionId: string) {
|
|
const [changes, setChanges] = useState<PendingChange[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const refresh = useCallback(() => {
|
|
setLoading(true);
|
|
fetch(`/api/coder/sessions/${sessionId}/pending`)
|
|
.then((res) => res.ok ? res.json() : [])
|
|
.then((data: PendingChange[]) => setChanges(data))
|
|
.catch(() => {/* noop */})
|
|
.finally(() => setLoading(false));
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => { refresh(); }, [refresh]);
|
|
|
|
const approve = useCallback(async (changeId: string) => {
|
|
const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
|
|
method: 'POST',
|
|
});
|
|
if (res.ok) {
|
|
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'approved' } : c));
|
|
}
|
|
}, [sessionId]);
|
|
|
|
const reject = useCallback(async (changeId: string) => {
|
|
const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
|
|
method: 'POST',
|
|
});
|
|
if (res.ok) {
|
|
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'rejected' } : c));
|
|
}
|
|
}, [sessionId]);
|
|
|
|
return { changes, loading, refresh, approve, reject };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DiffPanel({
|
|
changes,
|
|
loading,
|
|
onRefresh,
|
|
onApprove,
|
|
onReject,
|
|
}: {
|
|
changes: PendingChange[];
|
|
loading: boolean;
|
|
onRefresh: () => void;
|
|
onApprove: (id: string) => void;
|
|
onReject: (id: string) => void;
|
|
}) {
|
|
const pending = changes.filter((c) => c.status === 'pending');
|
|
|
|
return (
|
|
<div className="flex flex-col h-full border-t border-border">
|
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Pending Changes {pending.length > 0 && `(${pending.length})`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onRefresh}
|
|
disabled={loading}
|
|
className="inline-flex items-center justify-center size-6 rounded hover:bg-muted text-muted-foreground"
|
|
aria-label="Refresh pending changes"
|
|
>
|
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{pending.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
|
No pending changes
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{pending.map((change) => (
|
|
<div key={change.id} className="px-3 py-2">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-xs font-mono text-foreground truncate flex-1 mr-2">
|
|
<span className={cn(
|
|
'inline-block w-1.5 h-1.5 rounded-full mr-1.5',
|
|
change.operation === 'create' && 'bg-green-500',
|
|
change.operation === 'modify' && 'bg-yellow-500',
|
|
change.operation === 'delete' && 'bg-red-500',
|
|
)} />
|
|
{change.file_path}
|
|
</span>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => onApprove(change.id)}
|
|
className="inline-flex items-center justify-center size-6 rounded bg-green-500/10 hover:bg-green-500/20 text-green-600 dark:text-green-400"
|
|
aria-label="Approve change"
|
|
title="Approve"
|
|
>
|
|
<Check size={12} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onReject(change.id)}
|
|
className="inline-flex items-center justify-center size-6 rounded bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400"
|
|
aria-label="Reject change"
|
|
title="Reject"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{change.diff && (
|
|
<pre className="text-[11px] font-mono bg-muted/50 rounded p-2 overflow-x-auto max-h-32 whitespace-pre">
|
|
{change.diff}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function CoderPane({
|
|
sessionId,
|
|
paneId,
|
|
chatId,
|
|
chatPending = false,
|
|
projectPath,
|
|
onConnectedChange,
|
|
onAgentLabelChange,
|
|
}: Props) {
|
|
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
|
provider: 'boocode',
|
|
model: '',
|
|
modeId: null,
|
|
thinkingOptionId: null,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const parts = [agentConfig.provider || 'boocode'];
|
|
if (agentConfig.model) parts.push(agentConfig.model);
|
|
onAgentLabelChange?.(parts.join(' · '));
|
|
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
|
|
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 [queue, setQueue] = useState<string[]>([]);
|
|
const queueProcessing = useRef(false);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// Refresh pending changes when a message_complete arrives
|
|
useEffect(() => {
|
|
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, updatedInput?: Record<string, unknown>) => {
|
|
if (!permissionPrompt) return;
|
|
setPermissionBusy(true);
|
|
try {
|
|
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput);
|
|
setPermissionPrompt(null);
|
|
} finally {
|
|
setPermissionBusy(false);
|
|
}
|
|
}, [permissionPrompt]);
|
|
|
|
const handleSend = useCallback(async () => {
|
|
const text = input.trim();
|
|
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([]);
|
|
|
|
const tempId = `temp-${Date.now()}`;
|
|
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
|
|
|
|
try {
|
|
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 (data.user_message_id) {
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
|
);
|
|
}
|
|
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,
|
|
paneId,
|
|
chatId,
|
|
agentConfig,
|
|
skillsByName,
|
|
commandsByName,
|
|
setMessages,
|
|
]);
|
|
|
|
|
|
const sendOneMessage = useCallback(async (text: string) => {
|
|
if (!chatId) return;
|
|
setSending(true);
|
|
setPermissionPrompt(null);
|
|
setLiveTaskCommands([]);
|
|
|
|
const tempId = `temp-${Date.now()}`;
|
|
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
|
|
|
|
try {
|
|
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 (data.user_message_id) {
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
|
|
|
// Drain queue when not busy
|
|
useEffect(() => {
|
|
if (sending || queue.length === 0 || queueProcessing.current) return;
|
|
queueProcessing.current = true;
|
|
const next = queue[0]!;
|
|
setQueue((prev) => prev.slice(1));
|
|
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
|
}, [sending, queue, sendOneMessage]);
|
|
|
|
const handleChatInputSend = useCallback(async (content: string) => {
|
|
const text = content.trim();
|
|
if (!text || !chatId) return;
|
|
if (sending) {
|
|
setQueue((prev) => [...prev, text]);
|
|
return;
|
|
}
|
|
await sendOneMessage(text);
|
|
}, [sending, chatId, sendOneMessage]);
|
|
|
|
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
|
if (!chatId) return;
|
|
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
|
|
setSending(true);
|
|
setPermissionPrompt(null);
|
|
setLiveTaskCommands([]);
|
|
try {
|
|
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-background">
|
|
<AgentComposerBar
|
|
projectPath={projectPath}
|
|
value={agentConfig}
|
|
onChange={setAgentConfig}
|
|
onProviderCommandsChange={handleProviderCommandsChange}
|
|
connected={connected}
|
|
/>
|
|
{/* 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 flex-1 text-sm text-muted-foreground gap-2">
|
|
<Code size={32} className="opacity-40" />
|
|
<p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
|
|
</div>
|
|
) : (
|
|
<CoderMessageList
|
|
messages={messages as CoderTimelineWire[]}
|
|
chatId={chatId}
|
|
actions={{
|
|
onResend: async (_chatId, content) => { await sendOneMessage(content); },
|
|
}}
|
|
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, input) => void handlePermissionRespond(id, input)}
|
|
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">
|
|
<DiffPanel
|
|
changes={changes}
|
|
loading={loading}
|
|
onRefresh={refresh}
|
|
onApprove={approve}
|
|
onReject={reject}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Composer + input */}
|
|
<div className="shrink-0 border-t border-border">
|
|
<ChatInput
|
|
disabled={sending || !chatId || chatPending}
|
|
projectId={projectPath ?? ''}
|
|
onSend={handleChatInputSend}
|
|
onSlashCommand={handleChatInputSlash}
|
|
chatId={chatId ?? undefined}
|
|
chatLabel="BooCode"
|
|
messages={messages as unknown as import('@/api/types').Message[]}
|
|
modelContextLimit={null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|