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:
parent
8b5df3827b
commit
5c56f95cd6
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
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 (
|
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)]`
|
||||||
|
|||||||
@ -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: "" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'}`);
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user