From 5c56f95cd6e9fbc55467d8fee391f775c4f94fcc Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Sat, 2 May 2026 18:13:31 -0400 Subject: [PATCH] Implement workspace mode switching with validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isProcessingWorkOrder flag to track Agent work order processing - Update onRequestWorkspaceMode with mode transition matrix validation - Idle → User/Agent: Always allowed - User → Agent: Always allowed (file editor checks for future) - Agent → User: Only if !isProcessingWorkOrder - All other transitions: Rejected with reason - Extend RequestWorkspaceModeCallback with optional reason parameter - Update frontend socket client to capture rejection reason - Update handleWorkspaceModeChange to display rejection reason in toast - Update WorkspaceModeIndicator to allow mode transitions per matrix - Fix FilesPanel RW/RO indicator swap bug - Document mode transition matrix and behavior in workspace-management.md --- docs/workspace-management.md | 42 +++++++++- .../frontend/src/components/FilesPanel.tsx | 34 ++++---- .../src/components/WorkspaceModeIndicator.tsx | 24 +++++- gadget-code/frontend/src/lib/socket.ts | 7 +- .../frontend/src/pages/ChatSessionView.tsx | 9 +- gadget-drone/src/gadget-drone.ts | 84 +++++++++++++++++-- packages/api/src/messages/ide.ts | 1 + 7 files changed, 162 insertions(+), 39 deletions(-) diff --git a/docs/workspace-management.md b/docs/workspace-management.md index e83e56a..9e4c8d0 100644 --- a/docs/workspace-management.md +++ b/docs/workspace-management.md @@ -56,13 +56,47 @@ As for styling, think of audio gear - like a modern mixing console - with indica ### Changing Modes -When the workspace is Idle, the User can select/click the U or A mode indicator to request the drone to switch to that mode. +The User changes workspace modes by clicking the mode indicator buttons in the SESSION panel header. Mode transitions follow a strict matrix enforced by both the IDE (UI) and the drone (backend validation). -The system will send the `requestWorkspaceMode` message to the drone. If the drone can accomodate the request, it switches to the requested mode and acknowledges the switch. Or, it rejects the request with a reason. +#### Mode Transition Matrix -If the drone accepts the mode switch request, the IDE updates as required by enabling the associated features and disabling the prohibited featured defined by the mode. +| Current Mode | Target Mode | Allowed? | Validation | +|--------------|-------------|----------|------------| +| Idle | User | ✅ Yes | Always allowed | +| Idle | Agent | ✅ Yes | Always allowed | +| User | Agent | ✅ Yes | Always allowed | +| User | Idle | ❌ No | Blocked by UI (no defined circumstances to return to Idle) | +| Agent | User | ⚠️ Conditional | Rejected if Agent is processing a work order | +| Agent | Idle | ❌ No | Blocked by UI (no defined circumstances to return to Idle) | +| Any | Syncing | ❌ No | System-controlled only (git operations) | -Mode switch requests can fail or be rejected. The response will explain why if so. That message should be displayed to the User as a toast so they understand why the drone can't satisfy the request and solve the problem. +#### Mode Switch Request Flow + +1. User clicks a mode indicator button (U or A) +2. IDE validates the transition is allowed (see matrix above) +3. IDE sends `requestWorkspaceMode` message to the drone +4. Drone validates the request against current state: + - **Idle → User/Agent**: Always accepted + - **User → Agent**: Always accepted (file editor checks to be added later) + - **Agent → User**: Rejected if `isProcessingWorkOrder === true` with message: "Agent is currently working. Please wait for the current task to complete." +5. Drone responds with `(success: boolean, mode: WorkspaceMode, reason?: string)` +6. If rejected, IDE displays the rejection reason as a toast notification +7. If accepted, drone emits `workspaceModeChanged` event and IDE updates UI + +#### Workspace Mode Behavior + +- **Idle**: Initial state after `requestSessionLock`. User can transition to User or Agent mode. +- **User**: User can work with files in read/write mode (File Editor to be implemented). Can transition to Agent mode. +- **Agent**: Agent is working or available to accept work orders. User can submit prompts. Can transition to User mode only if no work order is processing. +- **Syncing**: System-controlled state during git operations (clone, push, pull, etc.). Not user-selectable. + +#### Important Notes + +- The workspace **does not** automatically return to Idle after the Agentic Workflow Loop completes +- The workspace remains in the current mode until the User explicitly requests a change +- The Agent **cannot** change the workspace mode; only the User can request mode changes +- When the Agent is processing a work order (from prompt submission until `workOrderComplete` event), mode switches from Agent → User are rejected +- Rejection messages are displayed as toast notifications in the IDE ### IDE Workspace Mode Features diff --git a/gadget-code/frontend/src/components/FilesPanel.tsx b/gadget-code/frontend/src/components/FilesPanel.tsx index 5182576..63d1bce 100644 --- a/gadget-code/frontend/src/components/FilesPanel.tsx +++ b/gadget-code/frontend/src/components/FilesPanel.tsx @@ -1,4 +1,4 @@ -import { WorkspaceMode } from '../lib/types'; +import { WorkspaceMode } from "../lib/types"; interface FilesPanelProps { workspaceMode: WorkspaceMode; @@ -15,38 +15,38 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) { Files
- - RO - RW + + RO +

