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 = (