Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/design/experimental-session-plan-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Experimental Session Plan & Review

## Goal

Make ordinary-session Workflow visualization opt-in and let users review the
exact Todo dependency graph before execution. Reuse Plan Mode, Todo snapshots,
and the existing permission lifecycle.

## Rollout

`experimental.sessionWorkflow` is disabled by default. When disabled, the Web
Shell keeps the existing Todo list and Plan Mode behavior but does not render
the Workflow DAG or rename Plan Mode. The setting changes presentation only;
it does not register tools, alter Todo semantics, or create another approval
mode.

When enabled, the existing `plan` mode is presented as **Plan & Review**. Plan
Mode remains the execution gate: read-only investigation is allowed, mutating
tools remain blocked, rejecting `exit_plan_mode` stays in Plan Mode, and
approving exits Plan Mode.

## Delivery

### Phase 1: opt-in presentation

- Expose the default-off setting through the existing daemon workspace settings
route.
- Read the effective setting from the Web Shell's active workspace and apply it
consistently to its main chat, split panes, and side-task panes.
- Keep Todo list rendering unchanged while gating Workflow DAG inputs.
- Rename the existing Plan entry only while the setting is enabled.

### Phase 2: revision-bound approval

- In Plan & Review, require a structured Todo execution snapshot whose nodes
remain pending before approval.
- Carry the Todo plan identity and source tool-call identity with the
`exit_plan_mode` approval request.
- Resolve the approval DAG from that identity instead of the latest active
Todo list.
- Preserve the approved plan identity while later snapshots and Agent
executions update its status.
- Fall back to the existing text-only approval when no matching snapshot is
available.

## Boundaries

The Workflow remains observational. It does not schedule dependencies, retry
Agents, propagate completion, or add a Workflow store. `blockedBy` and
`todo_id` remain optional for sessions outside Plan & Review.
11 changes: 11 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,17 @@ describe('SettingsSchema', () => {
checkBooleanDefaults(getSettingsSchema() as SettingsSchema);
});

it('keeps Session Workflow opt-in without requiring a restart', () => {
expect(
getSettingsSchema().experimental.properties.sessionWorkflow,
).toMatchObject({
type: 'boolean',
default: false,
requiresRestart: false,
showInDialog: true,
});
});