File browser coming soon

{isReadOnly - ? 'Files are read-only while Agent is working' + ? "Files are read-only while Agent is working" : isReadWrite - ? 'Files are read/write enabled' - : 'Select User or Agent mode to access files'} + ? "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/WorkspaceModeIndicator.tsx b/gadget-code/frontend/src/components/WorkspaceModeIndicator.tsx index 47e60a7..cf74db0 100644 --- a/gadget-code/frontend/src/components/WorkspaceModeIndicator.tsx +++ b/gadget-code/frontend/src/components/WorkspaceModeIndicator.tsx @@ -35,8 +35,26 @@ export default function WorkspaceModeIndicator({ const handleClick = (m: WorkspaceMode) => { if (disabled || m === WorkspaceMode.Syncing) return; if (m === mode) return; - if (mode !== WorkspaceMode.Idle) return; - onChange?.(m); + + switch (mode) { + case WorkspaceMode.Idle: + if (m === WorkspaceMode.User || m === WorkspaceMode.Agent) { + onChange?.(m); + } + break; + + case WorkspaceMode.User: + if (m === WorkspaceMode.Agent) { + onChange?.(m); + } + break; + + case WorkspaceMode.Agent: + if (m === WorkspaceMode.User) { + onChange?.(m); + } + break; + } }; return ( @@ -44,7 +62,7 @@ export default function WorkspaceModeIndicator({ {modes.map((m) => { const isActive = m === mode; const isClickable = - !disabled && m !== WorkspaceMode.Syncing && mode === WorkspaceMode.Idle; + !disabled && m !== WorkspaceMode.Syncing && m !== mode; const colorClass = MODE_COLORS[m]; const inactiveClass = isActive ? `border-2 ${colorClass} strobe shadow-[inset_0_1px_3px_rgba(0,0,0,0.5)]` diff --git a/gadget-code/frontend/src/lib/socket.ts b/gadget-code/frontend/src/lib/socket.ts index b9ae7f2..4a234ae 100644 --- a/gadget-code/frontend/src/lib/socket.ts +++ b/gadget-code/frontend/src/lib/socket.ts @@ -236,7 +236,7 @@ class SocketClient { project: any, chatSession: any, mode: string, - ): Promise { + ): Promise<{ success: boolean; mode: string; reason?: string }> { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( @@ -245,10 +245,11 @@ class SocketClient { project, chatSession, mode, - (success: boolean, _mode: string) => resolve(success), + (success: boolean, mode: string, reason?: string) => + resolve({ success, mode, reason }), ); } else { - resolve(false); + resolve({ success: false, mode: "" }); } }); } diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx index a245841..9fb8629 100644 --- a/gadget-code/frontend/src/pages/ChatSessionView.tsx +++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx @@ -223,6 +223,8 @@ export default function ChatSessionView() { const handleWorkspaceModeChange = async (mode: WorkspaceMode) => { if (!session || !project) return; + if (mode === workspaceMode) return; + try { const droneJson = localStorage.getItem('dtp_drone_registration'); if (!droneJson) { @@ -230,14 +232,15 @@ export default function ChatSessionView() { return; } const registration = JSON.parse(droneJson); - const success = await socketClient.requestWorkspaceMode( + const result = await socketClient.requestWorkspaceMode( registration, project, session, mode, ); - if (!success) { - showToast(`Cannot switch to ${mode} mode: workspace is not idle`); + 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'}`); diff --git a/gadget-drone/src/gadget-drone.ts b/gadget-drone/src/gadget-drone.ts index 9183136..9093b88 100644 --- a/gadget-drone/src/gadget-drone.ts +++ b/gadget-drone/src/gadget-drone.ts @@ -46,6 +46,7 @@ class GadgetDrone extends GadgetProcess { private workspaceMode: WorkspaceMode = WorkspaceMode.Syncing; private sessionLock: ISessionLock | undefined; + private isProcessingWorkOrder: boolean = false; private socket: ClientSocket | undefined; private isShuttingDown: boolean = false; @@ -303,20 +304,79 @@ class GadgetDrone extends GadgetProcess { this.log.warn("rejecting workspace mode request", { chatSession: { _id: chatSession._id, name: chatSession.name }, }); - return cb(false, this.workspaceMode); + return cb( + false, + this.workspaceMode, + `This drone is locked to a different session (${this.sessionLock.session.name})`, + ); } this.log.info("requestWorkspaceMode received", { - registration, - project, - chatSession, + registration: { _id: registration._id }, + project: { + _id: project._id, + name: project.name, + }, + chatSession: { + _id: chatSession._id, + name: chatSession.name, + }, + currentMode: this.workspaceMode, + requestedMode: mode, }); - if (this.workspaceMode === WorkspaceMode.Idle) { - this.workspaceMode = mode; - this.socket!.emit("workspaceModeChanged", this.workspaceMode); - return cb(true, this.workspaceMode); + + let newMode: WorkspaceMode | null = null; + let rejectionReason: string | undefined; + + switch (this.workspaceMode) { + case WorkspaceMode.Idle: + if (mode === WorkspaceMode.User || mode === WorkspaceMode.Agent) { + newMode = mode; + } else { + rejectionReason = "Invalid mode transition from Idle"; + } + break; + + case WorkspaceMode.User: + if (mode === WorkspaceMode.Agent) { + newMode = mode; + } else { + rejectionReason = "Invalid mode transition from User mode"; + } + break; + + case WorkspaceMode.Agent: + if (mode === WorkspaceMode.User) { + if (this.isProcessingWorkOrder) { + rejectionReason = + "Agent is currently working. Please wait for the current task to complete."; + } else { + newMode = mode; + } + } else { + rejectionReason = "Invalid mode transition from Agent mode"; + } + break; + + case WorkspaceMode.Syncing: + rejectionReason = "Cannot change mode during sync operation"; + break; + } + + if (newMode) { + this.workspaceMode = newMode; + this.socket!.emit("workspaceModeChanged", this.workspaceMode); + this.log.info("workspace mode changed", { + previousMode: this.workspaceMode, + newMode, + }); + return cb(true, this.workspaceMode); + } else { + this.log.info("workspace mode request rejected", { + reason: rejectionReason, + }); + return cb(false, this.workspaceMode, rejectionReason); } - return cb(false, this.workspaceMode); } async onProcessWorkOrder( @@ -374,6 +434,7 @@ class GadgetDrone extends GadgetProcess { cb(true, "work order accepted"); // confirm that drone has the work order + this.isProcessingWorkOrder = true; try { await AgentService.process(order, this.socket); await WorkspaceService.removeWorkOrderCache(); @@ -383,6 +444,11 @@ class GadgetDrone extends GadgetProcess { error: err.message, }); // Leave cache in place for recovery + } finally { + this.isProcessingWorkOrder = false; + this.log.info("work order processing complete", { + isProcessingWorkOrder: this.isProcessingWorkOrder, + }); } } diff --git a/packages/api/src/messages/ide.ts b/packages/api/src/messages/ide.ts index ded9ed9..f2bd769 100644 --- a/packages/api/src/messages/ide.ts +++ b/packages/api/src/messages/ide.ts @@ -36,6 +36,7 @@ export enum WorkspaceMode { export type RequestWorkspaceModeCallback = ( success: boolean, mode: WorkspaceMode, + reason?: string, ) => void; export type RequestWorkspaceModeMessage = (