agent's progress on ChatTurn
This commit is contained in:
parent
3a8f2e4f44
commit
09ebacc711
@ -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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { socketClient } from '../lib/socket';
|
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 { WorkspaceMode } from '../lib/types';
|
||||||
import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
|
import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
|
||||||
import FilesPanel from '../components/FilesPanel';
|
import FilesPanel from '../components/FilesPanel';
|
||||||
import LogPanel from '../components/LogPanel';
|
import LogPanel from '../components/LogPanel';
|
||||||
|
import ChatTurnComponent from '../components/ChatTurn';
|
||||||
import { AppContext } from '../App';
|
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 {
|
interface LogEntry {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
@ -37,7 +24,7 @@ export default function ChatSessionView() {
|
|||||||
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [session, setSession] = useState<ChatSession | null>(null);
|
const [session, setSession] = useState<ChatSession | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||||
const [promptInput, setPromptInput] = useState('');
|
const [promptInput, setPromptInput] = useState('');
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -51,6 +38,9 @@ export default function ChatSessionView() {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(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(() => {
|
useEffect(() => {
|
||||||
loadSessionData();
|
loadSessionData();
|
||||||
@ -63,13 +53,16 @@ export default function ChatSessionView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [turns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (toastTimerRef.current) {
|
if (toastTimerRef.current) {
|
||||||
clearTimeout(toastTimerRef.current);
|
clearTimeout(toastTimerRef.current);
|
||||||
}
|
}
|
||||||
|
if (updateRafRef.current) {
|
||||||
|
cancelAnimationFrame(updateRafRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -86,28 +79,8 @@ export default function ChatSessionView() {
|
|||||||
setProject(projectData);
|
setProject(projectData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const turns = await chatSessionApi.getTurns(sessionId);
|
const turnsData = await chatSessionApi.getTurns(sessionId);
|
||||||
const chatMessages: ChatMessage[] = turns.map((turn: ChatTurn) => ({
|
setTurns(turnsData);
|
||||||
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);
|
|
||||||
setSessionLocked(true);
|
setSessionLocked(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -141,68 +114,102 @@ export default function ChatSessionView() {
|
|||||||
socket.off('status', handleStatus);
|
socket.off('status', handleStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThinking = (content: string) => {
|
const scheduleUpdate = useCallback(() => {
|
||||||
setMessages(prev => {
|
if (updateRafRef.current) return;
|
||||||
const lastMessage = prev[prev.length - 1];
|
|
||||||
if (lastMessage && lastMessage.role === 'assistant') {
|
updateRafRef.current = requestAnimationFrame(() => {
|
||||||
return [
|
const pendingEntries = Array.from(pendingUpdatesRef.current.entries());
|
||||||
...prev.slice(0, -1),
|
if (pendingEntries.length === 0) {
|
||||||
{
|
updateRafRef.current = null;
|
||||||
...lastMessage,
|
return;
|
||||||
thinking: (lastMessage.thinking || '') + content,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResponse = (content: string) => {
|
setTurns(prevTurns => {
|
||||||
setMessages(prev => {
|
const newTurns = [...prevTurns];
|
||||||
const lastMessage = prev[prev.length - 1];
|
let changed = false;
|
||||||
if (lastMessage && lastMessage.role === 'assistant') {
|
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{
|
|
||||||
...lastMessage,
|
|
||||||
content: lastMessage.content + content,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToolCall = (callId: string, name: string, params: string, response: string) => {
|
for (const [turnId, turnUpdates] of pendingEntries) {
|
||||||
setMessages(prev => {
|
const turnIndex = newTurns.findIndex(t => t._id === turnId);
|
||||||
const lastMessage = prev[prev.length - 1];
|
if (turnIndex === -1) continue;
|
||||||
if (lastMessage && lastMessage.role === 'assistant') {
|
|
||||||
const toolCalls = lastMessage.toolCalls || [];
|
|
||||||
toolCalls.push({ callId, name, parameters: params, response });
|
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{
|
|
||||||
...lastMessage,
|
|
||||||
toolCalls,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWorkOrderComplete = (turnId: string, success: boolean, message?: string) => {
|
const oldTurn = newTurns[turnIndex];
|
||||||
|
const newTurn = { ...oldTurn, ...turnUpdates };
|
||||||
|
|
||||||
|
if (turnUpdates.thinking !== undefined) {
|
||||||
|
newTurn.thinking = (oldTurn.thinking || '') + turnUpdates.thinking;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
setIsProcessing(false);
|
||||||
|
currentTurnIdRef.current = null;
|
||||||
if (!success) {
|
if (!success) {
|
||||||
setError(message || 'Work order failed');
|
setError(message || 'Work order failed');
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleWorkspaceModeChanged = (mode: string) => {
|
const handleWorkspaceModeChanged = useCallback((mode: string) => {
|
||||||
setWorkspaceMode(mode as WorkspaceMode);
|
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 => [
|
setLogs(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@ -212,13 +219,13 @@ export default function ChatSessionView() {
|
|||||||
message: data.message,
|
message: data.message,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleStatus = (content: string) => {
|
const handleStatus = useCallback((content: string) => {
|
||||||
appContext?.setStatusMessage(content);
|
appContext?.setStatusMessage(content);
|
||||||
};
|
}, [appContext]);
|
||||||
|
|
||||||
const showToast = (message: string) => {
|
const showToast = useCallback((message: string) => {
|
||||||
setToast(message);
|
setToast(message);
|
||||||
if (toastTimerRef.current) {
|
if (toastTimerRef.current) {
|
||||||
clearTimeout(toastTimerRef.current);
|
clearTimeout(toastTimerRef.current);
|
||||||
@ -226,7 +233,7 @@ export default function ChatSessionView() {
|
|||||||
toastTimerRef.current = setTimeout(() => {
|
toastTimerRef.current = setTimeout(() => {
|
||||||
setToast(null);
|
setToast(null);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleWorkspaceModeChange = async (mode: WorkspaceMode) => {
|
const handleWorkspaceModeChange = async (mode: WorkspaceMode) => {
|
||||||
if (!session || !project) return;
|
if (!session || !project) return;
|
||||||
@ -260,25 +267,37 @@ export default function ChatSessionView() {
|
|||||||
if (!promptInput.trim() || isProcessing || !socket || !sessionLocked) return;
|
if (!promptInput.trim() || isProcessing || !socket || !sessionLocked) return;
|
||||||
if (workspaceMode !== WorkspaceMode.Agent) return;
|
if (workspaceMode !== WorkspaceMode.Agent) return;
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userTurn: ChatTurn = {
|
||||||
id: `temp-${Date.now()}`,
|
_id: `temp-${Date.now()}`,
|
||||||
role: 'user',
|
createdAt: new Date().toISOString(),
|
||||||
content: promptInput.trim(),
|
user: session?.user?._id || '',
|
||||||
timestamp: new Date(),
|
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('');
|
setPromptInput('');
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
setMessages(prev => [...prev, {
|
|
||||||
id: `response-${Date.now()}`,
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: new Date(),
|
|
||||||
}]);
|
|
||||||
|
|
||||||
socketClient.emitServer('submitPrompt', promptInput.trim());
|
socketClient.emitServer('submitPrompt', promptInput.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -286,6 +305,14 @@ export default function ChatSessionView() {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
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 promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked;
|
||||||
const promptPlaceholder = workspaceMode !== WorkspaceMode.Agent
|
const promptPlaceholder = workspaceMode !== WorkspaceMode.Agent
|
||||||
? 'Select Agent mode to enter a prompt.'
|
? 'Select Agent mode to enter a prompt.'
|
||||||
@ -330,8 +357,12 @@ export default function ChatSessionView() {
|
|||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{messages.map((message) => (
|
{turns.map((turn) => (
|
||||||
<ChatMessageBubble key={message.id} message={message} />
|
<ChatTurnComponent
|
||||||
|
key={turn._id}
|
||||||
|
turn={turn}
|
||||||
|
onUpdate={handleTurnUpdate}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
@ -435,64 +466,3 @@ export default function ChatSessionView() {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user