1063 lines
40 KiB
TypeScript
1063 lines
40 KiB
TypeScript
import { useState, useEffect, useRef, useContext, useCallback } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { socketClient } from '../lib/socket';
|
|
import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatSessionMode, type AiProvider, type Project } 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 LogEntry {
|
|
id: string;
|
|
timestamp: Date;
|
|
level: string;
|
|
component: string;
|
|
message: string;
|
|
metadata?: unknown;
|
|
}
|
|
|
|
interface StreamingState {
|
|
currentMode: 'thinking' | 'responding' | null;
|
|
thinkingContent: string;
|
|
respondingContent: string;
|
|
currentBlockIndex: number | null;
|
|
}
|
|
|
|
export default function ChatSessionView() {
|
|
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
|
const navigate = useNavigate();
|
|
const appContext = useContext(AppContext);
|
|
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [session, setSession] = useState<ChatSession | null>(null);
|
|
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
|
const [promptInput, setPromptInput] = useState('');
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [sessionLocked, setSessionLocked] = useState(true);
|
|
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle);
|
|
const [isUpdatingMode, setIsUpdatingMode] = useState(false);
|
|
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
|
|
const [isUpdatingReasoning, setIsUpdatingReasoning] = useState(false);
|
|
const [logExpanded, setLogExpanded] = useState(false);
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
const [providers, setProviders] = useState<AiProvider[]>([]);
|
|
const [selectedProviderId, setSelectedProviderId] = useState<string>('');
|
|
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
|
const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off');
|
|
const [isEditingProvider, setIsEditingProvider] = useState(false);
|
|
const [editProviderId, setEditProviderId] = useState('');
|
|
const [editModelId, setEditModelId] = useState('');
|
|
const [isEditingName, setIsEditingName] = useState(false);
|
|
const [editName, setEditName] = useState('');
|
|
const [isUpdatingName, setIsUpdatingName] = useState(false);
|
|
|
|
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);
|
|
const streamingStateRef = useRef<Map<string, StreamingState>>(new Map());
|
|
const sessionRef = useRef<ChatSession | null>(null);
|
|
const projectRef = useRef<Project | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadSessionData();
|
|
}, [projectId, sessionId]);
|
|
|
|
useEffect(() => {
|
|
setupSocketListeners();
|
|
return () => cleanupSocketListeners();
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [turns]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (toastTimerRef.current) {
|
|
clearTimeout(toastTimerRef.current);
|
|
}
|
|
if (updateRafRef.current) {
|
|
cancelAnimationFrame(updateRafRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Keep refs in sync with state for use in cleanup closures
|
|
useEffect(() => {
|
|
sessionRef.current = session;
|
|
}, [session]);
|
|
|
|
useEffect(() => {
|
|
projectRef.current = project;
|
|
}, [project]);
|
|
|
|
// Start heartbeat when session+project are loaded
|
|
useEffect(() => {
|
|
if (session && project) {
|
|
socketClient.startSessionHeartbeat();
|
|
}
|
|
return () => {
|
|
socketClient.stopSessionHeartbeat();
|
|
};
|
|
}, [session, project]);
|
|
|
|
// Re-lock on socket reconnect to restore lock on a new CodeSession
|
|
const handleSocketReconnect = useCallback(async () => {
|
|
if (!sessionRef.current || !projectRef.current) return;
|
|
const droneJson = localStorage.getItem('dtp_drone_registration');
|
|
if (!droneJson) return;
|
|
try {
|
|
const registration = JSON.parse(droneJson);
|
|
const success = await socketClient.requestSessionLock(
|
|
registration,
|
|
projectRef.current,
|
|
sessionRef.current,
|
|
);
|
|
if (!success) {
|
|
console.warn('Failed to re-lock drone after socket reconnect');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to re-lock drone after socket reconnect', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
socketClient.on('connect', handleSocketReconnect);
|
|
return () => {
|
|
socketClient.off('connect', handleSocketReconnect);
|
|
};
|
|
}, [handleSocketReconnect]);
|
|
|
|
// Release session lock on unmount only
|
|
useEffect(() => {
|
|
return () => {
|
|
const droneJson = localStorage.getItem('dtp_drone_registration');
|
|
if (droneJson && sessionRef.current && projectRef.current) {
|
|
try {
|
|
const registration = JSON.parse(droneJson);
|
|
socketClient.releaseSessionLock(
|
|
registration,
|
|
projectRef.current,
|
|
sessionRef.current,
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to release session lock', err);
|
|
}
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const loadSessionData = async () => {
|
|
try {
|
|
if (sessionId) {
|
|
const sessionData = await chatSessionApi.get(sessionId);
|
|
setSession(sessionData);
|
|
|
|
const projectRef = sessionData.project;
|
|
const projectObjectId = typeof projectRef === 'string' ? projectRef : projectRef?._id;
|
|
if (projectObjectId) {
|
|
const projectData = await projectApi.get(projectObjectId);
|
|
setProject(projectData);
|
|
}
|
|
|
|
const turnsData = await chatSessionApi.getTurns(sessionId);
|
|
setTurns(turnsData);
|
|
setSessionLocked(true);
|
|
|
|
const allProviders = await providerApi.getAll();
|
|
setProviders(allProviders);
|
|
|
|
const providerId = typeof sessionData.provider === 'string'
|
|
? sessionData.provider
|
|
: sessionData.provider?._id;
|
|
setSelectedProviderId(providerId || '');
|
|
setSelectedModelId(sessionData.selectedModel || '');
|
|
setSessionReasoningEffort(sessionData.reasoningEffort || 'off');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load session');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSessionUpdated = useCallback((updates: Partial<ChatSession>) => {
|
|
setSession(prev => prev ? { ...prev, ...updates } : null);
|
|
}, []);
|
|
|
|
const setupSocketListeners = () => {
|
|
socketClient.on('thinking', handleThinking);
|
|
socketClient.on('response', handleResponse);
|
|
socketClient.on('toolCall', handleToolCall);
|
|
socketClient.on('workOrderComplete', handleWorkOrderComplete);
|
|
socketClient.on('workspaceModeChanged', handleWorkspaceModeChanged);
|
|
socketClient.on('sessionUpdated', handleSessionUpdated);
|
|
socketClient.on('log:entry', handleLogEntry);
|
|
socketClient.on('status', handleStatus);
|
|
};
|
|
|
|
const cleanupSocketListeners = () => {
|
|
socketClient.off('thinking', handleThinking);
|
|
socketClient.off('response', handleResponse);
|
|
socketClient.off('toolCall', handleToolCall);
|
|
socketClient.off('workOrderComplete', handleWorkOrderComplete);
|
|
socketClient.off('workspaceModeChanged', handleWorkspaceModeChanged);
|
|
socketClient.off('sessionUpdated', handleSessionUpdated);
|
|
socketClient.off('log:entry', handleLogEntry);
|
|
socketClient.off('status', handleStatus);
|
|
};
|
|
|
|
const scheduleUpdate = useCallback(() => {
|
|
if (updateRafRef.current) return;
|
|
|
|
updateRafRef.current = requestAnimationFrame(() => {
|
|
const pendingEntries = Array.from(pendingUpdatesRef.current.entries());
|
|
if (pendingEntries.length === 0) {
|
|
updateRafRef.current = null;
|
|
return;
|
|
}
|
|
|
|
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.blocks !== undefined) {
|
|
const state = streamingStateRef.current.get(turnId);
|
|
const updatedBlocks = [...(oldTurn.blocks || [])];
|
|
|
|
for (const updateBlock of turnUpdates.blocks) {
|
|
let blockIndex = state?.currentBlockIndex ?? null;
|
|
|
|
if (
|
|
blockIndex === null ||
|
|
updatedBlocks[blockIndex]?.mode !== updateBlock.mode
|
|
) {
|
|
const lastIndex = updatedBlocks.length - 1;
|
|
blockIndex = updatedBlocks[lastIndex]?.mode === updateBlock.mode
|
|
? lastIndex
|
|
: null;
|
|
}
|
|
|
|
if (blockIndex !== null) {
|
|
updatedBlocks[blockIndex] = updateBlock;
|
|
} else {
|
|
updatedBlocks.push(updateBlock);
|
|
blockIndex = updatedBlocks.length - 1;
|
|
}
|
|
|
|
if (state) {
|
|
state.currentBlockIndex = blockIndex;
|
|
}
|
|
}
|
|
|
|
newTurn.blocks = updatedBlocks;
|
|
}
|
|
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 mergePendingUpdate = useCallback((turnId: string, updates: Partial<ChatTurn>) => {
|
|
const existing = pendingUpdatesRef.current.get(turnId);
|
|
const merged: Partial<ChatTurn> = {
|
|
...existing,
|
|
...updates,
|
|
};
|
|
|
|
if (existing?.blocks || updates.blocks) {
|
|
merged.blocks = [
|
|
...(existing?.blocks || []),
|
|
...(updates.blocks || []),
|
|
];
|
|
}
|
|
|
|
if (existing?.toolCalls || updates.toolCalls) {
|
|
merged.toolCalls = [
|
|
...(existing?.toolCalls || []),
|
|
...(updates.toolCalls || []),
|
|
];
|
|
}
|
|
|
|
pendingUpdatesRef.current.set(turnId, merged);
|
|
}, []);
|
|
|
|
const handleThinking = useCallback((content: string) => {
|
|
const turnId = currentTurnIdRef.current;
|
|
if (!turnId) return;
|
|
|
|
let state = streamingStateRef.current.get(turnId);
|
|
if (!state) {
|
|
state = { currentMode: null, thinkingContent: '', respondingContent: '', currentBlockIndex: null };
|
|
streamingStateRef.current.set(turnId, state);
|
|
}
|
|
|
|
// Check for mode transition
|
|
if (state.currentMode !== 'thinking') {
|
|
// Flush previous mode
|
|
if (state.currentMode === 'responding' && state.respondingContent) {
|
|
mergePendingUpdate(turnId, {
|
|
blocks: [{
|
|
mode: 'responding' as const,
|
|
createdAt: new Date().toISOString(),
|
|
content: state.respondingContent,
|
|
}],
|
|
});
|
|
state.respondingContent = '';
|
|
state.currentBlockIndex = null;
|
|
}
|
|
state.currentMode = 'thinking';
|
|
}
|
|
|
|
// Aggregate content
|
|
state.thinkingContent += content;
|
|
|
|
// Update with aggregated content
|
|
mergePendingUpdate(turnId, {
|
|
blocks: [{
|
|
mode: 'thinking' as const,
|
|
createdAt: new Date().toISOString(),
|
|
content: state.thinkingContent,
|
|
}],
|
|
});
|
|
scheduleUpdate();
|
|
}, [mergePendingUpdate, scheduleUpdate]);
|
|
|
|
const handleResponse = useCallback((content: string) => {
|
|
const turnId = currentTurnIdRef.current;
|
|
if (!turnId) return;
|
|
|
|
let state = streamingStateRef.current.get(turnId);
|
|
if (!state) {
|
|
state = { currentMode: null, thinkingContent: '', respondingContent: '', currentBlockIndex: null };
|
|
streamingStateRef.current.set(turnId, state);
|
|
}
|
|
|
|
// Check for mode transition
|
|
if (state.currentMode !== 'responding') {
|
|
// Flush previous mode
|
|
if (state.currentMode === 'thinking' && state.thinkingContent) {
|
|
mergePendingUpdate(turnId, {
|
|
blocks: [{
|
|
mode: 'thinking' as const,
|
|
createdAt: new Date().toISOString(),
|
|
content: state.thinkingContent,
|
|
}],
|
|
});
|
|
state.thinkingContent = '';
|
|
state.currentBlockIndex = null;
|
|
}
|
|
state.currentMode = 'responding';
|
|
}
|
|
|
|
// Aggregate content
|
|
state.respondingContent += content;
|
|
|
|
// Update with aggregated content
|
|
mergePendingUpdate(turnId, {
|
|
blocks: [{
|
|
mode: 'responding' as const,
|
|
createdAt: new Date().toISOString(),
|
|
content: state.respondingContent,
|
|
}],
|
|
});
|
|
scheduleUpdate();
|
|
}, [mergePendingUpdate, scheduleUpdate]);
|
|
|
|
const handleToolCall = useCallback((callId: string, name: string, params: string, response: string) => {
|
|
const turnId = currentTurnIdRef.current;
|
|
if (!turnId) return;
|
|
|
|
// Flush current streaming state
|
|
const state = streamingStateRef.current.get(turnId);
|
|
if (state) {
|
|
const blocksToFlush: ChatTurnBlock[] = [];
|
|
|
|
if (state.currentMode === 'thinking' && state.thinkingContent) {
|
|
blocksToFlush.push({
|
|
mode: 'thinking' as const,
|
|
createdAt: new Date().toISOString(),
|
|
content: state.thinkingContent,
|
|
});
|
|
state.thinkingContent = '';
|
|
}
|
|
if (state.currentMode === 'responding' && state.respondingContent) {
|
|
blocksToFlush.push({
|
|
mode: 'responding' as const,
|
|
createdAt: new Date().toISOString(),
|
|
content: state.respondingContent,
|
|
});
|
|
state.respondingContent = '';
|
|
}
|
|
|
|
if (blocksToFlush.length > 0) {
|
|
mergePendingUpdate(turnId, {
|
|
blocks: blocksToFlush,
|
|
});
|
|
scheduleUpdate();
|
|
}
|
|
|
|
state.currentMode = null;
|
|
state.currentBlockIndex = null;
|
|
}
|
|
|
|
// Add tool block
|
|
mergePendingUpdate(turnId, {
|
|
blocks: [{
|
|
mode: 'tool' as const,
|
|
createdAt: new Date().toISOString(),
|
|
content: { callId, name, parameters: params, response },
|
|
}],
|
|
toolCalls: [{ callId, name, parameters: params, response }],
|
|
});
|
|
scheduleUpdate();
|
|
}, [mergePendingUpdate, scheduleUpdate]);
|
|
|
|
const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string) => {
|
|
// Backend has already flushed and persisted all streaming content
|
|
// Just clean up frontend streaming state and update status
|
|
if (streamingStateRef.current.has(turnId)) {
|
|
if (pendingUpdatesRef.current.has(turnId) || updateRafRef.current) {
|
|
requestAnimationFrame(() => streamingStateRef.current.delete(turnId));
|
|
} else {
|
|
streamingStateRef.current.delete(turnId);
|
|
}
|
|
}
|
|
|
|
setTurns(prevTurns =>
|
|
prevTurns.map(turn =>
|
|
turn._id === turnId
|
|
? { ...turn, status: success ? 'finished' : 'error', errorMessage: message && !success ? message : turn.errorMessage }
|
|
: turn
|
|
)
|
|
);
|
|
setIsProcessing(false);
|
|
currentTurnIdRef.current = null;
|
|
if (!success) {
|
|
setError(message || 'Work order failed');
|
|
}
|
|
}, []);
|
|
|
|
const handleWorkspaceModeChanged = useCallback((mode: string) => {
|
|
setWorkspaceMode(mode as WorkspaceMode);
|
|
}, []);
|
|
|
|
const handleLogEntry = useCallback((data: { level: string; component: string; message: string; metadata?: unknown; timestamp: number }) => {
|
|
setLogs(prev => [
|
|
...prev,
|
|
{
|
|
id: `log-${Date.now()}-${Math.random()}`,
|
|
timestamp: new Date(data.timestamp),
|
|
level: data.level,
|
|
component: data.component,
|
|
message: data.message,
|
|
metadata: data.metadata,
|
|
},
|
|
]);
|
|
}, []);
|
|
|
|
const handleStatus = useCallback((content: string) => {
|
|
appContext?.setStatusMessage(content);
|
|
}, [appContext]);
|
|
|
|
const showToast = useCallback((message: string) => {
|
|
setToast(message);
|
|
if (toastTimerRef.current) {
|
|
clearTimeout(toastTimerRef.current);
|
|
}
|
|
toastTimerRef.current = setTimeout(() => {
|
|
setToast(null);
|
|
}, 4000);
|
|
}, []);
|
|
|
|
const handleWorkspaceModeChange = async (mode: WorkspaceMode) => {
|
|
if (!session || !project) return;
|
|
|
|
if (mode === workspaceMode) return;
|
|
|
|
try {
|
|
const droneJson = localStorage.getItem('dtp_drone_registration');
|
|
if (!droneJson) {
|
|
showToast('No drone registration found');
|
|
return;
|
|
}
|
|
const registration = JSON.parse(droneJson);
|
|
const result = await socketClient.requestWorkspaceMode(
|
|
registration,
|
|
project,
|
|
session,
|
|
mode,
|
|
);
|
|
if (!result.success) {
|
|
showToast(result.reason || `Cannot switch to ${mode} mode`);
|
|
setWorkspaceMode(workspaceMode);
|
|
}
|
|
} catch (err) {
|
|
showToast(`Failed to change workspace mode: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
}
|
|
};
|
|
|
|
const handleModeChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
if (!session) return;
|
|
|
|
const newMode = e.target.value as ChatSessionMode;
|
|
if (newMode === session.mode) return;
|
|
|
|
setIsUpdatingMode(true);
|
|
try {
|
|
const updatedSession = await chatSessionApi.update(session._id, { mode: newMode });
|
|
setSession(updatedSession);
|
|
showToast(`Session mode changed to ${newMode}`);
|
|
} catch (err) {
|
|
showToast(`Failed to change mode: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
} finally {
|
|
setIsUpdatingMode(false);
|
|
}
|
|
};
|
|
|
|
const getSelectedModelCapabilities = useCallback(() => {
|
|
const provider = providers.find(p => p._id === selectedProviderId);
|
|
if (!provider) return null;
|
|
const model = provider.models.find(m => m.id === selectedModelId);
|
|
return model?.capabilities || null;
|
|
}, [providers, selectedProviderId, selectedModelId]);
|
|
|
|
const handleReasoningChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
if (!session) return;
|
|
|
|
const newValue = e.target.value;
|
|
if (newValue === sessionReasoningEffort) return;
|
|
|
|
setIsUpdatingReasoning(true);
|
|
try {
|
|
const updatedSession = await chatSessionApi.update(session._id, {
|
|
reasoningEffort: newValue
|
|
});
|
|
setSession(updatedSession);
|
|
setSessionReasoningEffort(newValue);
|
|
showToast(`Reasoning effort set to ${newValue}`);
|
|
} catch (err) {
|
|
showToast(`Failed to change reasoning effort: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
} finally {
|
|
setIsUpdatingReasoning(false);
|
|
}
|
|
};
|
|
|
|
const getProviderName = (id: string) => {
|
|
return providers.find(p => p._id === id)?.name ?? 'None';
|
|
};
|
|
|
|
const getModelName = (providerId: string, modelId: string) => {
|
|
if (!providerId || !modelId) return 'None';
|
|
const provider = providers.find(p => p._id === providerId);
|
|
return provider?.models.find(m => m.id === modelId)?.name ?? modelId;
|
|
};
|
|
|
|
const getSortedModels = (providerId: string) => {
|
|
const provider = providers.find(p => p._id === providerId);
|
|
if (!provider) return [];
|
|
return [...provider.models].sort((a, b) => a.name.localeCompare(b.name));
|
|
};
|
|
|
|
const truncateModelName = (name: string, maxLength = 25) => {
|
|
if (name.length <= maxLength) return name;
|
|
return name.slice(0, maxLength - 3) + '...';
|
|
};
|
|
|
|
const handleStartEditProvider = () => {
|
|
setEditProviderId(selectedProviderId);
|
|
setEditModelId(selectedModelId);
|
|
setIsEditingProvider(true);
|
|
};
|
|
|
|
const handleSaveProvider = async () => {
|
|
if (!session || !editProviderId || !editModelId) return;
|
|
setIsUpdatingProvider(true);
|
|
try {
|
|
const updatedSession = await chatSessionApi.update(session._id, {
|
|
provider: editProviderId,
|
|
selectedModel: editModelId,
|
|
});
|
|
setSession(updatedSession);
|
|
setSelectedProviderId(editProviderId);
|
|
setSelectedModelId(editModelId);
|
|
setIsEditingProvider(false);
|
|
showToast('Provider and model updated');
|
|
} catch (err) {
|
|
showToast(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
} finally {
|
|
setIsUpdatingProvider(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelEditProvider = () => {
|
|
setEditProviderId(selectedProviderId);
|
|
setEditModelId(selectedModelId);
|
|
setIsEditingProvider(false);
|
|
};
|
|
|
|
const handleStartEditName = () => {
|
|
setEditName(session?.name || '');
|
|
setIsEditingName(true);
|
|
};
|
|
|
|
const handleSaveName = async () => {
|
|
if (!session || !editName.trim()) return;
|
|
setIsUpdatingName(true);
|
|
try {
|
|
const updatedSession = await chatSessionApi.update(session._id, { name: editName.trim() });
|
|
setSession(updatedSession);
|
|
setIsEditingName(false);
|
|
showToast('Session name updated');
|
|
} catch (err) {
|
|
showToast(`Failed to update name: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
} finally {
|
|
setIsUpdatingName(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelEditName = () => {
|
|
setIsEditingName(false);
|
|
};
|
|
|
|
const handleSubmitPrompt = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!promptInput.trim() || isProcessing || !socketClient.connected || !sessionLocked) return;
|
|
if (workspaceMode !== WorkspaceMode.Agent) return;
|
|
if (!session?.selectedModel) {
|
|
showToast('Please select a model before submitting a prompt');
|
|
return;
|
|
}
|
|
|
|
const tempTurnId = `temp-${Date.now()}`;
|
|
const userTurn: ChatTurn = {
|
|
_id: tempTurnId,
|
|
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'
|
|
}
|
|
};
|
|
|
|
setTurns(prev => [...prev, userTurn]);
|
|
currentTurnIdRef.current = tempTurnId;
|
|
setPromptInput('');
|
|
setIsProcessing(true);
|
|
setError('');
|
|
|
|
socketClient.submitPrompt(promptInput.trim(), (success, data) => {
|
|
if (success && data.turnId) {
|
|
setTurns(prevTurns =>
|
|
prevTurns.map(turn =>
|
|
turn._id === tempTurnId
|
|
? { ...turn, _id: data.turnId! }
|
|
: turn
|
|
)
|
|
);
|
|
currentTurnIdRef.current = data.turnId;
|
|
} else if (!success) {
|
|
setTurns(prevTurns =>
|
|
prevTurns.map(turn =>
|
|
turn._id === tempTurnId
|
|
? { ...turn, status: 'error', errorMessage: data.message || 'Failed to submit prompt' }
|
|
: turn
|
|
)
|
|
);
|
|
setIsProcessing(false);
|
|
currentTurnIdRef.current = null;
|
|
}
|
|
});
|
|
};
|
|
|
|
const scrollToBottom = () => {
|
|
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 || !session?.selectedModel || isEditingProvider;
|
|
const promptPlaceholder = isEditingProvider
|
|
? 'Save or cancel provider changes to continue.'
|
|
: workspaceMode !== WorkspaceMode.Agent
|
|
? 'Select Agent mode to enter a prompt.'
|
|
: !session?.selectedModel
|
|
? 'Please select a model first.'
|
|
: 'Enter your prompt... (Shift+Enter for new line)';
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
|
<p className="text-text-muted">Loading chat session...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !session) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
|
<div className="text-center">
|
|
<p className="text-red-500 mb-4">{error}</p>
|
|
<button
|
|
onClick={() => navigate(`/projects/${projectId}`)}
|
|
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors"
|
|
>
|
|
Back to Project
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex bg-bg-primary overflow-hidden relative">
|
|
{/* Toast notification */}
|
|
{toast && (
|
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-bg-elevated border border-border-default rounded shadow-lg text-sm text-text-primary">
|
|
{toast}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 flex flex-col relative">
|
|
{isEditingProvider && (
|
|
<div className="absolute inset-0 bg-bg-primary/80 z-30" />
|
|
)}
|
|
{/* Chat View (75%) */}
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{turns.map((turn) => (
|
|
<ChatTurnComponent
|
|
key={turn._id}
|
|
turn={turn}
|
|
onUpdate={handleTurnUpdate}
|
|
/>
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Prompt Input */}
|
|
<div className="border-t border-border-subtle p-4 bg-bg-secondary shrink-0">
|
|
<form onSubmit={handleSubmitPrompt} className="flex gap-2">
|
|
<textarea
|
|
ref={inputRef}
|
|
value={promptInput}
|
|
onChange={(e) => setPromptInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmitPrompt(e);
|
|
}
|
|
}}
|
|
placeholder={promptPlaceholder}
|
|
className="flex-1 px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none resize-none disabled:opacity-50"
|
|
rows={3}
|
|
disabled={promptDisabled}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={promptDisabled || !promptInput.trim()}
|
|
className="px-6 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isProcessing ? 'Processing...' : 'Send'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log Panel (25%) */}
|
|
<LogPanel
|
|
logs={logs}
|
|
expanded={logExpanded}
|
|
onToggleExpand={() => setLogExpanded(!logExpanded)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-y-auto">
|
|
{/* SESSION Panel */}
|
|
<div className="border-b border-border-subtle">
|
|
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
|
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
|
Session
|
|
</h3>
|
|
<WorkspaceModeIndicator
|
|
mode={workspaceMode}
|
|
onChange={handleWorkspaceModeChange}
|
|
disabled={!sessionLocked || isEditingProvider}
|
|
/>
|
|
</div>
|
|
<div className="p-4 space-y-3">
|
|
<div>
|
|
<div className="text-xs text-text-muted">Name</div>
|
|
{isEditingName ? (
|
|
<div className="flex gap-2 mt-1">
|
|
<input
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); handleSaveName(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); handleCancelEditName(); }
|
|
}}
|
|
className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
title="Save"
|
|
onClick={handleSaveName}
|
|
disabled={!editName.trim() || isUpdatingName}
|
|
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
title="Cancel"
|
|
onClick={handleCancelEditName}
|
|
disabled={isUpdatingName}
|
|
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2 items-center mt-1">
|
|
<div className="text-text-primary flex-1">{session?.name}</div>
|
|
<button
|
|
title="Edit name"
|
|
onClick={handleStartEditName}
|
|
className="text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isEditingProvider ? (
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Provider</div>
|
|
<select
|
|
value={editProviderId || ''}
|
|
onChange={(e) => { setEditProviderId(e.target.value); setEditModelId(''); }}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
|
>
|
|
<option value="">-- Select Provider --</option>
|
|
{providers.map((provider) => (
|
|
<option key={provider._id} value={provider._id}>
|
|
{provider.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Model</div>
|
|
<select
|
|
value={editModelId || ''}
|
|
onChange={(e) => setEditModelId(e.target.value)}
|
|
disabled={!editProviderId}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
|
|
>
|
|
{!editProviderId ? (
|
|
<option value="">Select provider first</option>
|
|
) : (
|
|
<>
|
|
<option value="">-- Select Model --</option>
|
|
{getSortedModels(editProviderId).map((model) => (
|
|
<option key={model.id} value={model.id}>
|
|
{truncateModelName(model.name)}
|
|
</option>
|
|
))}
|
|
</>
|
|
)}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end gap-1 pb-1">
|
|
<button
|
|
title="Save"
|
|
onClick={handleSaveProvider}
|
|
disabled={!editProviderId || !editModelId || isUpdatingProvider}
|
|
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
title="Cancel"
|
|
onClick={handleCancelEditProvider}
|
|
disabled={isUpdatingProvider}
|
|
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Provider</div>
|
|
<div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Model</div>
|
|
<div
|
|
className="text-text-primary text-sm mt-1 truncate"
|
|
title={getModelName(selectedProviderId, selectedModelId)}
|
|
>
|
|
{getModelName(selectedProviderId, selectedModelId)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
title="Change provider/model..."
|
|
onClick={handleStartEditProvider}
|
|
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="text-xs text-text-muted">Mode</div>
|
|
<select
|
|
value={session?.mode || ChatSessionMode.Build}
|
|
onChange={handleModeChange}
|
|
disabled={isUpdatingMode}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{Object.values(ChatSessionMode).map((mode) => (
|
|
<option key={mode} value={mode}>
|
|
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-text-muted">Reasoning</div>
|
|
<select
|
|
value={sessionReasoningEffort}
|
|
onChange={handleReasoningChange}
|
|
disabled={isUpdatingReasoning || !getSelectedModelCapabilities()?.hasThinking}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title={
|
|
!getSelectedModelCapabilities()?.hasThinking
|
|
? "Selected model does not support reasoning"
|
|
: "Controls how much the model thinks before responding"
|
|
}
|
|
>
|
|
<option value="off">Off</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* PROJECT Panel */}
|
|
<div className="border-b border-border-subtle">
|
|
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
|
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
|
Project
|
|
</h3>
|
|
<button
|
|
title="Project Manager"
|
|
onClick={() => navigate(`/projects/${projectId}`)}
|
|
className="text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="p-4 space-y-2">
|
|
{project ? (
|
|
<>
|
|
<div>
|
|
<div className="text-xs text-text-muted">Name</div>
|
|
<div className="text-text-primary">{project.name}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-text-muted">Slug</div>
|
|
<div className="font-mono text-xs text-text-secondary">{project.slug}</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-text-muted text-sm">Loading...</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* FILES Panel */}
|
|
<FilesPanel workspaceMode={workspaceMode} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|