From 4ec31764d5e2e32282b50d190f9aea878306ff8f Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Sat, 2 May 2026 15:34:26 -0400 Subject: [PATCH] workspace management checkpoint while agents are working on it --- .../frontend/src/components/FilesPanel.tsx | 52 +++ .../frontend/src/components/LogPanel.tsx | 122 +++++++ .../src/components/WorkspaceModeIndicator.tsx | 84 +++++ gadget-code/frontend/src/index.css | 9 + gadget-code/frontend/src/lib/socket.ts | 34 ++ gadget-code/frontend/src/lib/types.ts | 6 + .../frontend/src/pages/ChatSessionView.tsx | 328 ++++++++++-------- gadget-code/src/lib/code-session.ts | 12 + gadget-code/src/lib/drone-session.ts | 23 ++ gadget-drone/src/gadget-drone.ts | 1 + packages/api/src/messages/drone.ts | 3 + packages/api/src/messages/socket.ts | 3 + 12 files changed, 536 insertions(+), 141 deletions(-) create mode 100644 gadget-code/frontend/src/components/FilesPanel.tsx create mode 100644 gadget-code/frontend/src/components/LogPanel.tsx create mode 100644 gadget-code/frontend/src/components/WorkspaceModeIndicator.tsx create mode 100644 gadget-code/frontend/src/lib/types.ts diff --git a/gadget-code/frontend/src/components/FilesPanel.tsx b/gadget-code/frontend/src/components/FilesPanel.tsx new file mode 100644 index 0000000..5182576 --- /dev/null +++ b/gadget-code/frontend/src/components/FilesPanel.tsx @@ -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 ( +
+
+

+ Files +

+
+ + RO + + + RW + +
+
+
+

File browser coming soon

+

+ {isReadOnly + ? 'Files are read-only while Agent is working' + : isReadWrite + ? 'Files are read/write enabled' + : 'Select User or Agent mode to access files'} +

+
+
+ ); +} \ No newline at end of file diff --git a/gadget-code/frontend/src/components/LogPanel.tsx b/gadget-code/frontend/src/components/LogPanel.tsx new file mode 100644 index 0000000..5c5f93a --- /dev/null +++ b/gadget-code/frontend/src/components/LogPanel.tsx @@ -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(null); + + useEffect(() => { + if (scrollRef.current && !expanded) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs, expanded]); + + if (expanded) { + return ( +
+
+

+ Log +

+ +
+
+ {logs.length === 0 ? ( +
No log entries
+ ) : ( + logs.map((entry) => ( +
+ + {entry.timestamp.toLocaleTimeString()} + + + [{entry.level.toUpperCase()}] + + {entry.message} +
+ )) + )} +
+
+ ); + } + + return ( +
+
+

+ Log +

+ +
+
+ {logs.length === 0 ? ( +
No log entries
+ ) : ( + logs.map((entry) => ( +
+ + {entry.timestamp.toLocaleTimeString()} + + + [{entry.level.toUpperCase()}] + + {entry.message} +
+ )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/gadget-code/frontend/src/components/WorkspaceModeIndicator.tsx b/gadget-code/frontend/src/components/WorkspaceModeIndicator.tsx new file mode 100644 index 0000000..47e60a7 --- /dev/null +++ b/gadget-code/frontend/src/components/WorkspaceModeIndicator.tsx @@ -0,0 +1,84 @@ +import { WorkspaceMode } from '../lib/types'; + +interface WorkspaceModeIndicatorProps { + mode: WorkspaceMode; + onChange?: (mode: WorkspaceMode) => void; + disabled?: boolean; +} + +const MODE_LABELS: Record = { + [WorkspaceMode.Idle]: 'I', + [WorkspaceMode.Syncing]: 'S', + [WorkspaceMode.User]: 'U', + [WorkspaceMode.Agent]: 'A', +}; + +const MODE_COLORS: Record = { + [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 ( +
+ {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 ( + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/gadget-code/frontend/src/index.css b/gadget-code/frontend/src/index.css index 0b5670d..bda31df 100644 --- a/gadget-code/frontend/src/index.css +++ b/gadget-code/frontend/src/index.css @@ -74,4 +74,13 @@ 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; } \ No newline at end of file diff --git a/gadget-code/frontend/src/lib/socket.ts b/gadget-code/frontend/src/lib/socket.ts index 5f50de0..b9ae7f2 100644 --- a/gadget-code/frontend/src/lib/socket.ts +++ b/gadget-code/frontend/src/lib/socket.ts @@ -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 { + 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(); diff --git a/gadget-code/frontend/src/lib/types.ts b/gadget-code/frontend/src/lib/types.ts new file mode 100644 index 0000000..979d293 --- /dev/null +++ b/gadget-code/frontend/src/lib/types.ts @@ -0,0 +1,6 @@ +export enum WorkspaceMode { + Idle = "idle", + Syncing = "syncing", + User = "user", + Agent = "agent", +} \ No newline at end of file diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx index 9f318ec..477eeaa 100644 --- a/gadget-code/frontend/src/pages/ChatSessionView.tsx +++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx @@ -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,11 +21,18 @@ 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(); const socket = socketClient.socket; - + const [project, setProject] = useState(null); const [session, setSession] = useState(null); const [messages, setMessages] = useState([]); @@ -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.Idle); + const [logExpanded, setLogExpanded] = useState(false); + const [logs, setLogs] = useState([]); + const [toast, setToast] = useState(null); + const messagesEndRef = useRef(null); const inputRef = useRef(null); + const toastTimerRef = useRef | 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 (
@@ -229,49 +296,121 @@ export default function ChatSessionView() { } return ( -
- {/* Main Chat Area */} +
+ {/* Toast notification */} + {toast && ( +
+ {toast} +
+ )} + + {/* Content Area */}
- {/* Messages */} -
- {messages.map((message) => ( - - ))} -
+ {/* Chat View (75%) */} +
+ {/* Messages */} +
+ {messages.map((message) => ( + + ))} +
+
+ + {/* Prompt Input */} +
+
+