workspace management checkpoint while agents are working on it
This commit is contained in:
parent
20f9e495a6
commit
4ec31764d5
52
gadget-code/frontend/src/components/FilesPanel.tsx
Normal file
52
gadget-code/frontend/src/components/FilesPanel.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { WorkspaceMode } from '../lib/types';
|
||||
|
||||
interface FilesPanelProps {
|
||||
workspaceMode: WorkspaceMode;
|
||||
}
|
||||
|
||||
export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
|
||||
const isReadOnly = workspaceMode === WorkspaceMode.Agent;
|
||||
const isReadWrite = workspaceMode === WorkspaceMode.User;
|
||||
|
||||
return (
|
||||
<div className="border-t 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">
|
||||
Files
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
|
||||
isReadOnly
|
||||
? 'border-green-500 strobe text-green-500'
|
||||
: 'border-border-default text-text-muted'
|
||||
}`}
|
||||
title="Read Only"
|
||||
>
|
||||
RO
|
||||
</span>
|
||||
<span
|
||||
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
|
||||
isReadWrite
|
||||
? 'border-green-500 strobe text-green-500'
|
||||
: 'border-border-default text-text-muted'
|
||||
}`}
|
||||
title="Read / Write"
|
||||
>
|
||||
RW
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 text-center text-text-muted text-sm">
|
||||
<p>File browser coming soon</p>
|
||||
<p className="text-xs mt-1">
|
||||
{isReadOnly
|
||||
? 'Files are read-only while Agent is working'
|
||||
: isReadWrite
|
||||
? 'Files are read/write enabled'
|
||||
: 'Select User or Agent mode to access files'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
gadget-code/frontend/src/components/LogPanel.tsx
Normal file
122
gadget-code/frontend/src/components/LogPanel.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { WorkspaceMode } from '../lib/types';
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LogPanelProps {
|
||||
logs: LogEntry[];
|
||||
expanded: boolean;
|
||||
workspaceMode: WorkspaceMode;
|
||||
onToggleExpand: () => void;
|
||||
}
|
||||
|
||||
export default function LogPanel({ logs, expanded, workspaceMode, onToggleExpand }: LogPanelProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && !expanded) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, expanded]);
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 bg-bg-primary flex flex-col border-t border-border-subtle">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary shrink-0 border-b border-border-subtle">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Log
|
||||
</h3>
|
||||
<button
|
||||
onClick={onToggleExpand}
|
||||
className="w-6 h-6 flex items-center justify-center text-text-muted hover:text-text-secondary transition-colors"
|
||||
title="Collapse"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-2 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5">
|
||||
<span className="text-text-muted shrink-0">
|
||||
{entry.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 ${
|
||||
entry.level === 'error'
|
||||
? 'text-red-500'
|
||||
: entry.level === 'warn'
|
||||
? 'text-yellow-500'
|
||||
: entry.level === 'info'
|
||||
? 'text-blue-400'
|
||||
: 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
[{entry.level.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-text-primary">{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-48 border-t border-border-subtle bg-bg-secondary flex flex-col shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary shrink-0">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Log
|
||||
</h3>
|
||||
<button
|
||||
onClick={onToggleExpand}
|
||||
className="w-6 h-6 flex items-center justify-center text-text-muted hover:text-text-secondary transition-colors"
|
||||
title="Expand"
|
||||
>
|
||||
▴
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-2 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5">
|
||||
<span className="text-text-muted shrink-0">
|
||||
{entry.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 ${
|
||||
entry.level === 'error'
|
||||
? 'text-red-500'
|
||||
: entry.level === 'warn'
|
||||
? 'text-yellow-500'
|
||||
: entry.level === 'info'
|
||||
? 'text-blue-400'
|
||||
: 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
[{entry.level.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-text-primary">{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import { WorkspaceMode } from '../lib/types';
|
||||
|
||||
interface WorkspaceModeIndicatorProps {
|
||||
mode: WorkspaceMode;
|
||||
onChange?: (mode: WorkspaceMode) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<WorkspaceMode, string> = {
|
||||
[WorkspaceMode.Idle]: 'I',
|
||||
[WorkspaceMode.Syncing]: 'S',
|
||||
[WorkspaceMode.User]: 'U',
|
||||
[WorkspaceMode.Agent]: 'A',
|
||||
};
|
||||
|
||||
const MODE_COLORS: Record<WorkspaceMode, string> = {
|
||||
[WorkspaceMode.Idle]: 'text-gray-500 border-gray-500',
|
||||
[WorkspaceMode.Syncing]: 'text-yellow-500 border-yellow-500',
|
||||
[WorkspaceMode.User]: 'text-blue-400 border-blue-400',
|
||||
[WorkspaceMode.Agent]: 'text-green-500 border-green-500',
|
||||
};
|
||||
|
||||
export default function WorkspaceModeIndicator({
|
||||
mode,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: WorkspaceModeIndicatorProps) {
|
||||
const modes = [
|
||||
WorkspaceMode.Idle,
|
||||
WorkspaceMode.Syncing,
|
||||
WorkspaceMode.User,
|
||||
WorkspaceMode.Agent,
|
||||
];
|
||||
|
||||
const handleClick = (m: WorkspaceMode) => {
|
||||
if (disabled || m === WorkspaceMode.Syncing) return;
|
||||
if (m === mode) return;
|
||||
if (mode !== WorkspaceMode.Idle) return;
|
||||
onChange?.(m);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{modes.map((m) => {
|
||||
const isActive = m === mode;
|
||||
const isClickable =
|
||||
!disabled && m !== WorkspaceMode.Syncing && mode === WorkspaceMode.Idle;
|
||||
const colorClass = MODE_COLORS[m];
|
||||
const inactiveClass = isActive
|
||||
? `border-2 ${colorClass} strobe shadow-[inset_0_1px_3px_rgba(0,0,0,0.5)]`
|
||||
: `border border-border-default`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => handleClick(m)}
|
||||
disabled={!isClickable}
|
||||
title={
|
||||
m === WorkspaceMode.Idle
|
||||
? 'Idle'
|
||||
: m === WorkspaceMode.Syncing
|
||||
? 'Syncing'
|
||||
: m === WorkspaceMode.User
|
||||
? 'User Mode'
|
||||
: 'Agent Mode'
|
||||
}
|
||||
className={`
|
||||
w-7 h-7 flex items-center justify-center
|
||||
font-mono font-bold text-sm rounded
|
||||
transition-all duration-150 select-none
|
||||
${inactiveClass}
|
||||
${isActive ? `active:text-${colorClass.split('-')[1]}` : 'text-text-muted'}
|
||||
${isClickable ? 'hover:border-border-highlight hover:text-text-secondary cursor-pointer' : ''}
|
||||
${!isClickable && !isActive ? 'cursor-default' : ''}
|
||||
${isActive ? 'translate-y-0.5 shadow-inner' : 'translate-y-0'}
|
||||
`}
|
||||
>
|
||||
{MODE_LABELS[m]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -75,3 +75,12 @@ input, textarea {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-highlight);
|
||||
}
|
||||
|
||||
@keyframes strobe {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.strobe {
|
||||
animation: strobe 1.2s ease-in-out infinite;
|
||||
}
|
||||
@ -27,6 +27,13 @@ export interface ClientToServerEvents {
|
||||
chatSession: any,
|
||||
cb: (success: boolean, chatSessionId: string) => void,
|
||||
) => void;
|
||||
requestWorkspaceMode: (
|
||||
registration: any,
|
||||
project: any,
|
||||
chatSession: any,
|
||||
mode: string,
|
||||
cb: (success: boolean, mode: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface SocketEvents {
|
||||
@ -66,6 +73,7 @@ export interface SocketEvents {
|
||||
message: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
}) => void;
|
||||
workspaceModeChanged: (mode: string) => void;
|
||||
connect: () => void;
|
||||
disconnect: (reason: string) => void;
|
||||
error: (error: Error) => void;
|
||||
@ -132,6 +140,10 @@ class SocketClient {
|
||||
},
|
||||
);
|
||||
|
||||
this.socket.on("workspaceModeChanged", (mode: string) => {
|
||||
this.emit("workspaceModeChanged", mode);
|
||||
});
|
||||
|
||||
this._socket.on("connect", () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit("connect");
|
||||
@ -218,6 +230,28 @@ class SocketClient {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
requestWorkspaceMode(
|
||||
registration: any,
|
||||
project: any,
|
||||
chatSession: any,
|
||||
mode: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(
|
||||
"requestWorkspaceMode",
|
||||
registration,
|
||||
project,
|
||||
chatSession,
|
||||
mode,
|
||||
(success: boolean, _mode: string) => resolve(success),
|
||||
);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const socketClient = new SocketClient();
|
||||
|
||||
6
gadget-code/frontend/src/lib/types.ts
Normal file
6
gadget-code/frontend/src/lib/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum WorkspaceMode {
|
||||
Idle = "idle",
|
||||
Syncing = "syncing",
|
||||
User = "user",
|
||||
Agent = "agent",
|
||||
}
|
||||
@ -2,6 +2,10 @@ import { useState, useEffect, useRef } 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 { WorkspaceMode } from '../lib/types';
|
||||
import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
|
||||
import FilesPanel from '../components/FilesPanel';
|
||||
import LogPanel from '../components/LogPanel';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
@ -17,6 +21,13 @@ interface ChatMessage {
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function ChatSessionView() {
|
||||
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
@ -30,9 +41,14 @@ export default function ChatSessionView() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [sessionLocked, setSessionLocked] = useState(true);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle);
|
||||
const [logExpanded, setLogExpanded] = useState(false);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessionData();
|
||||
@ -47,14 +63,20 @@ export default function ChatSessionView() {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (toastTimerRef.current) {
|
||||
clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadSessionData = async () => {
|
||||
try {
|
||||
// Load chat session first
|
||||
if (sessionId) {
|
||||
const sessionData = await chatSessionApi.get(sessionId);
|
||||
setSession(sessionData);
|
||||
|
||||
// Load project using the project _id from the session
|
||||
const projectRef = sessionData.project;
|
||||
const projectObjectId = typeof projectRef === 'string' ? projectRef : projectRef?._id;
|
||||
if (projectObjectId) {
|
||||
@ -62,7 +84,6 @@ export default function ChatSessionView() {
|
||||
setProject(projectData);
|
||||
}
|
||||
|
||||
// Load existing turns
|
||||
const turns = await chatSessionApi.getTurns(sessionId);
|
||||
const chatMessages: ChatMessage[] = turns.map((turn: ChatTurn) => ({
|
||||
id: turn._id,
|
||||
@ -71,7 +92,6 @@ export default function ChatSessionView() {
|
||||
timestamp: new Date(turn.createdAt),
|
||||
}));
|
||||
|
||||
// Add assistant responses
|
||||
turns.forEach((turn: ChatTurn) => {
|
||||
if (turn.thinking || turn.response || turn.toolCalls?.length) {
|
||||
chatMessages.push({
|
||||
@ -86,8 +106,6 @@ export default function ChatSessionView() {
|
||||
});
|
||||
|
||||
setMessages(chatMessages);
|
||||
|
||||
// Session is already locked by ProjectManager before navigation
|
||||
setSessionLocked(true);
|
||||
}
|
||||
} catch (err) {
|
||||
@ -104,6 +122,8 @@ export default function ChatSessionView() {
|
||||
socket.on('response', handleResponse);
|
||||
socket.on('toolCall', handleToolCall);
|
||||
socket.on('workOrderComplete', handleWorkOrderComplete);
|
||||
socket.on('workspaceModeChanged', handleWorkspaceModeChanged);
|
||||
socket.on('log:entry', handleLogEntry);
|
||||
};
|
||||
|
||||
const cleanupSocketListeners = () => {
|
||||
@ -113,6 +133,8 @@ export default function ChatSessionView() {
|
||||
socket.off('response', handleResponse);
|
||||
socket.off('toolCall', handleToolCall);
|
||||
socket.off('workOrderComplete', handleWorkOrderComplete);
|
||||
socket.off('workspaceModeChanged', handleWorkspaceModeChanged);
|
||||
socket.off('log:entry', handleLogEntry);
|
||||
};
|
||||
|
||||
const handleThinking = (content: string) => {
|
||||
@ -172,9 +194,51 @@ export default function ChatSessionView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkspaceModeChanged = (mode: string) => {
|
||||
setWorkspaceMode(mode as WorkspaceMode);
|
||||
};
|
||||
|
||||
const handleLogEntry = (data: { level: string; message: string; timestamp: number }) => {
|
||||
setLogs(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: `log-${Date.now()}-${Math.random()}`,
|
||||
timestamp: new Date(data.timestamp),
|
||||
level: data.level,
|
||||
message: data.message,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const showToast = (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;
|
||||
|
||||
const registration = session.drone as any;
|
||||
const success = await socketClient.requestWorkspaceMode(
|
||||
registration,
|
||||
project,
|
||||
session,
|
||||
mode,
|
||||
);
|
||||
if (!success) {
|
||||
showToast(`Cannot switch to ${mode} mode: workspace is not idle`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitPrompt = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!promptInput.trim() || isProcessing || !socket || !sessionLocked) return;
|
||||
if (workspaceMode !== WorkspaceMode.Agent) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
@ -188,7 +252,6 @@ export default function ChatSessionView() {
|
||||
setIsProcessing(true);
|
||||
setError('');
|
||||
|
||||
// Add assistant message placeholder
|
||||
setMessages(prev => [...prev, {
|
||||
id: `response-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
@ -196,7 +259,6 @@ export default function ChatSessionView() {
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
|
||||
// Emit prompt to backend
|
||||
socketClient.emitServer('submitPrompt', promptInput.trim());
|
||||
};
|
||||
|
||||
@ -204,6 +266,11 @@ export default function ChatSessionView() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked;
|
||||
const promptPlaceholder = workspaceMode !== WorkspaceMode.Agent
|
||||
? 'Select Agent mode to enter a prompt.'
|
||||
: 'Enter your prompt... (Shift+Enter for new line)';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
||||
@ -229,9 +296,18 @@ export default function ChatSessionView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex bg-bg-primary overflow-hidden">
|
||||
{/* Main Chat Area */}
|
||||
<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">
|
||||
{/* 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">
|
||||
{messages.map((message) => (
|
||||
@ -241,7 +317,7 @@ export default function ChatSessionView() {
|
||||
</div>
|
||||
|
||||
{/* Prompt Input */}
|
||||
<div className="border-t border-border-subtle p-4 bg-bg-secondary">
|
||||
<div className="border-t border-border-subtle p-4 bg-bg-secondary shrink-0">
|
||||
<form onSubmit={handleSubmitPrompt} className="flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
@ -253,14 +329,14 @@ export default function ChatSessionView() {
|
||||
handleSubmitPrompt(e);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your prompt... (Shift+Enter for new line)"
|
||||
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"
|
||||
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={isProcessing || !sessionLocked}
|
||||
disabled={promptDisabled}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isProcessing || !promptInput.trim() || !sessionLocked}
|
||||
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'}
|
||||
@ -269,9 +345,72 @@ export default function ChatSessionView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Sidebar */}
|
||||
{/* Log Panel (25%) */}
|
||||
<LogPanel
|
||||
logs={logs}
|
||||
expanded={logExpanded}
|
||||
workspaceMode={workspaceMode}
|
||||
onToggleExpand={() => setLogExpanded(!logExpanded)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-y-auto">
|
||||
<SessionSidebar session={session} project={project} sessionLocked={sessionLocked} />
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-1 text-green-500">
|
||||
<span>✓</span>
|
||||
<span className="text-xs">{sessionLocked ? 'Locked' : 'Unlocked'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Name</div>
|
||||
<div className="text-text-primary">{session?.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Model</div>
|
||||
<div className="text-text-primary">{session?.selectedModel}</div>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
@ -337,96 +476,3 @@ function ChatMessageBubble({ message }: { message: ChatMessage }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionSidebar({ session, project, sessionLocked }: {
|
||||
session: ChatSession | null;
|
||||
project: Project | null;
|
||||
sessionLocked: boolean;
|
||||
}) {
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-text-muted">No session loaded</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
|
||||
Session
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Name</div>
|
||||
<div className="text-text-primary">{session.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">ID</div>
|
||||
<div className="font-mono text-xs text-text-secondary truncate">
|
||||
{session._id}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Mode</div>
|
||||
<div className="text-text-primary capitalize">{session.mode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Status</div>
|
||||
<div className="flex items-center gap-1 text-green-500">
|
||||
<span>✓</span>
|
||||
<span>{sessionLocked ? 'Locked' : 'Unlocked'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
|
||||
Model
|
||||
</h3>
|
||||
<div className="text-text-primary">
|
||||
{session.selectedModel}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
Provider: {typeof session.provider === 'string' ? 'Loaded' : session.provider?.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
|
||||
Stats
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">TC</div>
|
||||
<div className="text-lg font-mono">{session.stats.toolCallCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">FO</div>
|
||||
<div className="text-lg font-mono">0</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">SA</div>
|
||||
<div className="text-lg font-mono">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-2">
|
||||
Project
|
||||
</h3>
|
||||
{project ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-text-primary">{project.name}</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
ChatTurnStatus,
|
||||
GadgetId,
|
||||
ChatTurnDocument,
|
||||
WorkspaceMode,
|
||||
} from "@gadget/api";
|
||||
|
||||
import { ChatSessionService, SocketService } from "../services/index.ts";
|
||||
@ -26,6 +27,7 @@ export class CodeSession extends SocketSession {
|
||||
protected chatSession: IChatSession | undefined;
|
||||
protected selectedDrone: IDroneRegistration | undefined;
|
||||
protected currentTurnId: GadgetId | undefined;
|
||||
protected workspaceMode: WorkspaceMode = WorkspaceMode.Idle;
|
||||
|
||||
constructor(socket: GadgetSocket, user: IUser) {
|
||||
super(socket, user);
|
||||
@ -81,6 +83,7 @@ export class CodeSession extends SocketSession {
|
||||
this.project = project;
|
||||
SocketService.registerChatSession(chatSession._id, this);
|
||||
droneSession.setChatSessionId(chatSession._id);
|
||||
droneSession.setCodeSession(this);
|
||||
}
|
||||
cb(success, chatSessionId);
|
||||
},
|
||||
@ -142,4 +145,13 @@ export class CodeSession extends SocketSession {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by DroneSession when the drone emits a workspace mode change.
|
||||
* Updates local state and forwards to the IDE socket.
|
||||
*/
|
||||
onWorkspaceModeChanged(mode: WorkspaceMode): void {
|
||||
this.workspaceMode = mode;
|
||||
this.socket.emit("workspaceModeChanged", mode);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
IDroneRegistration,
|
||||
ChatTurnStatus,
|
||||
GadgetId,
|
||||
WorkspaceMode,
|
||||
} from "@gadget/api";
|
||||
import {
|
||||
GadgetSocket,
|
||||
@ -21,6 +22,8 @@ export class DroneSession extends SocketSession {
|
||||
registration: IDroneRegistration;
|
||||
chatSessionId: GadgetId | undefined;
|
||||
currentTurnId: GadgetId | undefined;
|
||||
workspaceMode: WorkspaceMode = WorkspaceMode.Idle;
|
||||
codeSession: import("./code-session.js").CodeSession | undefined;
|
||||
|
||||
constructor(socket: GadgetSocket, registration: IDroneRegistration) {
|
||||
super(socket, registration.user as IUser);
|
||||
@ -39,6 +42,7 @@ export class DroneSession extends SocketSession {
|
||||
this.onRequestCrashRecovery.bind(this),
|
||||
);
|
||||
this.socket.on("requestTermination", this.onRequestTermination.bind(this));
|
||||
this.socket.on("workspaceModeChanged", this.onWorkspaceModeChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -179,6 +183,25 @@ export class DroneSession extends SocketSession {
|
||||
this.currentTurnId = turnId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the associated code session for routing events back to the IDE.
|
||||
*/
|
||||
setCodeSession(codeSession: import("./code-session.js").CodeSession): void {
|
||||
this.codeSession = codeSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the drone emits a workspace mode change.
|
||||
*/
|
||||
async onWorkspaceModeChanged(mode: WorkspaceMode): Promise<void> {
|
||||
this.workspaceMode = mode;
|
||||
this.log.info("workspace mode changed", { mode });
|
||||
|
||||
if (this.codeSession) {
|
||||
this.codeSession.onWorkspaceModeChanged(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the drone requests crash recovery for an incomplete work order.
|
||||
*/
|
||||
|
||||
@ -313,6 +313,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
});
|
||||
if (this.workspaceMode === WorkspaceMode.Idle) {
|
||||
this.workspaceMode = mode;
|
||||
this.socket!.emit("workspaceModeChanged", this.workspaceMode);
|
||||
return cb(true, this.workspaceMode);
|
||||
}
|
||||
return cb(false, this.workspaceMode);
|
||||
|
||||
@ -6,6 +6,7 @@ import { IChatSession } from "../interfaces/chat-session.ts";
|
||||
import { IChatTurn } from "../interfaces/chat-turn.ts";
|
||||
import { IDroneRegistration } from "../interfaces/drone-registration.ts";
|
||||
import { IProject } from "../interfaces/project.ts";
|
||||
import { WorkspaceMode } from "./ide.ts";
|
||||
|
||||
export type ProcessWorkOrderCallback = (
|
||||
success: boolean,
|
||||
@ -49,3 +50,5 @@ export type CrashRecoveryResponseMessage = (data: {
|
||||
export type RequestTerminationMessage = (
|
||||
cb: (success: boolean) => void,
|
||||
) => void;
|
||||
|
||||
export type WorkspaceModeChangedMessage = (mode: WorkspaceMode) => void;
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
RequestCrashRecoveryMessage,
|
||||
CrashRecoveryResponseMessage,
|
||||
RequestTerminationMessage,
|
||||
WorkspaceModeChangedMessage,
|
||||
} from "./drone.ts";
|
||||
import {
|
||||
RequestSessionLockMessage,
|
||||
@ -55,6 +56,7 @@ export interface ClientToServerEvents {
|
||||
workOrderComplete: WorkOrderCompleteMessage;
|
||||
requestCrashRecovery: RequestCrashRecoveryMessage;
|
||||
requestTermination: RequestTerminationMessage;
|
||||
workspaceModeChanged: WorkspaceModeChangedMessage;
|
||||
}
|
||||
|
||||
export interface ServerToClientEvents {
|
||||
@ -76,6 +78,7 @@ export interface ServerToClientEvents {
|
||||
response: ResponseMessage;
|
||||
toolCall: ToolCallMessage;
|
||||
workOrderComplete: WorkOrderCompleteMessage;
|
||||
workspaceModeChanged: WorkspaceModeChangedMessage;
|
||||
}
|
||||
|
||||
export interface SocketData {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user