it('should have showInDialog property configured', () => {
// Check that user-facing settings are marked for dialog display
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3491,6 +3491,16 @@ const SETTINGS_SCHEMA = {
description: 'Settings to enable experimental features.',
showInDialog: false,
properties: {
sessionWorkflow: {
type: 'boolean',
label: 'Session Workflow Plan & Review',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Enable the daemon Web Shell Session Workflow DAG and present Plan mode as Plan & Review. Disabled by default and does not change ordinary Todo or execution behavior.',
showInDialog: true,
},
cron: {
type: 'boolean',
label: 'Enable Cron/Loop Tools',
Expand Down
5 changes: 5 additions & 0 deletions packages/vscode-ide-companion/schemas/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3318,6 +3318,11 @@
"description": "Settings to enable experimental features.",
"type": "object",
"properties": {
"sessionWorkflow": {
"description": "Enable the daemon Web Shell Session Workflow DAG and present Plan mode as Plan & Review. Disabled by default and does not change ordinary Todo or execution behavior.",
"type": "boolean",
"default": false
},
"cron": {
"description": "Enable in-session cron/loop tools. When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can be disabled via QWEN_CODE_DISABLE_CRON=1 environment variable.",
"type": "boolean",
Expand Down
23 changes: 21 additions & 2 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ function voiceSetting(effective: string): DaemonSettingDescriptor {
};
}

function sessionWorkflowSetting(): DaemonSettingDescriptor {
return {
key: 'experimental.sessionWorkflow',
type: 'boolean',
label: 'Session Workflow Plan & Review',
category: 'Experimental',
requiresRestart: false,
default: false,
values: { effective: true },
};
}

const {
mockConnection,
mockSessionActions,
Expand Down Expand Up @@ -2349,7 +2361,7 @@ afterEach(() => {
});

describe('App plan todos', () => {
it('passes the active workflow to an exit-plan approval', async () => {
it('gates the exit-plan workflow on the experimental setting', async () => {
testState.messages = [
{
id: 'plan',
Expand All @@ -2373,7 +2385,13 @@ describe('App plan todos', () => {
}),
];

renderApp();
const { rerender } = renderApp();
await flush();

expect(testState.latestToolApprovalPlanTodos).toEqual([]);

testState.settings = [sessionWorkflowSetting()];
rerender();
await flush();

expect(
Expand Down Expand Up @@ -2424,6 +2442,7 @@ describe('App plan todos', () => {
});

it('opens the workflow dialog with plan todos and linked agents', async () => {
testState.settings = [sessionWorkflowSetting()];
testState.messages = [
{
id: 'plan',
Expand Down
19 changes: 15 additions & 4 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5036,6 +5036,10 @@ export function App({
setValue: setWorkspaceSetting,
reload: reloadWorkspaceSettings,
} = workspaceSettingsState;
const sessionWorkflowEnabled =
workspaceSettings.find(
(setting) => setting.key === 'experimental.sessionWorkflow',
)?.values.effective === true;
const reloadTargetedWorkspaceSettings = useCallback(async () => {
const status = await reloadWorkspaceSettings();
if (mainVoiceTarget?.route === 'workspace-qualified') {
Expand Down Expand Up @@ -8769,6 +8773,7 @@ export function App({
>
<ApprovalModeDialog
currentMode={currentMode}
sessionWorkflowEnabled={sessionWorkflowEnabled}
onSelect={(modeId) => {
handleSetMode(modeId);
setShowApprovalModeDialog(false);
Expand Down Expand Up @@ -8799,7 +8804,7 @@ export function App({
{tasksDialogMessage && (
<DialogShell
title={
floatingTodos.length > 0
sessionWorkflowEnabled && floatingTodos.length > 0
? t('planExecution.dialogTitle')
: t('tasks.title')
}
Expand All @@ -8811,8 +8816,8 @@ export function App({
embedded
manageActiveEvent={false}
onClose={() => setTasksDialogMessage(null)}
planTodos={floatingTodos}
agentTools={planAgentTools}
planTodos={sessionWorkflowEnabled ? floatingTodos : []}
agentTools={sessionWorkflowEnabled ? planAgentTools : []}
onOpenSubagent={(tool) => {
setTasksDialogMessage(null);
openSubagentPanel(tool);
Expand Down Expand Up @@ -9710,6 +9715,7 @@ export function App({
? workspaces
: undefined
}
sessionWorkflowEnabled={sessionWorkflowEnabled}
/>
</CompactModeContext.Provider>
</WebShellCustomizationProvider>
Expand Down Expand Up @@ -9997,7 +10003,9 @@ export function App({
onConfirm={handleConfirm}
variant="floating"
keyboardActive={toolApprovalOverlayVisible}
planTodos={approvalPlanTodos}
planTodos={
sessionWorkflowEnabled ? approvalPlanTodos : []
}
/>
</div>
)}
Expand Down Expand Up @@ -10129,6 +10137,7 @@ export function App({
onPopQueuedMessages={editLastQueuedPrompt}
onClearQueuedMessages={clearQueuedPrompts}
currentMode={currentMode}
sessionWorkflowEnabled={sessionWorkflowEnabled}
currentModel={currentModel}
gitBranch={activeGitBranch}
gitWorktree={Boolean(sessionWorktree)}
Expand Down Expand Up @@ -10379,6 +10388,7 @@ export function App({
onNestedRightPanelOpen={handleTurnOutputOpen}
onNestedArtifactsChange={handlePaneArtifactsChange}
onError={reportError}
sessionWorkflowEnabled={sessionWorkflowEnabled}
onClose={closeArtifactPanel}
variant="drawer"
/>
Expand Down Expand Up @@ -10433,6 +10443,7 @@ export function App({
onNestedRightPanelOpen={handleTurnOutputOpen}
onNestedArtifactsChange={handlePaneArtifactsChange}
onError={reportError}
sessionWorkflowEnabled={sessionWorkflowEnabled}
onClose={closeArtifactPanel}
/>
</div>
Expand Down
20 changes: 16 additions & 4 deletions packages/web-shell/client/components/ChatEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ interface ChatEditorProps {
onPopQueuedMessages?: () => boolean;
onClearQueuedMessages?: () => boolean;
currentMode?: string;
sessionWorkflowEnabled?: boolean;
currentModel?: string;
gitBranch?: string;
/** Whether the session is in a worktree (styles the git chip purple). */
Expand Down Expand Up @@ -1156,6 +1157,7 @@ export const ChatEditor = memo(
queuedMessages = [],
onPopQueuedMessages,
currentMode = 'default',
sessionWorkflowEnabled = false,
currentModel = '',
gitBranch,
gitWorktree,
Expand Down Expand Up @@ -1357,11 +1359,18 @@ export const ChatEditor = memo(
() =>
DAEMON_APPROVAL_MODES.map((id) => ({
id,
label: getModeListLabel(id, t),
description: t(`mode.desc.${id}`),
label:
id === 'plan' && sessionWorkflowEnabled
? t('mode.listLabel.planReview')
: getModeListLabel(id, t),
description: t(
id === 'plan' && sessionWorkflowEnabled
? 'mode.desc.planReview'
: `mode.desc.${id}`,
),
icon: <ModeIcon mode={id} />,
})),
[t],
[sessionWorkflowEnabled, t],
);
const visibleActionSet = useMemo(() => {
if (!visibleToolbarActions) return null;
Expand Down Expand Up @@ -1633,7 +1642,10 @@ export const ChatEditor = memo(
};

// Mode display label
const modeLabel = getModeLabel(currentMode, t);
const modeLabel =
currentMode === 'plan' && sessionWorkflowEnabled
? t('mode.label.planReview')
: getModeLabel(currentMode, t);

const currentModelLabel = currentModel
? (availableModels.find((model) => model.id === currentModel)?.label ??
Expand Down
2 changes: 1 addition & 1 deletion packages/web-shell/client/components/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ describe('ChatPane', () => {
rawInput: {},
};

render();
render({ sessionWorkflowEnabled: true });

expect(testid('tool-approval')?.getAttribute('data-plan-todos')).toBe(
'["prepare","ship"]',
Expand Down
11 changes: 9 additions & 2 deletions packages/web-shell/client/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ export interface ChatPaneProps {
voiceUserRevision?: number;
voiceWorkspaceRevisions?: Readonly<Record<string, number>>;
voiceWorkspaces?: readonly DaemonWorkspaceCapability[];
/** Enable the app-scoped experimental Session Workflow presentation. */
sessionWorkflowEnabled?: boolean;
}

/**
Expand Down Expand Up @@ -212,6 +214,7 @@ export function ChatPane({
voiceUserRevision = 0,
voiceWorkspaceRevisions = EMPTY_VOICE_WORKSPACE_REVISIONS,
voiceWorkspaces,
sessionWorkflowEnabled = false,
}: ChatPaneProps) {
const { t } = useI18n();
const { renderComposerFooter: CustomComposerFooter } =
Expand Down Expand Up @@ -347,8 +350,11 @@ export function ChatPane({
pendingToolApproval?.toolKind === 'switch_mode' &&
pendingToolApproval?.toolName?.toLowerCase() === 'exit_plan_mode';
const planTodos = useMemo(
() => (isExitPlanApproval ? getLatestActiveTodos(messages) : []),
[isExitPlanApproval, messages],
() =>
sessionWorkflowEnabled && isExitPlanApproval
? getLatestActiveTodos(messages)
: [],
[isExitPlanApproval, messages, sessionWorkflowEnabled],
);
// Tracked in a ref so an async approval-mode switch (handleSelectMode) reads
// the approval current when setApprovalMode *resolves*, not a stale one
Expand Down Expand Up @@ -875,6 +881,7 @@ export function ChatPane({
workspaceTitle={paneWorkspaceCwd}
workspaceColor={workspaceAccent}
currentMode={connection.currentMode ?? 'default'}
sessionWorkflowEnabled={sessionWorkflowEnabled}
currentModel={connection.currentModel ?? ''}
availableModels={availableModels}
onSelectMode={handleSelectMode}
Expand Down
3 changes: 3 additions & 0 deletions packages/web-shell/client/components/SplitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface SplitViewProps {
voiceUserRevision?: number;
voiceWorkspaceRevisions?: Readonly<Record<string, number>>;
voiceWorkspaces?: readonly DaemonWorkspaceCapability[];
sessionWorkflowEnabled?: boolean;
}

/**
Expand Down Expand Up @@ -119,6 +120,7 @@ export function SplitView({
voiceUserRevision = 0,
voiceWorkspaceRevisions = {},
voiceWorkspaces,
sessionWorkflowEnabled = false,
}: SplitViewProps) {
const { t } = useI18n();
const connection = useConnection();
Expand Down Expand Up @@ -532,6 +534,7 @@ export function SplitView({
onPaneArtifactsChange={onPaneArtifactsChange}
messageTurnOutputs={messageTurnOutputs}
restartSseOnPrompt={restartSseOnPrompt}
sessionWorkflowEnabled={sessionWorkflowEnabled}
/>
</DaemonSessionProvider>
</ErrorBoundary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ interface ArtifactPanelProps {
workspaceActions: DaemonWorkspaceActions,
) => void;
onError?: (error: unknown, fallback: string) => void;
sessionWorkflowEnabled?: boolean;
onClose: () => void;
variant?: 'docked' | 'drawer';
}
Expand Down Expand Up @@ -257,6 +258,7 @@ export function ArtifactPanel({
onNestedRightPanelOpen,
onNestedArtifactsChange,
onError,
sessionWorkflowEnabled,
onClose,
variant = 'docked',
}: ArtifactPanelProps) {
Expand Down Expand Up @@ -650,6 +652,7 @@ export function ArtifactPanel({
onRightPanelOpen={onNestedRightPanelOpen}
onArtifactsChange={onNestedArtifactsChange}
onError={onError}
sessionWorkflowEnabled={sessionWorkflowEnabled}
/>
) : (
<ScheduledTaskDetail
Expand Down
Loading
Loading