Implement workspace mode switching with validation

- 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
This commit is contained in:
Rob Colbert 2026-05-02 18:13:31 -04:00
parent 8b5df3827b
commit 5c56f95cd6
7 changed files with 162 additions and 39 deletions

View File

@ -56,13 +56,47 @@ As for styling, think of audio gear - like a modern mixing console - with indica
### Changing Modes ### 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 ### IDE Workspace Mode Features

View File

@ -1,4 +1,4 @@
import { WorkspaceMode } from '../lib/types'; import { WorkspaceMode } from "../lib/types";
interface FilesPanelProps { interface FilesPanelProps {
workspaceMode: WorkspaceMode; workspaceMode: WorkspaceMode;
@ -15,36 +15,36 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
Files Files
</h3> </h3>
<div className="flex items-center gap-1"> <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 <span
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${ className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
isReadWrite isReadWrite
? 'border-green-500 strobe text-green-500' ? "border-green-500 strobe text-green-500"
: 'border-border-default text-text-muted' : "border-border-default text-text-muted"
}`} }`}
title="Read / Write" title="Read / Write"
> >
RW RW
</span> </span>
<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>
</div> </div>
</div> </div>
<div className="p-4 text-center text-text-muted text-sm"> <div className="p-4 text-center text-text-muted text-sm">
<p>File browser coming soon</p> <p>File browser coming soon</p>
<p className="text-xs mt-1"> <p className="text-xs mt-1">
{isReadOnly {isReadOnly
? 'Files are read-only while Agent is working' ? "Files are read-only while Agent is working"
: isReadWrite : isReadWrite
? 'Files are read/write enabled' ? "Files are read/write enabled"
: 'Select User or Agent mode to access files'} : "Select User or Agent mode to access files"}
</p> </p>
</div> </div>
</div> </div>

View File

@ -35,8 +35,26 @@ export default function WorkspaceModeIndicator({
const handleClick = (m: WorkspaceMode) => { const handleClick = (m: WorkspaceMode) => {
if (disabled || m === WorkspaceMode.Syncing) return; if (disabled || m === WorkspaceMode.Syncing) return;
if (m === mode) return; if (m === mode) return;
if (mode !== WorkspaceMode.Idle) return;
switch (mode) {
case WorkspaceMode.Idle:
if (m === WorkspaceMode.User || m === WorkspaceMode.Agent) {
onChange?.(m); onChange?.(m);
}
break;
case WorkspaceMode.User:
if (m === WorkspaceMode.Agent) {
onChange?.(m);
}
break;
case WorkspaceMode.Agent:
if (m === WorkspaceMode.User) {
onChange?.(m);
}
break;
}
}; };
return ( return (
@ -44,7 +62,7 @@ export default function WorkspaceModeIndicator({
{modes.map((m) => { {modes.map((m) => {
const isActive = m === mode; const isActive = m === mode;
const isClickable = const isClickable =
!disabled && m !== WorkspaceMode.Syncing && mode === WorkspaceMode.Idle; !disabled && m !== WorkspaceMode.Syncing && m !== mode;
const colorClass = MODE_COLORS[m]; const colorClass = MODE_COLORS[m];
const inactiveClass = isActive const inactiveClass = isActive
? `border-2 ${colorClass} strobe shadow-[inset_0_1px_3px_rgba(0,0,0,0.5)]` ? `border-2 ${colorClass} strobe shadow-[inset_0_1px_3px_rgba(0,0,0,0.5)]`

View File

@ -236,7 +236,7 @@ class SocketClient {
project: any, project: any,
chatSession: any, chatSession: any,
mode: string, mode: string,
): Promise<boolean> { ): Promise<{ success: boolean; mode: string; reason?: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this._socket?.connected) { if (this._socket?.connected) {
this._socket.emit( this._socket.emit(
@ -245,10 +245,11 @@ class SocketClient {
project, project,
chatSession, chatSession,
mode, mode,
(success: boolean, _mode: string) => resolve(success), (success: boolean, mode: string, reason?: string) =>
resolve({ success, mode, reason }),
); );
} else { } else {
resolve(false); resolve({ success: false, mode: "" });
} }
}); });
} }

View File

@ -223,6 +223,8 @@ export default function ChatSessionView() {
const handleWorkspaceModeChange = async (mode: WorkspaceMode) => { const handleWorkspaceModeChange = async (mode: WorkspaceMode) => {
if (!session || !project) return; if (!session || !project) return;
if (mode === workspaceMode) return;
try { try {
const droneJson = localStorage.getItem('dtp_drone_registration'); const droneJson = localStorage.getItem('dtp_drone_registration');
if (!droneJson) { if (!droneJson) {
@ -230,14 +232,15 @@ export default function ChatSessionView() {
return; return;
} }
const registration = JSON.parse(droneJson); const registration = JSON.parse(droneJson);
const success = await socketClient.requestWorkspaceMode( const result = await socketClient.requestWorkspaceMode(
registration, registration,
project, project,
session, session,
mode, mode,
); );
if (!success) { if (!result.success) {
showToast(`Cannot switch to ${mode} mode: workspace is not idle`); showToast(result.reason || `Cannot switch to ${mode} mode`);
setWorkspaceMode(workspaceMode);
} }
} catch (err) { } catch (err) {
showToast(`Failed to change workspace mode: ${err instanceof Error ? err.message : 'Unknown error'}`); showToast(`Failed to change workspace mode: ${err instanceof Error ? err.message : 'Unknown error'}`);

View File

@ -46,6 +46,7 @@ class GadgetDrone extends GadgetProcess {
private workspaceMode: WorkspaceMode = WorkspaceMode.Syncing; private workspaceMode: WorkspaceMode = WorkspaceMode.Syncing;
private sessionLock: ISessionLock | undefined; private sessionLock: ISessionLock | undefined;
private isProcessingWorkOrder: boolean = false;
private socket: ClientSocket | undefined; private socket: ClientSocket | undefined;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
@ -303,20 +304,79 @@ class GadgetDrone extends GadgetProcess {
this.log.warn("rejecting workspace mode request", { this.log.warn("rejecting workspace mode request", {
chatSession: { _id: chatSession._id, name: chatSession.name }, 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", { this.log.info("requestWorkspaceMode received", {
registration, registration: { _id: registration._id },
project, project: {
chatSession, _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; let newMode: WorkspaceMode | null = null;
this.socket!.emit("workspaceModeChanged", this.workspaceMode); let rejectionReason: string | undefined;
return cb(true, this.workspaceMode);
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( async onProcessWorkOrder(
@ -374,6 +434,7 @@ class GadgetDrone extends GadgetProcess {
cb(true, "work order accepted"); // confirm that drone has the work order cb(true, "work order accepted"); // confirm that drone has the work order
this.isProcessingWorkOrder = true;
try { try {
await AgentService.process(order, this.socket); await AgentService.process(order, this.socket);
await WorkspaceService.removeWorkOrderCache(); await WorkspaceService.removeWorkOrderCache();
@ -383,6 +444,11 @@ class GadgetDrone extends GadgetProcess {
error: err.message, error: err.message,
}); });
// Leave cache in place for recovery // Leave cache in place for recovery
} finally {
this.isProcessingWorkOrder = false;
this.log.info("work order processing complete", {
isProcessingWorkOrder: this.isProcessingWorkOrder,
});
} }
} }

View File

@ -36,6 +36,7 @@ export enum WorkspaceMode {
export type RequestWorkspaceModeCallback = ( export type RequestWorkspaceModeCallback = (
success: boolean, success: boolean,
mode: WorkspaceMode, mode: WorkspaceMode,
reason?: string,
) => void; ) => void;
export type RequestWorkspaceModeMessage = ( export type RequestWorkspaceModeMessage = (