workspace management checkpoint while agents are working on it

This commit is contained in:
Rob Colbert 2026-05-02 15:34:26 -04:00
parent 20f9e495a6
commit 4ec31764d5
12 changed files with 536 additions and 141 deletions

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

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

View File

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

View File

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

View File

@ -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();

View File

@ -0,0 +1,6 @@
export enum WorkspaceMode {
Idle = "idle",
Syncing = "syncing",
User = "user",
Agent = "agent",
}

View File

@ -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<Project | null>(null);
const [session, setSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
@ -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,49 +296,121 @@ 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">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<ChatMessageBubble key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
{/* 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) => (
<ChatMessageBubble key={message.id} message={message} />
))}
<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>
{/* Prompt Input */}
<div className="border-t border-border-subtle p-4 bg-bg-secondary">
<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="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"
rows={3}
disabled={isProcessing || !sessionLocked}
/>
<button
type="submit"
disabled={isProcessing || !promptInput.trim() || !sessionLocked}
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>
{/* Log Panel (25%) */}
<LogPanel
logs={logs}
expanded={logExpanded}
workspaceMode={workspaceMode}
onToggleExpand={() => setLogExpanded(!logExpanded)}
/>
</div>
{/* Session Sidebar */}
{/* 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>
);
@ -336,97 +475,4 @@ function ChatMessageBubble({ message }: { message: ChatMessage }) {
</div>
</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>
);
}
}

View File

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

View File

@ -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.
*/

View File

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

View File

@ -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;

View File

@ -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 {