gadget/gadget-code/frontend/src/pages/ChatSessionView.tsx
2026-05-09 11:51:09 -04:00

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>
);
}