agent's progress on ChatTurn

This commit is contained in:
Rob Colbert 2026-05-05 13:18:15 -04:00
parent 3a8f2e4f44
commit 09ebacc711

View File

@ -1,27 +1,14 @@
import { useState, useEffect, useRef, useContext } from 'react';
import { useState, useEffect, useRef, useContext, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { socketClient } from '../lib/socket';
import { chatSessionApi, projectApi, type ChatSession, type ChatTurn, type Project } from '../lib/api';
import { chatSessionApi, projectApi, type ChatSession, type ChatTurn } from '../lib/api';
import { WorkspaceMode } from '../lib/types';
import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
import FilesPanel from '../components/FilesPanel';
import LogPanel from '../components/LogPanel';
import ChatTurnComponent from '../components/ChatTurn';
import { AppContext } from '../App';
interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
thinking?: string;
toolCalls?: Array<{
callId: string;
name: string;
parameters?: string;
response?: string;
}>;
timestamp: Date;
}
interface LogEntry {
id: string;
timestamp: Date;
@ -37,7 +24,7 @@ export default function ChatSessionView() {
const [project, setProject] = useState<Project | null>(null);
const [session, setSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [promptInput, setPromptInput] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [loading, setLoading] = useState(true);
@ -51,6 +38,9 @@ export default function ChatSessionView() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingUpdatesRef = useRef<Map<string, Partial<ChatTurn>>>(new Map());
const updateRafRef = useRef<number | null>(null);
const currentTurnIdRef = useRef<string | null>(null);
useEffect(() => {
loadSessionData();
@ -63,13 +53,16 @@ export default function ChatSessionView() {
useEffect(() => {
scrollToBottom();
}, [messages]);
}, [turns]);
useEffect(() => {
return () => {
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current);
}
if (updateRafRef.current) {
cancelAnimationFrame(updateRafRef.current);
}
};
}, []);
@ -86,28 +79,8 @@ export default function ChatSessionView() {
setProject(projectData);
}
const turns = await chatSessionApi.getTurns(sessionId);
const chatMessages: ChatMessage[] = turns.map((turn: ChatTurn) => ({
id: turn._id,
role: 'user',
content: turn.prompts.user,
timestamp: new Date(turn.createdAt),
}));
turns.forEach((turn: ChatTurn) => {
if (turn.thinking || turn.response || turn.toolCalls?.length) {
chatMessages.push({
id: `${turn._id}-response`,
role: 'assistant',
content: turn.response || '',
thinking: turn.thinking,
toolCalls: turn.toolCalls,
timestamp: new Date(turn.createdAt),
});
}
});
setMessages(chatMessages);
const turnsData = await chatSessionApi.getTurns(sessionId);
setTurns(turnsData);
setSessionLocked(true);
}
} catch (err) {
@ -141,68 +114,102 @@ export default function ChatSessionView() {
socket.off('status', handleStatus);
};
const handleThinking = (content: string) => {
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
return [
...prev.slice(0, -1),
{
...lastMessage,
thinking: (lastMessage.thinking || '') + content,
},
];
}
return prev;
});
};
const scheduleUpdate = useCallback(() => {
if (updateRafRef.current) return;
const handleResponse = (content: string) => {
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
return [
...prev.slice(0, -1),
{
...lastMessage,
content: lastMessage.content + content,
},
];
updateRafRef.current = requestAnimationFrame(() => {
const pendingEntries = Array.from(pendingUpdatesRef.current.entries());
if (pendingEntries.length === 0) {
updateRafRef.current = null;
return;
}
return prev;
});
};
const handleToolCall = (callId: string, name: string, params: string, response: string) => {
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
const toolCalls = lastMessage.toolCalls || [];
toolCalls.push({ callId, name, parameters: params, response });
return [
...prev.slice(0, -1),
{
...lastMessage,
toolCalls,
},
];
setTurns(prevTurns => {
const newTurns = [...prevTurns];
let changed = false;
for (const [turnId, turnUpdates] of pendingEntries) {
const turnIndex = newTurns.findIndex(t => t._id === turnId);
if (turnIndex === -1) continue;
const oldTurn = newTurns[turnIndex];
const newTurn = { ...oldTurn, ...turnUpdates };
if (turnUpdates.thinking !== undefined) {
newTurn.thinking = (oldTurn.thinking || '') + turnUpdates.thinking;
}
return prev;
});
if (turnUpdates.response !== undefined) {
newTurn.response = (oldTurn.response || '') + turnUpdates.response;
}
if (turnUpdates.toolCalls !== undefined) {
newTurn.toolCalls = [...(oldTurn.toolCalls || []), ...turnUpdates.toolCalls];
newTurn.stats = {
...oldTurn.stats,
toolCallCount: newTurn.toolCalls.length
};
}
if (turnUpdates.status !== undefined) {
newTurn.status = turnUpdates.status;
}
const handleWorkOrderComplete = (turnId: string, success: boolean, message?: string) => {
newTurns[turnIndex] = newTurn;
changed = true;
}
return changed ? newTurns : prevTurns;
});
pendingUpdatesRef.current.clear();
updateRafRef.current = null;
});
}, []);
const handleThinking = useCallback((content: string) => {
const turnId = currentTurnIdRef.current;
if (!turnId) return;
pendingUpdatesRef.current.set(turnId, { thinking: content });
scheduleUpdate();
}, [scheduleUpdate]);
const handleResponse = useCallback((content: string) => {
const turnId = currentTurnIdRef.current;
if (!turnId) return;
pendingUpdatesRef.current.set(turnId, { response: content });
scheduleUpdate();
}, [scheduleUpdate]);
const handleToolCall = useCallback((callId: string, name: string, params: string, response: string) => {
const turnId = currentTurnIdRef.current;
if (!turnId) return;
pendingUpdatesRef.current.set(turnId, {
toolCalls: [{ callId, name, parameters: params, response }]
});
scheduleUpdate();
}, [scheduleUpdate]);
const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string) => {
setTurns(prevTurns =>
prevTurns.map(turn =>
turn._id === turnId
? { ...turn, status: success ? 'finished' : 'error', response: message && !success ? message : turn.response }
: turn
)
);
setIsProcessing(false);
currentTurnIdRef.current = null;
if (!success) {
setError(message || 'Work order failed');
}
};
}, []);
const handleWorkspaceModeChanged = (mode: string) => {
const handleWorkspaceModeChanged = useCallback((mode: string) => {
setWorkspaceMode(mode as WorkspaceMode);
};
}, []);
const handleLogEntry = (data: { level: string; message: string; timestamp: number }) => {
const handleLogEntry = useCallback((data: { level: string; message: string; timestamp: number }) => {
setLogs(prev => [
...prev,
{
@ -212,13 +219,13 @@ export default function ChatSessionView() {
message: data.message,
},
]);
};
}, []);
const handleStatus = (content: string) => {
const handleStatus = useCallback((content: string) => {
appContext?.setStatusMessage(content);
};
}, [appContext]);
const showToast = (message: string) => {
const showToast = useCallback((message: string) => {
setToast(message);
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current);
@ -226,7 +233,7 @@ export default function ChatSessionView() {
toastTimerRef.current = setTimeout(() => {
setToast(null);
}, 4000);
};
}, []);
const handleWorkspaceModeChange = async (mode: WorkspaceMode) => {
if (!session || !project) return;
@ -260,25 +267,37 @@ export default function ChatSessionView() {
if (!promptInput.trim() || isProcessing || !socket || !sessionLocked) return;
if (workspaceMode !== WorkspaceMode.Agent) return;
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
role: 'user',
content: promptInput.trim(),
timestamp: new Date(),
const userTurn: ChatTurn = {
_id: `temp-${Date.now()}`,
createdAt: new Date().toISOString(),
user: session?.user?._id || '',
project: session?.project?._id || projectId || '',
session: sessionId || '',
provider: session?.provider?._id || '',
llm: session?.selectedModel || '',
mode: session?.mode || 'develop',
status: 'processing',
prompts: { user: promptInput.trim() },
thinking: '',
response: '',
toolCalls: [],
subagents: [],
stats: {
toolCallCount: 0,
inputTokens: 0,
thinkingTokenCount: 0,
responseTokens: 0,
durationMs: 0,
durationLabel: '0ms'
}
};
setMessages(prev => [...prev, userMessage]);
setTurns(prev => [...prev, userTurn]);
currentTurnIdRef.current = userTurn._id;
setPromptInput('');
setIsProcessing(true);
setError('');
setMessages(prev => [...prev, {
id: `response-${Date.now()}`,
role: 'assistant',
content: '',
timestamp: new Date(),
}]);
socketClient.emitServer('submitPrompt', promptInput.trim());
};
@ -286,6 +305,14 @@ export default function ChatSessionView() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const handleTurnUpdate = useCallback((turnId: string, updates: Partial<ChatTurn>) => {
setTurns(prevTurns =>
prevTurns.map(turn =>
turn._id === turnId ? { ...turn, ...updates } : turn
)
);
}, []);
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked;
const promptPlaceholder = workspaceMode !== WorkspaceMode.Agent
? 'Select Agent mode to enter a prompt.'
@ -330,8 +357,12 @@ export default function ChatSessionView() {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<ChatMessageBubble key={message.id} message={message} />
{turns.map((turn) => (
<ChatTurnComponent
key={turn._id}
turn={turn}
onUpdate={handleTurnUpdate}
/>
))}
<div ref={messagesEndRef} />
</div>
@ -435,64 +466,3 @@ export default function ChatSessionView() {
</div>
);
}
function ChatMessageBubble({ message }: { message: ChatMessage }) {
const [thinkingExpanded, setThinkingExpanded] = useState(false);
return (
<div
className={`max-w-3xl ${
message.role === 'user' ? 'ml-auto' : ''
}`}
>
<div
className={`rounded p-4 ${
message.role === 'user'
? 'bg-brand text-white'
: 'bg-bg-tertiary border border-border-default'
}`}
>
{message.role === 'assistant' && message.thinking && (
<div className="mb-3">
<button
onClick={() => setThinkingExpanded(!thinkingExpanded)}
className="text-xs text-text-muted hover:text-text-secondary flex items-center gap-1"
>
<span className={`transform transition-transform ${thinkingExpanded ? 'rotate-90' : ''}`}></span>
Thinking ({message.thinking.length} chars)
</button>
{thinkingExpanded && (
<div className="mt-2 p-3 bg-bg-secondary rounded text-sm text-text-secondary whitespace-pre-wrap">
{message.thinking}
</div>
)}
</div>
)}
<div className="whitespace-pre-wrap">{message.content}</div>
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{message.toolCalls.map((toolCall, idx) => (
<div
key={toolCall.callId || idx}
className="p-2 bg-bg-secondary rounded border border-border-default"
>
<div className="text-xs font-mono text-text-secondary">
<span className="text-brand"></span> {toolCall.name}
{toolCall.response && (
<span className="ml-2 text-green-500"></span>
)}
</div>
</div>
))}
</div>
)}
<div className="mt-2 text-xs text-text-muted">
{message.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
);
}