diff --git a/docs/design/experimental-session-plan-review.md b/docs/design/experimental-session-plan-review.md new file mode 100644 index 00000000000..4664e373daf --- /dev/null +++ b/docs/design/experimental-session-plan-review.md @@ -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. diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index c95fe65af44..2b0dbf6ce15 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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( diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index a38b46f94f1..59c0dd5dc35 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 391e8c08f58..bf075e2d798 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -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", diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 2932fe98c2e..b69f9df2177 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -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, @@ -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', @@ -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( @@ -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', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 87cac7a6df3..dc874a278f5 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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') { @@ -8769,6 +8773,7 @@ export function App({ > { handleSetMode(modeId); setShowApprovalModeDialog(false); @@ -8799,7 +8804,7 @@ export function App({ {tasksDialogMessage && ( 0 + sessionWorkflowEnabled && floatingTodos.length > 0 ? t('planExecution.dialogTitle') : t('tasks.title') } @@ -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); @@ -9710,6 +9715,7 @@ export function App({ ? workspaces : undefined } + sessionWorkflowEnabled={sessionWorkflowEnabled} /> @@ -9997,7 +10003,9 @@ export function App({ onConfirm={handleConfirm} variant="floating" keyboardActive={toolApprovalOverlayVisible} - planTodos={approvalPlanTodos} + planTodos={ + sessionWorkflowEnabled ? approvalPlanTodos : [] + } /> )} @@ -10129,6 +10137,7 @@ export function App({ onPopQueuedMessages={editLastQueuedPrompt} onClearQueuedMessages={clearQueuedPrompts} currentMode={currentMode} + sessionWorkflowEnabled={sessionWorkflowEnabled} currentModel={currentModel} gitBranch={activeGitBranch} gitWorktree={Boolean(sessionWorktree)} @@ -10379,6 +10388,7 @@ export function App({ onNestedRightPanelOpen={handleTurnOutputOpen} onNestedArtifactsChange={handlePaneArtifactsChange} onError={reportError} + sessionWorkflowEnabled={sessionWorkflowEnabled} onClose={closeArtifactPanel} variant="drawer" /> @@ -10433,6 +10443,7 @@ export function App({ onNestedRightPanelOpen={handleTurnOutputOpen} onNestedArtifactsChange={handlePaneArtifactsChange} onError={reportError} + sessionWorkflowEnabled={sessionWorkflowEnabled} onClose={closeArtifactPanel} /> diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 6a2aa6c56bb..a757a7a6bd7 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -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). */ @@ -1156,6 +1157,7 @@ export const ChatEditor = memo( queuedMessages = [], onPopQueuedMessages, currentMode = 'default', + sessionWorkflowEnabled = false, currentModel = '', gitBranch, gitWorktree, @@ -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: , })), - [t], + [sessionWorkflowEnabled, t], ); const visibleActionSet = useMemo(() => { if (!visibleToolbarActions) return null; @@ -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 ?? diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index f5df9f1e624..818577da41e 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -1004,7 +1004,7 @@ describe('ChatPane', () => { rawInput: {}, }; - render(); + render({ sessionWorkflowEnabled: true }); expect(testid('tool-approval')?.getAttribute('data-plan-todos')).toBe( '["prepare","ship"]', diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 61de0141571..fbd425fdef5 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -183,6 +183,8 @@ export interface ChatPaneProps { voiceUserRevision?: number; voiceWorkspaceRevisions?: Readonly>; voiceWorkspaces?: readonly DaemonWorkspaceCapability[]; + /** Enable the app-scoped experimental Session Workflow presentation. */ + sessionWorkflowEnabled?: boolean; } /** @@ -212,6 +214,7 @@ export function ChatPane({ voiceUserRevision = 0, voiceWorkspaceRevisions = EMPTY_VOICE_WORKSPACE_REVISIONS, voiceWorkspaces, + sessionWorkflowEnabled = false, }: ChatPaneProps) { const { t } = useI18n(); const { renderComposerFooter: CustomComposerFooter } = @@ -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 @@ -875,6 +881,7 @@ export function ChatPane({ workspaceTitle={paneWorkspaceCwd} workspaceColor={workspaceAccent} currentMode={connection.currentMode ?? 'default'} + sessionWorkflowEnabled={sessionWorkflowEnabled} currentModel={connection.currentModel ?? ''} availableModels={availableModels} onSelectMode={handleSelectMode} diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index 1db5d93b655..53c2252d89c 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -91,6 +91,7 @@ export interface SplitViewProps { voiceUserRevision?: number; voiceWorkspaceRevisions?: Readonly>; voiceWorkspaces?: readonly DaemonWorkspaceCapability[]; + sessionWorkflowEnabled?: boolean; } /** @@ -119,6 +120,7 @@ export function SplitView({ voiceUserRevision = 0, voiceWorkspaceRevisions = {}, voiceWorkspaces, + sessionWorkflowEnabled = false, }: SplitViewProps) { const { t } = useI18n(); const connection = useConnection(); @@ -532,6 +534,7 @@ export function SplitView({ onPaneArtifactsChange={onPaneArtifactsChange} messageTurnOutputs={messageTurnOutputs} restartSseOnPrompt={restartSseOnPrompt} + sessionWorkflowEnabled={sessionWorkflowEnabled} /> diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index 778f383ea2a..7366cca4af7 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -226,6 +226,7 @@ interface ArtifactPanelProps { workspaceActions: DaemonWorkspaceActions, ) => void; onError?: (error: unknown, fallback: string) => void; + sessionWorkflowEnabled?: boolean; onClose: () => void; variant?: 'docked' | 'drawer'; } @@ -257,6 +258,7 @@ export function ArtifactPanel({ onNestedRightPanelOpen, onNestedArtifactsChange, onError, + sessionWorkflowEnabled, onClose, variant = 'docked', }: ArtifactPanelProps) { @@ -650,6 +652,7 @@ export function ArtifactPanel({ onRightPanelOpen={onNestedRightPanelOpen} onArtifactsChange={onNestedArtifactsChange} onError={onError} + sessionWorkflowEnabled={sessionWorkflowEnabled} /> ) : ( void; onError?: (error: unknown, fallback: string) => void; + sessionWorkflowEnabled?: boolean; } const FIRST_PROMPT_RENAME_ATTEMPTS = 3; @@ -62,6 +63,7 @@ export function SideTaskPanel({ onRightPanelOpen, onArtifactsChange, onError, + sessionWorkflowEnabled, }: SideTaskPanelProps) { if (!sessionId) { return ( @@ -98,6 +100,7 @@ export function SideTaskPanel({ onRightPanelOpen={onRightPanelOpen} onArtifactsChange={onArtifactsChange} onError={onError} + sessionWorkflowEnabled={sessionWorkflowEnabled} /> ); @@ -189,6 +192,7 @@ function SideTaskSession({ onRightPanelOpen, onArtifactsChange, onError, + sessionWorkflowEnabled, }: Omit< SideTaskPanelProps, 'sessionId' | 'parentSessionId' | 'createSession' | 'onCreated' @@ -280,6 +284,7 @@ function SideTaskSession({ } onRightPanelOpen={onRightPanelOpen} onPaneArtifactsChange={onArtifactsChange} + sessionWorkflowEnabled={sessionWorkflowEnabled} /> ); } diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx index cab4e3cf52b..77f4e5179a3 100644 --- a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx @@ -55,6 +55,24 @@ afterEach(() => { }); describe('ApprovalModeDialog', () => { + it('renames only the plan entry when Session Workflow is enabled', () => { + mount(); + expect( + container!.querySelector('[data-mode-id="plan"]')?.textContent, + ).toContain('Plan (plan)'); + + rerender( + , + ); + expect( + container!.querySelector('[data-mode-id="plan"]')?.textContent, + ).toContain('Plan & Review (plan)'); + }); + it('opens with the highlight on the current mode and confirms on Enter', () => { const onSelect = vi.fn(); mount(); diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx index 2aa0c37f6c5..82d6d0b17e6 100644 --- a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx @@ -8,6 +8,7 @@ import styles from './ApprovalModeDialog.module.css'; interface ApprovalModeDialogProps { currentMode: string; + sessionWorkflowEnabled?: boolean; onSelect: (modeId: string) => void; } @@ -19,14 +20,23 @@ interface ModeItem { export function ApprovalModeDialog({ currentMode, + sessionWorkflowEnabled = false, onSelect, }: ApprovalModeDialogProps) { const { t } = useI18n(); const listRef = useRef(null); const approvalModes: ModeItem[] = DAEMON_APPROVAL_MODES.map((id) => ({ id, - name: t(`mode.listLabel.${id}`), - description: t(`mode.desc.${id}`), + name: t( + id === 'plan' && sessionWorkflowEnabled + ? 'mode.listLabel.planReview' + : `mode.listLabel.${id}`, + ), + description: t( + id === 'plan' && sessionWorkflowEnabled + ? 'mode.desc.planReview' + : `mode.desc.${id}`, + ), })); const currentIdx = approvalModes.findIndex((m) => m.id === currentMode); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index a74897cab9d..825dfaf179c 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1901,16 +1901,20 @@ const EN: Messages = { 'mode.name.auto': 'auto', 'mode.name.yolo': 'yolo', 'mode.label.plan': 'Plan', + 'mode.label.planReview': 'Plan & Review', 'mode.label.default': 'Ask Approval', 'mode.label.auto-edit': 'Auto Edit', 'mode.label.auto': 'Classifier Approval', 'mode.label.yolo': 'Full Access', 'mode.listLabel.plan': 'Plan (plan)', + 'mode.listLabel.planReview': 'Plan & Review (plan)', 'mode.listLabel.default': 'Ask Approval (default)', 'mode.listLabel.auto-edit': 'Auto Edit (auto-edit)', 'mode.listLabel.auto': 'Classifier Approval (auto)', 'mode.listLabel.yolo': 'Full Access (yolo)', 'mode.desc.plan': 'Analyze only, do not modify files or execute commands', + 'mode.desc.planReview': + 'Use Plan mode and review its workflow when one is available', 'mode.desc.default': 'Ask before running commands, editing files, or accessing external resources', 'mode.desc.auto-edit': @@ -4453,16 +4457,19 @@ const ZH: Messages = { 'mode.name.auto': 'auto', 'mode.name.yolo': 'yolo', 'mode.label.plan': '计划', + 'mode.label.planReview': '计划并审阅', 'mode.label.default': '请求批准', 'mode.label.auto-edit': '自动编辑', 'mode.label.auto': '智能审批', 'mode.label.yolo': '完全访问权限', 'mode.listLabel.plan': '计划(plan)', + 'mode.listLabel.planReview': '计划并审阅(plan)', 'mode.listLabel.default': '请求批准(default)', 'mode.listLabel.auto-edit': '自动编辑(auto-edit)', 'mode.listLabel.auto': '智能审批(auto)', 'mode.listLabel.yolo': '完全访问权限(yolo)', 'mode.desc.plan': '仅分析,不修改文件或执行命令', + 'mode.desc.planReview': '使用 Plan 模式,并在 Workflow 可用时进行审阅', 'mode.desc.default': '执行命令、编辑文件或访问外部资源前请求确认', 'mode.desc.auto-edit': '自动批准文件编辑,命令执行等敏感操作仍会询问', 'mode.desc.auto': '自动评估工具风险,安全操作直接执行,风险操作再确认', @@ -5216,6 +5223,9 @@ const ZH: Messages = { 'settings.label.experimental.enableCronTools': '启用 Cron/Loop 工具', 'settings.description.experimental.enableCronTools': '启用会话内 cron/loop 工具(实验性)。启用后,模型可以用 cron_create、cron_list 和 cron_delete 创建周期性提示。也可通过 QWEN_CODE_ENABLE_CRON=1 环境变量启用。', + 'settings.label.experimental.sessionWorkflow': 'Session Workflow 计划并审阅', + 'settings.description.experimental.sessionWorkflow': + '显示 Session Workflow DAG,并将 Plan 模式展示为计划并审阅。', 'settings.label.experimental.emitToolUseSummaries': '工具使用摘要', 'settings.description.experimental.emitToolUseSummaries': '每个工具批次完成后生成一个简短的 LLM 标签。紧凑模式下会替代通用的 Tool × N 标题;完整模式下显示为工具组下方的弱化 ●