web(coder UI): ChatInput migration + Thinking render + DiffPanel route fix
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>
This commit is contained in:
@@ -4,11 +4,10 @@
|
||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { Code, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||
import { PermissionCard } from '@/components/PermissionCard';
|
||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||
import { useSkills } from '@/hooks/useSkills';
|
||||
@@ -32,6 +31,8 @@ interface CoderMessage {
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
ctx_used?: number | null;
|
||||
ctx_max?: number | null;
|
||||
}
|
||||
|
||||
interface CoderToolMessage {
|
||||
@@ -63,6 +64,7 @@ interface Props {
|
||||
chatPending?: boolean;
|
||||
projectPath?: string;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
onAgentLabelChange?: (label: string) => void;
|
||||
}
|
||||
|
||||
interface WsHandlers {
|
||||
@@ -91,6 +93,8 @@ type RawCoderMessage = {
|
||||
| { 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 {
|
||||
@@ -126,6 +130,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,7 +234,12 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
);
|
||||
const next = prev.map((m) =>
|
||||
m.id === frame.message_id && m.role !== 'tool'
|
||||
? { ...m, status: 'complete' as const }
|
||||
? {
|
||||
...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) {
|
||||
@@ -343,7 +354,7 @@ function usePendingChanges(sessionId: string) {
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const approve = useCallback(async (changeId: string) => {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, {
|
||||
const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -352,7 +363,7 @@ function usePendingChanges(sessionId: string) {
|
||||
}, [sessionId]);
|
||||
|
||||
const reject = useCallback(async (changeId: string) => {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, {
|
||||
const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -463,6 +474,7 @@ export function CoderPane({
|
||||
chatPending = false,
|
||||
projectPath,
|
||||
onConnectedChange,
|
||||
onAgentLabelChange,
|
||||
}: Props) {
|
||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||
provider: 'boocode',
|
||||
@@ -470,6 +482,12 @@ export function CoderPane({
|
||||
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);
|
||||
@@ -515,6 +533,8 @@ export function CoderPane({
|
||||
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
|
||||
@@ -658,43 +678,87 @@ export function CoderPane({
|
||||
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 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]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (slashState) return;
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
[handleSend, slashState]
|
||||
);
|
||||
}
|
||||
}, [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 ? (
|
||||
@@ -706,6 +770,9 @@ export function CoderPane({
|
||||
<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>
|
||||
@@ -738,44 +805,16 @@ export function CoderPane({
|
||||
|
||||
{/* 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}
|
||||
<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 className="p-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
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 || !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