diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dee2d40..de8623f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,11 @@ jobs: - name: Test run: pnpm test + env: + NODE_OPTIONS: --max-old-space-size=8192 + + - name: Typecheck + run: ./scripts/check-typecheck-baseline.sh - name: Lint run: npx eslint src/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cfb89..9c85107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Workflow actions (Apply, Verify, Archive, Continue, etc.) now launch through a unified IDE Agent panel path on Cursor and VS Code instead of opening an editor-area `agent` terminal by default. +- Cursor hosts use runtime command detection with layered fallback: `composer.createNew` → `workbench.action.chat.open` + `composerMode.agent` → deeplink → clipboard. +- Added `openspec.agentAutoSubmit` (`never` / `readOnly` / `always`, default `readOnly`). Auto-send applies only when `composer.createNew` succeeds. +- Legacy `openspec.cursorLaunchMode` values `deeplink` and `chatCommand` migrate to `agentPanel`. Dashboard Ready to Verify CTA label is now **Run Verify** (action matches label). + ## [0.2.3] - 2026-09-22 ### Added diff --git a/README.md b/README.md index 13919a8..cf0f490 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ OpenSpec brings change planning and execution into the editor. Review what needs - **Stores and Worksets**: Use registered Stores as planning roots, then browse, inspect, create, and open trusted multi-folder Worksets without leaving the sidebar. - **Recommended actions**: A compact, resolver-backed action rail surfaces up to three next steps by priority: Needs Attention, Ready to Verify, then Recommended. - **Change detail**: Proposal, Specs, Design, Tasks, and Verify & Archive tabs with markdown rendering, task progress, and workflow controls. -- **Safe workflow routing**: Review and verify actions open the appropriate detail or interactive terminal flow; high-impact archive actions stay confirmation-protected. +- **Safe workflow routing**: Workflow buttons open the IDE Agent panel with `/opsx-*` commands; high-impact archive actions stay confirmation-protected. - **CLI integration**: OpenSpec CLI list, status, new, archive, Store, and Workset flows with retry, timeout, and `openspec.cliPath` fallback. - **Editor-native**: Uses VS Code theme tokens, Codicons, keyboard-accessible controls, and narrow-sidebar-friendly layouts. @@ -105,7 +105,8 @@ Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`): | `openspec.taskExecutionMode` | `fillChat` | When clicking task execute: `auto` = run via adapter; `fillChat` = fill chat or copy to clipboard | | `openspec.workflowLaunchMode` | `clipboard` (VS Code) / smart launch in Cursor | **Launch with adapter (recommended)** or **Copy command only (safe)**. In Cursor with unset settings, defaults to launch mode. | | `openspec.preferredAgentAdapter` | `clipboard` (VS Code) / `cursor` in Cursor launch mode | Preferred agent executor when launch mode is adapter: `clipboard`, `cursor`, `vscode-copilot`, `claude-code`, or `opencode` | -| `openspec.cursorLaunchMode` | `clipboard` (stored default) / `deeplink` in Cursor launch mode | Cursor launch behavior: `deeplink`, `chatCommand`, `clipboard`, or explicit `agentCli` (**may modify workspace files**; never auto-selected) | +| `openspec.agentAutoSubmit` | `readOnly` | Auto-send in Cursor Agent panel: `never`, `readOnly` (explore/verify only), or `always` (composer layer only) | +| `openspec.cursorLaunchMode` | `clipboard` (stored default) / `agentPanel` in Cursor launch mode | `agentPanel` (IDE Agent with fallback), `clipboard`, or explicit `agentCli` (**may modify workspace files**) | | `openspec.taskDependencyPolicy` | `block` | When preceding tasks are incomplete: `block` = prevent execution; `warn` = show warning and allow proceed | | `openspec.cursorAgentModel` | `auto` | Cursor Agent CLI model for explicit Agent CLI execution. Use `auto` or a specific model name | | `openspec.agentModel` | `auto` | Legacy Cursor Agent CLI model setting; prefer `openspec.cursorAgentModel` | @@ -114,11 +115,10 @@ Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`): **Task execution & Adapters** - **Clipboard** (`clipboard`): Always available. Select **Copy command only (safe)** when you only want `/opsx:*` commands copied. -- **Cursor** (`cursor`): In Cursor, defaults to opening the prompt deeplink (`deeplink`) or Chat (`chatCommand`). `agentCli` is available only when explicitly selected and may modify workspace files. +- **Cursor** (`cursor`): In Cursor, defaults to the Agent panel (`agentPanel`) with runtime fallback (composer → chat → deeplink → clipboard). `agentCli` is opt-in and may modify workspace files. - **OpenCode** (`opencode`): Uses `/opsx-` command format when routed through the adapter. - In Cursor, unset workflow settings smart-default to launch mode with the Cursor adapter. Choose **Copy command only (safe)** in settings to restore copy-only behavior. If launch fails, the extension falls back to clipboard with a visible notice. -- Verify and Archive are intentionally different: they open the dedicated `Verify & Archive` tab and run inside a real VS Code terminal editor instead of the headless `agentCli` path. -- Within that tab, `Review & Archive` keeps Agent review interactive, while `Archive Now` is the explicit direct-archive escape path and is disabled until the bound workflow is complete. +- Verify and Archive use the same Agent panel launch path as Apply and Continue. The **Verify & Archive** tab also offers **Archive Now** (direct filesystem archive) when the workflow is complete. ### Dashboard diff --git a/package.json b/package.json index cd74238..921ee51 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openspec-workflow", "displayName": "OpenSpec Workflow", "description": "Project-first OpenSpec dashboard for changes, specs, stores, worksets, and safe workflow actions", - "version": "0.2.3", + "version": "0.2.4-agent-panel.18", "publisher": "randysss", "license": "MIT", "engines": { @@ -101,23 +101,40 @@ "default": "clipboard", "description": "Preferred Agent executor adapter used when workflowLaunchMode is adapter." }, + "openspec.agentAutoSubmit": { + "type": "string", + "enum": [ + "never", + "readOnly", + "always" + ], + "default": "readOnly", + "description": "When launching in the Cursor Agent panel via composer.createNew, auto-send the workflow command. readOnly auto-sends explore and verify only; other layers always prefill.", + "enumDescriptions": [ + "Never auto-send; always prefill and wait for Enter.", + "Auto-send read-only workflow commands (explore, verify).", + "Auto-send every workflow command (including apply and archive)." + ] + }, "openspec.cursorLaunchMode": { "type": "string", "enum": [ "clipboard", + "agentPanel", "deeplink", "chatCommand", "agentCli" ], "default": "clipboard", - "description": "Cursor adapter launch behavior for non-interactive workflow actions. Verify and Archive use the dedicated Verify & Archive tab; `agentCli` here means headless Agent CLI.", + "description": "Cursor launch behavior. agentPanel opens the IDE Agent panel with runtime fallback (composer → chat → deeplink → clipboard). agentCli runs headless Agent CLI and may modify files.", "enumDescriptions": [ - "Only copy the generated command to the clipboard. Does not open Cursor or start Agent CLI.", - "Open Cursor through the prompt deeplink with the generated command prefilled. The command is also copied as fallback.", - "Open Cursor Chat through the VS Code/Cursor chat command query. The command is also copied as fallback.", - "Run the generated command through Cursor Agent CLI in headless mode. May modify workspace files. Requires explicit selection." + "Only copy the generated command to the clipboard.", + "Open the Cursor Agent panel with `/opsx-*` prefilled (recommended).", + "Legacy alias for agentPanel.", + "Legacy alias for agentPanel.", + "Headless Cursor Agent CLI in the Output panel. May modify workspace files." ], - "markdownDescription": "Cursor adapter launch behavior for workflow actions.\\n\\n- `clipboard`: only copy the generated `/opsx:*` command.\\n- `deeplink`: open Cursor prompt deeplink with `/opsx-*` prefilled, while keeping clipboard fallback.\\n- `chatCommand`: try Cursor Chat query prefill, while keeping clipboard fallback.\\n- `agentCli`: **May modify workspace files.** Run Cursor Agent CLI directly in headless mode; never used unless you explicitly select it." + "markdownDescription": "Cursor workflow launch behavior.\\n\\n- `agentPanel` (default on Cursor): IDE Agent panel with layered fallback.\\n- `clipboard`: copy only.\\n- `agentCli`: **May modify workspace files.** Headless CLI opt-in.\\n- `deeplink` / `chatCommand`: migrated to `agentPanel`." }, "openspec.taskDependencyPolicy": { "type": "string", @@ -245,7 +262,7 @@ "build:webview": "vite build", "dev:webview": "vite", "postinstall": "node scripts/link-vitest-pnpm.js", - "test": "vitest run", + "test": "NODE_OPTIONS=--max-old-space-size=8192 vitest run", "test:watch": "vitest", "package": "node scripts/package-with-marketplace-readme.js", "verify:vsix-webview": "node scripts/verify-vsix-webview.js", diff --git a/scripts/check-typecheck-baseline.sh b/scripts/check-typecheck-baseline.sh new file mode 100755 index 0000000..61739be --- /dev/null +++ b/scripts/check-typecheck-baseline.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASELINE="${TSC_BASELINE_ERROR_COUNT:-13}" +LOG="$(mktemp)" +trap 'rm -f "$LOG"' EXIT + +# Must match local: npx tsc --noEmit -p tsconfig.typecheck.json +npx tsc --noEmit -p tsconfig.typecheck.json >"$LOG" 2>&1 || true +COUNT="$(rg -c 'error TS' "$LOG" || true)" +COUNT="${COUNT:-0}" + +if [ "$COUNT" -gt "$BASELINE" ]; then + echo "TypeScript error count $COUNT exceeds baseline $BASELINE" + cat "$LOG" + exit 1 +fi + +echo "TypeScript check OK ($COUNT errors, baseline $BASELINE)" diff --git a/scripts/verify-vsix-webview.js b/scripts/verify-vsix-webview.js index c72aea7..cffa253 100644 --- a/scripts/verify-vsix-webview.js +++ b/scripts/verify-vsix-webview.js @@ -8,7 +8,9 @@ const path = require('path'); const { execSync } = require('child_process'); const rootDir = path.resolve(__dirname, '..'); -const inputPath = path.resolve(rootDir, process.argv[2] ?? 'openspec-workflow-0.2.2.vsix'); +const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')); +const defaultVsixName = `${pkg.name}-${pkg.version}.vsix`; +const inputPath = path.resolve(rootDir, process.argv[2] ?? defaultVsixName); const REQUIRED_MARKERS = [ 'openspec-webview-executor-ui-v1', diff --git a/src/extension/adapters/adapterRegistry.ts b/src/extension/adapters/adapterRegistry.ts index e566fd4..19717ae 100644 --- a/src/extension/adapters/adapterRegistry.ts +++ b/src/extension/adapters/adapterRegistry.ts @@ -7,6 +7,7 @@ import { opencodeAdapter } from './opencode-adapter'; import { vscodeChatAdapter } from './vscode-chat-adapter'; import { vscodeCopilotAdapter } from './vscode-copilot-adapter'; import type { IAgentExecutorAdapter } from '../services/agentExecutor.types'; +import { isCursorHost } from '../utils/isCursorHost'; const registeredAdapters: IAgentExecutorAdapter[] = [ vscodeCopilotAdapter, @@ -17,10 +18,6 @@ const registeredAdapters: IAgentExecutorAdapter[] = [ clipboardAdapter, ]; -function isCursorHost(): boolean { - return (vscode.env.appName ?? '').toLowerCase().includes('cursor'); -} - /** Runtime launch adapters (must pass isAvailable). */ export async function getAvailableAdapters(): Promise { const results = await Promise.all( diff --git a/src/extension/adapters/cursor-adapter.ts b/src/extension/adapters/cursor-adapter.ts index f2a0c3f..cc38085 100644 --- a/src/extension/adapters/cursor-adapter.ts +++ b/src/extension/adapters/cursor-adapter.ts @@ -8,11 +8,12 @@ import type { import { logger } from '../utils/logger'; import { t } from '../../i18n'; import { buildWorkflowCommand } from '../../shared/workflowCommand'; -import { buildCursorPromptDeeplink } from '../services/cursorDeeplink'; import { getCursorAgentModel, getWorkflowLaunchConfig } from '../services/workflowLaunchConfig'; +import { launchAgentPanelPrompt } from '../services/agentPanelLauncher'; +import { notifyWorkflowCommandCopied } from '../services/workflowClipboardNotify'; const ADAPTER_ID = 'cursor'; -const DISPLAY_NAME = 'Cursor (agent CLI)'; +const DISPLAY_NAME = 'Cursor (Agent)'; const OUTPUT_CHANNEL_NAME = 'OpenSpec (Agent)'; const NO_OUTPUT_NOTICE_MS = 10000; const STILL_RUNNING_NOTICE_MS = 30000; @@ -160,31 +161,27 @@ export const cursorAdapter: IAgentExecutorAdapter = { } if (cursorLaunchMode === 'clipboard') { - vscode.window.showInformationMessage(t('workflow.copiedCommand', { command: text })); + notifyWorkflowCommandCopied(text); return { success: true, adapterId: ADAPTER_ID, message: 'Copied to clipboard' }; } - if (cursorLaunchMode === 'chatCommand') { - try { - await vscode.commands.executeCommand('workbench.action.chat.open', { - query: text, - isPartialQuery: true, - }); - vscode.window.showInformationMessage(t('cursor.chatOpenedCopied', { command: text })); - return { success: true, adapterId: ADAPTER_ID, message: 'Chat opened with prompt' }; - } catch { - vscode.window.showInformationMessage(t('cursor.chatOpenFailedCopied', { command: text })); - return { success: true, adapterId: ADAPTER_ID, message: 'Copied to clipboard' }; - } - } - - try { - await vscode.env.openExternal(vscode.Uri.parse(buildCursorPromptDeeplink(text))); - vscode.window.showInformationMessage(t('cursor.promptOpenedCopied', { command: text })); - return { success: true, adapterId: ADAPTER_ID, message: 'Cursor prompt opened' }; - } catch { - vscode.window.showInformationMessage(t('cursor.openFailedCopied', { command: text })); - return { success: true, adapterId: ADAPTER_ID, message: 'Copied to clipboard' }; + const panelResult = await launchAgentPanelPrompt({ + text, + autoSubmit: false, + }); + if (panelResult.outcome === 'copied') { + vscode.window.showInformationMessage(t('agentLaunch.copied', { command: text })); + } else if (panelResult.outcome === 'started') { + vscode.window.showInformationMessage(t('agentLaunch.started', { command: text })); + } else if (panelResult.layer === 'deeplink') { + vscode.window.showInformationMessage(t('agentLaunch.deeplinkPrefilled', { command: text })); + } else { + vscode.window.showInformationMessage(t('agentLaunch.openedPanel', { command: text })); } + return { + success: panelResult.success, + adapterId: ADAPTER_ID, + message: panelResult.layer, + }; }, }; diff --git a/src/extension/adapters/vscode-chat-adapter.ts b/src/extension/adapters/vscode-chat-adapter.ts index 24db518..bcaf151 100644 --- a/src/extension/adapters/vscode-chat-adapter.ts +++ b/src/extension/adapters/vscode-chat-adapter.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { IAgentExecutorAdapter, TaskExecuteRequest, TaskExecuteResult } from '../services/agentExecutor.types'; import { t } from '../../i18n'; +import { launchAgentPanelPrompt } from '../services/agentPanelLauncher'; const ADAPTER_ID = 'vscode-chat'; const DISPLAY_NAME = 'VS Code Chat'; @@ -23,16 +24,17 @@ export const vscodeChatAdapter: IAgentExecutorAdapter = { async fillChat(request: TaskExecuteRequest): Promise { const prompt = request.promptOverride ?? `/opsx:apply ${request.changeName}`; - try { - await vscode.commands.executeCommand('workbench.action.chat.open', { - query: prompt, - isPartialQuery: true, - }); - return { success: true, adapterId: ADAPTER_ID, message: 'Chat opened with prompt' }; - } catch { - await vscode.env.clipboard.writeText(prompt); - vscode.window.showInformationMessage(t('clipboard.copiedChat')); - return { success: true, adapterId: ADAPTER_ID, message: 'Copied to clipboard (fallback)' }; + await vscode.env.clipboard.writeText(prompt); + const panelResult = await launchAgentPanelPrompt({ text: prompt, autoSubmit: false }); + if (panelResult.outcome === 'copied') { + vscode.window.showInformationMessage(t('agentLaunch.copied', { command: prompt })); + } else { + vscode.window.showInformationMessage(t('agentLaunch.prefilledChat', { command: prompt })); } + return { + success: panelResult.success, + adapterId: ADAPTER_ID, + message: panelResult.layer, + }; }, }; diff --git a/src/extension/adapters/vscode-copilot-adapter.ts b/src/extension/adapters/vscode-copilot-adapter.ts index 0f87b56..c6ed9b3 100644 --- a/src/extension/adapters/vscode-copilot-adapter.ts +++ b/src/extension/adapters/vscode-copilot-adapter.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { IAgentExecutorAdapter, TaskExecuteRequest, TaskExecuteResult } from '../services/agentExecutor.types'; import { t } from '../../i18n'; +import { launchAgentPanelPrompt } from '../services/agentPanelLauncher'; const ADAPTER_ID = 'vscode-copilot'; const DISPLAY_NAME = 'VS Code Copilot Chat'; @@ -20,16 +21,17 @@ export const vscodeCopilotAdapter: IAgentExecutorAdapter = { async fillChat(request: TaskExecuteRequest): Promise { const prompt = request.promptOverride ?? `/opsx:apply ${request.changeName}`; - try { - await vscode.commands.executeCommand('workbench.action.chat.open', { - query: prompt, - isPartialQuery: true, - }); - return { success: true, adapterId: ADAPTER_ID, message: 'Chat opened with prompt' }; - } catch { - await vscode.env.clipboard.writeText(prompt); - vscode.window.showInformationMessage(t('clipboard.copiedChat')); - return { success: true, adapterId: ADAPTER_ID, message: 'Copied to clipboard (fallback)' }; + await vscode.env.clipboard.writeText(prompt); + const panelResult = await launchAgentPanelPrompt({ text: prompt, autoSubmit: false }); + if (panelResult.outcome === 'copied') { + vscode.window.showInformationMessage(t('agentLaunch.copied', { command: prompt })); + } else { + vscode.window.showInformationMessage(t('agentLaunch.prefilled', { command: prompt })); } + return { + success: panelResult.success, + adapterId: ADAPTER_ID, + message: panelResult.layer, + }; }, }; diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 17364e9..b97fe20 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -153,6 +153,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidChangeWorkspaceFolders(() => { void dashboardViewProvider.syncOpenSpecRootAvailability(); + void dashboardViewProvider.syncProjectContextAfterWorkspaceChange(); }), ); @@ -162,7 +163,9 @@ export async function activate(context: vscode.ExtensionContext) { logger.info('OpenSpec extension activated successfully'); console.log('OpenSpec extension is now active!'); - await promptReloadAfterInstallOrUpdate(context); + setTimeout(() => { + void promptReloadAfterInstallOrUpdate(context); + }, 4000); } catch (error) { logger.error('Failed to activate OpenSpec extension', error as Error); vscode.window.showErrorMessage( @@ -181,13 +184,14 @@ async function promptReloadAfterInstallOrUpdate(context: vscode.ExtensionContext await context.globalState.update(key, marker); logger.info(`OpenSpec installation marker changed: previous=${previous ?? 'none'}, current=${marker}`); const reload = t('extension.reloadWindow'); - const selected = await vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( t('extension.reloadRecommended'), - reload - ); - if (selected === reload) { - await vscode.commands.executeCommand('workbench.action.reloadWindow'); - } + reload, + ).then((selected) => { + if (selected === reload) { + void vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + }); } export function deactivate() { diff --git a/src/extension/providers/changeDetailPanelManager.ts b/src/extension/providers/changeDetailPanelManager.ts index 0fdf163..f1c3fcc 100644 --- a/src/extension/providers/changeDetailPanelManager.ts +++ b/src/extension/providers/changeDetailPanelManager.ts @@ -3,6 +3,8 @@ import * as path from 'path'; import { logger } from '../utils/logger'; import { DataManager } from '../services/dataManager'; import { InteractiveAgentTerminalManager } from '../services/interactiveAgentTerminalManager'; +import { registerWorkflowReceiptWebview } from '../services/workflowWebviewRegistry'; +import { pathsEqualForWorkspace } from '../utils/workspacePathCompare'; import { handleWebviewMessage, getWebviewContent, @@ -48,6 +50,13 @@ export function createProjectBoundScope( label, rootPath: binding.rootPath, source: binding.storeId ? 'store' : 'declared', + workflowBinding: { + projectId: binding.projectId, + commandCwd: binding.commandCwd, + rootPath: binding.rootPath, + rootSource: binding.rootSource, + ...(binding.storeId ? { storeId: binding.storeId } : {}), + }, ...(binding.storeId ? { storeId: binding.storeId } : {}), runtimeSource: 'installed', capabilities: { @@ -340,9 +349,13 @@ export class ChangeDetailPanelManager { this.pendingSetContext.set(panel.webview, { changeName, options: boundOptions }); panel.webview.html = getWebviewContent(panel.webview, this.extensionPath); + const receiptRegistration = registerWorkflowReceiptWebview(panel.webview); // Proactively send setContext so webview can show ChangeDetail without waiting for first message setTimeout(() => { + if (this.panels.get(key) === panel) { + panel.webview.postMessage({ type: 'panelVisibility', visible: panel.visible }); + } this.buildSetContextPayload(changeName, boundOptions).then((payload) => this.panels.get(key) === panel && panel.webview.postMessage(payload) ); @@ -385,6 +398,11 @@ export class ChangeDetailPanelManager { ); panel.onDidChangeViewState((e) => { + try { + panel.webview.postMessage({ type: 'panelVisibility', visible: e.webviewPanel.visible }); + } catch { + // panel disposed + } if (e.webviewPanel.visible && this.onAfterOpen) { logger.debug('Change detail panel became visible, calling onAfterOpen'); this.onAfterOpen(); @@ -392,6 +410,7 @@ export class ChangeDetailPanelManager { }); panel.onDidDispose(() => { + receiptRegistration.dispose(); this.panels.delete(key); this.panelRootPaths.delete(key); this.panelScopes.delete(panel.webview); @@ -416,7 +435,7 @@ export class ChangeDetailPanelManager { if ( rootPath !== undefined && panelRootPath !== undefined - && path.normalize(panelRootPath) !== path.normalize(rootPath) + && !pathsEqualForWorkspace(panelRootPath, rootPath) ) continue; try { panel.webview.postMessage({ type: 'artifactInvalidated', changeName, artifactTypes }); diff --git a/src/extension/providers/dashboardViewProvider.ts b/src/extension/providers/dashboardViewProvider.ts index 15f27d7..c0513c2 100644 --- a/src/extension/providers/dashboardViewProvider.ts +++ b/src/extension/providers/dashboardViewProvider.ts @@ -32,6 +32,10 @@ import { workspaceHasOpenSpecRoot, workspaceHasOpenSpecRootSync, } from '../services/openspecRootGate'; +import { getOpenSpecProjectRoots } from '../utils/workspaceRoot'; +import { createProjectContext } from '../services/projectDataGateway'; +import { isPathInWorkspaceFolders } from '../utils/workspaceFolders'; +import { registerWorkflowReceiptWebview } from '../services/workflowWebviewRegistry'; type ProjectPageCache = Pick; type PendingExplorerContext = { message: ExtensionMessage; sent: boolean }; @@ -86,6 +90,7 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { /** Single-flight lock: at most one Workset creation may run at a time. */ private worksetCreateInFlight = false; private readonly agentUnavailableWebviews = new WeakSet(); + private readonly projectSidebarAutoRetried = new WeakSet(); constructor( private dataManager: DataManager, @@ -149,10 +154,12 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { webviewView.webview.html = getWebviewContent(webviewView.webview, this.extensionPath); // Setup message handler + const receiptRegistration = registerWorkflowReceiptWebview(webviewView.webview); this.setupMessageHandler(webviewView.webview); // Handle view disposal webviewView.onDidDispose(() => { + receiptRegistration.dispose(); this._view = undefined; }); @@ -169,6 +176,42 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { this.refreshSubscription = undefined; } + /** + * When workspace folders change, re-bind Project-first Dashboard to a root that + * still exists in the window (avoids launching against a removed folder). + */ + public async syncProjectContextAfterWorkspaceChange(): Promise { + if (!this.isProjectFirst() || !this.projectContext || !this.projectDataGateway) return; + + const binding = this.currentProjectBinding; + if (binding && isPathInWorkspaceFolders(binding.rootPath)) { + return; + } + + logger.warn( + 'OpenSpec project binding is outside current workspace folders; re-resolving project context', + ); + const roots = await getOpenSpecProjectRoots(); + if (roots.length === 0) { + await this.syncOpenSpecRootAvailability(); + return; + } + + const nextRoot = roots[0]; + this.projectContext = await createProjectContext(nextRoot.label, nextRoot.path); + this.cachedProjectSidebarData = undefined; + this.currentProjectBinding = undefined; + this.projectRequestGeneration += 1; + + const webviews = [this._view?.webview, this.dashboardPanel?.webview].filter( + (webview): webview is vscode.Webview => webview != null, + ); + for (const webview of webviews) { + void this.reloadProjectSidebarData(webview, webview === this.dashboardPanel?.webview ? 'dashboard' : 'sidebar'); + } + void vscode.window.showInformationMessage(t('workflow.workspaceRebound')); + } + /** Reconcile sidebar HTML when workspace folders change without window reload. */ public async syncOpenSpecRootAvailability(): Promise { const hasRoot = await workspaceHasOpenSpecRoot(); @@ -375,6 +418,19 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { ? `OpenSpec workspace is not initialized for this project: ${this.errorMessage(error)}` : this.errorMessage(error); targetWebview.postMessage({ type: 'error', message }); + this.scheduleProjectSidebarAutoRetry(targetWebview, targetSurface); + } + + private scheduleProjectSidebarAutoRetry( + targetWebview: vscode.Webview, + targetSurface: ProjectSurface, + ): void { + if (this.projectSidebarAutoRetried.has(targetWebview)) return; + this.projectSidebarAutoRetried.add(targetWebview); + setTimeout(() => { + logger.info('Retrying Project Sidebar load after initial failure'); + void this.reloadProjectSidebarData(targetWebview, targetSurface); + }, 2500); } private errorMessage(error: unknown): string { @@ -652,9 +708,16 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { } public postWorkflowLaunchConfig(targetWebview?: vscode.Webview): void { - const webview = targetWebview ?? this._view?.webview; - if (!webview) return; - webview.postMessage(getWorkflowLaunchConfigMessage()); + const message = getWorkflowLaunchConfigMessage(); + if (targetWebview) { + targetWebview.postMessage(message); + return; + } + for (const webview of [this._view?.webview, this.dashboardPanel?.webview]) { + if (webview) { + webview.postMessage(message); + } + } } public openInEditor(): void { @@ -680,8 +743,10 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { ); this.dashboardPanel = panel; panel.webview.html = getWebviewContent(panel.webview, this.extensionPath); + const panelReceiptRegistration = registerWorkflowReceiptWebview(panel.webview); this.setupMessageHandler(panel.webview); panel.onDidDispose(() => { + panelReceiptRegistration.dispose(); this.dashboardPanel = undefined; }); logger.info('Dashboard editor panel opened'); diff --git a/src/extension/providers/launchWorkflowAction.ts b/src/extension/providers/launchWorkflowAction.ts new file mode 100644 index 0000000..9753969 --- /dev/null +++ b/src/extension/providers/launchWorkflowAction.ts @@ -0,0 +1,349 @@ +import * as vscode from 'vscode'; +import { logger } from '../utils/logger'; +import { DataManager } from '../services/dataManager'; +import { t } from '../../i18n'; +import { buildWorkflowLaunchPayload } from '../../shared/workflowCommand'; +import type { WorkflowAction } from '../../shared/workflowCommand'; +import { getWorkflowLaunchConfig } from '../services/workflowLaunchConfig'; +import { isCopyOnlyWorkflowMode, toWorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; +import { + launchWorkflowAgentCommand, + notifyWorkflowLaunchFailure, +} from '../services/workflowAgentLaunch'; +import { isPathInWorkspaceFolders } from '../utils/workspaceFolders'; +import { broadcastWorkflowActionReceipt } from '../services/workflowWebviewRegistry'; +import type { OpenSpecScope } from '../services/openspecScope'; +import { + createWorkflowRequestId, + getWorkflowBindingKey, + type ChangeWorkflowSnapshot, + type WorkflowActionReceipt, + type WorkflowBindingIdentity, +} from '../../shared/changeWorkflow'; + +export const WORKFLOW_LAUNCHING_MIN_VISIBLE_MS = 400; + +function postWorkflowReceipt(webview: vscode.Webview, receipt: WorkflowActionReceipt): void { + try { + webview.postMessage({ type: 'workflowActionReceipt', ...receipt }); + } catch { + // Originating webview may be disposed. + } + broadcastWorkflowActionReceipt(receipt, webview); +} + +export async function disposeLaunchStatusBarAfterMinimum( + disposable: vscode.Disposable | undefined, + launchStartedAt: number, +): Promise { + if (!disposable) return; + const elapsed = Date.now() - launchStartedAt; + const remaining = WORKFLOW_LAUNCHING_MIN_VISIBLE_MS - elapsed; + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); + } + disposable.dispose(); +} + +function parseWorkflowBindingFromKey(bindingKey: string): WorkflowBindingIdentity | undefined { + try { + const parsed: unknown = JSON.parse(bindingKey); + if (!Array.isArray(parsed) || parsed.length < 4) return undefined; + const [projectId, commandCwd, rootPath, rootSource, storeId] = parsed; + if ( + typeof projectId !== 'string' + || typeof commandCwd !== 'string' + || typeof rootPath !== 'string' + || typeof rootSource !== 'string' + ) { + return undefined; + } + return { + projectId, + commandCwd, + rootPath, + rootSource, + ...(typeof storeId === 'string' && storeId.length > 0 ? { storeId } : {}), + }; + } catch { + return undefined; + } +} + +function resolveWorkflowBindingForLaunch( + scope: OpenSpecScope | undefined, + bindingKey?: string, +): WorkflowBindingIdentity | undefined { + if (scope?.workflowBinding) { + return scope.workflowBinding; + } + if (typeof bindingKey === 'string' && bindingKey.trim()) { + return parseWorkflowBindingFromKey(bindingKey); + } + return undefined; +} + +function resolveScopeRoot( + dataManager: DataManager, + scopeId?: string, + boundScope?: OpenSpecScope, +): { rootPath: string; scope: OpenSpecScope | undefined } { + if (boundScope) { + return { rootPath: boundScope.rootPath, scope: boundScope }; + } + const scopedDataManager = dataManager as DataManager & { + resolveScope?: (id?: string) => OpenSpecScope | undefined; + }; + const scope = typeof scopedDataManager.resolveScope === 'function' + ? scopedDataManager.resolveScope(scopeId) + : undefined; + const rootPath = scope?.rootPath ?? dataManager.getWorkspaceRoot(); + return { rootPath, scope }; +} + +function postLaunchValidationFailure( + webview: vscode.Webview, + receipt: WorkflowActionReceipt, + logMessage: string, + userMessage: string, +): void { + logger.warn(logMessage); + void vscode.window.showWarningMessage(userMessage); + postWorkflowReceipt(webview, { ...receipt, suppressPriorityAttention: true }); +} + +export interface LaunchWorkflowActionRequest { + webview: vscode.Webview; + dataManager: DataManager; + boundScope?: OpenSpecScope; + action: WorkflowAction; + changeName: string; + requestId?: string; + bindingKey?: string; + scopeId?: string; +} + +export async function processLaunchWorkflowAction( + request: LaunchWorkflowActionRequest, +): Promise { + const { + webview, + dataManager, + boundScope, + action, + changeName, + scopeId, + } = request; + + const launchStartedAt = Date.now(); + const logWorkflowTiming = (phase: string, detail?: string) => { + logger.info( + `[workflow-timing] ${phase} +${Date.now() - launchStartedAt}ms` + + (detail ? ` ${detail}` : ''), + ); + }; + + const { rootPath: scopeRootPath, scope } = resolveScopeRoot(dataManager, scopeId, boundScope); + const correlated = request.requestId !== undefined || request.bindingKey !== undefined; + const requestId = request.requestId ?? createWorkflowRequestId('legacy'); + const workflowBinding = resolveWorkflowBindingForLaunch(scope, request.bindingKey); + let receiptBindingKeyForPost = request.bindingKey ?? 'legacy'; + const postReceipt = ( + target: WorkflowActionReceipt['target'], + status: WorkflowActionReceipt['status'], + receiptMessage?: string, + suppressPriorityAttention?: boolean, + ) => postWorkflowReceipt(webview, { + requestId, + changeName, + bindingKey: receiptBindingKeyForPost, + action, + target, + status, + ...(receiptMessage ? { message: receiptMessage } : {}), + ...(suppressPriorityAttention ? { suppressPriorityAttention: true } : {}), + }); + + const retryParams: LaunchWorkflowActionRequest = { + ...request, + requestId, + bindingKey: receiptBindingKeyForPost, + }; + + let launchStatusDisposable: vscode.Disposable | undefined; + const copyOnlyLaunch = isCopyOnlyWorkflowMode(toWorkflowLaunchConfigView(getWorkflowLaunchConfig())); + try { + if (correlated && !copyOnlyLaunch) { + postReceipt('unknown', 'running'); + launchStatusDisposable = vscode.window.setStatusBarMessage?.(t('workflow.launching')); + } + logWorkflowTiming('received', `action=${action} change=${changeName}`); + + if (!isPathInWorkspaceFolders(scopeRootPath)) { + if (correlated) { + postLaunchValidationFailure( + webview, + { + requestId, + changeName, + bindingKey: request.bindingKey ?? 'unknown', + action, + target: 'unknown', + status: 'failed', + message: t('workflow.workspaceRootStale'), + }, + `Workflow launch blocked: scope root not in workspace folders (${scopeRootPath})`, + t('workflow.workspaceRootStale'), + ); + } else { + void vscode.window.showWarningMessage(t('workflow.workspaceRootStale')); + } + return; + } + + const snapshotReader = dataManager as DataManager & { + getChangeWorkflowSnapshot?: ( + name: string, + scope?: OpenSpecScope, + binding?: WorkflowBindingIdentity + ) => Promise; + }; + let expectedBindingKey: string | undefined; + if (workflowBinding && request.bindingKey) { + const identityKey = getWorkflowBindingKey(workflowBinding); + if (identityKey === request.bindingKey) { + expectedBindingKey = identityKey; + } + } + if ( + expectedBindingKey === undefined + && typeof snapshotReader.getChangeWorkflowSnapshot === 'function' + ) { + try { + expectedBindingKey = ( + await snapshotReader.getChangeWorkflowSnapshot(changeName, scope, workflowBinding) + )?.bindingKey; + } catch (error) { + logger.warn('Failed to validate workflow action binding', error as Error); + if (correlated) { + postLaunchValidationFailure( + webview, + { + requestId, + changeName, + bindingKey: request.bindingKey ?? 'unknown', + action, + target: 'unknown', + status: 'failed', + message: t('workflow.bindingValidationFailed'), + }, + `Workflow binding validation error for ${changeName}: ${(error as Error).message}`, + t('workflow.bindingValidationFailed'), + ); + return; + } + } + } + receiptBindingKeyForPost = request.bindingKey ?? expectedBindingKey ?? 'legacy'; + retryParams.bindingKey = receiptBindingKeyForPost; + + if (correlated && (!request.requestId || !request.bindingKey)) { + postLaunchValidationFailure( + webview, + { + requestId, + changeName, + bindingKey: receiptBindingKeyForPost, + action, + target: 'unknown', + status: 'failed', + message: t('workflow.bindingRequestIncomplete'), + }, + `Workflow launch rejected for ${changeName}: missing requestId or bindingKey`, + t('workflow.bindingRequestIncomplete'), + ); + return; + } + if (request.bindingKey && expectedBindingKey && request.bindingKey !== expectedBindingKey) { + postLaunchValidationFailure( + webview, + { + requestId, + changeName, + bindingKey: request.bindingKey, + action, + target: 'unknown', + status: 'failed', + message: t('workflow.bindingMismatch'), + }, + `Workflow binding mismatch for ${changeName}: expected=${expectedBindingKey}, received=${request.bindingKey}`, + t('workflow.bindingMismatch'), + ); + return; + } + + logWorkflowTiming('validated', `bindingKey=${receiptBindingKeyForPost}`); + + const launchConfig = getWorkflowLaunchConfig(); + const launchConfigView = toWorkflowLaunchConfigView(launchConfig); + const effectiveAdapterId = launchConfigView.effectiveAdapterId; + logger.info( + `[workflow] launchWorkflowAction: action=${action}, changeName=${changeName}, ` + + `scopeId=${scopeId ?? ''}, scopeRoot=${scopeRootPath}, ` + + `workflowLaunchMode=${launchConfig.workflowLaunchMode}, ` + + `preferredAgentAdapter=${launchConfig.preferredAgentAdapter}, ` + + `cursorLaunchMode=${launchConfig.cursorLaunchMode}, ` + + `cursorLaunchModeExplicit=${launchConfig.cursorLaunchModeExplicit}, ` + + `effectiveAdapterId=${effectiveAdapterId ?? 'none'}` + ); + + logWorkflowTiming('launcher-start'); + const launchResult = await launchWorkflowAgentCommand({ + action, + changeName, + workspaceRoot: scopeRootPath, + }); + const timingOutcome = launchResult.outcome === 'deduped' + ? 'deduped' + : (launchResult.outcome ?? 'n/a'); + logWorkflowTiming( + `layer-${launchResult.layer ?? 'none'}-done`, + `target=${launchResult.target} outcome=${timingOutcome} command=${launchResult.command}`, + ); + logger.info( + `[workflow] agent launch: target=${launchResult.target}, layer=${launchResult.layer ?? 'n/a'}, ` + + `outcome=${launchResult.outcome ?? 'n/a'}, command=${launchResult.command}` + ); + const receiptTarget = launchResult.target === 'agentPanel' + ? 'cursor' + : launchResult.target === 'agentCli' + ? 'cursor' + : launchResult.target === 'externalAdapter' + ? buildWorkflowLaunchPayload({ + action, + changeName, + workflowLaunchMode: 'adapter', + adapterId: effectiveAdapterId ?? undefined, + }).target + : 'clipboard'; + if (launchResult.outcome === 'deduped') { + postReceipt(receiptTarget, 'completed', launchResult.message, true); + return; + } + if (launchResult.success) { + const status = launchResult.outcome === 'copied' ? 'copied' : 'delivered'; + postReceipt(receiptTarget, status, launchResult.message); + } else { + const reason = launchResult.message ?? t('agentLaunch.failed'); + postReceipt(receiptTarget, 'failed', reason, true); + void notifyWorkflowLaunchFailure(reason, () => processLaunchWorkflowAction(retryParams)); + } + } catch (error) { + logger.error('launchWorkflowAction failed', error as Error); + const reason = (error as Error).message; + postReceipt('unknown', 'failed', reason, true); + void notifyWorkflowLaunchFailure(reason, () => processLaunchWorkflowAction(retryParams)); + } finally { + void disposeLaunchStatusBarAfterMinimum(launchStatusDisposable, launchStartedAt); + } +} diff --git a/src/extension/providers/webviewMessageHandler.ts b/src/extension/providers/webviewMessageHandler.ts index 7e37df2..73bb70b 100644 --- a/src/extension/providers/webviewMessageHandler.ts +++ b/src/extension/providers/webviewMessageHandler.ts @@ -5,7 +5,7 @@ import { logger } from '../utils/logger'; import { DataManager } from '../services/dataManager'; import { getChangesBasePath } from '../utils/workspaceRoot'; import { isPathUnderRoot } from '../utils/pathSafety'; -import { getAdapterById, getCurrentAdapter } from '../adapters'; +import { getCurrentAdapter } from '../adapters'; import type { ArtifactOutputDescriptor, CacheStatsView, @@ -13,18 +13,17 @@ import type { } from '../../webview/types/messages'; import { t } from '../../i18n'; import { buildWorkflowLaunchPayload } from '../../shared/workflowCommand'; +import type { WorkflowAction } from '../../shared/workflowCommand'; import { getWorkflowLaunchConfig } from '../services/workflowLaunchConfig'; +import { launchWorkflowAgentCommand } from '../services/workflowAgentLaunch'; import { postExecutorLaunchPresentationFromHost } from '../services/executorLaunchPresentation'; import { InteractiveAgentTerminalManager, } from '../services/interactiveAgentTerminalManager'; import { confirmDirectArchive } from '../commands/archiveConfirm'; import { formatBytes } from '../utils/formatBytes'; -import { - isCopyOnlyWorkflowMode, - shouldForceCursorWorkflowRoute, - toWorkflowLaunchConfigView, -} from '../../shared/workflowLaunchConfig'; +import { toWorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; +import { isCursorHost } from '../utils/isCursorHost'; import type { InteractiveWorkflowAction, InteractiveWorkflowState, @@ -39,14 +38,73 @@ import { workspaceHasOpenSpecRoot, } from '../services/openspecRootGate'; import type { OpenSpecScope } from '../services/openspecScope'; +import { isPathInWorkspaceFolders } from '../utils/workspaceFolders'; +import { broadcastWorkflowActionReceipt } from '../services/workflowWebviewRegistry'; +import { disposeLaunchStatusBarAfterMinimum, processLaunchWorkflowAction } from './launchWorkflowAction'; import { createWorkflowRequestId, + getWorkflowBindingKey, type ChangeWorkflowSnapshot, type WorkflowActionReceipt, + type WorkflowBindingIdentity, } from '../../shared/changeWorkflow'; function postWorkflowReceipt(webview: vscode.Webview, receipt: WorkflowActionReceipt): void { - webview.postMessage({ type: 'workflowActionReceipt', ...receipt }); + try { + webview.postMessage({ type: 'workflowActionReceipt', ...receipt }); + } catch { + // Originating webview may be disposed. + } + broadcastWorkflowActionReceipt(receipt, webview); +} + +function parseWorkflowBindingFromKey(bindingKey: string): WorkflowBindingIdentity | undefined { + try { + const parsed: unknown = JSON.parse(bindingKey); + if (!Array.isArray(parsed) || parsed.length < 4) return undefined; + const [projectId, commandCwd, rootPath, rootSource, storeId] = parsed; + if ( + typeof projectId !== 'string' + || typeof commandCwd !== 'string' + || typeof rootPath !== 'string' + || typeof rootSource !== 'string' + ) { + return undefined; + } + return { + projectId, + commandCwd, + rootPath, + rootSource, + ...(typeof storeId === 'string' && storeId.length > 0 ? { storeId } : {}), + }; + } catch { + return undefined; + } +} + +function resolveWorkflowBindingForLaunch( + scope: OpenSpecScope | undefined, + bindingKey?: string, +): WorkflowBindingIdentity | undefined { + if (scope?.workflowBinding) { + return scope.workflowBinding; + } + if (typeof bindingKey === 'string' && bindingKey.trim()) { + return parseWorkflowBindingFromKey(bindingKey); + } + return undefined; +} + +function postLaunchValidationFailure( + webview: vscode.Webview, + receipt: WorkflowActionReceipt, + logMessage: string, + userMessage: string, +): void { + logger.warn(logMessage); + void vscode.window.showWarningMessage(userMessage); + postWorkflowReceipt(webview, { ...receipt, suppressPriorityAttention: true }); } /** @@ -95,12 +153,17 @@ async function resolveActiveArtifactPath( const snapshotReader = dataManager as DataManager & { getChangeWorkflowSnapshot?: ( name: string, - scope?: OpenSpecScope + scope?: OpenSpecScope, + binding?: WorkflowBindingIdentity ) => Promise; }; if (typeof snapshotReader.getChangeWorkflowSnapshot !== 'function') return null; - const snapshot = await snapshotReader.getChangeWorkflowSnapshot(changeName, scope); + const snapshot = await snapshotReader.getChangeWorkflowSnapshot( + changeName, + scope, + scope?.workflowBinding, + ); const artifact = snapshot?.artifacts.find((candidate) => candidate.id === artifactType); if (!artifact || artifact.existingOutputPaths.length === 0) return null; @@ -617,6 +680,16 @@ export async function handleWebviewMessage( const artifactType = message.artifactId ?? message.artifactType; if (!changeName || !artifactType) break; const { rootPath, scope } = resolveScopeRoot(dataManager, message.scopeId, boundScope); + if (!isPathInWorkspaceFolders(rootPath)) { + webview.postMessage({ + type: 'artifactContentError', + changeName, + artifactType, + message: t('workflow.workspaceRootStale'), + code: 'WORKSPACE_ROOT_STALE', + }); + break; + } let activeArtifactPath: string | undefined; let activeArtifactOutputs: ArtifactOutputDescriptor[] | undefined; if (!changeName.startsWith('archive:')) { @@ -684,7 +757,7 @@ export async function handleWebviewMessage( cache: { source: 'fresh', stale: false }, }); } catch (err) { - logger.info('getArtifactContent active output read failed', err as Error); + logger.warn('getArtifactContent active output read failed', err as Error); webview.postMessage({ type: 'artifactContentError', changeName, @@ -816,15 +889,47 @@ export async function handleWebviewMessage( vscode.window.showInformationMessage(t('archive.readOnly')); break; } - const { scope } = resolveScopeRoot(dataManager, message.scopeId, boundScope); + const { rootPath: taskScopeRoot, scope } = resolveScopeRoot(dataManager, message.scopeId, boundScope); + const taskLaunchStartedAt = Date.now(); + const logTaskTiming = (phase: string, detail?: string) => { + logger.info( + `[workflow-timing] task-${phase} +${Date.now() - taskLaunchStartedAt}ms` + + (detail ? ` ${detail}` : ''), + ); + }; + logTaskTiming('received', `change=${changeName} task=${taskIndex}`); + if (!isPathInWorkspaceFolders(taskScopeRoot)) { + logTaskTiming('blocked', 'workspaceRootStale'); + void vscode.window.showWarningMessage(t('workflow.workspaceRootStale')); + try { + webview.postMessage({ + type: 'taskExecutionFinished', + changeName, + taskIndex, + success: false, + executionState: await dataManager.getTaskExecutionState(changeName, scope), + }); + } catch { + // webview disposed + } + break; + } + logTaskTiming('validated'); + const taskLaunchStatus = vscode.window.setStatusBarMessage?.(t('workflow.launching')); let success = false; try { + logTaskTiming('executor-start'); const result = await dataManager.executeTaskRequest(changeName, taskIndex, taskText, scope); success = result.success; - await dataManager.setTaskExecutionState(changeName, taskIndex, success, scope); + logTaskTiming('executor-done', `success=${success}`); + if (success) { + await dataManager.setTaskExecutionState(changeName, taskIndex, true, scope); + } } catch (err) { logger.error('executeTask failed', err as Error); vscode.window.showErrorMessage((err as Error).message || t('task.executionFailed')); + } finally { + void disposeLaunchStatusBarAfterMinimum(taskLaunchStatus, taskLaunchStartedAt); } try { const executionState = await dataManager.getTaskExecutionState(changeName, scope); @@ -1002,165 +1107,16 @@ export async function handleWebviewMessage( const action = message.action; const changeName = message.changeName; if (typeof changeName !== 'string' || !changeName.trim()) break; - - // Resolve the effective root (scope-aware) so store-scoped workflows run against - // the store root, not the workspace root. - const { rootPath: scopeRootPath, scope } = resolveScopeRoot(dataManager, message.scopeId, boundScope); - const correlated = message.requestId !== undefined || message.bindingKey !== undefined; - const requestId = message.requestId ?? createWorkflowRequestId('legacy'); - const snapshotReader = dataManager as DataManager & { - getChangeWorkflowSnapshot?: ( - name: string, - scope?: OpenSpecScope - ) => Promise; - }; - let expectedBindingKey: string | undefined; - if (typeof snapshotReader.getChangeWorkflowSnapshot === 'function') { - try { - expectedBindingKey = (await snapshotReader.getChangeWorkflowSnapshot(changeName, scope))?.bindingKey; - } catch (error) { - logger.warn('Failed to validate workflow action binding', error as Error); - if (correlated) { - postWorkflowReceipt(webview, { - requestId, - changeName, - bindingKey: message.bindingKey ?? 'unknown', - action, - target: 'unknown', - status: 'failed', - message: 'Unable to validate the current Change binding.', - }); - break; - } - } - } - const receiptBindingKey = message.bindingKey ?? expectedBindingKey ?? 'legacy'; - if (correlated && (!message.requestId || !message.bindingKey)) { - postWorkflowReceipt(webview, { - requestId, - changeName, - bindingKey: receiptBindingKey, - action, - target: 'unknown', - status: 'failed', - message: 'A request id and bound Change root are required.', - }); - break; - } - if (message.bindingKey && expectedBindingKey && message.bindingKey !== expectedBindingKey) { - postWorkflowReceipt(webview, { - requestId, - changeName, - bindingKey: message.bindingKey, - action, - target: 'unknown', - status: 'failed', - message: 'The workflow request belongs to a different Change root.', - }); - break; - } - - const postReceipt = ( - target: WorkflowActionReceipt['target'], - status: WorkflowActionReceipt['status'], - message?: string, - ) => postWorkflowReceipt(webview, { - requestId, - changeName, - bindingKey: receiptBindingKey, - action, - target, - status, - ...(message ? { message } : {}), - }); - - const launchConfig = getWorkflowLaunchConfig(); - const launchConfigView = toWorkflowLaunchConfigView(launchConfig); - const effectiveAdapterId = launchConfigView.effectiveAdapterId; - logger.info( - `[workflow] launchWorkflowAction: action=${action}, changeName=${changeName}, ` + - `scopeId=${message.scopeId ?? ''}, scopeRoot=${scopeRootPath}, ` + - `workflowLaunchMode=${launchConfig.workflowLaunchMode}, ` + - `preferredAgentAdapter=${launchConfig.preferredAgentAdapter}, ` + - `cursorLaunchMode=${launchConfig.cursorLaunchMode}, ` + - `cursorLaunchModeExplicit=${launchConfig.cursorLaunchModeExplicit}, ` + - `effectiveAdapterId=${effectiveAdapterId ?? 'none'}` - ); - - if (isCopyOnlyWorkflowMode(launchConfigView)) { - const payload = buildWorkflowLaunchPayload({ - action, - changeName, - workflowLaunchMode: 'clipboard', - }); - logger.debug(`[workflow] copy-only route: command=${payload.command}`); - try { - await vscode.env.clipboard.writeText(payload.command); - vscode.window.showInformationMessage(t('workflow.copiedCommand', { command: payload.command })); - postReceipt('clipboard', 'copied', 'Command copied. Paste or send it to continue.'); - } catch (error) { - postReceipt('clipboard', 'failed', (error as Error).message); - } - break; - } - - const adapter = shouldForceCursorWorkflowRoute(launchConfig) - && launchConfig.preferredAgentAdapter !== 'clipboard' - ? await getAdapterById('cursor') - : await getCurrentAdapter(); - if (!adapter) { - const payload = buildWorkflowLaunchPayload({ - action, - changeName, - workflowLaunchMode: 'clipboard', - }); - logger.warn(`[workflow] no available adapter for effectiveAdapterId=${effectiveAdapterId}; copied fallback command=${payload.command}`); - try { - await vscode.env.clipboard.writeText(payload.command); - vscode.window.showInformationMessage(t('workflow.adapterFallback')); - postReceipt('clipboard', 'fallback', t('workflow.adapterFallback')); - } catch (error) { - postReceipt('clipboard', 'failed', (error as Error).message); - } - break; - } - - const payload = buildWorkflowLaunchPayload({ - action, + await processLaunchWorkflowAction({ + webview, + dataManager, + boundScope, + action: action as WorkflowAction, changeName, - workflowLaunchMode: 'adapter', - adapterId: adapter.id, + requestId: message.requestId, + bindingKey: message.bindingKey, + scopeId: message.scopeId, }); - logger.info(`[workflow] launching via adapter: id=${adapter.id}, displayName=${adapter.displayName}, command=${payload.command}`); - try { - const result = await adapter.fillChat({ - changeName, - taskIndex: -1, - taskText: '', - contextFiles: [], - workspaceRoot: scopeRootPath, - promptOverride: payload.command, - }); - logger.info( - `[workflow] adapter result: id=${result.adapterId}, success=${result.success}, message=${result.message ?? ''}` - ); - if (result.success) { - postReceipt(payload.target, 'delivered', result.message ?? 'Workflow command delivered to the selected target.'); - } else { - await vscode.env.clipboard.writeText(payload.command); - vscode.window.showInformationMessage(t('workflow.adapterFallback')); - postReceipt('clipboard', 'fallback', t('workflow.adapterFallback')); - } - } catch (error) { - logger.error('launchWorkflowAction adapter failed', error as Error); - try { - await vscode.env.clipboard.writeText(payload.command); - vscode.window.showInformationMessage(t('workflow.adapterFallback')); - postReceipt('clipboard', 'fallback', t('workflow.adapterFallback')); - } catch (fallbackError) { - postReceipt(payload.target, 'failed', (fallbackError as Error).message || (error as Error).message); - } - } break; } @@ -1243,15 +1199,7 @@ export async function handleWebviewMessage( case 'getInteractiveWorkflowState': { const { changeName } = message; if (typeof changeName !== 'string' || !changeName.trim()) break; - const { rootPath: scopeRootPath, scope } = resolveScopeRoot(dataManager, message.scopeId, boundScope); - const state = interactiveTerminalManager - ? interactiveTerminalManager.getState(scopeRootPath, changeName, scope) - : buildInteractiveWorkflowErrorState( - changeName, - 'verify', - t('verifyArchive.managerUnavailable') - ); - postInteractiveWorkflowState(webview, changeName, state); + postInteractiveWorkflowState(webview, changeName, { changeName, sessions: {} }); break; } @@ -1440,6 +1388,14 @@ export async function handleWebviewMessage( break; } + case 'webviewReady': + await postExecutorLaunchPresentationFromHost(webview, dataManager); + break; + + case 'getProjectSidebarData': + logger.debug('Ignoring getProjectSidebarData in non-dashboard webview'); + break; + default: logger.warn(`Unknown message type: ${message.type}`); } @@ -1510,6 +1466,7 @@ export function getWorkflowLaunchConfigMessage() { return { type: 'workflowLaunchConfig' as const, config: toWorkflowLaunchConfigView(config), + isCursorHost: isCursorHost(), }; } @@ -1561,13 +1518,6 @@ async function handleInteractiveWorkflowAction(params: { `Invalid interactive workflow action: ${String(params.action)}` ); } - if (!params.interactiveTerminalManager) { - return buildInteractiveWorkflowErrorState( - params.changeName, - params.action, - t('verifyArchive.managerUnavailable') - ); - } if (params.changeName.startsWith('archive:') && params.action === 'archive') { return buildInteractiveWorkflowErrorState( params.changeName, @@ -1576,36 +1526,24 @@ async function handleInteractiveWorkflowAction(params: { ); } - switch (params.kind) { - case 'run': - return params.interactiveTerminalManager.start({ - workspaceRoot: params.workspaceRoot, - changeName: params.changeName, + if (params.kind === 'run') { + try { + await launchWorkflowAgentCommand({ action: params.action, - scope: params.scope, + changeName: params.changeName, + workspaceRoot: params.workspaceRoot, }); - case 'reveal': - return params.interactiveTerminalManager.reveal( - params.workspaceRoot, + } catch (error) { + return buildInteractiveWorkflowErrorState( params.changeName, params.action, - params.scope - ); - case 'stop': - return params.interactiveTerminalManager.stop( - params.workspaceRoot, - params.changeName, - params.action, - params.scope - ); - case 'clear': - return params.interactiveTerminalManager.clear( - params.workspaceRoot, - params.changeName, - params.action, - params.scope + (error as Error).message || t('agentLaunch.failed'), ); + } + return { changeName: params.changeName, sessions: {} }; } + + return { changeName: params.changeName, sessions: {} }; } /** diff --git a/src/extension/services/agentPanelLauncher.ts b/src/extension/services/agentPanelLauncher.ts new file mode 100644 index 0000000..8580166 --- /dev/null +++ b/src/extension/services/agentPanelLauncher.ts @@ -0,0 +1,269 @@ +import * as vscode from 'vscode'; +import { buildCursorPromptDeeplink } from './cursorDeeplink'; +import { logger } from '../utils/logger'; +import { t } from '../../i18n'; + +export type AgentLaunchLayer = + | 'composerCreateNew' + | 'chatOpen' + | 'deeplink' + | 'clipboard' + | 'vscodeChat'; + +export type AgentLaunchOutcome = 'started' | 'prefilled' | 'submitted' | 'copied' | 'failed' | 'deduped'; + +export interface AgentLaunchResult { + success: boolean; + layer: AgentLaunchLayer; + outcome: AgentLaunchOutcome; + message?: string; +} + +export interface AgentPanelLaunchRequest { + text: string; + /** Only honored when launching through Cursor `composer.createNew`. */ + autoSubmit: boolean; +} + +export interface AgentPanelLauncherDeps { + getCommands?: () => Promise>; + executeCommand?: (command: string, ...rest: unknown[]) => Thenable; + openExternal?: (target: vscode.Uri) => Thenable; + writeClipboard?: (text: string) => Thenable; + sleep?: (ms: number) => Promise; + isCursorHost?: () => boolean; + now?: () => number; +} + +const COMPOSER_SUCCESS_WAIT_MS = 1500; +/** Only treat createNew as failed (chat fallback) if it rejects within this window. */ +const COMPOSER_CREATE_NEW_QUICK_FAIL_MS = 50; + +let cachedCommands: Set | undefined; +let cachedCommandsAt = 0; +const COMMAND_CACHE_TTL_MS = 60_000; + +function defaultIsCursorHost(): boolean { + return (vscode.env.uriScheme ?? '').toLowerCase() === 'cursor' + || (vscode.env.appName ?? '').toLowerCase().includes('cursor'); +} + +async function getAvailableCommands(deps: AgentPanelLauncherDeps): Promise> { + const now = deps.now?.() ?? Date.now(); + if (cachedCommands && now - cachedCommandsAt < COMMAND_CACHE_TTL_MS) { + return cachedCommands; + } + const list = await (deps.getCommands?.() ?? vscode.commands.getCommands(true)); + cachedCommands = new Set(list); + cachedCommandsAt = now; + return cachedCommands; +} + +async function readComposerPaneIds( + executeCommand: AgentPanelLauncherDeps['executeCommand'], + cmds: Set, +): Promise { + if (!cmds.has('composer.getOrderedSelectedComposerIds')) { + return undefined; + } + try { + const ids = await executeCommand?.('composer.getOrderedSelectedComposerIds'); + return Array.isArray(ids) ? ids.map(String) : undefined; + } catch { + return undefined; + } +} + +function composerIdsIncreased(before: string[] | undefined, after: string[] | undefined): boolean { + if (!before || !after) return true; + return after.length > before.length; +} + +async function tryCursorComposerCreateNew( + request: AgentPanelLaunchRequest, + deps: AgentPanelLauncherDeps, +): Promise { + const cmds = await getAvailableCommands(deps); + if (!cmds.has('composer.createNew')) { + return undefined; + } + const executeCommand = deps.executeCommand ?? vscode.commands.executeCommand.bind(vscode.commands); + + const beforeIds = await readComposerPaneIds(executeCommand, cmds); + const payload = { + openInNewTab: true, + view: 'pane', + unifiedMode: 'agent', + partialState: { + text: request.text, + richText: request.text, + unifiedMode: 'agent', + }, + ...(request.autoSubmit ? { autoSubmit: true } : {}), + }; + + const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + let createNewPending: Thenable; + try { + createNewPending = executeCommand('composer.createNew', payload); + } catch (error) { + logger.warn('composer.createNew failed', error as Error); + return undefined; + } + + const quickOutcome = await Promise.race([ + Promise.resolve(createNewPending).then( + () => 'settled' as const, + () => 'rejected' as const, + ), + sleep(COMPOSER_CREATE_NEW_QUICK_FAIL_MS).then(() => 'slow' as const), + ]); + + if (quickOutcome === 'rejected') { + logger.warn('composer.createNew rejected quickly'); + return undefined; + } + + if (quickOutcome === 'slow') { + void Promise.resolve(createNewPending).catch((error: unknown) => { + logger.warn('composer.createNew failed after invoke', error as Error); + }); + } + + void (async () => { + await sleep(COMPOSER_SUCCESS_WAIT_MS); + const afterIds = await readComposerPaneIds(executeCommand, cmds); + if (!composerIdsIncreased(beforeIds, afterIds)) { + logger.warn( + `composer.createNew did not confirm a new pane within ${COMPOSER_SUCCESS_WAIT_MS}ms (background check)`, + ); + void vscode.window.showWarningMessage( + request.autoSubmit + ? t('agentLaunch.submitNotConfirmed') + : t('agentLaunch.panelNotConfirmed'), + ); + } + })(); + + return { + success: true, + layer: 'composerCreateNew', + outcome: request.autoSubmit ? 'submitted' : 'prefilled', + }; +} + +async function tryCursorChatOpen( + request: AgentPanelLaunchRequest, + deps: AgentPanelLauncherDeps, +): Promise { + const cmds = await getAvailableCommands(deps); + if (!cmds.has('workbench.action.chat.open')) { + return undefined; + } + const executeCommand = deps.executeCommand ?? vscode.commands.executeCommand.bind(vscode.commands); + + try { + await executeCommand('workbench.action.chat.open', { query: request.text }); + if (cmds.has('composerMode.agent')) { + void executeCommand('composerMode.agent').then(undefined, () => undefined); + } + return { + success: true, + layer: 'chatOpen', + outcome: 'prefilled', + }; + } catch (error) { + logger.warn('workbench.action.chat.open failed on Cursor', error as Error); + return undefined; + } +} + +async function tryDeeplink( + request: AgentPanelLaunchRequest, + deps: AgentPanelLauncherDeps, +): Promise { + const openExternal = deps.openExternal ?? vscode.env.openExternal.bind(vscode.env); + try { + const opened = await openExternal(vscode.Uri.parse(buildCursorPromptDeeplink(request.text))); + if (!opened) return undefined; + return { + success: true, + layer: 'deeplink', + outcome: 'prefilled', + }; + } catch (error) { + logger.warn('Cursor deeplink launch failed', error as Error); + return undefined; + } +} + +async function tryVsCodeChatOpen( + request: AgentPanelLaunchRequest, + deps: AgentPanelLauncherDeps, +): Promise { + const cmds = await getAvailableCommands(deps); + if (!cmds.has('workbench.action.chat.open')) { + return undefined; + } + const executeCommand = deps.executeCommand ?? vscode.commands.executeCommand.bind(vscode.commands); + + try { + await executeCommand('workbench.action.chat.open', { + query: request.text, + isPartialQuery: true, + mode: 'agent', + }); + return { + success: true, + layer: 'vscodeChat', + outcome: 'prefilled', + }; + } catch (error) { + logger.warn('workbench.action.chat.open failed on VS Code', error as Error); + return undefined; + } +} + +async function clipboardFallback( + request: AgentPanelLaunchRequest, + deps: AgentPanelLauncherDeps, +): Promise { + const writeClipboard = deps.writeClipboard ?? vscode.env.clipboard.writeText.bind(vscode.env.clipboard); + await writeClipboard(request.text); + return { + success: true, + layer: 'clipboard', + outcome: 'copied', + }; +} + +export function resetAgentPanelLauncherCommandCache(): void { + cachedCommands = undefined; + cachedCommandsAt = 0; +} + +export async function launchAgentPanelPrompt( + request: AgentPanelLaunchRequest, + deps: AgentPanelLauncherDeps = {}, +): Promise { + const isCursor = deps.isCursorHost?.() ?? defaultIsCursorHost(); + + if (isCursor) { + const composerResult = await tryCursorComposerCreateNew(request, deps); + if (composerResult) return composerResult; + + const chatResult = await tryCursorChatOpen(request, deps); + if (chatResult) return chatResult; + + const deeplinkResult = await tryDeeplink(request, deps); + if (deeplinkResult) return deeplinkResult; + + return clipboardFallback(request, deps); + } + + const vscodeResult = await tryVsCodeChatOpen(request, deps); + if (vscodeResult) return vscodeResult; + + return clipboardFallback(request, deps); +} diff --git a/src/extension/services/executorLaunchPresentation.ts b/src/extension/services/executorLaunchPresentation.ts index 08071f5..757e8c4 100644 --- a/src/extension/services/executorLaunchPresentation.ts +++ b/src/extension/services/executorLaunchPresentation.ts @@ -6,17 +6,21 @@ import { import { toWorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; import type { DataManager } from './dataManager'; import { getWorkflowLaunchConfig } from './workflowLaunchConfig'; +import { isCursorHost } from '../utils/isCursorHost'; export async function createExecutorLaunchPresentation( dataManager: DataManager, ): Promise { const workflowLaunchConfig = toWorkflowLaunchConfigView(getWorkflowLaunchConfig()); const adapters = await dataManager.getAgentAdaptersInfo(); - return buildExecutorLaunchPresentation( - workflowLaunchConfig, - adapters.available, - adapters.currentId, - ); + return { + ...buildExecutorLaunchPresentation( + workflowLaunchConfig, + adapters.available, + adapters.currentId, + ), + isCursorHost: isCursorHost(), + }; } export function postExecutorLaunchPresentation( diff --git a/src/extension/services/openspecCli.ts b/src/extension/services/openspecCli.ts index c77c2b3..a0d37d9 100644 --- a/src/extension/services/openspecCli.ts +++ b/src/extension/services/openspecCli.ts @@ -86,10 +86,17 @@ export function parseWindowsCmdShimEntry(content: string): string | undefined { } export class OpenSpecCliService { + private static versionByRuntimeKey = new Map(); + + static resetVersionCacheForTests(): void { + OpenSpecCliService.versionByRuntimeKey.clear(); + } + private workspaceRoot: string; private resolver: OpenSpecCliResolver; private cliActivationDiagnostic: CliActivationDiagnostic | null = null; private shownCliDiagnosticKeys = new Set(); + private cachedCliVersion?: string; /** Resolved shell-free spawn targets for Windows launcher commands. */ private windowsSpawnTargets = new Map(); @@ -248,9 +255,13 @@ export class OpenSpecCliService { * Get OpenSpec CLI version */ async getVersion(): Promise { + if (this.cachedCliVersion) { + return this.cachedCliVersion; + } try { const output = await this.execOpenSpec(['--version']); - return output.trim(); + this.cachedCliVersion = output.trim(); + return this.cachedCliVersion; } catch (error) { logger.error('Failed to get OpenSpec version', error as Error); throw error; @@ -659,8 +670,36 @@ export class OpenSpecCliService { * execution path for every CLI command, so localSource/customPath/installed modes * all flow through the same code. */ + private runtimeVersionCacheKey(runtime: ResolvedOpenSpecRuntime): string { + const configuredCliPath = vscode.workspace.getConfiguration('openspec').get('cliPath') ?? ''; + const command = path.isAbsolute(runtime.command) + ? path.resolve(runtime.command) + : runtime.command; + const argsPrefix = runtime.argsPrefix + .map((arg) => (path.isAbsolute(arg) ? path.resolve(arg) : arg)) + .join('\u0000'); + const configuredKey = configuredCliPath + ? (path.isAbsolute(configuredCliPath) ? path.resolve(configuredCliPath) : configuredCliPath) + : ''; + return `${command}\u0000${argsPrefix}\u0000${runtime.source}\u0000${configuredKey}`; + } + private async execOpenSpecOnce(args: string[], timeoutMs: number): Promise { const runtime = await this.resolver.resolveRuntime(); + const isVersionProbe = args.length === 1 && args[0] === '--version'; + const versionCacheKey = isVersionProbe ? this.runtimeVersionCacheKey(runtime) : undefined; + if (versionCacheKey) { + const cachedVersion = OpenSpecCliService.versionByRuntimeKey.get(versionCacheKey); + if (cachedVersion !== undefined) { + return cachedVersion.endsWith('\n') ? cachedVersion : `${cachedVersion}\n`; + } + const resolvedVersion = runtime.version?.trim(); + if (resolvedVersion) { + OpenSpecCliService.versionByRuntimeKey.set(versionCacheKey, resolvedVersion); + this.cachedCliVersion = resolvedVersion; + return `${resolvedVersion}\n`; + } + } const isLocalSource = runtime.source === 'localSource'; // Windows + non-local-source: never route through cmd.exe. Shell parsing would // corrupt arguments that must reach the CLI verbatim — a workset member path @@ -709,6 +748,11 @@ export class OpenSpecCliService { ); reject(error); } else { + const trimmed = stdout.trim(); + if (versionCacheKey) { + OpenSpecCliService.versionByRuntimeKey.set(versionCacheKey, trimmed); + this.cachedCliVersion = trimmed; + } resolve(stdout); } }); @@ -1004,6 +1048,9 @@ export class OpenSpecCliService { private defaultWorkflowBinding(scope?: ScopeOption | OpenSpecScope): WorkflowBindingIdentity { const scoped = scope as Partial | undefined; + if (scoped?.workflowBinding) { + return scoped.workflowBinding; + } return { projectId: this.workspaceRoot, commandCwd: this.workspaceRoot, diff --git a/src/extension/services/openspecCliResolver.ts b/src/extension/services/openspecCliResolver.ts index 1d53ec8..0f2c816 100644 --- a/src/extension/services/openspecCliResolver.ts +++ b/src/extension/services/openspecCliResolver.ts @@ -29,6 +29,24 @@ export interface OpenSpecCliResolverOptions { } const DEFAULT_TIMEOUT_MS = 15000; + +/** Session-wide cache so every resolver instance probes --version once per CLI path. */ +const sessionVersionProbeByCommandKey = new Map< + string, + { version: string; env: NodeJS.ProcessEnv } +>(); + +export function resetOpenSpecCliVersionProbeSessionCache(): void { + sessionVersionProbeByCommandKey.clear(); +} + +function versionProbeCacheKey(command: string): string { + if (process.platform === 'win32') { + return path.win32.isAbsolute(command) ? path.win32.resolve(command) : command; + } + return path.isAbsolute(command) ? path.resolve(command) : command; +} + const DEFAULT_KNOWN_PATHS = [ '/opt/homebrew/bin/openspec', '/usr/local/bin/openspec', @@ -57,6 +75,12 @@ export class OpenSpecCliResolver { clearCache(): void { this.cached = null; + resetOpenSpecCliVersionProbeSessionCache(); + } + + /** Drop cached version probes (e.g. after CLI binary replacement). */ + static clearSessionVersionProbeCache(): void { + resetOpenSpecCliVersionProbeSessionCache(); } async resolve(): Promise { @@ -195,6 +219,20 @@ export class OpenSpecCliResolver { const command = process.execPath; // Node.js executable const argsPrefix = [openspecBin]; const env = { ...process.env }; + const localProbeKey = `${versionProbeCacheKey(command)}\u0000${path.resolve(openspecBin)}`; + const cachedLocal = sessionVersionProbeByCommandKey.get(localProbeKey); + if (cachedLocal) { + diagnostics.push(`${label}: ok (${command} ${openspecBin}) -> ${cachedLocal.version} (cached)`); + return { + command, + argsPrefix, + env: cachedLocal.env, + version: cachedLocal.version, + source: 'localSource', + sourceLabel: `local source (${sourcePath})`, + diagnostics: [...diagnostics], + }; + } try { const version = (await this.spawnAndCollect( command, @@ -202,6 +240,7 @@ export class OpenSpecCliResolver { this.options.timeoutMs, env )).trim(); + sessionVersionProbeByCommandKey.set(localProbeKey, { version, env }); diagnostics.push(`${label}: ok (${command} ${openspecBin}) -> ${version}`); return { command, @@ -232,9 +271,21 @@ export class OpenSpecCliResolver { diagnostics: string[], label: string ): Promise { + const probeKey = versionProbeCacheKey(command); + const cachedProbe = sessionVersionProbeByCommandKey.get(probeKey); + if (cachedProbe) { + diagnostics.push(`${label}: ok (${command}) -> ${cachedProbe.version} (cached)`); + return { + command, + env: cachedProbe.env, + version: cachedProbe.version, + diagnostics: [...diagnostics], + }; + } try { const env = this.buildCommandEnv(command); const version = (await this.spawnAndCollect(command, ['--version'], this.options.timeoutMs, env)).trim(); + sessionVersionProbeByCommandKey.set(probeKey, { version, env }); diagnostics.push(`${label}: ok (${command}) -> ${version}`); return { command, env, version, diagnostics: [...diagnostics] }; } catch (err) { diff --git a/src/extension/services/openspecScope.ts b/src/extension/services/openspecScope.ts index b3ec165..06b2218 100644 --- a/src/extension/services/openspecScope.ts +++ b/src/extension/services/openspecScope.ts @@ -8,6 +8,7 @@ import type { OpenSpecCapabilities } from './openspecFeatures'; import type { OpenSpecRuntimeSource } from './openspecCliResolver'; +import type { WorkflowBindingIdentity } from '../../shared/changeWorkflow'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -26,6 +27,8 @@ export interface OpenSpecScope { label: string; rootPath: string; source: OpenSpecScopeSource; + /** When set, workflow binding keys use this identity instead of inferring from `source`. */ + workflowBinding?: WorkflowBindingIdentity; storeId?: string; /** How the OpenSpec CLI runtime was resolved for this scope (installed/customPath/localSource). */ runtimeSource: OpenSpecRuntimeSource; diff --git a/src/extension/services/taskExecutorService.ts b/src/extension/services/taskExecutorService.ts index 95d5ec9..f9857bc 100644 --- a/src/extension/services/taskExecutorService.ts +++ b/src/extension/services/taskExecutorService.ts @@ -4,11 +4,11 @@ import type { TaskExecuteRequest } from './agentExecutor.types'; import type { IOpenSpecContentAccess } from './contentAccess'; import { getCurrentAdapter } from '../adapters'; import { t } from '../../i18n'; -import { - buildWorkflowCommand, - getWorkflowCommandTargetForAdapter, -} from '../../shared/workflowCommand'; import { getWorkflowLaunchConfig } from './workflowLaunchConfig'; +import { isCopyOnlyWorkflowMode, toWorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; +import { buildResolvedLaunchPayload } from './workflowAgentLaunch'; +import { notifyWorkflowCommandCopied } from './workflowClipboardNotify'; +import { isPathInWorkspaceFolders } from '../utils/workspaceFolders'; export class TaskExecutorService { constructor( @@ -21,6 +21,10 @@ export class TaskExecutorService { * @returns { success: boolean } so the UI can clear the "running" state. */ async execute(changeName: string, taskIndex: number, taskText: string): Promise<{ success: boolean }> { + if (!isPathInWorkspaceFolders(this.workspaceRoot)) { + vscode.window.showWarningMessage(t('workflow.workspaceRootStale')); + return { success: false }; + } const config = vscode.workspace.getConfiguration('openspec'); const mode = config.get<'auto' | 'fillChat'>('taskExecutionMode') ?? 'fillChat'; const policy = config.get<'block' | 'warn'>('taskDependencyPolicy') ?? 'block'; @@ -87,15 +91,12 @@ export class TaskExecutorService { } const launchConfig = getWorkflowLaunchConfig(); - const clipboardPrompt = buildWorkflowCommand({ - action: 'apply', - changeName, - target: 'clipboard', - }); + const launchConfigView = toWorkflowLaunchConfigView(launchConfig); + const resolvedPayload = buildResolvedLaunchPayload('apply', changeName); - if (mode === 'fillChat' && launchConfig.workflowLaunchMode === 'clipboard') { - await vscode.env.clipboard.writeText(clipboardPrompt); - vscode.window.showInformationMessage(t('clipboard.copiedChat')); + if (mode === 'fillChat' && (launchConfig.workflowLaunchMode === 'clipboard' || isCopyOnlyWorkflowMode(launchConfigView))) { + await vscode.env.clipboard.writeText(resolvedPayload.command); + notifyWorkflowCommandCopied(resolvedPayload.command); return { success: true }; } @@ -107,12 +108,7 @@ export class TaskExecutorService { return { success: false }; } - const target = getWorkflowCommandTargetForAdapter(adapter.id); - const promptOverride = buildWorkflowCommand({ - action: 'apply', - changeName, - target, - }); + const promptOverride = resolvedPayload.command; const request: TaskExecuteRequest = { changeName, @@ -130,9 +126,12 @@ export class TaskExecutorService { : await adapter.fillChat(request); if (result.success) { - vscode.window.showInformationMessage( - result.message || t('task.processedVia', { adapter: adapter.displayName }) - ); + const panelLaunchAdapterIds = new Set(['vscode-chat', 'cursor', 'vscode-copilot']); + if (!panelLaunchAdapterIds.has(adapter.id)) { + vscode.window.showInformationMessage( + result.message || t('task.processedVia', { adapter: adapter.displayName }) + ); + } } else { vscode.window.showErrorMessage(result.message || t('task.executionFailed')); } diff --git a/src/extension/services/workflowAgentLaunch.ts b/src/extension/services/workflowAgentLaunch.ts new file mode 100644 index 0000000..4da8791 --- /dev/null +++ b/src/extension/services/workflowAgentLaunch.ts @@ -0,0 +1,326 @@ +import * as vscode from 'vscode'; +import type { WorkflowAction } from '../../shared/workflowCommand'; +import { + buildWorkflowLaunchPayload, + getWorkflowCommandTargetForAdapter, + buildWorkflowCommand, +} from '../../shared/workflowCommand'; +import { + type AgentAutoSubmitMode, + shouldAutoSubmitWorkflowAction, +} from '../../shared/agentAutoSubmit'; +import { + isCopyOnlyWorkflowMode, + shouldForceCursorWorkflowRoute, + toWorkflowLaunchConfigView, +} from '../../shared/workflowLaunchConfig'; +import { getCurrentAdapter, getAdapterById } from '../adapters'; +import { cursorAdapter } from '../adapters/cursor-adapter'; +import { t } from '../../i18n'; +import { getWorkflowLaunchConfig } from './workflowLaunchConfig'; +import { + launchAgentPanelPrompt, + type AgentLaunchLayer, + type AgentLaunchOutcome, +} from './agentPanelLauncher'; +import { isCursorHost } from '../utils/isCursorHost'; +import { notifyWorkflowCommandCopied } from './workflowClipboardNotify'; + +export type WorkflowAgentLaunchTarget = + | 'clipboard' + | 'agentPanel' + | 'agentCli' + | 'externalAdapter'; + +export interface WorkflowAgentLaunchRequest { + action: WorkflowAction; + changeName: string; + workspaceRoot: string; +} + +export interface WorkflowAgentLaunchResult { + success: boolean; + command: string; + target: WorkflowAgentLaunchTarget; + layer?: AgentLaunchLayer; + outcome?: AgentLaunchOutcome; + message?: string; +} + +const PANEL_ADAPTER_IDS = new Set(['cursor', 'vscode-copilot', 'vscode-chat']); + +function resolveLaunchTargetFromPayload( + payload: ReturnType, + view: ReturnType, +): WorkflowAgentLaunchTarget { + if (view.cursorLaunchMode === 'agentCli') return 'agentCli'; + if (isCopyOnlyWorkflowMode(view) || payload.target === 'clipboard') return 'clipboard'; + if (payload.target === 'cursor') return 'agentPanel'; + return 'externalAdapter'; +} + +export function buildResolvedLaunchPayload( + action: WorkflowAction, + changeName: string, +): ReturnType { + const launchConfig = getWorkflowLaunchConfig(); + const launchConfigView = toWorkflowLaunchConfigView(launchConfig); + if (isCopyOnlyWorkflowMode(launchConfigView)) { + return buildWorkflowLaunchPayload({ + action, + changeName, + workflowLaunchMode: 'clipboard', + adapterId: isCursorHost() ? 'cursor' : 'clipboard', + }); + } + return buildWorkflowLaunchPayload({ + action, + changeName, + workflowLaunchMode: 'adapter', + adapterId: launchConfigView.effectiveAdapterId ?? launchConfig.preferredAgentAdapter, + }); +} + +const LAUNCH_DEDUPE_MS = 2500; +let inFlightLaunchKey: string | undefined; +let lastSuccessfulLaunch: { key: string; at: number } | undefined; + +function workflowLaunchDedupeKey(request: WorkflowAgentLaunchRequest): string { + return `${request.workspaceRoot}\u0000${request.changeName}\u0000${request.action}`; +} + +function readAgentAutoSubmitMode(): AgentAutoSubmitMode { + const raw = vscode.workspace.getConfiguration('openspec').get('agentAutoSubmit'); + if (raw === 'never' || raw === 'always' || raw === 'readOnly') { + return raw; + } + return 'readOnly'; +} + +function notifyLaunchResult( + result: WorkflowAgentLaunchResult, + command: string, +): void { + if (result.outcome === 'deduped') { + void vscode.window.showInformationMessage(t('workflow.launchDeduped')); + return; + } + if (result.target === 'clipboard' || result.outcome === 'copied') { + notifyWorkflowCommandCopied(command); + return; + } + if (result.layer === 'deeplink') { + void vscode.window.showInformationMessage(t('agentLaunch.deeplinkPrefilled', { command })); + return; + } + if (result.layer === 'vscodeChat' || result.target === 'externalAdapter') { + void vscode.window.showInformationMessage(t('agentLaunch.prefilledChat', { command })); + return; + } + void vscode.window.showInformationMessage(t('agentLaunch.openedPanel', { command })); +} + +export async function notifyWorkflowLaunchFailure( + reason: string, + retry?: () => void | Promise, +): Promise { + const retryLabel = t('workflow.retryLaunch'); + const settingsLabel = t('workflow.openSettings'); + const picked = await vscode.window.showErrorMessage( + t('agentLaunch.failedWithReason', { reason }), + retryLabel, + settingsLabel, + ); + if (picked === retryLabel && retry) { + await retry(); + } else if (picked === settingsLabel) { + void vscode.commands.executeCommand('workbench.action.openSettings', '@ext:randysss.openspec-workflow'); + } +} + +function buildDedupedLaunchResult(request: WorkflowAgentLaunchRequest): WorkflowAgentLaunchResult { + const payload = buildResolvedLaunchPayload(request.action, request.changeName); + const view = toWorkflowLaunchConfigView(getWorkflowLaunchConfig()); + return { + success: true, + command: payload.command, + target: resolveLaunchTargetFromPayload(payload, view), + layer: undefined, + outcome: 'deduped', + message: t('workflow.launchDeduped'), + }; +} + +function recordSuccessfulLaunch(dedupeKey: string): void { + lastSuccessfulLaunch = { key: dedupeKey, at: Date.now() }; +} + +export function resetWorkflowLaunchDedupeForTests(): void { + inFlightLaunchKey = undefined; + lastSuccessfulLaunch = undefined; +} + +export async function launchWorkflowAgentCommand( + request: WorkflowAgentLaunchRequest, +): Promise { + const launchConfigView = toWorkflowLaunchConfigView(getWorkflowLaunchConfig()); + const copyOnly = isCopyOnlyWorkflowMode(launchConfigView); + + if (!copyOnly) { + const dedupeKey = workflowLaunchDedupeKey(request); + const now = Date.now(); + if (inFlightLaunchKey === dedupeKey) { + const deduped = buildDedupedLaunchResult(request); + notifyLaunchResult(deduped, deduped.command); + return deduped; + } + if ( + lastSuccessfulLaunch + && lastSuccessfulLaunch.key === dedupeKey + && now - lastSuccessfulLaunch.at < LAUNCH_DEDUPE_MS + ) { + const deduped = buildDedupedLaunchResult(request); + notifyLaunchResult(deduped, deduped.command); + return deduped; + } + + inFlightLaunchKey = dedupeKey; + try { + const result = await executeWorkflowAgentLaunch(request); + if (result.success) { + recordSuccessfulLaunch(dedupeKey); + } + return result; + } finally { + if (inFlightLaunchKey === dedupeKey) { + inFlightLaunchKey = undefined; + } + } + } + + return executeWorkflowAgentLaunch(request); +} + +async function executeWorkflowAgentLaunch( + request: WorkflowAgentLaunchRequest, +): Promise { + const launchConfig = getWorkflowLaunchConfig(); + const launchConfigView = toWorkflowLaunchConfigView(launchConfig); + const autoSubmit = shouldAutoSubmitWorkflowAction(request.action, readAgentAutoSubmitMode()); + + const clipboardPayload = buildResolvedLaunchPayload(request.action, request.changeName); + + if (isCopyOnlyWorkflowMode(launchConfigView)) { + await vscode.env.clipboard.writeText(clipboardPayload.command); + const result: WorkflowAgentLaunchResult = { + success: true, + command: clipboardPayload.command, + target: 'clipboard', + layer: 'clipboard', + outcome: 'copied', + }; + notifyLaunchResult(result, clipboardPayload.command); + return result; + } + + const adapter = shouldForceCursorWorkflowRoute(launchConfig) + && launchConfig.preferredAgentAdapter !== 'clipboard' + ? await getAdapterById('cursor') + : await getCurrentAdapter(); + + const adapterId = adapter?.id ?? launchConfig.preferredAgentAdapter; + const payload = buildWorkflowLaunchPayload({ + action: request.action, + changeName: request.changeName, + workflowLaunchMode: 'adapter', + adapterId, + }); + + if (launchConfig.cursorLaunchMode === 'agentCli') { + const cliAdapter = adapter?.id === 'cursor' ? adapter : await getAdapterById('cursor'); + if (cliAdapter?.id === 'cursor') { + const cliResult = await cursorAdapter.executeTask({ + changeName: request.changeName, + taskIndex: -1, + taskText: '', + contextFiles: [], + workspaceRoot: request.workspaceRoot, + promptOverride: payload.command, + }); + const result: WorkflowAgentLaunchResult = { + success: cliResult.success, + command: payload.command, + target: 'agentCli', + message: cliResult.message, + }; + if (cliResult.success) { + void vscode.window.showInformationMessage(t('agentLaunch.agentCliStarted', { command: payload.command })); + } + return result; + } + } + + if (adapter?.id === 'cursor' && launchConfig.cursorLaunchMode === 'clipboard') { + await vscode.env.clipboard.writeText(payload.command); + const result: WorkflowAgentLaunchResult = { + success: true, + command: payload.command, + target: 'clipboard', + layer: 'clipboard', + outcome: 'copied', + }; + notifyLaunchResult(result, payload.command); + return result; + } + + if (adapter && PANEL_ADAPTER_IDS.has(adapter.id)) { + await vscode.env.clipboard.writeText(payload.command); + const panelResult = await launchAgentPanelPrompt({ + text: payload.command, + autoSubmit, + }); + const result: WorkflowAgentLaunchResult = { + success: panelResult.success, + command: payload.command, + target: 'agentPanel', + layer: panelResult.layer, + outcome: panelResult.outcome, + message: panelResult.message, + }; + notifyLaunchResult(result, payload.command); + return result; + } + + if (adapter) { + const target = getWorkflowCommandTargetForAdapter(adapter.id); + const command = buildWorkflowCommand({ + action: request.action, + changeName: request.changeName, + target, + }); + const external = await adapter.fillChat({ + changeName: request.changeName, + taskIndex: -1, + taskText: '', + contextFiles: [], + workspaceRoot: request.workspaceRoot, + promptOverride: command, + }); + return { + success: external.success, + command, + target: 'externalAdapter', + message: external.message, + }; + } + + await vscode.env.clipboard.writeText(clipboardPayload.command); + notifyWorkflowCommandCopied(clipboardPayload.command); + return { + success: true, + command: clipboardPayload.command, + target: 'clipboard', + layer: 'clipboard', + outcome: 'copied', + }; +} diff --git a/src/extension/services/workflowClipboardNotify.ts b/src/extension/services/workflowClipboardNotify.ts new file mode 100644 index 0000000..a377754 --- /dev/null +++ b/src/extension/services/workflowClipboardNotify.ts @@ -0,0 +1,7 @@ +import * as vscode from 'vscode'; +import { t } from '../../i18n'; + +/** Single clipboard success toast for workflow copy paths (no duplicate wording). */ +export function notifyWorkflowCommandCopied(command: string): void { + void vscode.window.showInformationMessage(t('workflow.commandCopied', { command })); +} diff --git a/src/extension/services/workflowLaunchConfig.ts b/src/extension/services/workflowLaunchConfig.ts index 6aba36b..750ba21 100644 --- a/src/extension/services/workflowLaunchConfig.ts +++ b/src/extension/services/workflowLaunchConfig.ts @@ -7,6 +7,7 @@ import type { WorkflowLaunchMode, } from '../../shared/workflowLaunchConfig'; import { resolveWorkflowLaunchConfig } from '../../shared/workflowLaunchConfig'; +import { isCursorHost } from '../utils/isCursorHost'; export type { CursorLaunchMode, PreferredAgentAdapter, @@ -16,11 +17,22 @@ export type { const WORKFLOW_LAUNCH_MODES = new Set(['clipboard', 'adapter']); const CURSOR_LAUNCH_MODES = new Set([ + 'agentPanel', 'deeplink', 'chatCommand', 'clipboard', 'agentCli', ]); + +function normalizeCursorLaunchMode(value: string | undefined): CursorLaunchMode { + if (!value) return 'clipboard'; + if (value === 'deeplink' || value === 'chatCommand') { + return 'agentPanel'; + } + return CURSOR_LAUNCH_MODES.has(value as CursorLaunchMode) + ? (value as CursorLaunchMode) + : 'clipboard'; +} const PREFERRED_AGENT_ADAPTERS = new Set([ 'clipboard', 'cursor', @@ -61,10 +73,6 @@ export function getCursorAgentModel(): string { return legacyModel || 'auto'; } -function isCursorHost(): boolean { - return (vscode.env.appName ?? '').toLowerCase().includes('cursor'); -} - function readRawWorkflowLaunchConfig(): WorkflowLaunchConfigWithExplicit { const workflowLaunchMode = readString('workflowLaunchMode'); const preferredAgentAdapter = readString('preferredAgentAdapter'); @@ -77,9 +85,7 @@ function readRawWorkflowLaunchConfig(): WorkflowLaunchConfigWithExplicit { preferredAgentAdapter: PREFERRED_AGENT_ADAPTERS.has(preferredAgentAdapter as PreferredAgentAdapter) ? (preferredAgentAdapter as PreferredAgentAdapter) : 'clipboard', - cursorLaunchMode: CURSOR_LAUNCH_MODES.has(cursorLaunchMode as CursorLaunchMode) - ? (cursorLaunchMode as CursorLaunchMode) - : 'clipboard', + cursorLaunchMode: normalizeCursorLaunchMode(cursorLaunchMode), cursorAgentModel: getCursorAgentModel(), cursorLaunchModeExplicit: hasExplicitConfigValue('cursorLaunchMode'), workflowLaunchModeExplicit: hasExplicitConfigValue('workflowLaunchMode'), diff --git a/src/extension/services/workflowWebviewRegistry.ts b/src/extension/services/workflowWebviewRegistry.ts new file mode 100644 index 0000000..fac8870 --- /dev/null +++ b/src/extension/services/workflowWebviewRegistry.ts @@ -0,0 +1,31 @@ +import type * as vscode from 'vscode'; +import type { WorkflowActionReceipt } from '../../shared/changeWorkflow'; + +const webviews = new Set(); + +export function registerWorkflowReceiptWebview(webview: vscode.Webview): vscode.Disposable { + webviews.add(webview); + return { + dispose: () => { + webviews.delete(webview); + }, + }; +} + +export function broadcastWorkflowActionReceipt( + receipt: WorkflowActionReceipt, + except?: vscode.Webview, +): void { + for (const webview of webviews) { + if (webview === except) continue; + try { + webview.postMessage({ type: 'workflowActionReceipt', ...receipt }); + } catch { + // Panel may be disposed. + } + } +} + +export function resetWorkflowReceiptWebviewsForTests(): void { + webviews.clear(); +} diff --git a/src/extension/utils/isCursorHost.ts b/src/extension/utils/isCursorHost.ts new file mode 100644 index 0000000..c4f29a1 --- /dev/null +++ b/src/extension/utils/isCursorHost.ts @@ -0,0 +1,6 @@ +import * as vscode from 'vscode'; + +export function isCursorHost(): boolean { + return (vscode.env.uriScheme ?? '').toLowerCase() === 'cursor' + || (vscode.env.appName ?? '').toLowerCase().includes('cursor'); +} diff --git a/src/extension/utils/workspaceFolders.ts b/src/extension/utils/workspaceFolders.ts new file mode 100644 index 0000000..ac7ff14 --- /dev/null +++ b/src/extension/utils/workspaceFolders.ts @@ -0,0 +1,17 @@ +import * as vscode from 'vscode'; +import { isPathUnderWorkspaceFolder, normalizePathForComparison } from './workspacePathCompare'; + +/** True when resolvedPath is the workspace folder or a path under it. */ +export function isPathInWorkspaceFolders(resolvedPath: string): boolean { + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length === 0) { + return true; + } + return folders.some((folder) => + isPathUnderWorkspaceFolder(folder.uri.fsPath, resolvedPath), + ); +} + +export function normalizeScopeRootForWorkspaceCheck(rootPath: string): string { + return normalizePathForComparison(rootPath); +} diff --git a/src/extension/utils/workspacePathCompare.ts b/src/extension/utils/workspacePathCompare.ts new file mode 100644 index 0000000..421295f --- /dev/null +++ b/src/extension/utils/workspacePathCompare.ts @@ -0,0 +1,30 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +/** Canonical path for workspace membership checks (realpath + platform case rules). */ +export function normalizePathForComparison(inputPath: string): string { + let resolved = path.resolve(inputPath); + try { + resolved = fs.realpathSync.native(resolved); + } catch { + // Symlink target missing or permission error — fall back to resolved path. + } + if (os.platform() === 'darwin' || os.platform() === 'win32') { + return resolved.toLowerCase(); + } + return resolved; +} + +/** True when two paths refer to the same location for workspace binding checks. */ +export function pathsEqualForWorkspace(a: string, b: string): boolean { + return normalizePathForComparison(a) === normalizePathForComparison(b); +} + +export function isPathUnderWorkspaceFolder(folderRoot: string, candidatePath: string): boolean { + const root = normalizePathForComparison(folderRoot); + const normalized = normalizePathForComparison(candidatePath); + if (normalized === root) return true; + const rel = path.relative(root, normalized); + return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a4fd468..faa2c47 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -52,7 +52,16 @@ "workflow.copiedCommand": "Copied command: {command}", "workflow.noAdapterCopied": "No executor available. Copied command: {command}", "workflow.adapterFallback": "Couldn't launch adapter; command copied.", - "workflow.launchHelp": "In Cursor, workflow buttons launch by default (Open Chat / Open Cursor). Choose Copy command only (safe) in settings when you only want clipboard copy.", + "workflow.bindingMismatch": "Could not start the workflow because the Change root changed. Refresh the dashboard and try again.", + "workflow.bindingValidationFailed": "Could not validate the Change root for this workflow.", + "workflow.bindingRequestIncomplete": "This workflow request is missing binding information. Refresh and try again.", + "workflow.launchDeduped": "A launch for this change is already in progress.", + "workflow.commandCopied": "Copied to clipboard: {command}", + "workflow.retryLaunch": "Retry", + "workflow.openSettings": "Open Settings", + "workflow.workspaceRootStale": "This change belongs to a project folder that is no longer in the workspace. Reload the window or re-add the folder, then try again.", + "workflow.workspaceRebound": "Workspace folders changed. OpenSpec rebound the dashboard to the current project root.", + "workflow.launchHelp": "Workflow buttons open the IDE Agent panel with `/opsx-*` prefilled. Use Copy command only when you want clipboard-only mode.", "workflow.launching": "Launching…", "workflow.button.copy": "Copy {action}", "workflow.button.openCursor": "Open Cursor · {action}", @@ -65,7 +74,8 @@ "workflow.title.runAgent": "{action}: copy command and run Cursor Agent CLI", "workflow.title.launch": "{action}: launch via configured adapter", "workflow.modeHint.copyOnly": "Copies command", - "workflow.modeHint.executableCursor": "Runs in Cursor", + "workflow.modeHint.executableCursor": "Opens Agent panel", + "workflow.modeHint.executableCursorCli": "Runs Cursor Agent CLI (may modify files)", "workflow.modeHint.executableGeneric": "Launch via adapter", "workflow.highImpact": "High-impact actions", "workflow.receiptPending": "Pending", @@ -82,6 +92,8 @@ "detail.nav.blocked": "Blocked", "detail.nav.skipped": "Skipped", "detail.verifyArchive": "Verify & Archive", + "detail.loadingTaskProgress": "Loading task progress…", + "detail.tasksProgressUnknown": "Task progress unavailable", "fillChat.failed": "fillChat failed: {error}", @@ -152,7 +164,20 @@ "verify.debugLabel": "Debug: Run IDE Command", "verify.executed": "Command executed.", "verifyArchive.title": "Verify and archive this change", - "verifyArchive.description": "Verify and Archive run in a real VS Code terminal session so Agent follow-up questions can stay interactive.", + "verifyArchive.description": "Verify and Archive launch `/opsx-verify` and `/opsx-archive` in the IDE Agent panel, the same path as Apply and Continue.", + "verifyArchive.descriptionAgentPanel": "Run Verify and Archive through the Cursor Agent panel, the same path as Apply and Continue.", + "verifyArchive.descriptionAgentCli": "Run Verify and Archive via the Cursor Agent CLI, the same path as Apply and Continue.", + "verifyArchive.descriptionCopy": "Copy {verifyCommand} and {archiveCommand} to the clipboard, the same path as other Copy actions.", + "verifyArchive.descriptionAdapter": "Launch Verify and Archive through your configured chat adapter, the same path as Apply and Continue.", + "verifyArchive.verifyAgentHint": "Opens {command} in the Agent panel.", + "verifyArchive.archiveAgentHint": "Prefills {command} in the Agent panel (confirm before sending).", + "verifyArchive.verifyCopyHint": "Copies {command} to the clipboard.", + "verifyArchive.archiveCopyHint": "Copies {command} to the clipboard.", + "verifyArchive.verifyAgentCliHint": "Runs {command} via Cursor Agent CLI.", + "verifyArchive.archiveAgentCliHint": "Runs {command} via Cursor Agent CLI.", + "verifyArchive.verifyChatHint": "Prefills {command} in Chat (press Enter to send).", + "verifyArchive.archiveChatHint": "Prefills {command} in Chat (confirm before sending).", + "verifyArchive.descriptionChatAdapter": "Verify and Archive use the same Chat path as Apply and Continue (e.g. {verifyCommand}, {archiveCommand}).", "verifyArchive.verifyTitle": "Verify & Validate", "verifyArchive.archiveTitle": "Archive", "verifyArchive.runVerify": "Run Verify", @@ -169,8 +194,8 @@ "verifyArchive.ready": "Ready", "verifyArchive.startedAt": "Started {time}", "verifyArchive.agentCliNotFound": "Cursor Agent CLI not found.", - "verifyArchive.terminalCreateFailed": "Failed to create interactive terminal.", - "verifyArchive.managerUnavailable": "Interactive Agent terminal is unavailable.", + "verifyArchive.terminalCreateFailed": "Could not start the Agent workflow session.", + "verifyArchive.managerUnavailable": "Agent workflow session is unavailable.", "verifyArchive.archivedArchiveRejected": "Archived changes are read-only. Archive cannot run again.", "verifyArchive.statusArchived": "Archived change · read-only", "verifyArchive.statusTasks": "{artifacts} artifacts · {completed}/{total} tasks complete", @@ -196,8 +221,21 @@ "cursor.promptOpenedCopied": "Opened Cursor prompt and copied command: {command}", "cursor.openFailedCopied": "Cursor could not be opened. Copied command: {command}", + "agentLaunch.started": "Started in Agent: {command}", + "agentLaunch.openedPanel": "Opened in Agent panel: {command}", + "agentLaunch.prefilledChat": "Prefilled in Chat: {command}", + "agentLaunch.panelNotConfirmed": "Could not confirm the Agent panel opened. Open the panel and press Enter to send.", + "agentLaunch.prefilled": "Prefilled in Agent — press Enter to send: {command}", + "agentLaunch.submitNotConfirmed": "Command may not have been sent automatically — press Enter in the Agent panel.", + "agentLaunch.failedWithReason": "Failed to start Agent: {reason}", + "agentLaunch.deeplinkPrefilled": "Review the prompt in Cursor, then create the chat: {command}", + "agentLaunch.copied": "Copied to clipboard — paste into Agent: {command}", + "agentLaunch.agentCliStarted": "Running headless Agent CLI: {command}", + "agentLaunch.failed": "Could not launch the workflow in Agent.", + "dashboard.loading": "Loading OpenSpec data...", "dashboard.loadFailed": "Failed to load data. Try refreshing.", + "dashboard.retryLoad": "Retry", "dashboard.staleData": "Showing cached data while refreshing...", "dashboard.refreshing": "Refreshing OpenSpec data...", "dashboard.changes": "Changes ({count})", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index a0f4ffb..4f1752c 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -54,7 +54,16 @@ "workflow.copiedCommand": "已复制命令:{command}", "workflow.noAdapterCopied": "当前没有可用执行者,已复制命令:{command}", "workflow.adapterFallback": "无法启动适配器,命令已复制。", - "workflow.launchHelp": "在 Cursor 中,工作流按钮默认可执行(打开聊天 / 在 Cursor 打开)。若只需复制命令,请在设置中选择「仅复制(安全)」。", + "workflow.bindingMismatch": "无法启动工作流:Change 根目录已变化。请刷新 Dashboard 后重试。", + "workflow.bindingValidationFailed": "无法校验此工作流对应的 Change 根目录。", + "workflow.bindingRequestIncomplete": "工作流请求缺少绑定信息。请刷新后重试。", + "workflow.launchDeduped": "该 change 的工作流已在启动中。", + "workflow.commandCopied": "已复制到剪贴板:{command}", + "workflow.retryLaunch": "重试", + "workflow.openSettings": "打开设置", + "workflow.workspaceRootStale": "该 change 所属的项目文件夹已不在当前工作区。请重新加载窗口或重新添加文件夹后再试。", + "workflow.workspaceRebound": "工作区文件夹已变化,OpenSpec 已重新绑定到当前项目根目录。", + "workflow.launchHelp": "工作流按钮会在 IDE Agent 面板预填 `/opsx-*` 命令。若只需复制,请在设置中选择「仅复制」。", "workflow.launching": "启动中…", "workflow.button.copy": "复制{action}", "workflow.button.openCursor": "在 Cursor 打开 · {action}", @@ -67,7 +76,8 @@ "workflow.title.runAgent": "{action}:复制命令并运行 Cursor Agent CLI", "workflow.title.launch": "{action}:通过所选适配器启动", "workflow.modeHint.copyOnly": "仅复制命令", - "workflow.modeHint.executableCursor": "在 Cursor 中执行", + "workflow.modeHint.executableCursor": "打开 Agent 面板", + "workflow.modeHint.executableCursorCli": "运行 Cursor Agent CLI(可能修改文件)", "workflow.modeHint.executableGeneric": "通过适配器启动", "workflow.highImpact": "高影响动作", "workflow.receiptPending": "等待中", @@ -83,7 +93,9 @@ "detail.nav.available": "当前可用", "detail.nav.blocked": "已阻塞", "detail.nav.skipped": "已跳过", - "detail.verifyArchive": "验证与归档", + "detail.verifyArchive": "验证并归档", + "detail.loadingTaskProgress": "正在加载任务进度…", + "detail.tasksProgressUnknown": "任务进度不可用", "fillChat.failed": "fillChat 失败: {error}", @@ -153,9 +165,22 @@ "verify.invalidJson": "参数不是合法 JSON", "verify.debugLabel": "Debug: 执行 IDE 命令", "verify.executed": "命令已执行。", - "verifyArchive.title": "在交互式终端中执行 Verify 与 Archive", - "verifyArchive.description": "Verify 和 Archive 会在 VS Code 官方终端里运行,这样 Agent 中途反问时用户可以直接继续输入。", - "verifyArchive.verifyTitle": "Verify & Validate", + "verifyArchive.title": "验证并归档此 change", + "verifyArchive.description": "Verify 与 Archive 与 Apply、Continue 相同,在 IDE Agent 面板启动 `/opsx-verify` 与 `/opsx-archive`。", + "verifyArchive.descriptionAgentPanel": "Verify 与 Archive 与 Apply、Continue 相同,通过 Cursor Agent 面板启动。", + "verifyArchive.descriptionAgentCli": "Verify 与 Archive 与 Apply、Continue 相同,通过 Cursor Agent CLI 启动。", + "verifyArchive.descriptionCopy": "Verify 与 Archive 与其他 Copy 操作相同,将 {verifyCommand} 与 {archiveCommand} 复制到剪贴板。", + "verifyArchive.descriptionAdapter": "Verify 与 Archive 与 Apply、Continue 相同,通过已配置的 Chat 适配器启动。", + "verifyArchive.verifyAgentHint": "在 Agent 面板打开 {command}。", + "verifyArchive.archiveAgentHint": "在 Agent 面板预填 {command}(发送前请确认)。", + "verifyArchive.verifyCopyHint": "将 {command} 复制到剪贴板。", + "verifyArchive.archiveCopyHint": "将 {command} 复制到剪贴板。", + "verifyArchive.verifyAgentCliHint": "通过 Cursor Agent CLI 运行 {command}。", + "verifyArchive.archiveAgentCliHint": "通过 Cursor Agent CLI 运行 {command}。", + "verifyArchive.verifyChatHint": "在 Chat 中预填 {command}(按 Enter 发送)。", + "verifyArchive.archiveChatHint": "在 Chat 中预填 {command}(发送前请确认)。", + "verifyArchive.descriptionChatAdapter": "Verify 与 Archive 与 Apply、Continue 相同,通过 Chat 启动(例如 {verifyCommand}、{archiveCommand})。", + "verifyArchive.verifyTitle": "验证与确认", "verifyArchive.archiveTitle": "Archive", "verifyArchive.runVerify": "Run Verify", "verifyArchive.runArchive": "Run Archive", @@ -171,8 +196,8 @@ "verifyArchive.ready": "可开始", "verifyArchive.startedAt": "开始于 {time}", "verifyArchive.agentCliNotFound": "未找到 Cursor Agent CLI。", - "verifyArchive.terminalCreateFailed": "创建交互式终端失败。", - "verifyArchive.managerUnavailable": "交互式 Agent 终端不可用。", + "verifyArchive.terminalCreateFailed": "无法启动 Agent 工作流会话。", + "verifyArchive.managerUnavailable": "Agent 工作流会话不可用。", "verifyArchive.archivedArchiveRejected": "已归档 change 为只读,不能再次执行 Archive。", "verifyArchive.statusArchived": "已归档 change · 只读", "verifyArchive.statusTasks": "{artifacts} 个 artifact · 已完成 {completed}/{total} 个任务", @@ -198,8 +223,21 @@ "cursor.promptOpenedCopied": "已打开 Cursor prompt 并复制命令:{command}", "cursor.openFailedCopied": "无法打开 Cursor,已复制命令:{command}", + "agentLaunch.started": "已在 Agent 中开始:{command}", + "agentLaunch.openedPanel": "已在 Agent 面板打开:{command}", + "agentLaunch.prefilledChat": "已在 Chat 中预填:{command}", + "agentLaunch.panelNotConfirmed": "无法确认 Agent 面板已打开。请打开面板并按 Enter 发送。", + "agentLaunch.submitNotConfirmed": "命令可能未自动发送 — 请在 Agent 面板中按 Enter。", + "agentLaunch.failedWithReason": "无法启动 Agent:{reason}", + "agentLaunch.prefilled": "已在 Agent 预填,请按回车发送:{command}", + "agentLaunch.deeplinkPrefilled": "请在 Cursor 中审阅提示并创建对话:{command}", + "agentLaunch.copied": "已复制到剪贴板,请粘贴到 Agent:{command}", + "agentLaunch.agentCliStarted": "正在以 headless Agent CLI 运行:{command}", + "agentLaunch.failed": "无法在 Agent 中启动该工作流。", + "dashboard.loading": "正在加载 OpenSpec 数据...", "dashboard.loadFailed": "加载数据失败,请尝试刷新。", + "dashboard.retryLoad": "重试", "dashboard.staleData": "正在刷新,当前显示缓存数据...", "dashboard.refreshing": "正在刷新 OpenSpec 数据...", "dashboard.changes": "Changes ({count})", diff --git a/src/shared/agentAutoSubmit.ts b/src/shared/agentAutoSubmit.ts new file mode 100644 index 0000000..f5c6d58 --- /dev/null +++ b/src/shared/agentAutoSubmit.ts @@ -0,0 +1,25 @@ +import type { WorkflowAction } from './workflowCommand'; + +export type AgentAutoSubmitMode = 'never' | 'readOnly' | 'always'; + +/** + * Workflow actions that only inspect or report and do not intentionally modify + * workspace artifacts as their primary outcome. + */ +export const READ_ONLY_WORKFLOW_ACTIONS = new Set([ + 'explore', + 'verify', +]); + +export function isReadOnlyWorkflowAction(action: WorkflowAction): boolean { + return READ_ONLY_WORKFLOW_ACTIONS.has(action); +} + +export function shouldAutoSubmitWorkflowAction( + action: WorkflowAction, + mode: AgentAutoSubmitMode, +): boolean { + if (mode === 'never') return false; + if (mode === 'always') return true; + return isReadOnlyWorkflowAction(action); +} diff --git a/src/shared/changeWorkflow.ts b/src/shared/changeWorkflow.ts index 7169095..b37c1f3 100644 --- a/src/shared/changeWorkflow.ts +++ b/src/shared/changeWorkflow.ts @@ -45,6 +45,8 @@ export interface WorkflowActionReceipt { readonly target: WorkflowCommandTarget; readonly status: WorkflowActionReceiptStatus; readonly message?: string; + /** When false, Dashboard must not promote the change to NEEDS ATTENTION (launch validation failures). */ + readonly suppressPriorityAttention?: boolean; } export function createWorkflowRequestId(prefix = 'workflow'): string { diff --git a/src/shared/executorLaunchPresentation.ts b/src/shared/executorLaunchPresentation.ts index 20f092b..14d0f92 100644 --- a/src/shared/executorLaunchPresentation.ts +++ b/src/shared/executorLaunchPresentation.ts @@ -18,6 +18,8 @@ export interface ExecutorLaunchPresentation { agentAdapters: AgentAdaptersState; workflowLaunchConfig: WorkflowLaunchConfigView; uiWorkflowLaunchConfig: WorkflowLaunchConfigView; + /** Extension host runtime (Cursor vs VS Code) for copy-command formatting in the webview. */ + isCursorHost?: boolean; } export function normalizeAgentAdaptersState( diff --git a/src/shared/workflowCommand.ts b/src/shared/workflowCommand.ts index a085233..aa34173 100644 --- a/src/shared/workflowCommand.ts +++ b/src/shared/workflowCommand.ts @@ -21,6 +21,12 @@ export interface WorkflowCommandRequest { target: WorkflowCommandTarget; } +import { + isCopyOnlyWorkflowMode, + toWorkflowLaunchConfigView, + type WorkflowLaunchConfigView, +} from './workflowLaunchConfig'; + export type WorkflowLaunchMode = 'clipboard' | 'adapter'; export interface WorkflowLaunchPayloadRequest { @@ -67,12 +73,34 @@ export function getWorkflowCommandTargetForAdapter( } } +export function resolveCopyOnlyWorkflowCommandTarget( + config: WorkflowLaunchConfigView, + runtime: { isCursorHost: boolean }, +): WorkflowCommandTarget { + return runtime.isCursorHost ? 'cursor' : 'clipboard'; +} + +/** Webview-safe target when copying commands; aligns with host buildResolvedLaunchPayload. */ +export function resolveWorkflowCommandTargetForUi( + config: WorkflowLaunchConfigView | null | undefined, + runtime?: { isCursorHost?: boolean }, +): WorkflowCommandTarget { + if (!config) return 'clipboard'; + const view = toWorkflowLaunchConfigView(config); + if (isCopyOnlyWorkflowMode(view)) { + return resolveCopyOnlyWorkflowCommandTarget(view, { + isCursorHost: runtime?.isCursorHost ?? false, + }); + } + return getWorkflowCommandTargetForAdapter(view.effectiveAdapterId); +} + export function buildWorkflowLaunchPayload( request: WorkflowLaunchPayloadRequest ): WorkflowLaunchPayload { const target = request.workflowLaunchMode === 'clipboard' - ? 'clipboard' + ? (request.adapterId === 'cursor' ? 'cursor' : 'clipboard') : getWorkflowCommandTargetForAdapter(request.adapterId); const command = buildWorkflowCommand({ action: request.action, diff --git a/src/shared/workflowLaunchConfig.ts b/src/shared/workflowLaunchConfig.ts index 69251f5..45738d2 100644 --- a/src/shared/workflowLaunchConfig.ts +++ b/src/shared/workflowLaunchConfig.ts @@ -1,5 +1,10 @@ export type WorkflowLaunchMode = 'clipboard' | 'adapter'; -export type CursorLaunchMode = 'deeplink' | 'chatCommand' | 'clipboard' | 'agentCli'; +export type CursorLaunchMode = + | 'agentPanel' + | 'deeplink' + | 'chatCommand' + | 'clipboard' + | 'agentCli'; export type PreferredAgentAdapter = | 'clipboard' | 'cursor' @@ -31,41 +36,56 @@ export interface WorkflowLaunchConfigView extends WorkflowLaunchConfig { effectiveAdapterId: EffectiveWorkflowAdapterId; } +/** User explicitly chose copy-only via settings (package defaults do not count). */ +export function isExplicitCopyOnlyConfig(config: WorkflowLaunchConfigWithExplicit): boolean { + return (config.workflowLaunchModeExplicit && config.workflowLaunchMode === 'clipboard') + || (config.preferredAgentAdapterExplicit && config.preferredAgentAdapter === 'clipboard'); +} + export function shouldForceCursorWorkflowRoute(config: WorkflowLaunchConfig): boolean { - return config.cursorLaunchModeExplicit && config.cursorLaunchMode !== 'clipboard'; + return config.cursorLaunchModeExplicit && config.cursorLaunchMode === 'agentCli'; } export function resolveWorkflowLaunchConfig( config: WorkflowLaunchConfigWithExplicit, context: WorkflowLaunchRuntimeContext, ): WorkflowLaunchConfig { - if (shouldForceCursorWorkflowRoute(config)) { - return { - workflowLaunchMode: config.workflowLaunchMode, - preferredAgentAdapter: config.preferredAgentAdapter, - cursorLaunchMode: config.cursorLaunchMode, - cursorAgentModel: config.cursorAgentModel, - cursorLaunchModeExplicit: config.cursorLaunchModeExplicit, - }; - } + const explicitCopyOnly = isExplicitCopyOnlyConfig(config); let { workflowLaunchMode, preferredAgentAdapter, cursorLaunchMode } = config; - if (!config.workflowLaunchModeExplicit && !config.cursorLaunchModeExplicit && context.isCursorHost) { - workflowLaunchMode = 'adapter'; - } - - if (!config.preferredAgentAdapterExplicit && workflowLaunchMode === 'adapter' && context.isCursorHost) { - preferredAgentAdapter = 'cursor'; + if (context.isCursorHost && !explicitCopyOnly) { + if (!config.workflowLaunchModeExplicit && workflowLaunchMode === 'clipboard') { + workflowLaunchMode = 'adapter'; + } + if ( + !config.preferredAgentAdapterExplicit + && preferredAgentAdapter === 'clipboard' + && workflowLaunchMode === 'adapter' + ) { + preferredAgentAdapter = 'cursor'; + } + if ( + config.cursorLaunchModeExplicit + && cursorLaunchMode !== 'clipboard' + && cursorLaunchMode !== 'agentCli' + ) { + if (!config.workflowLaunchModeExplicit) { + workflowLaunchMode = 'adapter'; + } + if (!config.preferredAgentAdapterExplicit && preferredAgentAdapter === 'clipboard') { + preferredAgentAdapter = 'cursor'; + } + } } if ( - !config.cursorLaunchModeExplicit && - config.cursorLaunchMode === 'clipboard' && - workflowLaunchMode === 'adapter' && - preferredAgentAdapter === 'cursor' + !config.cursorLaunchModeExplicit + && cursorLaunchMode === 'clipboard' + && workflowLaunchMode === 'adapter' + && preferredAgentAdapter === 'cursor' ) { - cursorLaunchMode = 'deeplink'; + cursorLaunchMode = 'agentPanel'; } return { @@ -78,11 +98,37 @@ export function resolveWorkflowLaunchConfig( } export function isCopyOnlyWorkflowMode(config: WorkflowLaunchConfigView): boolean { + if (config.workflowLaunchMode === 'clipboard') return true; + if (config.preferredAgentAdapter === 'clipboard') return true; + if (config.effectiveAdapterId === 'cursor' && config.cursorLaunchMode === 'clipboard') { + return true; + } return config.effectiveAdapterId == null || config.effectiveAdapterId === 'clipboard'; } +/** + * Labels, pending UI, and dedupe must follow resolved host settings — not executor UI override. + */ +export function isHostWorkflowLaunchCopyOnly( + hostSettingsConfig: WorkflowLaunchConfigView | null | undefined, +): boolean { + if (!hostSettingsConfig) return true; + return isCopyOnlyWorkflowMode(hostSettingsConfig); +} + +/** Config for workflow button copy/launch labels (host settings when copy-only). */ +export function resolveWorkflowLabelLaunchConfig( + hostSettingsConfig: WorkflowLaunchConfigView, + uiConfig: WorkflowLaunchConfigView, +): WorkflowLaunchConfigView { + return isHostWorkflowLaunchCopyOnly(hostSettingsConfig) ? hostSettingsConfig : uiConfig; +} + /** Label/intent: adapter mode with a non-clipboard preferred adapter uses Agent verbs. */ export function shouldUseAgentWorkflowLabels(config: WorkflowLaunchConfigView): boolean { + if (config.effectiveAdapterId === 'cursor' && config.cursorLaunchMode === 'clipboard') { + return false; + } if (config.workflowLaunchMode === 'adapter' && config.preferredAgentAdapter !== 'clipboard') { return true; } @@ -90,25 +136,22 @@ export function shouldUseAgentWorkflowLabels(config: WorkflowLaunchConfigView): } export function getEffectiveWorkflowAdapterId( - config: WorkflowLaunchConfig + config: WorkflowLaunchConfig, ): EffectiveWorkflowAdapterId { - if (config.preferredAgentAdapter === 'clipboard') { + if (config.workflowLaunchMode === 'clipboard') { return 'clipboard'; } - if (config.workflowLaunchMode === 'clipboard') { - return null; + if (config.workflowLaunchMode === 'adapter') { + return config.preferredAgentAdapter === 'clipboard' ? 'clipboard' : config.preferredAgentAdapter; } if (shouldForceCursorWorkflowRoute(config)) { return 'cursor'; } - if (config.workflowLaunchMode === 'adapter') { - return config.preferredAgentAdapter; - } return null; } export function toWorkflowLaunchConfigView( - config: WorkflowLaunchConfig + config: WorkflowLaunchConfig, ): WorkflowLaunchConfigView { return { ...config, diff --git a/src/webview/components/ChangeCard.tsx b/src/webview/components/ChangeCard.tsx index 6bc0b4d..1051711 100644 --- a/src/webview/components/ChangeCard.tsx +++ b/src/webview/components/ChangeCard.tsx @@ -7,6 +7,8 @@ import { type ResolvedWorkflowAction, } from '../../shared/changeWorkflow'; import type { ChangeLifecycleStatus } from '../../shared/changeLifecycle'; +import { resolveChangeBindingKey } from '../utils/changeBindingKey'; +import type { OpenSpecRootBinding } from '../types/messages'; import { getWorkflowActionButtonLabel, getWorkflowActionTitle, @@ -71,6 +73,9 @@ export interface ChangeCardProps { onArchive?: (changeName: string) => void; onLaunchWorkflow?: (action: WorkflowAction, changeName: string, bindingKey?: string) => void; workflowLaunchConfig?: WorkflowLaunchConfigView | null; + workflowLaunchPendingKeys?: ReadonlySet; + workflowLaunchFailureHint?: { message: string } | null; + projectBinding?: OpenSpecRootBinding | null; } export const ChangeCard: React.FC = ({ @@ -80,6 +85,9 @@ export const ChangeCard: React.FC = ({ onCopyApply, onLaunchWorkflow, workflowLaunchConfig, + workflowLaunchPendingKeys, + workflowLaunchFailureHint, + projectBinding, }) => { const [hover, setHover] = React.useState(false); const [focusWithin, setFocusWithin] = React.useState(false); @@ -100,6 +108,11 @@ export const ChangeCard: React.FC = ({ ...resolvedActions.highImpact, ] : []; + const bindingKey = resolveChangeBindingKey(change, projectBinding); + const launchPendingKey = bindingKey ? `${change.name}\u0000${bindingKey}` : undefined; + const isWorkflowLaunchPending = launchPendingKey + ? workflowLaunchPendingKeys?.has(launchPendingKey) === true + : false; const needsAttention = change.attention?.required === true; const additionalActionCount = resolvedActions ? resolvedActions.available.length + resolvedActions.highImpact.length @@ -240,13 +253,12 @@ export const ChangeCard: React.FC = ({ {onLaunchWorkflow && workflowActions.map((descriptor) => { const label = descriptor.label; - const isInteractiveVerifyOrArchive = - descriptor.action === 'verify' || descriptor.action === 'archive'; - const buttonLabel = isInteractiveVerifyOrArchive - ? label - : getWorkflowActionButtonLabel(label, workflowLaunchConfig); - const title = isInteractiveVerifyOrArchive - ? label + const buttonLabel = getWorkflowActionButtonLabel(label, workflowLaunchConfig, { + launching: isWorkflowLaunchPending, + }); + const receiptForCard = workflowLaunchFailureHint; + const title = receiptForCard?.message + ? `${getWorkflowActionTitle(label, workflowLaunchConfig) ?? label}: ${receiptForCard.message}` : getWorkflowActionTitle(label, workflowLaunchConfig); return ( diff --git a/src/webview/components/ChangeDetail.tsx b/src/webview/components/ChangeDetail.tsx index b7e39b4..52da4d4 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -9,15 +9,25 @@ import { ConfirmDialog } from './ui/ConfirmDialog'; import { VerifyArchivePanel } from './VerifyArchivePanel'; import { IconButton } from './ui/IconButton'; import { t } from '../../i18n'; -import { buildWorkflowCommand, type WorkflowAction } from '../../shared/workflowCommand'; +import { + buildWorkflowCommand, + resolveWorkflowCommandTargetForUi, + type WorkflowAction, +} from '../../shared/workflowCommand'; +import { useWorkflowLaunchPending } from '../hooks/useWorkflowLaunchPending'; import { createWorkflowRequestId, resolveWorkflowActions, type ChangeWorkflowSnapshot, } from '../../shared/changeWorkflow'; +import { + isHostWorkflowLaunchCopyOnly, + resolveWorkflowLabelLaunchConfig, +} from '../../shared/workflowLaunchConfig'; import { getWorkflowLaunchModeHint } from '../utils/workflowLaunchLabels'; import type { ExecutorLaunchPresentation } from '../../shared/executorLaunchPresentation'; import { + getCopyOnlyFallbackLaunchConfig, getExecutorUiModeLabel, normalizeExecutorPresentation, normalizePresentationAdapters, @@ -30,6 +40,24 @@ import type { InteractiveWorkflowAction, InteractiveWorkflowState, } from '../../shared/interactiveWorkflow'; +import { ArtifactFetchCoordinator } from '../utils/artifactFetchCoordinator'; +import { + artifactContentCacheKey, + artifactFetchCoordinatorKey, +} from '../utils/artifactFetchKeys'; +import { + completeArtifactFetchAndStoreContent, + handleTasksArtifactInvalidated, + tasksFetchCoordinatorKey, +} from '../utils/changeDetailArtifactInvalidation'; +import { isArchiveNowAllowed } from '../utils/changeDetailArchiveGating'; +import { + isArchiveNowBlockedByTasksProgress, + readArtifactCacheStale, + shouldCompleteVerifyArchiveTasksFetch, + shouldShowActiveTabLoadingForArtifactFetch, +} from '../utils/changeDetailTasksFetch'; +import type { WorkflowActionReceipt } from '../../shared/changeWorkflow'; const MISSING_ARTIFACT_MESSAGE = t('artifact.missing'); @@ -49,9 +77,7 @@ export interface ChangeDetailProps { initialExecutorPresentation?: ExecutorLaunchPresentation | null; } -// Cache key includes scopeId so the same change name in two roots never shares content. -const cacheKey = (scopeId: string | undefined, type: string, specId?: string | null) => - `${scopeId ? `${scopeId}::` : ''}${type === 'specs' && specId ? `specs:${specId}` : specId ? `${type}:${specId}` : type}`; +const cacheKey = artifactContentCacheKey; function getCreateDisabledReason( artifactType: string, @@ -124,6 +150,20 @@ export const ChangeDetail: React.FC = ({ const { postMessage, onMessage } = useVscode(); const [activeTab, setActiveTab] = useState(initialTab ?? 'proposal'); const contentCacheRef = useRef>(new Map()); + const artifactFetchCoordinatorRef = useRef(new ArtifactFetchCoordinator()); + const verifyArchiveTasksLoadedRef = useRef(null); + const initialArtifactFetchKeyRef = useRef(null); + const workflowReceiptBeforeLaunchRef = useRef<{ + requestId: string; + bindingKey: string; + status: string; + message?: string; + } | null>(null); + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + const workflowLaunchPending = useWorkflowLaunchPending(); + type LaunchSurface = 'actionBar' | 'verifyArchive'; + const [pendingLaunchSurface, setPendingLaunchSurface] = useState(null); const persistedExecutorRef = useRef(null); const [completedTasks, setCompletedTasks] = useState(0); const [totalTasks, setTotalTasks] = useState(0); @@ -133,8 +173,12 @@ export const ChangeDetail: React.FC = ({ const [errorCode, setErrorCode] = useState(undefined); const [deltaSpecIds, setDeltaSpecIds] = useState([]); const [selectedSpecId, setSelectedSpecId] = useState(null); + const selectedSpecIdRef = useRef(null); + selectedSpecIdRef.current = selectedSpecId; const [artifactOutputs, setArtifactOutputs] = useState>({}); const [selectedOutputPaths, setSelectedOutputPaths] = useState>({}); + const selectedOutputPathsRef = useRef(selectedOutputPaths); + selectedOutputPathsRef.current = selectedOutputPaths; const [executorPresentation, setExecutorPresentation] = useState( initialExecutorPresentation ? normalizeExecutorPresentation(initialExecutorPresentation) : null, ); @@ -151,7 +195,13 @@ export const ChangeDetail: React.FC = ({ const [pendingInteractiveAction, setPendingInteractiveAction] = useState(interactiveAction ?? null); const [copiedName, setCopiedName] = useState(false); const [artifactStateMessage, setArtifactStateMessage] = useState(null); - const [pendingWorkflowAction, setPendingWorkflowAction] = useState(null); + const [pendingLaunchAction, setPendingLaunchAction] = useState(null); + const [verifyArchiveTasksLoading, setVerifyArchiveTasksLoading] = useState(false); + const [tasksProgressError, setTasksProgressError] = useState(null); + const [tasksProgressUnknown, setTasksProgressUnknown] = useState(false); + const verifyArchiveTasksLoadingRef = useRef(verifyArchiveTasksLoading); + verifyArchiveTasksLoadingRef.current = verifyArchiveTasksLoading; + const dispatchExtensionMessageRef = useRef<(msg: unknown) => void>(() => undefined); const [archivedLocally, setArchivedLocally] = useState(false); const [workflowReceipt, setWorkflowReceipt] = useState<{ requestId: string; @@ -182,7 +232,28 @@ export const ChangeDetail: React.FC = ({ () => resolveExecutorUiLaunchConfig(executorPresentation), [executorPresentation], ); - const tasksLaunchModeHint = getWorkflowLaunchModeHint(executorUiLaunchConfig); + const hostWorkflowLaunchConfig = executorPresentation?.workflowLaunchConfig + ?? getCopyOnlyFallbackLaunchConfig(); + const copyOnlyWorkflowLaunch = isHostWorkflowLaunchCopyOnly(hostWorkflowLaunchConfig); + const workflowLabelLaunchConfig = resolveWorkflowLabelLaunchConfig( + hostWorkflowLaunchConfig, + executorUiLaunchConfig, + ); + const isCursorHost = executorPresentation?.isCursorHost ?? false; + const executorLaunchConfigKey = useMemo( + () => [ + executorUiLaunchConfig.effectiveAdapterId ?? '', + executorUiLaunchConfig.cursorLaunchMode ?? '', + executorUiLaunchConfig.workflowLaunchMode ?? '', + executorUiLaunchConfig.preferredAgentAdapter ?? '', + ].join('\u0000'), + [executorUiLaunchConfig], + ); + + useEffect(() => { + setWorkflowReceipt(null); + }, [executorLaunchConfigKey]); + const tasksLaunchModeHint = getWorkflowLaunchModeHint(workflowLabelLaunchConfig); const executorUiModeLabel = getExecutorUiModeLabel(executorUiLaunchConfig); const executorSelectValue = resolveExecutorSelectValue(agentAdapters); const resolvedWorkflowActions = useMemo( @@ -196,14 +267,30 @@ export const ChangeDetail: React.FC = ({ : undefined, [workflowSnapshot, completedTasks, totalTasks, isArchived, deltaSpecIds.length] ); - const canArchiveNow = Boolean( + const resolverAllowsArchiveNow = Boolean( resolvedWorkflowActions?.highImpact.some( (action) => action.action === 'archive' && action.highImpact === true ) ); + const tasksProgressBlocked = isArchiveNowBlockedByTasksProgress({ + tasksProgressError, + tasksProgressUnknown, + verifyArchiveTasksLoading, + }); + const canArchiveNow = isArchiveNowAllowed( + resolverAllowsArchiveNow, + verifyArchiveTasksLoading, + tasksProgressBlocked, + ); const archiveNowDisabledReason = isArchived ? t('verifyArchive.archiveDisabledArchived') - : t('verifyArchive.archiveDisabledIncomplete'); + : tasksProgressError + ? tasksProgressError + : verifyArchiveTasksLoading + ? t('detail.loadingTaskProgress') + : tasksProgressUnknown + ? t('detail.tasksProgressUnknown') + : t('verifyArchive.archiveDisabledIncomplete'); const showVerifyArchiveTab = debug || !isArchived || archivedLocally; const navigationArtifacts = useMemo(() => { if (workflowSnapshot) return workflowSnapshot.artifacts; @@ -236,6 +323,10 @@ export const ChangeDetail: React.FC = ({ artifacts: navigationArtifacts.filter((artifact) => group.statuses.includes(artifact.status as never)), })).filter((group) => group.artifacts.length > 0)), [navigationArtifacts]); + useEffect(() => { + initialArtifactFetchKeyRef.current = null; + }, [changeName, scopeId]); + useEffect(() => { if (initialTab && tabs.some((tab) => tab.id === initialTab)) { setActiveTab(initialTab); @@ -251,13 +342,31 @@ export const ChangeDetail: React.FC = ({ } }, [interactiveAction]); + const resolveFetchKey = (artifactType: string) => + artifactFetchCoordinatorKey( + scopeId, + artifactType, + artifactType === 'specs' ? selectedSpecIdRef.current : undefined, + ); + const requestArtifact = (artifactType: string, outputPath?: string) => { - setLoading(true); - setError(null); - setErrorCode(undefined); - setArtifactStateMessage(null); - setContent(null); - postMessage(sendMessage.getArtifactContent(changeName, artifactType, scopeId, outputPath)); + const fetchKey = resolveFetchKey(artifactType); + const resolvedOutputPath = outputPath ?? selectedOutputPathsRef.current[artifactType]; + artifactFetchCoordinatorRef.current.schedule(fetchKey, () => { + if (shouldShowActiveTabLoadingForArtifactFetch(activeTabRef.current, artifactType)) { + setLoading(true); + setError(null); + setErrorCode(undefined); + setArtifactStateMessage(null); + setContent(null); + } + postMessage(sendMessage.getArtifactContent( + changeName, + artifactType, + scopeId, + outputPath ?? resolvedOutputPath, + )); + }); }; const requestSpecsList = () => { @@ -275,9 +384,6 @@ export const ChangeDetail: React.FC = ({ setLoading(false); setError(null); setContent(null); - if (!isArchived) { - postMessage(sendMessage.getArtifactContent(changeName, 'tasks', scopeId)); - } return; } @@ -321,7 +427,11 @@ export const ChangeDetail: React.FC = ({ } if (activeTab === 'specs') { - requestSpecsList(); + const specsLoadKey = `${changeName}\u0000${scopeId ?? ''}\u0000specs`; + if (initialArtifactFetchKeyRef.current !== specsLoadKey) { + initialArtifactFetchKeyRef.current = specsLoadKey; + requestSpecsList(); + } return; } @@ -332,14 +442,13 @@ export const ChangeDetail: React.FC = ({ setLoading(false); setError(null); setErrorCode(undefined); - postMessage(sendMessage.getArtifactContent( - changeName, - activeTab, - scopeId, - selectedOutputPaths[activeTab] - )); return; } + const fetchIdentity = `${changeName}\u0000${scopeId ?? ''}\u0000${activeTab}\u0000${selectedOutputPaths[activeTab] ?? ''}`; + if (initialArtifactFetchKeyRef.current === fetchIdentity) { + return; + } + initialArtifactFetchKeyRef.current = fetchIdentity; requestArtifact(activeTab); }, [ changeName, @@ -354,114 +463,213 @@ export const ChangeDetail: React.FC = ({ ]); useEffect(() => { - const cleanup = onMessage((event: MessageEvent) => { - const msg = event.data; - if (msg.type === 'dashboardData' - && Array.isArray(msg.data?.archivedChanges) - && msg.data.archivedChanges.some((archived: { name?: string }) => archived.name === changeName)) { + if (activeTab !== 'verifyArchive' || isArchived) { + return; + } + const loadKey = `${changeName}\u0000${scopeId ?? ''}`; + if (verifyArchiveTasksLoadedRef.current === loadKey) { + return; + } + verifyArchiveTasksLoadedRef.current = loadKey; + setVerifyArchiveTasksLoading(true); + requestArtifact('tasks'); + }, [activeTab, changeName, isArchived, scopeId]); + + dispatchExtensionMessageRef.current = (msg: unknown) => { + const data = msg as Record; + if (data.type === 'panelVisibility') { + const visible = data.visible !== false; + const tasksFetchKey = tasksFetchCoordinatorKey(scopeId); + const tasksDirtyWhileHidden = visible + && artifactFetchCoordinatorRef.current.isDirty(tasksFetchKey); + artifactFetchCoordinatorRef.current.setPanelVisible(visible); + if (visible && activeTabRef.current === 'verifyArchive') { + if (tasksDirtyWhileHidden || verifyArchiveTasksLoadingRef.current) { + verifyArchiveTasksLoadedRef.current = null; + setVerifyArchiveTasksLoading(true); + requestArtifact('tasks'); + } + } + } else if (data.type === 'dashboardData' + && Array.isArray((data.data as { archivedChanges?: { name?: string }[] } | undefined)?.archivedChanges) + && (data.data as { archivedChanges: { name?: string }[] }).archivedChanges.some( + (archived) => archived.name === changeName, + )) { setArchivedLocally(true); - } else if (msg.type === 'artifactContent' && msg.changeName === changeName) { - const key = cacheKey(scopeId, msg.artifactType, msg.artifactPath); - contentCacheRef.current.set(key, msg.content ?? ''); - setContent(msg.content ?? ''); + } else if (data.type === 'artifactContent' && data.changeName === changeName) { + completeArtifactFetchAndStoreContent({ + scopeId, + artifactType: String(data.artifactType), + artifactPath: typeof data.artifactPath === 'string' ? data.artifactPath : undefined, + content: String(data.content ?? ''), + coordinator: artifactFetchCoordinatorRef.current, + contentCache: contentCacheRef.current, + }); + setContent(String(data.content ?? '')); setArtifactStateMessage(null); - if (Array.isArray(msg.outputs)) { - setArtifactOutputs((previous) => ({ ...previous, [msg.artifactType]: msg.outputs })); + if (Array.isArray(data.outputs)) { + const artifactType = String(data.artifactType); + setArtifactOutputs((previous) => ({ + ...previous, + [artifactType]: data.outputs as ArtifactOutputDescriptor[], + })); setSelectedOutputPaths((previous) => ({ ...previous, - [msg.artifactType]: reconcileSelectedOutputPath( - typeof msg.artifactPath === 'string' - ? msg.artifactPath - : previous[msg.artifactType], - msg.outputs, + [artifactType]: reconcileSelectedOutputPath( + typeof data.artifactPath === 'string' + ? data.artifactPath + : previous[artifactType], + data.outputs as ArtifactOutputDescriptor[], ), })); - } else if (typeof msg.artifactPath === 'string') { - setSelectedOutputPaths((previous) => ({ ...previous, [msg.artifactType]: msg.artifactPath })); + } else if (typeof data.artifactPath === 'string') { + setSelectedOutputPaths((previous) => ({ + ...previous, + [String(data.artifactType)]: data.artifactPath as string, + })); } setLoading(false); setError(null); - if (msg.artifactType === 'tasks' && msg.content) { - const { completed, total } = countTaskProgress(msg.content); + const cacheStale = readArtifactCacheStale(data.cache); + if (data.artifactType === 'tasks' && typeof data.content === 'string') { + setTasksProgressError(null); + setTasksProgressUnknown(false); + const { completed, total } = countTaskProgress(data.content); setTotalTasks(total); setCompletedTasks(completed); + if (cacheStale) { + setVerifyArchiveTasksLoading(true); + } + } + if (shouldCompleteVerifyArchiveTasksFetch({ + artifactType: String(data.artifactType), + content: data.content, + cacheStale, + })) { + setVerifyArchiveTasksLoading(false); } - } else if (msg.type === 'workflowActionReceipt' - && msg.changeName === changeName + } else if (data.type === 'workflowActionReceipt' + && data.changeName === changeName && workflowSnapshot - && msg.bindingKey === workflowSnapshot.bindingKey - && workflowReceipt?.requestId === msg.requestId) { - setWorkflowReceipt({ - requestId: msg.requestId, - bindingKey: msg.bindingKey, - status: msg.status, - message: msg.message, - }); - if (!['running'].includes(msg.status)) setPendingWorkflowAction(null); - } else if (msg.type === 'artifactContentError' && msg.changeName === changeName) { - setError(msg.message ?? 'Failed to load'); - setErrorCode(msg.code); - setLoading(false); - setContent(null); - setArtifactStateMessage(null); - } else if (msg.type === 'deltaSpecList' && msg.changeName === changeName) { - setDeltaSpecIds(msg.specIds ?? []); - if (msg.specIds?.length) { - setSelectedSpecId(msg.specIds[0]); + && data.bindingKey === workflowSnapshot.bindingKey) { + workflowLaunchPending.handleReceipt(data as unknown as WorkflowActionReceipt); + if (data.status === 'running') { + if (data.action === 'verify' || data.action === 'archive') { + setPendingLaunchAction(data.action as WorkflowAction); + } + } else if (data.suppressPriorityAttention === true && data.status === 'completed') { + setPendingLaunchAction(null); + setPendingLaunchSurface(null); + if (workflowReceiptBeforeLaunchRef.current) { + setWorkflowReceipt(workflowReceiptBeforeLaunchRef.current); + workflowReceiptBeforeLaunchRef.current = null; + } + } else { + workflowReceiptBeforeLaunchRef.current = null; + setWorkflowReceipt({ + requestId: String(data.requestId), + bindingKey: String(data.bindingKey), + status: String(data.status), + message: typeof data.message === 'string' ? data.message : undefined, + }); + } + } else if (data.type === 'artifactContentError' && data.changeName === changeName) { + const artifactType = typeof data.artifactType === 'string' + ? data.artifactType + : activeTabRef.current; + const fetchKey = artifactFetchCoordinatorKey( + scopeId, + artifactType, + artifactType === 'specs' ? selectedSpecIdRef.current : undefined, + ); + artifactFetchCoordinatorRef.current.complete(fetchKey); + if (artifactType === 'tasks') { + const message = typeof data.message === 'string' ? data.message : t('artifact.readError'); + setTasksProgressError(message); + setTasksProgressUnknown(true); + setCompletedTasks(0); + setTotalTasks(0); + setVerifyArchiveTasksLoading(false); + } else if (shouldShowActiveTabLoadingForArtifactFetch(activeTabRef.current, artifactType)) { + if (data.code === 'WORKSPACE_ROOT_STALE') { + setArtifactStateMessage( + typeof data.message === 'string' ? data.message : t('workflow.workspaceRootStale'), + ); + setLoading(false); + setError(null); + setErrorCode(String(data.code)); + setContent(null); + } else { + setError(typeof data.message === 'string' ? data.message : 'Failed to load'); + setErrorCode(typeof data.code === 'string' ? data.code : undefined); + setLoading(false); + setContent(null); + setArtifactStateMessage(null); + } + } + } else if (data.type === 'deltaSpecList' && data.changeName === changeName) { + const specIds = Array.isArray(data.specIds) ? data.specIds as string[] : []; + setDeltaSpecIds(specIds); + if (specIds.length) { + setSelectedSpecId(specIds[0]); setLoading(true); } else { setLoading(false); setContent(null); setError(null); } - } else if (msg.type === 'deltaSpecContent' && msg.changeName === changeName) { - const key = cacheKey(scopeId, 'specs', msg.specId); - contentCacheRef.current.set(key, msg.content ?? ''); - setContent(msg.content ?? ''); + } else if (data.type === 'deltaSpecContent' && data.changeName === changeName) { + const key = cacheKey(scopeId, 'specs', String(data.specId)); + contentCacheRef.current.set(key, String(data.content ?? '')); + setContent(String(data.content ?? '')); setLoading(false); setError(null); - } else if (msg.type === 'deltaSpecContentError' && msg.changeName === changeName) { - setError(msg.message ?? 'Failed to load spec'); + } else if (data.type === 'deltaSpecContentError' && data.changeName === changeName) { + setError(typeof data.message === 'string' ? data.message : 'Failed to load spec'); setLoading(false); setContent(null); } else if ( - (msg.type === 'setContext' - && msg.view === 'changeDetail' - && msg.changeName === changeName - && msg.executorLaunchPresentation) - || msg.type === 'executorLaunchPresentation' + (data.type === 'setContext' + && data.view === 'changeDetail' + && data.changeName === changeName + && data.executorLaunchPresentation) + || data.type === 'executorLaunchPresentation' ) { - const presentation = msg.type === 'executorLaunchPresentation' - ? msg - : msg.executorLaunchPresentation; + const presentation = data.type === 'executorLaunchPresentation' + ? data + : data.executorLaunchPresentation as Record; if (presentation) { setExecutorPresentation(normalizeExecutorPresentation({ - agentAdapters: presentation.agentAdapters, - workflowLaunchConfig: presentation.workflowLaunchConfig, - uiWorkflowLaunchConfig: presentation.uiWorkflowLaunchConfig, + agentAdapters: presentation.agentAdapters as ExecutorLaunchPresentation['agentAdapters'], + workflowLaunchConfig: presentation.workflowLaunchConfig as ExecutorLaunchPresentation['workflowLaunchConfig'], + uiWorkflowLaunchConfig: presentation.uiWorkflowLaunchConfig as ExecutorLaunchPresentation['uiWorkflowLaunchConfig'], + isCursorHost: presentation.isCursorHost === true, })); } - } else if (msg.type === 'taskExecutionFinished' && msg.changeName === changeName) { + } else if (data.type === 'taskExecutionFinished' && data.changeName === changeName) { setExecutingTaskIndex(null); - if (msg.executionState && typeof msg.executionState === 'object') { - setTaskExecutionState(msg.executionState); + if (data.executionState && typeof data.executionState === 'object') { + setTaskExecutionState(data.executionState as Record); } - } else if (msg.type === 'taskExecutionState' && msg.changeName === changeName) { - if (msg.executionState && typeof msg.executionState === 'object') { - setTaskExecutionState(msg.executionState); + } else if (data.type === 'taskExecutionState' && data.changeName === changeName) { + if (data.executionState && typeof data.executionState === 'object') { + setTaskExecutionState(data.executionState as Record); } - } else if (msg.type === 'runCommandResult') { - setRunCommandResult({ success: msg.success, message: msg.message }); - } else if (msg.type === 'interactiveWorkflowState' && msg.changeName === changeName) { - setInteractiveState(msg.state ?? { changeName, sessions: {} }); - } else if (msg.type === 'artifactInvalidated' && msg.changeName === changeName) { - const invalidated: string[] = msg.artifactTypes ?? []; + } else if (data.type === 'runCommandResult') { + setRunCommandResult({ + success: Boolean(data.success), + message: typeof data.message === 'string' ? data.message : undefined, + }); + } else if (data.type === 'interactiveWorkflowState' && data.changeName === changeName) { + setInteractiveState((data.state as InteractiveWorkflowState | undefined) ?? { changeName, sessions: {} }); + } else if (data.type === 'artifactInvalidated' && data.changeName === changeName) { + const invalidated: string[] = Array.isArray(data.artifactTypes) + ? data.artifactTypes as string[] + : []; const scopePrefix = scopeId ? `${scopeId}::` : ''; for (const type of invalidated) { if (type === 'specs') { for (const key of Array.from(contentCacheRef.current.keys())) { - // Cache keys are optionally scope-prefixed; drop the specs entries that - // belong to this panel's scope (and any legacy unscoped specs:* keys). const suffix = scopePrefix ? key.slice(scopePrefix.length) : key; const isLegacyUnscopedSpecs = scopePrefix && (key === 'specs' || key.startsWith('specs:')); if ( @@ -472,24 +680,44 @@ export const ChangeDetail: React.FC = ({ contentCacheRef.current.delete(key); } } - } else { + } else if (type !== 'tasks') { contentCacheRef.current.delete(`${scopePrefix}${type}`); if (scopePrefix) { contentCacheRef.current.delete(type); } } } - if (invalidated.includes(activeTab)) { - if (activeTab === 'specs') { + if (invalidated.includes('tasks')) { + handleTasksArtifactInvalidated({ + scopeId, + coordinator: artifactFetchCoordinatorRef.current, + contentCache: contentCacheRef.current, + resetVerifyArchiveLoadedFlag: () => { + verifyArchiveTasksLoadedRef.current = null; + }, + markVerifyArchiveLoading: () => { + if (activeTabRef.current === 'verifyArchive') { + setVerifyArchiveTasksLoading(true); + } + }, + scheduleTasksRefetch: () => requestArtifact('tasks'), + }); + } else if (invalidated.includes(activeTabRef.current)) { + initialArtifactFetchKeyRef.current = null; + if (activeTabRef.current === 'specs') { requestSpecsList(); - } else if (activeTab !== 'verifyArchive') { - requestArtifact(activeTab); + } else if (activeTabRef.current !== 'verifyArchive') { + requestArtifact(activeTabRef.current); } } } + }; + + useEffect(() => { + return onMessage((event: MessageEvent) => { + dispatchExtensionMessageRef.current(event.data); }); - return cleanup; - }, [activeTab, changeName, onMessage, postMessage, scopeId, workflowReceipt, workflowSnapshot]); + }, [onMessage]); useEffect(() => { postMessage(sendMessage.getExecutorLaunchPresentation()); @@ -539,14 +767,6 @@ export const ChangeDetail: React.FC = ({ } }, [activeTab, changeName, postMessage, scopeId]); - useEffect(() => { - if (activeTab !== 'verifyArchive') return; - postMessage(sendMessage.getInteractiveWorkflowState(changeName, scopeId)); - if (pendingInteractiveAction) { - postMessage(sendMessage.runInteractiveWorkflow(changeName, pendingInteractiveAction, scopeId)); - setPendingInteractiveAction(null); - } - }, [activeTab, changeName, pendingInteractiveAction, postMessage, scopeId]); const handleOpenInEditor = () => { @@ -565,6 +785,7 @@ export const ChangeDetail: React.FC = ({ const handleRefresh = () => { contentCacheRef.current.clear(); + initialArtifactFetchKeyRef.current = null; postMessage(sendMessage.refresh()); if (activeTab === 'verifyArchive') { postMessage(sendMessage.getInteractiveWorkflowState(changeName, scopeId)); @@ -588,17 +809,64 @@ export const ChangeDetail: React.FC = ({ postMessage(sendMessage.runCommand(commandId, verifyArgsJson.trim() || undefined, changeName)); }; + const copyCommandTarget = resolveWorkflowCommandTargetForUi( + hostWorkflowLaunchConfig, + { isCursorHost }, + ); + + const workflowBindingKey = workflowSnapshot?.bindingKey; + const isWorkflowLaunchPending = workflowBindingKey + ? workflowLaunchPending.isPending(changeName, workflowBindingKey) + : false; + const actionBarPendingAction = isWorkflowLaunchPending + && pendingLaunchSurface === 'actionBar' + ? pendingLaunchAction + : null; + const verifyArchivePendingAction = isWorkflowLaunchPending + && pendingLaunchSurface === 'verifyArchive' + && (pendingLaunchAction === 'verify' || pendingLaunchAction === 'archive') + ? pendingLaunchAction + : null; + + useEffect(() => { + if (!workflowBindingKey || !pendingLaunchAction) return; + if (!workflowLaunchPending.isPending(changeName, workflowBindingKey)) { + setPendingLaunchAction(null); + setPendingLaunchSurface(null); + } + }, [changeName, pendingLaunchAction, workflowBindingKey, workflowLaunchPending.pendingKeys]); + + useEffect(() => { + if (!verifyArchiveTasksLoading) return; + const timeout = window.setTimeout(() => { + verifyArchiveTasksLoadedRef.current = null; + artifactFetchCoordinatorRef.current.forceRelease(tasksFetchCoordinatorKey(scopeId)); + requestArtifact('tasks'); + }, 15_000); + return () => window.clearTimeout(timeout); + }, [verifyArchiveTasksLoading, changeName, scopeId]); + const handleLaunchWorkflow = ( - action: 'explore' | 'continue' | 'ff' | 'apply' | 'verify' | 'archive' | 'sync' + action: 'explore' | 'continue' | 'ff' | 'apply' | 'verify' | 'archive' | 'sync', + surface: LaunchSurface = 'actionBar', ) => { - if (!workflowSnapshot?.bindingKey || pendingWorkflowAction === action) return; - const requestId = createWorkflowRequestId(); - setPendingWorkflowAction(action); - setWorkflowReceipt({ - requestId, - bindingKey: workflowSnapshot.bindingKey, - status: 'pending', - }); + if (!workflowSnapshot?.bindingKey) return; + if (pendingLaunchAction === action && workflowLaunchPending.isPending(changeName, workflowSnapshot.bindingKey)) { + return; + } + const requestId = copyOnlyWorkflowLaunch + ? createWorkflowRequestId('workflow') + : workflowLaunchPending.registerLaunch(changeName, workflowSnapshot.bindingKey).requestId; + if (!copyOnlyWorkflowLaunch) { + workflowReceiptBeforeLaunchRef.current = workflowReceipt; + setPendingLaunchAction(action); + setPendingLaunchSurface(surface); + setWorkflowReceipt({ + requestId, + bindingKey: workflowSnapshot.bindingKey, + status: 'pending', + }); + } postMessage(sendMessage.launchWorkflowAction( action, changeName, @@ -608,15 +876,21 @@ export const ChangeDetail: React.FC = ({ )); }; + useEffect(() => { + if (activeTab !== 'verifyArchive' || !pendingInteractiveAction) return; + handleLaunchWorkflow(pendingInteractiveAction, 'verifyArchive'); + setPendingInteractiveAction(null); + }, [activeTab, pendingInteractiveAction]); + const handleResolvedAction = ( action: 'explore' | 'continue' | 'ff' | 'apply' | 'verify' | 'archive' | 'sync' ) => { if (action === 'verify' || action === 'archive') { setActiveTab('verifyArchive'); - setPendingInteractiveAction(action); + handleLaunchWorkflow(action, 'actionBar'); return; } - handleLaunchWorkflow(action); + handleLaunchWorkflow(action, 'actionBar'); }; const handleConfirmTaskToggle = () => { @@ -651,7 +925,13 @@ export const ChangeDetail: React.FC = ({ />
- {getStatusSummary(existingArtifactIds, completedTasks, totalTasks, isArchived)} + {verifyArchiveTasksLoading && activeTab === 'verifyArchive' + ? t('detail.loadingTaskProgress') + : tasksProgressError + ? tasksProgressError + : tasksProgressUnknown + ? t('detail.tasksProgressUnknown') + : getStatusSummary(existingArtifactIds, completedTasks, totalTasks, isArchived)}
{workflowSnapshot && (
@@ -671,16 +951,16 @@ export const ChangeDetail: React.FC = ({ changeName={changeName} isArchived={isArchived} resolvedActions={resolvedWorkflowActions} - pendingAction={pendingWorkflowAction} + pendingAction={actionBarPendingAction} receiptStatus={workflowReceipt?.status} receiptMessage={workflowReceipt?.message} - executorUiLaunchConfig={executorUiLaunchConfig} + executorUiLaunchConfig={workflowLabelLaunchConfig} onAction={handleResolvedAction} onCopyFf={(name) => - postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'ff', changeName: name, target: 'clipboard' }))) + postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'ff', changeName: name, target: copyCommandTarget }))) } onCopyApply={(name) => - postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'apply', changeName: name, target: 'clipboard' }))) + postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'apply', changeName: name, target: copyCommandTarget }))) } /> @@ -748,11 +1028,11 @@ export const ChangeDetail: React.FC = ({ isArchived={isArchived} canArchiveNow={canArchiveNow} archiveNowDisabledReason={archiveNowDisabledReason} - sessions={interactiveState.sessions} - onRun={(action) => postMessage(sendMessage.runInteractiveWorkflow(changeName, action, scopeId))} - onReveal={(action) => postMessage(sendMessage.revealInteractiveWorkflow(changeName, action, scopeId))} - onStop={(action) => postMessage(sendMessage.stopInteractiveWorkflow(changeName, action, scopeId))} - onClear={(action) => postMessage(sendMessage.clearInteractiveWorkflow(changeName, action, scopeId))} + tasksProgressError={tasksProgressError} + pendingAction={verifyArchivePendingAction} + workflowLaunchConfig={workflowLabelLaunchConfig} + commandFormatRuntime={{ isCursorHost }} + onRun={(action) => handleLaunchWorkflow(action, 'verifyArchive')} onArchiveNow={handleArchiveNow} /> diff --git a/src/webview/components/ChangesExplorer.tsx b/src/webview/components/ChangesExplorer.tsx index 916ff01..e44bf05 100644 --- a/src/webview/components/ChangesExplorer.tsx +++ b/src/webview/components/ChangesExplorer.tsx @@ -1,13 +1,17 @@ import React, { useEffect, useMemo, useState } from 'react'; import { buildChangeStatusCounts } from '../../shared/changeLifecycle'; import type { WorkflowAction } from '../../shared/workflowCommand'; -import { buildWorkflowCommand } from '../../shared/workflowCommand'; +import { buildWorkflowCommand, resolveWorkflowCommandTargetForUi } from '../../shared/workflowCommand'; import { t } from '../../i18n'; import { useVscode } from '../hooks/useVscode'; import { ChangesSection } from './ChangesSection'; import type { ProjectChangesExplorerData, ExtensionMessage } from '../types/messages'; +import { createWorkflowRequestId, type WorkflowActionReceipt } from '../../shared/changeWorkflow'; +import { isHostWorkflowLaunchCopyOnly } from '../../shared/workflowLaunchConfig'; +import { getCopyOnlyFallbackLaunchConfig } from '../utils/executorUiLaunchConfig'; import { sendMessage } from '../types/messages'; import type { WorkflowLaunchConfigView } from '../utils/workflowLaunchLabels'; +import { useWorkflowLaunchPending } from '../hooks/useWorkflowLaunchPending'; export interface ChangesExplorerProps { data: ProjectChangesExplorerData; @@ -16,6 +20,8 @@ export interface ChangesExplorerProps { export const ChangesExplorer: React.FC = ({ data }) => { const { postMessage, onMessage } = useVscode(); const [workflowLaunchConfig, setWorkflowLaunchConfig] = useState(null); + const [workflowLaunchIsCursorHost, setWorkflowLaunchIsCursorHost] = useState(false); + const workflowLaunchPending = useWorkflowLaunchPending(); const counts = useMemo( () => buildChangeStatusCounts(data.changes, data.archivedChanges), [data.changes, data.archivedChanges], @@ -25,11 +31,19 @@ export const ChangesExplorer: React.FC = ({ data }) => { const cleanup = onMessage((event: MessageEvent) => { if (event.data.type === 'workflowLaunchConfig') { setWorkflowLaunchConfig(event.data.config); + setWorkflowLaunchIsCursorHost(event.data.isCursorHost === true); + } else if (event.data.type === 'workflowActionReceipt') { + workflowLaunchPending.handleReceipt(event.data as WorkflowActionReceipt); } }); postMessage(sendMessage.getWorkflowLaunchConfig()); return cleanup; - }, [onMessage, postMessage]); + }, [onMessage, postMessage, workflowLaunchPending]); + + const copyCommandTarget = resolveWorkflowCommandTargetForUi( + workflowLaunchConfig ?? getCopyOnlyFallbackLaunchConfig(), + { isCursorHost: workflowLaunchIsCursorHost }, + ); const openChange = (changeName: string) => { postMessage(sendMessage.openChangeDetailInEditor( @@ -50,23 +64,24 @@ export const ChangesExplorer: React.FC = ({ data }) => { postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action, changeName, - target: 'clipboard', + target: copyCommandTarget, }))); }; const launchWorkflow = (action: WorkflowAction, changeName: string, bindingKey?: string) => { - if (action === 'verify' || action === 'archive') { - postMessage(sendMessage.openChangeDetailInEditor( - changeName, - 'verifyArchive', - action, - undefined, - data.project, - data.binding, - )); - return; - } - postMessage(sendMessage.launchWorkflowAction(action, changeName, undefined, undefined, bindingKey)); + const copyOnlyLaunch = isHostWorkflowLaunchCopyOnly( + workflowLaunchConfig ?? getCopyOnlyFallbackLaunchConfig(), + ); + const { requestId } = bindingKey && !copyOnlyLaunch + ? workflowLaunchPending.registerLaunch(changeName, bindingKey, 'explorer') + : { requestId: createWorkflowRequestId('explorer') }; + postMessage(sendMessage.launchWorkflowAction( + action, + changeName, + undefined, + requestId, + bindingKey, + )); }; return ( @@ -94,6 +109,7 @@ export const ChangesExplorer: React.FC = ({ data }) => { onCopyApply={(changeName) => copyWorkflow('apply', changeName)} onLaunchWorkflow={launchWorkflow} workflowLaunchConfig={workflowLaunchConfig} + workflowLaunchPendingKeys={workflowLaunchPending.pendingKeys} rootLabel={data.project.label} /> diff --git a/src/webview/components/ChangesSection.tsx b/src/webview/components/ChangesSection.tsx index e016f30..cf5582d 100644 --- a/src/webview/components/ChangesSection.tsx +++ b/src/webview/components/ChangesSection.tsx @@ -19,6 +19,9 @@ import { import { t } from '../../i18n'; import type { WorkflowAction } from '../../shared/workflowCommand'; import type { WorkflowLaunchConfigView } from '../utils/workflowLaunchLabels'; +import type { WorkflowActionReceipt } from '../../shared/changeWorkflow'; +import { resolveChangeBindingKey } from '../utils/changeBindingKey'; +import type { OpenSpecRootBinding } from '../types/messages'; export type ChangesSectionLayout = 'wide' | 'narrow' | 'auto'; @@ -30,6 +33,9 @@ interface ChangesSectionProps { onCopyFf?: (changeName: string) => void; onCopyApply?: (changeName: string) => void; onLaunchWorkflow?: (action: WorkflowAction, changeName: string, bindingKey?: string) => void; + workflowLaunchPendingKeys?: ReadonlySet; + workflowActionReceipts?: readonly WorkflowActionReceipt[]; + projectBinding?: OpenSpecRootBinding | null; archivedItems?: ArchivedChangeInfo[]; onOpenArchivedChange?: (directoryName: string) => void; workflowLaunchConfig?: WorkflowLaunchConfigView | null; @@ -101,6 +107,9 @@ export const ChangesSection: React.FC = ({ onCopyFf, onCopyApply, onLaunchWorkflow, + workflowLaunchPendingKeys, + workflowActionReceipts = [], + projectBinding = null, archivedItems = [], onOpenArchivedChange, workflowLaunchConfig, @@ -283,6 +292,19 @@ export const ChangesSection: React.FC = ({ onCopyApply={onCopyApply} onLaunchWorkflow={onLaunchWorkflow} workflowLaunchConfig={workflowLaunchConfig} + workflowLaunchPendingKeys={workflowLaunchPendingKeys} + projectBinding={projectBinding} + workflowLaunchFailureHint={(() => { + const bindingKey = resolveChangeBindingKey(item.change, projectBinding); + if (!bindingKey) return null; + const failed = workflowActionReceipts.find((receipt) => + receipt.changeName === item.change.name + && receipt.bindingKey === bindingKey + && receipt.status === 'failed' + && receipt.message, + ); + return failed?.message ? { message: failed.message } : null; + })()} /> ) : ( 0; if (change.attention?.required === true || hasReceiptAttention || hasResolverAttention) { needsAttention.push(change); @@ -345,6 +351,7 @@ export const Dashboard: React.FC = () => { const [cacheActionMessage, setCacheActionMessage] = useState(null); const [pendingCacheAction, setPendingCacheAction] = useState(null); const [workflowReceipts, setWorkflowReceipts] = useState([]); + const workflowLaunchPending = useWorkflowLaunchPending(); const pendingWorkflowRequestsRef = useRef(new Map()); const latestWorkflowRequestRef = useRef(new Map()); // Tracks the scope the current requirements cache was loaded under; reset on change. @@ -353,6 +360,7 @@ export const Dashboard: React.FC = () => { // effect run) can read the latest scope without closing over stale state. const scopeIdRef = useRef(undefined); const [workflowLaunchConfig, setWorkflowLaunchConfig] = useState(null); + const [workflowLaunchIsCursorHost, setWorkflowLaunchIsCursorHost] = useState(false); // Sequence-stamped Host responses for the Workset create flow. The sequence // lets the picker apply each Host message at most once, and ignore responses // that arrive after the form was left. The payloads stay untrusted: the @@ -474,25 +482,30 @@ export const Dashboard: React.FC = () => { })); } else if (message.type === 'workflowLaunchConfig') { setWorkflowLaunchConfig(message.config ?? null); + setWorkflowLaunchIsCursorHost(message.isCursorHost === true); } else if (message.type === 'workflowActionReceipt') { - const pending = pendingWorkflowRequestsRef.current.get(message.requestId); - const key = `${message.changeName}\u0000${message.bindingKey}`; - if ( - pending - && pending.changeName === message.changeName - && pending.bindingKey === message.bindingKey - && latestWorkflowRequestRef.current.get(key) === message.requestId - ) { + const receipt = message as WorkflowActionReceipt; + const pending = pendingWorkflowRequestsRef.current.get(receipt.requestId); + const key = `${receipt.changeName}\u0000${receipt.bindingKey}`; + if (receipt.status !== 'running') { setWorkflowReceipts((previous) => [ - ...previous.filter((receipt) => ( - receipt.changeName !== message.changeName - || receipt.bindingKey !== message.bindingKey + ...previous.filter((item) => ( + item.changeName !== receipt.changeName + || item.bindingKey !== receipt.bindingKey )), - message as WorkflowActionReceipt, + receipt, ]); - if (message.status !== 'running') { - pendingWorkflowRequestsRef.current.delete(message.requestId); + } + if ( + pending + && pending.changeName === receipt.changeName + && pending.bindingKey === receipt.bindingKey + && latestWorkflowRequestRef.current.get(key) === receipt.requestId + ) { + if (receipt.status !== 'running') { + pendingWorkflowRequestsRef.current.delete(receipt.requestId); } + workflowLaunchPending.handleReceipt(receipt); } } else if (message.type === 'cacheStats') { setCacheStats(message.stats ?? null); @@ -533,6 +546,16 @@ export const Dashboard: React.FC = () => { return cleanup; }, [postMessage, onMessage, dispatch, projectFirst]); + useEffect(() => { + if (!projectFirst || projectSidebar) return undefined; + const timer = window.setTimeout(() => { + if (!projectSidebar && loading) { + postMessage(sendMessage.getProjectSidebarData()); + } + }, 8000); + return () => window.clearTimeout(timer); + }, [projectFirst, projectSidebar, loading, postMessage]); + const handleSelectScope = useCallback( createScopeSelectHandler(dispatch, postMessage), [dispatch, postMessage], @@ -601,32 +624,28 @@ export const Dashboard: React.FC = () => { )); }; + const copyCommandTarget = resolveWorkflowCommandTargetForUi( + workflowLaunchConfig ?? getCopyOnlyFallbackLaunchConfig(), + { isCursorHost: workflowLaunchIsCursorHost }, + ); + const handleCopyFf = (changeName: string) => { - postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'ff', changeName, target: 'clipboard' }))); + postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'ff', changeName, target: copyCommandTarget }))); }; const handleCopyApply = (changeName: string) => { - postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'apply', changeName, target: 'clipboard' }))); + postMessage(sendMessage.copyToClipboard(buildWorkflowCommand({ action: 'apply', changeName, target: copyCommandTarget }))); }; const handleLaunchWorkflow = (action: WorkflowAction, changeName: string, bindingKey?: string) => { - if (action === 'verify' || action === 'archive') { - postMessage( - projectSidebar - ? sendMessage.openChangeDetailInEditor( - changeName, - 'verifyArchive', - action, - undefined, - projectSidebar.project, - projectSidebar.binding, - ) - : sendMessage.openChangeDetailInEditor(changeName, 'verifyArchive', action, state.data?.scope?.id) - ); - return; - } - const requestId = createWorkflowRequestId('dashboard'); - if (bindingKey) { + const launchConfigForUi = workflowLaunchConfig ?? projectSidebar?.workflowLaunchConfig ?? null; + const copyOnlyLaunch = isHostWorkflowLaunchCopyOnly( + launchConfigForUi ?? getCopyOnlyFallbackLaunchConfig(), + ); + const requestId = bindingKey && !copyOnlyLaunch + ? workflowLaunchPending.registerLaunch(changeName, bindingKey, 'dashboard').requestId + : createWorkflowRequestId('dashboard'); + if (bindingKey && !copyOnlyLaunch) { const key = `${changeName}\u0000${bindingKey}`; pendingWorkflowRequestsRef.current.set(requestId, { changeName, bindingKey }); latestWorkflowRequestRef.current.set(key, requestId); @@ -706,7 +725,8 @@ export const Dashboard: React.FC = () => { return true; }) .slice(0, 3); - const priorityWorkflowConfig = projectSidebar?.workflowLaunchConfig ?? workflowLaunchConfig; + const priorityWorkflowConfig = workflowLaunchConfig ?? projectSidebar?.workflowLaunchConfig; + const projectBinding = projectSidebar?.binding; return (
{ isArchived: change.lifecycleStatus === 'archived', }).recommended : null; + const bindingKey = resolveChangeBindingKey(change, projectBinding); + const pendingKey = `${change.name}\u0000${bindingKey ?? ''}`; + const isLaunchPending = workflowLaunchPending.pendingKeys.has(pendingKey); const ctaLabel = group.key === 'needs-attention' ? t('verifyArchive.reviewArchive') : group.key === 'ready-to-verify' - ? t('detail.verifyArchive') + ? getWorkflowActionButtonLabel( + 'Verify', + priorityWorkflowConfig, + { launching: isLaunchPending }, + ) : recommended - ? getWorkflowActionButtonLabel(recommended.label, priorityWorkflowConfig) + ? getWorkflowActionButtonLabel( + recommended.label, + priorityWorkflowConfig, + { launching: isLaunchPending }, + ) : null; if (!ctaLabel) return null; const ctaAccessibleName = t('dashboard.priorityActionAriaLabel', { @@ -872,7 +903,10 @@ export const Dashboard: React.FC = () => { onOpenArchivedChange={handleOpenArchivedChange} archivedItems={[...(projectSidebar.archivedChanges ?? [])]} onLaunchWorkflow={handleLaunchWorkflow} - workflowLaunchConfig={projectSidebar.workflowLaunchConfig ?? workflowLaunchConfig} + workflowLaunchConfig={workflowLaunchConfig ?? projectSidebar.workflowLaunchConfig} + workflowLaunchPendingKeys={workflowLaunchPending.pendingKeys} + workflowActionReceipts={workflowReceipts} + projectBinding={projectSidebar.binding} layout="narrow" />
@@ -1023,9 +1057,12 @@ export const Dashboard: React.FC = () => { onCopyFf={handleCopyFf} onCopyApply={handleCopyApply} onLaunchWorkflow={handleLaunchWorkflow} + workflowLaunchPendingKeys={workflowLaunchPending.pendingKeys} + workflowActionReceipts={workflowReceipts} archivedItems={pendingScopeId ? [] : (data.archivedChanges ?? [])} onOpenArchivedChange={handleOpenArchivedChange} workflowLaunchConfig={workflowLaunchConfig} + projectBinding={data?.scope ? undefined : projectBinding} rootLabel={selectedRootLabel} viewState={changesViewState} onViewStateChange={persistChangesViewState} @@ -1041,19 +1078,35 @@ export const Dashboard: React.FC = () => { )} - ) : projectDiagnostic ? null : loading ? ( + ) : loading ? (
{t('dashboard.loading')}
- ) : ( -
- {t('dashboard.loadFailed')} + {t('dashboard.loadFailed')} + {projectFirst && ( + + )}
- )} + ) : null}
); diff --git a/src/webview/components/VerifyArchivePanel.tsx b/src/webview/components/VerifyArchivePanel.tsx index 0f2b2ea..c48ffb1 100644 --- a/src/webview/components/VerifyArchivePanel.tsx +++ b/src/webview/components/VerifyArchivePanel.tsx @@ -1,19 +1,23 @@ import React from 'react'; -import type { - InteractiveWorkflowAction, - InteractiveWorkflowSessionState, -} from '../../shared/interactiveWorkflow'; +import type { InteractiveWorkflowAction } from '../../shared/interactiveWorkflow'; import { t } from '../../i18n'; +import { + getVerifyArchiveDescription, + getVerifyArchiveHint, + getVerifyArchiveRunLabel, + type WorkflowCommandFormatRuntime, + type WorkflowLaunchConfigView, +} from '../utils/workflowLaunchLabels'; export interface VerifyArchivePanelProps { isArchived: boolean; canArchiveNow?: boolean; archiveNowDisabledReason?: string; - sessions: Partial>; + tasksProgressError?: string | null; + pendingAction?: InteractiveWorkflowAction | null; + workflowLaunchConfig?: WorkflowLaunchConfigView | null; + commandFormatRuntime?: WorkflowCommandFormatRuntime; onRun: (action: InteractiveWorkflowAction) => void; - onReveal: (action: InteractiveWorkflowAction) => void; - onStop: (action: InteractiveWorkflowAction) => void; - onClear: (action: InteractiveWorkflowAction) => void; onArchiveNow?: () => void; } @@ -51,59 +55,57 @@ const mutedTextStyle: React.CSSProperties = { lineHeight: 1.5, }; -/** - * Format a session start timestamp (ms since epoch) as a locale-aware time. - * Falls back to a raw string if the platform cannot format it. - */ -function formatStartTime(startedAt: number): string { - try { - return new Date(startedAt).toLocaleTimeString(); - } catch { - return new Date(startedAt).toISOString(); - } -} - export const VerifyArchivePanel: React.FC = ({ isArchived, canArchiveNow = false, archiveNowDisabledReason, - sessions, + tasksProgressError, + pendingAction, + workflowLaunchConfig, + commandFormatRuntime, onRun, - onReveal, - onStop, - onClear, onArchiveNow, }) => { - const directArchiveDisabledReason = archiveNowDisabledReason + const directArchiveDisabledReason = tasksProgressError + ?? archiveNowDisabledReason ?? (isArchived ? t('verifyArchive.archiveDisabledArchived') : t('verifyArchive.archiveDisabledIncomplete')); return (
+ {tasksProgressError && ( +
+ {tasksProgressError} +
+ )}
{t('verifyArchive.title')}
-

{t('verifyArchive.description')}

+

{getVerifyArchiveDescription(workflowLaunchConfig, commandFormatRuntime)}

@@ -146,43 +148,32 @@ export const VerifyArchivePanel: React.FC = ({ export const WorkflowActionCard: React.FC<{ action: InteractiveWorkflowAction; - session?: InteractiveWorkflowSessionState; disabled: boolean; disabledMessage?: string; + launching?: boolean; + workflowLaunchConfig?: WorkflowLaunchConfigView | null; + commandFormatRuntime?: WorkflowCommandFormatRuntime; onRun: (action: InteractiveWorkflowAction) => void; - onReveal: (action: InteractiveWorkflowAction) => void; - onStop: (action: InteractiveWorkflowAction) => void; - onClear: (action: InteractiveWorkflowAction) => void; }> = ({ action, - session, disabled, disabledMessage, + launching = false, + workflowLaunchConfig, + commandFormatRuntime, onRun, - onReveal, - onStop, - onClear, }) => { const isVerify = action === 'verify'; const title = isVerify ? t('verifyArchive.verifyTitle') : t('verifyArchive.reviewArchiveTitle'); - const runLabel = isVerify ? t('verifyArchive.runVerify') : t('verifyArchive.reviewArchive'); - - const startedAtLabel = - session?.status === 'running' && typeof session.startedAt === 'number' - ? t('verifyArchive.startedAt', { time: formatStartTime(session.startedAt) }) - : undefined; + const runLabel = getVerifyArchiveRunLabel(action, workflowLaunchConfig, { launching }); + const hint = getVerifyArchiveHint(action, workflowLaunchConfig, commandFormatRuntime); return (
{title}
-
- {session?.status === 'running' - ? session.terminalName ?? t('verifyArchive.running') - : disabledMessage ?? t('verifyArchive.ready')} -
- {startedAtLabel &&
{startedAtLabel}
} +
{disabledMessage ?? hint}
- - {session && ( -
- {session.status === 'error' && session.message && ( -
- {session.message} -
- )} - -
- - - -
- - {session.lastCommand && ( - - {session.lastCommand} - - )} -
- )}
); }; diff --git a/src/webview/hooks/useWorkflowLaunchPending.ts b/src/webview/hooks/useWorkflowLaunchPending.ts new file mode 100644 index 0000000..fe4eeae --- /dev/null +++ b/src/webview/hooks/useWorkflowLaunchPending.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createWorkflowRequestId } from '../../shared/changeWorkflow'; +import type { WorkflowActionReceipt } from '../../shared/changeWorkflow'; + +export const WORKFLOW_LAUNCH_PENDING_TIMEOUT_MS = 45_000; +export const WORKFLOW_LAUNCH_PENDING_MIN_VISIBLE_MS = 400; + +export function workflowLaunchPendingKey(changeName: string, bindingKey: string): string { + return `${changeName}\u0000${bindingKey}`; +} + +export function useWorkflowLaunchPending(onTimeout?: () => void) { + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); + const pendingRequestsRef = useRef(new Map()); + const latestRequestRef = useRef(new Map()); + const timeoutHandlesRef = useRef(new Map>()); + const pendingStartedAtRef = useRef(new Map()); + + const clearPendingKey = useCallback((key: string) => { + setPendingKeys((previous) => { + if (!previous.has(key)) return previous; + const next = new Set(previous); + next.delete(key); + return next; + }); + const handle = timeoutHandlesRef.current.get(key); + if (handle) { + clearTimeout(handle); + timeoutHandlesRef.current.delete(key); + } + }, []); + + const registerLaunch = useCallback(( + changeName: string, + bindingKey: string, + requestPrefix = 'workflow', + ) => { + const requestId = createWorkflowRequestId(requestPrefix); + const key = workflowLaunchPendingKey(changeName, bindingKey); + pendingRequestsRef.current.set(requestId, { changeName, bindingKey }); + latestRequestRef.current.set(key, requestId); + setPendingKeys((previous) => new Set(previous).add(key)); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + pendingStartedAtRef.current.set(key, Date.now()); + }); + }); + + const existing = timeoutHandlesRef.current.get(key); + if (existing) clearTimeout(existing); + timeoutHandlesRef.current.set( + key, + setTimeout(() => { + clearPendingKey(key); + onTimeout?.(); + }, WORKFLOW_LAUNCH_PENDING_TIMEOUT_MS), + ); + + return { requestId, key }; + }, [clearPendingKey, onTimeout]); + + const handleReceipt = useCallback((message: WorkflowActionReceipt) => { + const pending = pendingRequestsRef.current.get(message.requestId); + const key = workflowLaunchPendingKey(message.changeName, message.bindingKey); + if ( + !pending + || pending.changeName !== message.changeName + || pending.bindingKey !== message.bindingKey + || latestRequestRef.current.get(key) !== message.requestId + ) { + return; + } + if (message.status === 'running') return; + pendingRequestsRef.current.delete(message.requestId); + + const clearAfterMinVisible = (startedAt: number) => { + const delay = Math.max(0, WORKFLOW_LAUNCH_PENDING_MIN_VISIBLE_MS - (Date.now() - startedAt)); + if (delay > 0) { + setTimeout(() => { + clearPendingKey(key); + pendingStartedAtRef.current.delete(key); + }, delay); + } else { + clearPendingKey(key); + pendingStartedAtRef.current.delete(key); + } + }; + + const startedAt = pendingStartedAtRef.current.get(key); + if (startedAt !== undefined) { + clearAfterMinVisible(startedAt); + return; + } + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + const anchored = pendingStartedAtRef.current.get(key) ?? Date.now(); + if (!pendingStartedAtRef.current.has(key)) { + pendingStartedAtRef.current.set(key, anchored); + } + clearAfterMinVisible(anchored); + }); + }); + }, [clearPendingKey]); + + useEffect(() => () => { + for (const handle of timeoutHandlesRef.current.values()) { + clearTimeout(handle); + } + timeoutHandlesRef.current.clear(); + }, []); + + return { + pendingKeys, + registerLaunch, + handleReceipt, + isPending: (changeName: string, bindingKey?: string) => + bindingKey ? pendingKeys.has(workflowLaunchPendingKey(changeName, bindingKey)) : false, + }; +} diff --git a/src/webview/types/messages.ts b/src/webview/types/messages.ts index 74635ec..8b985d9 100644 --- a/src/webview/types/messages.ts +++ b/src/webview/types/messages.ts @@ -224,8 +224,9 @@ export type ExtensionMessage = agentAdapters: { available: { id: string; displayName: string }[]; currentId: string | null }; workflowLaunchConfig: WorkflowLaunchConfigView; uiWorkflowLaunchConfig: WorkflowLaunchConfigView; + isCursorHost?: boolean; } - | { type: 'workflowLaunchConfig'; config: WorkflowLaunchConfigView } + | { type: 'workflowLaunchConfig'; config: WorkflowLaunchConfigView; isCursorHost?: boolean } | { type: 'taskExecutionFinished'; changeName: string; taskIndex: number; success: boolean; executionState?: Record } | { type: 'taskExecutionState'; changeName: string; executionState: Record } | { type: 'runCommandResult'; success: boolean; message?: string } diff --git a/src/webview/utils/artifactFetchCoordinator.ts b/src/webview/utils/artifactFetchCoordinator.ts new file mode 100644 index 0000000..72c991e --- /dev/null +++ b/src/webview/utils/artifactFetchCoordinator.ts @@ -0,0 +1,122 @@ +export type ArtifactFetchCoordinatorOptions = { + debounceMs?: number; + isVisible?: () => boolean; +}; + +/** Coalesces artifact fetches: debounced scheduling, one in-flight request per key. */ +export class ArtifactFetchCoordinator { + private readonly debounceMs: number; + private panelVisible = true; + private readonly isDocumentVisible: () => boolean; + private readonly inFlight = new Set(); + private readonly debounceTimers = new Map>(); + private readonly pendingRuns = new Map void>(); + private readonly dirtyKeys = new Set(); + private readonly rerunAfterComplete = new Set(); + + constructor(options: ArtifactFetchCoordinatorOptions = {}) { + this.debounceMs = options.debounceMs ?? 400; + this.isDocumentVisible = options.isVisible ?? (() => + typeof document === 'undefined' || document.visibilityState !== 'hidden'); + } + + setPanelVisible(visible: boolean): void { + this.panelVisible = visible; + if (visible) { + this.flushDirtyKeys(); + } + } + + private isVisible(): boolean { + return this.panelVisible && this.isDocumentVisible(); + } + + get hasInFlight(): boolean { + return this.inFlight.size > 0; + } + + isInFlight(key: string): boolean { + return this.inFlight.has(key); + } + + isDirty(key: string): boolean { + return this.dirtyKeys.has(key); + } + + schedule(key: string, run: () => void): void { + this.pendingRuns.set(key, run); + if (!this.isVisible()) { + this.dirtyKeys.add(key); + return; + } + const existing = this.debounceTimers.get(key); + if (existing) clearTimeout(existing); + this.debounceTimers.set( + key, + setTimeout(() => { + this.debounceTimers.delete(key); + this.startRun(key); + }, this.debounceMs), + ); + } + + private startRun(key: string): void { + if (!this.isVisible()) { + this.dirtyKeys.add(key); + return; + } + if (this.inFlight.has(key)) { + this.rerunAfterComplete.add(key); + return; + } + const run = this.pendingRuns.get(key); + if (!run) return; + this.inFlight.add(key); + run(); + } + + private flushDirtyKeys(): void { + for (const key of Array.from(this.dirtyKeys)) { + this.dirtyKeys.delete(key); + const existing = this.debounceTimers.get(key); + if (existing) clearTimeout(existing); + this.debounceTimers.set( + key, + setTimeout(() => { + this.debounceTimers.delete(key); + this.startRun(key); + }, this.debounceMs), + ); + } + } + + complete(key: string): void { + this.inFlight.delete(key); + if (this.rerunAfterComplete.has(key)) { + this.rerunAfterComplete.delete(key); + this.startRun(key); + } + } + + /** Release a stuck in-flight slot (e.g. host never responded) so a retry can run. */ + forceRelease(key: string): void { + this.inFlight.delete(key); + this.rerunAfterComplete.delete(key); + const timer = this.debounceTimers.get(key); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(key); + } + } + + reset(): void { + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + this.inFlight.clear(); + this.dirtyKeys.clear(); + this.rerunAfterComplete.clear(); + this.pendingRuns.clear(); + } +} diff --git a/src/webview/utils/artifactFetchKeys.ts b/src/webview/utils/artifactFetchKeys.ts new file mode 100644 index 0000000..2dca487 --- /dev/null +++ b/src/webview/utils/artifactFetchKeys.ts @@ -0,0 +1,28 @@ +/** In-flight / debounce key: one fetch slot per scope + artifact kind (not output path). */ +export function artifactFetchCoordinatorKey( + scopeId: string | undefined, + artifactType: string, + specId?: string | null, +): string { + const scopePrefix = scopeId ? `${scopeId}::` : ''; + if (artifactType === 'specs' && specId) { + return `${scopePrefix}specs:${specId}`; + } + return `${scopePrefix}${artifactType}`; +} + +/** Content cache key; may include output path or spec id. */ +export function artifactContentCacheKey( + scopeId: string | undefined, + type: string, + pathOrSpecId?: string | null, +): string { + const scopePrefix = scopeId ? `${scopeId}::` : ''; + const suffix = + type === 'specs' && pathOrSpecId + ? `specs:${pathOrSpecId}` + : pathOrSpecId + ? `${type}:${pathOrSpecId}` + : type; + return `${scopePrefix}${suffix}`; +} diff --git a/src/webview/utils/changeBindingKey.ts b/src/webview/utils/changeBindingKey.ts new file mode 100644 index 0000000..64b7341 --- /dev/null +++ b/src/webview/utils/changeBindingKey.ts @@ -0,0 +1,21 @@ +import { getWorkflowBindingKey } from '../../shared/changeWorkflow'; +import type { ChangeInfo } from '../types/messages'; +import type { OpenSpecRootBinding } from '../types/messages'; + +/** Binding key for workflow receipts when change.workflowSnapshot is absent (Project Sidebar). */ +export function resolveChangeBindingKey( + change: ChangeInfo, + projectBinding?: OpenSpecRootBinding | null, +): string | undefined { + if (change.workflowSnapshot?.bindingKey) { + return change.workflowSnapshot.bindingKey; + } + if (!projectBinding) return undefined; + return getWorkflowBindingKey({ + projectId: projectBinding.projectId, + commandCwd: projectBinding.commandCwd, + rootPath: projectBinding.rootPath, + rootSource: projectBinding.rootSource, + ...(projectBinding.storeId ? { storeId: projectBinding.storeId } : {}), + }); +} diff --git a/src/webview/utils/changeDetailArchiveGating.ts b/src/webview/utils/changeDetailArchiveGating.ts new file mode 100644 index 0000000..11bfe20 --- /dev/null +++ b/src/webview/utils/changeDetailArchiveGating.ts @@ -0,0 +1,8 @@ +/** Archive Now stays disabled while task progress for V&A is loading or refetching. */ +export function isArchiveNowAllowed( + resolverAllowsArchiveNow: boolean, + verifyArchiveTasksLoading: boolean, + tasksProgressBlocked = false, +): boolean { + return resolverAllowsArchiveNow && !verifyArchiveTasksLoading && !tasksProgressBlocked; +} diff --git a/src/webview/utils/changeDetailArtifactInvalidation.ts b/src/webview/utils/changeDetailArtifactInvalidation.ts new file mode 100644 index 0000000..04dace0 --- /dev/null +++ b/src/webview/utils/changeDetailArtifactInvalidation.ts @@ -0,0 +1,59 @@ +import { + artifactContentCacheKey, + artifactFetchCoordinatorKey, +} from './artifactFetchKeys'; +import type { ArtifactFetchCoordinator } from './artifactFetchCoordinator'; + +export function tasksFetchCoordinatorKey(scopeId: string | undefined): string { + return artifactFetchCoordinatorKey(scopeId, 'tasks'); +} + +export function clearTasksContentCache( + contentCache: Map, + scopeId: string | undefined, +): void { + const scopePrefix = scopeId ? `${scopeId}::` : ''; + for (const key of Array.from(contentCache.keys())) { + const suffix = scopePrefix && key.startsWith(scopePrefix) + ? key.slice(scopePrefix.length) + : key; + if (suffix === 'tasks' || suffix.startsWith('tasks:')) { + contentCache.delete(key); + } + } +} + +/** Drop any stuck tasks fetch slot, clear cache, and schedule a refetch. */ +export function handleTasksArtifactInvalidated(params: { + scopeId: string | undefined; + coordinator: ArtifactFetchCoordinator; + contentCache: Map; + resetVerifyArchiveLoadedFlag: () => void; + markVerifyArchiveLoading: () => void; + scheduleTasksRefetch: () => void; +}): void { + params.resetVerifyArchiveLoadedFlag(); + params.coordinator.complete(tasksFetchCoordinatorKey(params.scopeId)); + clearTasksContentCache(params.contentCache, params.scopeId); + params.markVerifyArchiveLoading(); + params.scheduleTasksRefetch(); +} + +export function completeArtifactFetchAndStoreContent(params: { + scopeId: string | undefined; + artifactType: string; + artifactPath?: string; + content: string; + coordinator: ArtifactFetchCoordinator; + contentCache: Map; +}): string { + const fetchKey = artifactFetchCoordinatorKey(params.scopeId, params.artifactType); + params.coordinator.complete(fetchKey); + const storageKey = artifactContentCacheKey( + params.scopeId, + params.artifactType, + params.artifactPath, + ); + params.contentCache.set(storageKey, params.content); + return storageKey; +} diff --git a/src/webview/utils/changeDetailTasksFetch.ts b/src/webview/utils/changeDetailTasksFetch.ts new file mode 100644 index 0000000..e3f2382 --- /dev/null +++ b/src/webview/utils/changeDetailTasksFetch.ts @@ -0,0 +1,39 @@ +/** Only the active artifact tab should enter the main viewer loading state. */ +export function shouldShowActiveTabLoadingForArtifactFetch( + activeTab: string, + artifactType: string, +): boolean { + return activeTab === artifactType; +} + +export function isArchiveNowBlockedByTasksProgress(params: { + tasksProgressError: string | null; + tasksProgressUnknown: boolean; + verifyArchiveTasksLoading: boolean; +}): boolean { + return params.verifyArchiveTasksLoading + || params.tasksProgressUnknown + || params.tasksProgressError !== null; +} + +/** Whether a tasks artifact response should end the V&A task-progress loading state. */ +export function shouldCompleteVerifyArchiveTasksFetch(params: { + artifactType: string; + content: unknown; + cacheStale: boolean; +}): boolean { + if (params.artifactType !== 'tasks') { + return false; + } + if (params.cacheStale) { + return false; + } + return typeof params.content === 'string'; +} + +export function readArtifactCacheStale(cache: unknown): boolean { + if (!cache || typeof cache !== 'object') { + return false; + } + return (cache as { stale?: boolean }).stale === true; +} diff --git a/src/webview/utils/executorUiLaunchConfig.ts b/src/webview/utils/executorUiLaunchConfig.ts index b3cbcaf..b8dd074 100644 --- a/src/webview/utils/executorUiLaunchConfig.ts +++ b/src/webview/utils/executorUiLaunchConfig.ts @@ -134,9 +134,12 @@ export function normalizeExecutorPresentation( presentation: ExecutorLaunchPresentation, ): ExecutorLaunchPresentation { const normalizedAdapters = normalizePresentationAdapters(presentation); - return buildExecutorLaunchPresentation( - presentation.workflowLaunchConfig, - normalizedAdapters.available, - normalizedAdapters.currentId, - ); + return { + ...buildExecutorLaunchPresentation( + presentation.workflowLaunchConfig, + normalizedAdapters.available, + normalizedAdapters.currentId, + ), + isCursorHost: presentation.isCursorHost, + }; } diff --git a/src/webview/utils/workflowLaunchLabels.ts b/src/webview/utils/workflowLaunchLabels.ts index b3ff9ae..f79daf7 100644 --- a/src/webview/utils/workflowLaunchLabels.ts +++ b/src/webview/utils/workflowLaunchLabels.ts @@ -1,6 +1,27 @@ import { t } from '../../i18n'; import type { WorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; -import { isCopyOnlyWorkflowMode, shouldUseAgentWorkflowLabels } from '../../shared/workflowLaunchConfig'; +import { shouldUseAgentWorkflowLabels } from '../../shared/workflowLaunchConfig'; +import { + buildWorkflowCommand, + resolveWorkflowCommandTargetForUi, + type WorkflowAction, +} from '../../shared/workflowCommand'; + +const VERIFY_ARCHIVE_EXAMPLE_CHANGE = 'my-change'; + +export type WorkflowCommandFormatRuntime = { isCursorHost?: boolean }; + +export function getExampleWorkflowCommand( + action: WorkflowAction, + config?: WorkflowLaunchConfigView | null, + runtime?: WorkflowCommandFormatRuntime, +): string { + return buildWorkflowCommand({ + action, + changeName: VERIFY_ARCHIVE_EXAMPLE_CHANGE, + target: resolveWorkflowCommandTargetForUi(config ?? null, runtime), + }); +} export type { WorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; @@ -59,6 +80,83 @@ export function getWorkflowActionTitle( return t('workflow.title.launch', { action: actionLabel }); } +export function getVerifyArchiveRunLabel( + action: 'verify' | 'archive', + config?: WorkflowLaunchConfigView | null, + options?: { launching?: boolean }, +): string { + const actionLabel = action === 'verify' ? 'Verify' : 'Archive'; + return getWorkflowActionButtonLabel(actionLabel, config, options); +} + +function verifyArchiveDescriptionCopy( + config: WorkflowLaunchConfigView | null | undefined, + runtime?: WorkflowCommandFormatRuntime, +): string { + return t('verifyArchive.descriptionCopy', { + verifyCommand: getExampleWorkflowCommand('verify', config, runtime), + archiveCommand: getExampleWorkflowCommand('archive', config, runtime), + }); +} + +export function getVerifyArchiveDescription( + config?: WorkflowLaunchConfigView | null, + runtime?: WorkflowCommandFormatRuntime, +): string { + if (!config || !shouldUseAgentWorkflowLabels(config)) { + return verifyArchiveDescriptionCopy(config, runtime); + } + if (config.effectiveAdapterId === 'cursor') { + if (config.cursorLaunchMode === 'agentCli') { + return t('verifyArchive.descriptionAgentCli'); + } + if (config.cursorLaunchMode === 'clipboard') { + return verifyArchiveDescriptionCopy(config, runtime); + } + return t('verifyArchive.descriptionAgentPanel'); + } + return t('verifyArchive.descriptionChatAdapter', { + verifyCommand: getExampleWorkflowCommand('verify', config, runtime), + archiveCommand: getExampleWorkflowCommand('archive', config, runtime), + }); +} + +export function getVerifyArchiveHint( + action: 'verify' | 'archive', + config?: WorkflowLaunchConfigView | null, + runtime?: WorkflowCommandFormatRuntime, +): string { + const command = getExampleWorkflowCommand(action, config, runtime); + if (!config || !shouldUseAgentWorkflowLabels(config)) { + return action === 'verify' + ? t('verifyArchive.verifyCopyHint', { command }) + : t('verifyArchive.archiveCopyHint', { command }); + } + if (config.effectiveAdapterId === 'cursor') { + if (config.cursorLaunchMode === 'agentCli') { + return action === 'verify' + ? t('verifyArchive.verifyAgentCliHint', { command }) + : t('verifyArchive.archiveAgentCliHint', { command }); + } + if (config.cursorLaunchMode === 'clipboard') { + return action === 'verify' + ? t('verifyArchive.verifyCopyHint', { command }) + : t('verifyArchive.archiveCopyHint', { command }); + } + if (config.cursorLaunchMode === 'chatCommand') { + return action === 'verify' + ? t('verifyArchive.verifyChatHint', { command }) + : t('verifyArchive.archiveChatHint', { command }); + } + return action === 'verify' + ? t('verifyArchive.verifyAgentHint', { command }) + : t('verifyArchive.archiveAgentHint', { command }); + } + return action === 'verify' + ? t('verifyArchive.verifyChatHint', { command }) + : t('verifyArchive.archiveChatHint', { command }); +} + export function getWorkflowLaunchModeHint( config?: WorkflowLaunchConfigView | null, ): string | null { @@ -67,6 +165,12 @@ export function getWorkflowLaunchModeHint( return t('workflow.modeHint.copyOnly'); } if (config.effectiveAdapterId === 'cursor') { + if (config.cursorLaunchMode === 'agentCli') { + return t('workflow.modeHint.executableCursorCli'); + } + if (config.cursorLaunchMode === 'clipboard') { + return t('workflow.modeHint.copyOnly'); + } return t('workflow.modeHint.executableCursor'); } return t('workflow.modeHint.executableGeneric'); diff --git a/src/webview/utils/workflowState.ts b/src/webview/utils/workflowState.ts index 3be55c5..9a1d8bf 100644 --- a/src/webview/utils/workflowState.ts +++ b/src/webview/utils/workflowState.ts @@ -1,7 +1,10 @@ import { buildWorkflowCommand, + resolveWorkflowCommandTargetForUi, type WorkflowAction as WorkflowCommandAction, + type WorkflowCommandTarget, } from '../../shared/workflowCommand'; +import type { WorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; export type WorkflowStep = | 'proposal' @@ -64,8 +67,10 @@ export function deriveWorkflowState( completedTasks: number, totalTasks: number, isArchived: boolean, - hasDeltaSpecs: boolean + hasDeltaSpecs: boolean, + workflowLaunchConfig?: WorkflowLaunchConfigView | null, ): WorkflowState { + const copyTarget: WorkflowCommandTarget = resolveWorkflowCommandTargetForUi(workflowLaunchConfig); if (isArchived) { return { steps: ALL_STEPS.map((step) => ({ step, status: 'done' as StepStatus })), @@ -108,7 +113,7 @@ export function deriveWorkflowState( return { step, status: 'upcoming' as StepStatus }; }); - const nextAction = buildPrimaryAction(changeName, currentStep); + const nextAction = buildPrimaryAction(changeName, currentStep, copyTarget); const secondaryActions: WorkflowAction[] = []; @@ -117,7 +122,7 @@ export function deriveWorkflowState( secondaryActions.push({ label: 'FF', action: 'ff', - command: buildWorkflowCommand({ action: 'ff', changeName, target: 'clipboard' }), + command: buildWorkflowCommand({ action: 'ff', changeName, target: copyTarget }), variant: 'secondary', }); } @@ -130,7 +135,7 @@ export function deriveWorkflowState( secondaryActions.push({ label: 'Sync Specs', action: 'sync', - command: buildWorkflowCommand({ action: 'sync', changeName, target: 'clipboard' }), + command: buildWorkflowCommand({ action: 'sync', changeName, target: copyTarget }), variant: 'secondary', }); } @@ -138,7 +143,11 @@ export function deriveWorkflowState( return { steps, currentStep, nextAction, secondaryActions }; } -function buildPrimaryAction(changeName: string, currentStep: WorkflowStep): WorkflowAction | null { +function buildPrimaryAction( + changeName: string, + currentStep: WorkflowStep, + copyTarget: WorkflowCommandTarget, +): WorkflowAction | null { switch (currentStep) { case 'proposal': case 'specs': @@ -147,14 +156,14 @@ function buildPrimaryAction(changeName: string, currentStep: WorkflowStep): Work return { label: 'Continue planning', action: 'continue', - command: buildWorkflowCommand({ action: 'continue', changeName, target: 'clipboard' }), + command: buildWorkflowCommand({ action: 'continue', changeName, target: copyTarget }), variant: 'primary', }; case 'apply': return { label: 'Apply', action: 'apply', - command: buildWorkflowCommand({ action: 'apply', changeName, target: 'clipboard' }), + command: buildWorkflowCommand({ action: 'apply', changeName, target: copyTarget }), variant: 'primary', }; case 'verify': diff --git a/test/extension/adapters/cursorAdapter.test.ts b/test/extension/adapters/cursorAdapter.test.ts index 6134a05..3d5de67 100644 --- a/test/extension/adapters/cursorAdapter.test.ts +++ b/test/extension/adapters/cursorAdapter.test.ts @@ -16,9 +16,16 @@ vi.mock('child_process', () => ({ spawn: mocks.spawn, })); +const launchAgentPanelPrompt = vi.hoisted(() => vi.fn()); + +vi.mock('@extension/services/agentPanelLauncher', () => ({ + launchAgentPanelPrompt, +})); + vi.mock('vscode', () => ({ env: { appName: 'Cursor', + uriScheme: 'cursor', clipboard: { writeText: vi.fn(), }, @@ -64,9 +71,14 @@ describe('cursorAdapter fillChat launch modes', () => { beforeEach(() => { vi.clearAllMocks(); mockConfig({ - cursorLaunchMode: 'deeplink', + cursorLaunchMode: 'agentPanel', cursorAgentModel: 'auto', }); + launchAgentPanelPrompt.mockResolvedValue({ + success: true, + layer: 'composerCreateNew', + outcome: 'prefilled', + }); openExternal.mockResolvedValue(true); executeCommand.mockResolvedValue(undefined); mocks.spawn.mockReturnValue({ @@ -83,29 +95,7 @@ describe('cursorAdapter fillChat launch modes', () => { expect(await cursorAdapter.isAvailable()).toBe(true); }); - it('copies command and opens Cursor deeplink in deeplink mode', async () => { - await cursorAdapter.fillChat({ - changeName: 'demo-change', - taskIndex: -1, - taskText: '', - contextFiles: [], - workspaceRoot: '/workspace', - promptOverride: '/opsx-apply demo-change', - }); - - expect(writeText).toHaveBeenCalledWith('/opsx-apply demo-change'); - expect(openExternal).toHaveBeenCalledWith( - 'cursor://anysphere.cursor-deeplink/prompt?text=%2Fopsx-apply%20demo-change' - ); - expect(showInformationMessage).toHaveBeenCalledWith(expect.stringContaining('/opsx-apply')); - }); - - it('copies command and opens chat query in chatCommand mode', async () => { - mockConfig({ - cursorLaunchMode: 'chatCommand', - cursorAgentModel: 'auto', - }); - + it('copies command and opens the Agent panel for default panel mode', async () => { await cursorAdapter.fillChat({ changeName: 'demo-change', taskIndex: -1, @@ -116,10 +106,11 @@ describe('cursorAdapter fillChat launch modes', () => { }); expect(writeText).toHaveBeenCalledWith('/opsx-apply demo-change'); - expect(executeCommand).toHaveBeenCalledWith('workbench.action.chat.open', { - query: '/opsx-apply demo-change', - isPartialQuery: true, + expect(launchAgentPanelPrompt).toHaveBeenCalledWith({ + text: '/opsx-apply demo-change', + autoSubmit: false, }); + expect(showInformationMessage).toHaveBeenCalledWith(expect.stringContaining('/opsx-apply')); }); it('only copies command in clipboard mode', async () => { @@ -211,8 +202,12 @@ describe('cursorAdapter fillChat launch modes', () => { } }); - it('falls back to copied command notification when deeplink fails', async () => { - openExternal.mockRejectedValue(new Error('no handler')); + it('shows copied notification when panel launch falls back to clipboard', async () => { + launchAgentPanelPrompt.mockResolvedValueOnce({ + success: true, + layer: 'clipboard', + outcome: 'copied', + }); await cursorAdapter.fillChat({ changeName: 'demo-change', diff --git a/test/extension/adapters/vscodeChatAdapter.test.ts b/test/extension/adapters/vscodeChatAdapter.test.ts index 45f977c..fa9425e 100644 --- a/test/extension/adapters/vscodeChatAdapter.test.ts +++ b/test/extension/adapters/vscodeChatAdapter.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import * as vscode from 'vscode'; import { vscodeChatAdapter } from '@extension/adapters/vscode-chat-adapter'; +const launchAgentPanelPrompt = vi.hoisted(() => vi.fn()); + +vi.mock('@extension/services/agentPanelLauncher', () => ({ + launchAgentPanelPrompt, +})); + vi.mock('vscode', () => ({ env: { appName: 'Visual Studio Code', @@ -18,6 +24,11 @@ vi.mock('vscode', () => ({ describe('vscodeChatAdapter', () => { beforeEach(() => { vi.clearAllMocks(); + launchAgentPanelPrompt.mockResolvedValue({ + success: true, + layer: 'vscodeChat', + outcome: 'prefilled', + }); }); it('is available on VS Code hosts', async () => { @@ -31,10 +42,10 @@ describe('vscodeChatAdapter', () => { taskText: 'Do thing', }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'workbench.action.chat.open', - expect.objectContaining({ query: '/opsx:apply demo' }), - ); + expect(launchAgentPanelPrompt).toHaveBeenCalledWith({ + text: '/opsx:apply demo', + autoSubmit: false, + }); expect(result.adapterId).toBe('vscode-chat'); }); }); diff --git a/test/extension/integration/workflowBindingLaunch.test.ts b/test/extension/integration/workflowBindingLaunch.test.ts new file mode 100644 index 0000000..da63f1b --- /dev/null +++ b/test/extension/integration/workflowBindingLaunch.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { spawn } from 'child_process'; +import { handleWebviewMessage } from '@extension/providers/webviewMessageHandler'; +import { createProjectBoundScope } from '@extension/providers/changeDetailPanelManager'; +import { OpenSpecCliService } from '@extension/services/openspecCli'; +import { getWorkflowBindingKey } from '@/shared/changeWorkflow'; +import { launchWorkflowAgentCommand, resetWorkflowLaunchDedupeForTests } from '@extension/services/workflowAgentLaunch'; + +vi.mock('@extension/services/workflowAgentLaunch', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + launchWorkflowAgentCommand: vi.fn(actual.launchWorkflowAgentCommand), + }; +}); + +vi.mock('vscode', () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue?: unknown) => defaultValue), + })), + }, + window: { + showInformationMessage: vi.fn(() => Promise.resolve()), + showWarningMessage: vi.fn(() => Promise.resolve()), + showErrorMessage: vi.fn(() => Promise.resolve()), + createOutputChannel: () => ({ + appendLine: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + }), + }, + env: { + clipboard: { writeText: vi.fn(() => Promise.resolve()) }, + openExternal: vi.fn(() => Promise.resolve(true)), + uriScheme: 'cursor', + appName: 'Cursor', + }, + commands: { + getCommands: vi.fn(() => Promise.resolve(['composer.createNew'])), + executeCommand: vi.fn(() => Promise.resolve()), + }, +})); + +vi.mock('@extension/utils/logger', () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); + +function mockSpawnSequence(responses: string[]) { + let index = 0; + vi.mocked(spawn).mockImplementation(() => { + const stdout = responses[Math.min(index, responses.length - 1)] ?? '{}'; + index += 1; + const proc = { + stdout: { + on: (_e: string, fn: (d: Buffer) => void) => { + setImmediate(() => fn(Buffer.from(stdout))); + }, + }, + stderr: { on: vi.fn() }, + on: (_e: string, fn: (...args: unknown[]) => void) => { + if (_e === 'close') setImmediate(() => fn(0)); + }, + kill: vi.fn(), + }; + return proc as never; + }); +} + +describe('workflow binding launch (nearest rootSource)', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetWorkflowLaunchDedupeForTests(); + vi.mocked(launchWorkflowAgentCommand).mockResolvedValue({ + success: true, + command: '/opsx-verify demo-nearest', + target: 'agentPanel', + layer: 'composerCreateNew', + outcome: 'started', + }); + }); + + it('uses real snapshot bindingKey and launches when project scope carries workflowBinding', async () => { + const binding = { + projectId: '/projects/demo', + commandCwd: '/projects/demo', + rootPath: '/projects/demo/planning', + rootSource: 'nearest', + }; + const scope = createProjectBoundScope(binding, 'Demo'); + const bindingKey = getWorkflowBindingKey(binding); + const changeName = 'demo-nearest'; + + mockSpawnSequence([ + JSON.stringify({ changes: [{ name: changeName, completedTasks: 0, totalTasks: 0 }] }), + JSON.stringify({ + schema: 'spec-driven', + artifacts: [{ id: 'proposal', status: 'done', requires: [], missingDeps: [] }], + }), + ]); + + const cli = new OpenSpecCliService(binding.rootPath); + const dataManager = { + getWorkspaceRoot: () => binding.rootPath, + async getChangeWorkflowSnapshot( + name: string, + scoped?: typeof scope, + workflowBinding?: typeof binding, + ) { + return (await cli.listChanges(scoped, workflowBinding)) + .find((change) => change.name === name) + ?.workflowSnapshot; + }, + }; + + const webview = { postMessage: vi.fn() }; + await handleWebviewMessage( + { + type: 'launchWorkflowAction', + action: 'verify', + changeName, + requestId: 'req-nearest-1', + bindingKey, + }, + webview as never, + dataManager as never, + undefined, + scope, + ); + + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ + action: 'verify', + changeName, + workspaceRoot: binding.rootPath, + }); + expect(webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'workflowActionReceipt', + status: 'delivered', + bindingKey, + })); + }); + + it('recovers binding from bindingKey when scope lacks workflowBinding', async () => { + const binding = { + projectId: '/projects/demo', + commandCwd: '/projects/demo', + rootPath: '/projects/demo/planning', + rootSource: 'nearest', + }; + const bindingKey = getWorkflowBindingKey(binding); + const changeName = 'demo-parse-key'; + const scope = { + id: 'local', + label: 'Local', + rootPath: binding.rootPath, + source: 'declared', + runtimeSource: 'installed' as const, + capabilities: { + stores: false, + context: false, + doctor: false, + worksets: false, + diagnostics: [], + }, + diagnostics: [], + }; + + mockSpawnSequence([ + JSON.stringify({ changes: [{ name: changeName, completedTasks: 0, totalTasks: 0 }] }), + JSON.stringify({ + schema: 'spec-driven', + artifacts: [{ id: 'proposal', status: 'done', requires: [], missingDeps: [] }], + }), + ]); + + const cli = new OpenSpecCliService(binding.rootPath); + const dataManager = { + getWorkspaceRoot: () => binding.rootPath, + async getChangeWorkflowSnapshot( + name: string, + scoped?: typeof scope, + workflowBinding?: typeof binding, + ) { + return (await cli.listChanges(scoped, workflowBinding)) + .find((change) => change.name === name) + ?.workflowSnapshot; + }, + }; + + const webview = { postMessage: vi.fn() }; + await handleWebviewMessage( + { + type: 'launchWorkflowAction', + action: 'apply', + changeName, + requestId: 'req-parse-key', + bindingKey, + }, + webview as never, + dataManager as never, + ); + + expect(launchWorkflowAgentCommand).toHaveBeenCalled(); + }); +}); diff --git a/test/extension/packageConfiguration.test.ts b/test/extension/packageConfiguration.test.ts index 558cafd..41753a5 100644 --- a/test/extension/packageConfiguration.test.ts +++ b/test/extension/packageConfiguration.test.ts @@ -36,17 +36,22 @@ describe('OpenSpec package configuration', () => { 'Copy command only (safe): copies /opsx:* commands without launching an adapter.', 'Launch with adapter (recommended): opens Chat, Cursor, or the selected executor.', ]); + expect(properties['openspec.agentAutoSubmit']).toMatchObject({ + type: 'string', + enum: ['never', 'readOnly', 'always'], + default: 'readOnly', + }); expect(properties['openspec.cursorLaunchMode']).toMatchObject({ type: 'string', - enum: ['clipboard', 'deeplink', 'chatCommand', 'agentCli'], + enum: ['clipboard', 'agentPanel', 'deeplink', 'chatCommand', 'agentCli'], default: 'clipboard', }); - expect(properties['openspec.cursorLaunchMode'].enumDescriptions).toHaveLength(4); + expect(properties['openspec.cursorLaunchMode'].enumDescriptions).toHaveLength(5); expect(properties['openspec.cursorLaunchMode'].description).toContain('headless'); expect(properties['openspec.cursorLaunchMode'].markdownDescription).toContain('`deeplink`'); expect(properties['openspec.cursorLaunchMode'].markdownDescription).toContain('`agentCli`'); expect(properties['openspec.cursorLaunchMode'].markdownDescription).toContain('May modify workspace files'); - expect(properties['openspec.cursorLaunchMode'].enumDescriptions?.[3]).toContain('May modify workspace files'); + expect(properties['openspec.cursorLaunchMode'].enumDescriptions?.[4]).toContain('May modify workspace files'); expect(properties['openspec.cursorAgentModel']).toMatchObject({ type: 'string', default: 'auto', diff --git a/test/extension/providers/dashboardViewProvider.test.ts b/test/extension/providers/dashboardViewProvider.test.ts index 999e2ec..cb8090b 100644 --- a/test/extension/providers/dashboardViewProvider.test.ts +++ b/test/extension/providers/dashboardViewProvider.test.ts @@ -17,6 +17,7 @@ import { } from '@extension/services/openspecRootGate'; const adapterFillChat = vi.hoisted(() => vi.fn()); +const launchWorkflowAgentCommand = vi.hoisted(() => vi.fn()); // realpath is the Host's canonicalization step for Workset member paths. The // fake fixture paths do not exist on disk, so the identity implementation is @@ -33,6 +34,10 @@ vi.mock('@extension/services/openspecRootGate', () => ({ getPrimaryWorkspacePath: vi.fn(() => '/tmp/project'), })); +vi.mock('@extension/services/workflowAgentLaunch', () => ({ + launchWorkflowAgentCommand, +})); + vi.mock('@extension/adapters', () => ({ getCurrentAdapter: vi.fn(async () => ({ id: 'cursor', @@ -986,6 +991,11 @@ describe('DashboardViewProvider', () => { inspect: vi.fn(() => undefined), } as any); adapterFillChat.mockResolvedValue({ success: true, adapterId: 'cursor' }); + launchWorkflowAgentCommand.mockResolvedValue({ + success: true, + command: '/opsx-apply project-change', + target: 'agentPanel', + }); provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); await vi.runAllTimersAsync(); @@ -1007,9 +1017,11 @@ describe('DashboardViewProvider', () => { expect(createChange.mock.calls[0][1]).not.toEqual( expect.objectContaining({ rootPath: selectedStoreScope.rootPath }), ); - expect(adapterFillChat).toHaveBeenCalledWith(expect.objectContaining({ + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ + action: 'apply', + changeName: 'project-change', workspaceRoot: fixture.binding.rootPath, - })); + }); }); it('project Sidebar keeps navigation data when there are no active Changes', async () => { diff --git a/test/extension/providers/launchWorkflowAction.test.ts b/test/extension/providers/launchWorkflowAction.test.ts new file mode 100644 index 0000000..8dc2e22 --- /dev/null +++ b/test/extension/providers/launchWorkflowAction.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { processLaunchWorkflowAction } from '../../../src/extension/providers/launchWorkflowAction'; +import { getWorkflowBindingKey } from '../../../src/shared/changeWorkflow'; + +const launchWorkflowAgentCommand = vi.fn(); +const notifyWorkflowLaunchFailure = vi.fn(); + +vi.mock('../../../src/extension/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../src/extension/utils/workspaceFolders', () => ({ + isPathInWorkspaceFolders: () => true, +})); + +const getWorkflowLaunchConfigMock = vi.fn(() => ({ + workflowLaunchMode: 'adapter' as const, + preferredAgentAdapter: 'cursor' as const, + cursorLaunchMode: 'agentCli' as const, + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, +})); + +vi.mock('../../../src/extension/services/workflowLaunchConfig', () => ({ + getWorkflowLaunchConfig: () => getWorkflowLaunchConfigMock(), +})); + +vi.mock('../../../src/extension/services/workflowAgentLaunch', () => ({ + launchWorkflowAgentCommand: (...args: unknown[]) => launchWorkflowAgentCommand(...args), + notifyWorkflowLaunchFailure: (...args: unknown[]) => notifyWorkflowLaunchFailure(...args), +})); + +vi.mock('../../../src/extension/services/workflowWebviewRegistry', () => ({ + broadcastWorkflowActionReceipt: vi.fn(), +})); + +describe('processLaunchWorkflowAction', () => { + const binding = { + projectId: 'ws2', + commandCwd: '/workspace/fixtures/ws2', + rootPath: '/workspace/fixtures/ws2', + rootSource: 'declared' as const, + }; + const bindingKey = getWorkflowBindingKey(binding); + const webview = { postMessage: vi.fn() } as unknown as vscode.Webview; + + beforeEach(() => { + vi.clearAllMocks(); + launchWorkflowAgentCommand + .mockResolvedValueOnce({ + success: false, + message: 'spawn agent ENOENT', + target: 'agentCli', + layer: 'agentCli', + command: '/opsx-apply x', + }) + .mockResolvedValueOnce({ + success: true, + target: 'agentPanel', + layer: 'composerCreateNew', + outcome: 'prefilled', + command: '/opsx-apply x', + }); + }); + + it('re-enters the launch pipeline when Retry is chosen after failure', async () => { + const dataManager = { + resolveScope: () => ({ + id: 'scope-1', + label: 'ws2', + rootPath: binding.rootPath, + source: 'declared', + workflowBinding: binding, + runtimeSource: 'installed', + capabilities: { stores: false, context: false, doctor: false, worksets: false, diagnostics: [] }, + diagnostics: [], + }), + } as unknown as import('../../../src/extension/services/dataManager').DataManager; + + await processLaunchWorkflowAction({ + webview, + dataManager, + action: 'apply', + changeName: 'ws2-add-beta', + requestId: 'req-retry', + bindingKey, + }); + + expect(notifyWorkflowLaunchFailure).toHaveBeenCalledTimes(1); + expect(launchWorkflowAgentCommand).toHaveBeenCalledTimes(1); + const retry = notifyWorkflowLaunchFailure.mock.calls[0]?.[1]; + expect(retry).toBeTypeOf('function'); + await retry?.(); + expect(launchWorkflowAgentCommand).toHaveBeenCalledTimes(2); + const posts = vi.mocked(webview.postMessage).mock.calls.map((call) => call[0]); + expect(posts.some((msg) => msg.status === 'running')).toBe(true); + expect(posts.filter((msg) => msg.status === 'failed').length).toBeGreaterThanOrEqual(1); + }); + + it('posts a suppressed completed receipt when launch is deduped so pending UI clears', async () => { + launchWorkflowAgentCommand.mockReset(); + launchWorkflowAgentCommand.mockResolvedValue({ + success: true, + outcome: 'deduped', + target: 'agentCli', + command: '/opsx-apply x', + }); + const dataManager = { + resolveScope: () => ({ + id: 'scope-1', + label: 'ws2', + rootPath: binding.rootPath, + source: 'declared', + workflowBinding: binding, + runtimeSource: 'installed', + capabilities: { stores: false, context: false, doctor: false, worksets: false, diagnostics: [] }, + diagnostics: [], + }), + getChangeWorkflowSnapshot: async () => ({ bindingKey }), + } as unknown as import('../../../src/extension/services/dataManager').DataManager; + + await processLaunchWorkflowAction({ + webview, + dataManager, + action: 'apply', + changeName: 'ws2-add-beta', + requestId: 'req-dedup', + bindingKey, + }); + + const posts = vi.mocked(webview.postMessage).mock.calls.map((call) => call[0]); + expect(posts.some((msg) => msg.status === 'delivered')).toBe(false); + expect(posts.some((msg) => msg.status === 'running')).toBe(true); + const completed = posts.find((msg) => msg.status === 'completed'); + expect(completed).toBeDefined(); + expect(completed?.suppressPriorityAttention).toBe(true); + }); + + it('does not post running or status-bar launching for copy-only workflow mode', async () => { + getWorkflowLaunchConfigMock.mockImplementation(() => ({ + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: true, + preferredAgentAdapterExplicit: false, + })); + launchWorkflowAgentCommand.mockReset(); + launchWorkflowAgentCommand.mockResolvedValue({ + success: true, + outcome: 'copied', + target: 'clipboard', + command: '/opsx:apply x', + }); + const dataManager = { + resolveScope: () => ({ + id: 'scope-1', + label: 'ws2', + rootPath: binding.rootPath, + source: 'declared', + workflowBinding: binding, + runtimeSource: 'installed', + capabilities: { stores: false, context: false, doctor: false, worksets: false, diagnostics: [] }, + diagnostics: [], + }), + getChangeWorkflowSnapshot: async () => ({ bindingKey }), + } as unknown as import('../../../src/extension/services/dataManager').DataManager; + + await processLaunchWorkflowAction({ + webview, + dataManager, + action: 'apply', + changeName: 'ws2-add-beta', + requestId: 'req-copy', + bindingKey, + }); + + const posts = vi.mocked(webview.postMessage).mock.calls.map((call) => call[0]); + expect(posts.some((msg) => msg.status === 'running')).toBe(false); + expect(posts.some((msg) => msg.status === 'copied')).toBe(true); + + getWorkflowLaunchConfigMock.mockImplementation(() => ({ + workflowLaunchMode: 'adapter' as const, + preferredAgentAdapter: 'cursor' as const, + cursorLaunchMode: 'agentCli' as const, + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + })); + }); +}); diff --git a/test/extension/providers/webviewMessageHandler.test.ts b/test/extension/providers/webviewMessageHandler.test.ts index fd10437..2aa34e0 100644 --- a/test/extension/providers/webviewMessageHandler.test.ts +++ b/test/extension/providers/webviewMessageHandler.test.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { handleWebviewMessage } from '@extension/providers/webviewMessageHandler'; import { setLocale, t } from '../../../src/i18n'; +const launchWorkflowAgentCommand = vi.hoisted(() => vi.fn()); const adapterFillChat = vi.hoisted(() => vi.fn()); const cursorAdapterMock = vi.hoisted(() => ({ id: 'cursor', @@ -54,6 +55,14 @@ vi.mock('@extension/adapters', () => ({ getAdapterById: vi.fn(async (id: string) => (id === 'cursor' ? cursorAdapterMock : null)), })); +vi.mock('@extension/services/workflowAgentLaunch', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + launchWorkflowAgentCommand, + }; +}); + vi.mock('@extension/utils/logger', () => ({ logger: { debug: vi.fn(), @@ -78,6 +87,13 @@ describe('handleWebviewMessage toggleTask', () => { vi.clearAllMocks(); setLocale('en'); adapterFillChat.mockResolvedValue({ success: true, adapterId: 'cursor' }); + launchWorkflowAgentCommand.mockImplementation(async ({ action, changeName }) => ({ + success: true, + command: `/opsx:${action} ${changeName}`, + target: 'clipboard', + layer: 'clipboard', + outcome: 'copied', + })); }); it('bound detail reads Specs from the originating binding instead of the selected scope', async () => { @@ -236,7 +252,11 @@ describe('handleWebviewMessage toggleTask', () => { dataManager as any ); - expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith('/opsx:apply demo-change'); + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ + action: 'apply', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); expect(adapterFillChat).not.toHaveBeenCalled(); }); @@ -254,7 +274,11 @@ describe('handleWebviewMessage toggleTask', () => { dataManager as any ); - expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith('/opsx:archive demo-change'); + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ + action: 'archive', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); expect(adapterFillChat).not.toHaveBeenCalled(); }); @@ -279,6 +303,7 @@ describe('handleWebviewMessage toggleTask', () => { expect(vscode.env.clipboard.writeText).not.toHaveBeenCalled(); expect(adapterFillChat).not.toHaveBeenCalled(); + expect(vscode.window.showWarningMessage).toHaveBeenCalled(); expect(webview.postMessage).toHaveBeenCalledWith({ type: 'workflowActionReceipt', requestId: 'request-1', @@ -287,10 +312,45 @@ describe('handleWebviewMessage toggleTask', () => { action: 'apply', target: 'unknown', status: 'failed', - message: 'The workflow request belongs to a different Change root.', + message: expect.stringContaining(''), + suppressPriorityAttention: true, }); }); + it('marks agent launch failures as suppressPriorityAttention and surfaces an error toast', async () => { + launchWorkflowAgentCommand.mockResolvedValueOnce({ + success: false, + command: '/opsx-apply demo-change', + target: 'agentCli', + message: 'spawn agent ENOENT', + }); + const dataManager = { + getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), + getChangeWorkflowSnapshot: vi.fn().mockResolvedValue({ bindingKey: 'root-current' }), + }; + const webview = { postMessage: vi.fn() }; + + await handleWebviewMessage( + { + type: 'launchWorkflowAction', + action: 'apply', + changeName: 'demo-change', + requestId: 'request-fail', + bindingKey: 'root-current', + }, + webview as any, + dataManager as any, + ); + + expect(webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'workflowActionReceipt', + status: 'failed', + message: 'spawn agent ENOENT', + suppressPriorityAttention: true, + })); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + }); + it('reports clipboard delivery as copied rather than completed', async () => { const dataManager = { getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), @@ -485,7 +545,14 @@ describe('handleWebviewMessage toggleTask', () => { }); }); - it('routes launchWorkflowAction through Cursor adapter with hyphen command when adapter mode is selected', async () => { + it('routes launchWorkflowAction through unified agent launch when adapter mode is selected', async () => { + launchWorkflowAgentCommand.mockResolvedValueOnce({ + success: true, + command: '/opsx-apply demo-change', + target: 'agentPanel', + layer: 'composerCreateNew', + outcome: 'prefilled', + }); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn((key: string) => { if (key === 'workflowLaunchMode') return 'adapter'; @@ -507,15 +574,19 @@ describe('handleWebviewMessage toggleTask', () => { dataManager as any ); - expect(adapterFillChat).toHaveBeenCalledWith( - expect.objectContaining({ - promptOverride: '/opsx-apply demo-change', - changeName: 'demo-change', - }) - ); + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ + action: 'apply', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); }); - it('routes launchWorkflowAction through Cursor adapter when Cursor launch mode is explicitly agentCli', async () => { + it('routes launchWorkflowAction through agentCli when explicitly configured', async () => { + launchWorkflowAgentCommand.mockResolvedValueOnce({ + success: true, + command: '/opsx-apply demo-change', + target: 'agentCli', + }); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn((key: string) => { if (key === 'workflowLaunchMode') return 'adapter'; @@ -540,13 +611,11 @@ describe('handleWebviewMessage toggleTask', () => { dataManager as any ); - expect(vscode.env.clipboard.writeText).not.toHaveBeenCalledWith('/opsx:apply demo-change'); - expect(adapterFillChat).toHaveBeenCalledWith( - expect.objectContaining({ - promptOverride: '/opsx-apply demo-change', - changeName: 'demo-change', - }) - ); + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ + action: 'apply', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); }); it('copies launchWorkflowAction when executor is clipboard even with explicit cursor launch mode', async () => { @@ -576,56 +645,19 @@ describe('handleWebviewMessage toggleTask', () => { dataManager as any ); + expect(launchWorkflowAgentCommand).toHaveBeenCalled(); expect(adapterFillChat).not.toHaveBeenCalled(); - expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith('/opsx:continue demo-change'); - }); - - it('routes launchWorkflowAction through Cursor adapter when Cursor launch mode is explicitly deeplink', async () => { - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn((key: string) => { - if (key === 'workflowLaunchMode') return 'adapter'; - if (key === 'preferredAgentAdapter') return 'cursor'; - if (key === 'cursorLaunchMode') return 'deeplink'; - return false; - }), - inspect: vi.fn((key: string) => - key === 'cursorLaunchMode' ? { globalValue: 'deeplink' } : undefined - ), - } as any); - const dataManager = { - getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), - }; - const webview = { - postMessage: vi.fn(), - }; - - await handleWebviewMessage( - { type: 'launchWorkflowAction', action: 'apply', changeName: 'demo-change' }, - webview as any, - dataManager as any - ); - - expect(vscode.env.clipboard.writeText).not.toHaveBeenCalledWith('/opsx:apply demo-change'); - expect(adapterFillChat).toHaveBeenCalledWith( - expect.objectContaining({ - promptOverride: '/opsx-apply demo-change', - changeName: 'demo-change', - }) - ); }); - it('starts interactive verify workflow and posts session state', async () => { + it('starts interactive verify workflow through unified agent launch', async () => { + launchWorkflowAgentCommand.mockResolvedValueOnce({ + success: true, + command: '/opsx-verify demo-change', + target: 'agentPanel', + outcome: 'started', + }); const interactiveTerminalManager = { - start: vi.fn().mockResolvedValue({ - changeName: 'demo-change', - sessions: { - verify: { - action: 'verify', - status: 'running', - terminalName: 'OpenSpec Verify: demo-change', - }, - }, - }), + start: vi.fn(), }; const dataManager = { getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), @@ -641,7 +673,8 @@ describe('handleWebviewMessage toggleTask', () => { interactiveTerminalManager as any ); - expect(interactiveTerminalManager.start).toHaveBeenCalledWith({ + expect(interactiveTerminalManager.start).not.toHaveBeenCalled(); + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ workspaceRoot: '/workspace', changeName: 'demo-change', action: 'verify', @@ -651,33 +684,17 @@ describe('handleWebviewMessage toggleTask', () => { changeName: 'demo-change', state: { changeName: 'demo-change', - sessions: { - verify: { - action: 'verify', - status: 'running', - terminalName: 'OpenSpec Verify: demo-change', - }, - }, + sessions: {}, }, }); }); - it('reveals, stops, clears, and gets interactive workflow state', async () => { - const state = { - changeName: 'demo-change', - sessions: { - archive: { - action: 'archive', - status: 'running', - terminalName: 'OpenSpec Archive: demo-change', - }, - }, - }; + it('reveals, stops, clears, and gets interactive workflow state without terminal sessions', async () => { const interactiveTerminalManager = { - reveal: vi.fn().mockReturnValue(state), - stop: vi.fn().mockReturnValue({ changeName: 'demo-change', sessions: {} }), - clear: vi.fn().mockReturnValue({ changeName: 'demo-change', sessions: {} }), - getState: vi.fn().mockReturnValue(state), + reveal: vi.fn(), + stop: vi.fn(), + clear: vi.fn(), + getState: vi.fn(), }; const dataManager = { getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), @@ -711,14 +728,13 @@ describe('handleWebviewMessage toggleTask', () => { interactiveTerminalManager as any ); - expect(interactiveTerminalManager.reveal).toHaveBeenCalledWith('/workspace', 'demo-change', 'archive', undefined); - expect(interactiveTerminalManager.stop).toHaveBeenCalledWith('/workspace', 'demo-change', 'archive', undefined); - expect(interactiveTerminalManager.clear).toHaveBeenCalledWith('/workspace', 'demo-change', 'archive', undefined); - expect(interactiveTerminalManager.getState).toHaveBeenCalledWith('/workspace', 'demo-change', undefined); + expect(interactiveTerminalManager.reveal).not.toHaveBeenCalled(); + expect(interactiveTerminalManager.stop).not.toHaveBeenCalled(); + expect(interactiveTerminalManager.clear).not.toHaveBeenCalled(); expect(webview.postMessage).toHaveBeenNthCalledWith(4, { type: 'interactiveWorkflowState', changeName: 'demo-change', - state, + state: { changeName: 'demo-change', sessions: {} }, }); }); @@ -792,7 +808,7 @@ describe('handleWebviewMessage toggleTask', () => { }); }); - it('returns a localized error state when the interactive terminal manager is unavailable', async () => { + it('returns empty interactive workflow state when manager is unavailable', async () => { const dataManager = { getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), }; @@ -812,18 +828,12 @@ describe('handleWebviewMessage toggleTask', () => { changeName: 'demo-change', state: { changeName: 'demo-change', - sessions: { - verify: { - action: 'verify', - status: 'error', - message: t('verifyArchive.managerUnavailable'), - }, - }, + sessions: {}, }, }); }); - it('returns a localized error state when running without an interactive terminal manager', async () => { + it('runs interactive archive without requiring the terminal manager', async () => { const dataManager = { getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), }; @@ -838,18 +848,17 @@ describe('handleWebviewMessage toggleTask', () => { undefined ); + expect(launchWorkflowAgentCommand).toHaveBeenCalledWith({ + action: 'archive', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); expect(webview.postMessage).toHaveBeenCalledWith({ type: 'interactiveWorkflowState', changeName: 'demo-change', state: { changeName: 'demo-change', - sessions: { - archive: { - action: 'archive', - status: 'error', - message: t('verifyArchive.managerUnavailable'), - }, - }, + sessions: {}, }, }); }); diff --git a/test/extension/services/agentPanelLauncher.test.ts b/test/extension/services/agentPanelLauncher.test.ts new file mode 100644 index 0000000..185f374 --- /dev/null +++ b/test/extension/services/agentPanelLauncher.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + launchAgentPanelPrompt, + resetAgentPanelLauncherCommandCache, +} from '@extension/services/agentPanelLauncher'; + +vi.mock('@extension/utils/logger', () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('vscode', () => ({ + Uri: { + parse: (value: string) => ({ toString: () => value }), + }, + window: { + showWarningMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(async () => false), + clipboard: { writeText: vi.fn() }, + }, + commands: { + executeCommand: vi.fn(), + }, +})); + +describe('agentPanelLauncher', () => { + beforeEach(() => { + resetAgentPanelLauncherCommandCache(); + }); + + it('uses composer.createNew on Cursor when available and ids increase', async () => { + const executeCommand = vi.fn(async (command: string) => { + if (command === 'composer.getOrderedSelectedComposerIds') { + return executeCommand.mock.calls.filter(([c]) => c === 'composer.getOrderedSelectedComposerIds').length === 1 + ? ['a'] + : ['a', 'b']; + } + return undefined; + }); + + const result = await launchAgentPanelPrompt( + { text: '/opsx-verify demo', autoSubmit: true }, + { + isCursorHost: () => true, + getCommands: async () => new Set([ + 'composer.createNew', + 'composer.getOrderedSelectedComposerIds', + ]), + executeCommand, + sleep: async () => undefined, + }, + ); + + expect(result.layer).toBe('composerCreateNew'); + expect(result.outcome).toBe('submitted'); + expect(executeCommand).toHaveBeenCalledWith( + 'composer.createNew', + expect.objectContaining({ autoSubmit: true, view: 'pane' }), + ); + }); + + it('returns optimistically after composer.createNew without waiting for pane confirmation', async () => { + const executeCommand = vi.fn(async (command: string) => { + if (command === 'composer.getOrderedSelectedComposerIds') return ['only']; + return undefined; + }); + const sleep = vi.fn(async () => undefined); + + const started = Date.now(); + const result = await launchAgentPanelPrompt( + { text: '/opsx-apply demo', autoSubmit: false }, + { + isCursorHost: () => true, + getCommands: async () => new Set([ + 'composer.createNew', + 'composer.getOrderedSelectedComposerIds', + ]), + executeCommand, + sleep, + }, + ); + const elapsed = Date.now() - started; + + expect(result.layer).toBe('composerCreateNew'); + expect(elapsed).toBeLessThan(200); + expect(executeCommand).toHaveBeenCalledWith('composer.createNew', expect.any(Object)); + }); + + it('uses VS Code chat.open with agent mode on non-Cursor hosts', async () => { + const executeCommand = vi.fn(async () => undefined); + const result = await launchAgentPanelPrompt( + { text: '/opsx:apply demo', autoSubmit: false }, + { + isCursorHost: () => false, + getCommands: async () => new Set(['workbench.action.chat.open']), + executeCommand, + }, + ); + + expect(result.layer).toBe('vscodeChat'); + expect(executeCommand).toHaveBeenCalledWith('workbench.action.chat.open', { + query: '/opsx:apply demo', + isPartialQuery: true, + mode: 'agent', + }); + }); + + it('clipboard is the final fallback', async () => { + const writeClipboard = vi.fn(async () => undefined); + const result = await launchAgentPanelPrompt( + { text: '/opsx-verify demo', autoSubmit: false }, + { + isCursorHost: () => true, + getCommands: async () => new Set([]), + openExternal: async () => false, + writeClipboard, + }, + ); + + expect(result.layer).toBe('clipboard'); + expect(result.outcome).toBe('copied'); + expect(writeClipboard).toHaveBeenCalledWith('/opsx-verify demo'); + }); + + it('awaits composer.createNew and does not open chat when composer succeeds', async () => { + const executeCommand = vi.fn(async (command: string) => { + if (command === 'composer.getOrderedSelectedComposerIds') return ['a']; + return undefined; + }); + + const result = await launchAgentPanelPrompt( + { text: '/opsx:apply demo', autoSubmit: false }, + { + isCursorHost: () => true, + getCommands: async () => new Set([ + 'composer.createNew', + 'composer.getOrderedSelectedComposerIds', + 'workbench.action.chat.open', + ]), + executeCommand, + sleep: async () => undefined, + }, + ); + + expect(result.layer).toBe('composerCreateNew'); + expect(executeCommand).toHaveBeenCalledWith('composer.createNew', expect.any(Object)); + expect(executeCommand).not.toHaveBeenCalledWith( + 'workbench.action.chat.open', + expect.anything(), + ); + }); + + it('returns quickly when composer.createNew never resolves (autoSubmit) and does not block a second launch', async () => { + const executeCommand = vi.fn((command: string) => { + if (command === 'composer.getOrderedSelectedComposerIds') return Promise.resolve(['a']); + if (command === 'composer.createNew') { + return new Promise(() => undefined); + } + return Promise.resolve(undefined); + }); + + const deps = { + isCursorHost: () => true, + getCommands: async () => new Set([ + 'composer.createNew', + 'composer.getOrderedSelectedComposerIds', + 'workbench.action.chat.open', + ]), + executeCommand, + sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + }; + + const started = Date.now(); + const first = await launchAgentPanelPrompt( + { text: '/opsx:verify change-a', autoSubmit: true }, + deps, + ); + const firstElapsed = Date.now() - started; + + expect(first.layer).toBe('composerCreateNew'); + expect(firstElapsed).toBeLessThan(300); + expect(executeCommand).not.toHaveBeenCalledWith( + 'workbench.action.chat.open', + expect.anything(), + ); + + const secondStarted = Date.now(); + const second = await launchAgentPanelPrompt( + { text: '/opsx:verify change-b', autoSubmit: true }, + deps, + ); + const secondElapsed = Date.now() - secondStarted; + + expect(second.layer).toBe('composerCreateNew'); + expect(secondElapsed).toBeLessThan(300); + expect(executeCommand.mock.calls.filter(([c]) => c === 'composer.createNew')).toHaveLength(2); + }); + + it('falls back to chat.open when composer.createNew rejects quickly', async () => { + const executeCommand = vi.fn((command: string) => { + if (command === 'composer.getOrderedSelectedComposerIds') return Promise.resolve(['a']); + if (command === 'composer.createNew') { + return Promise.reject(new Error('createNew unavailable')); + } + return Promise.resolve(undefined); + }); + + const result = await launchAgentPanelPrompt( + { text: '/opsx:apply demo', autoSubmit: false }, + { + isCursorHost: () => true, + getCommands: async () => new Set([ + 'composer.createNew', + 'composer.getOrderedSelectedComposerIds', + 'workbench.action.chat.open', + ]), + executeCommand, + sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + }, + ); + + expect(result.layer).toBe('chatOpen'); + expect(executeCommand).toHaveBeenCalledWith('composer.createNew', expect.any(Object)); + expect(executeCommand).toHaveBeenCalledWith('workbench.action.chat.open', { query: '/opsx:apply demo' }); + }); +}); diff --git a/test/extension/services/openspecCli.test.ts b/test/extension/services/openspecCli.test.ts index c362c27..2f8d193 100644 --- a/test/extension/services/openspecCli.test.ts +++ b/test/extension/services/openspecCli.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { OpenSpecCliService } from '@extension/services/openspecCli'; +import { resetOpenSpecCliVersionProbeSessionCache } from '@extension/services/openspecCliResolver'; vi.mock('vscode', () => ({ workspace: { @@ -141,6 +142,8 @@ describe('OpenSpecCliService', () => { const workspaceRoot = '/fake/workspace'; beforeEach(() => { + OpenSpecCliService.resetVersionCacheForTests(); + resetOpenSpecCliVersionProbeSessionCache(); vi.mocked(spawn).mockReset(); }); @@ -802,6 +805,18 @@ describe('OpenSpecCliService', () => { expect(version).toBe('1.2.3'); }); + it('getVersion caches result for the service instance session', async () => { + mockSpawnSuccess('1.2.3\n'); + const service = new OpenSpecCliService(workspaceRoot); + await service.getVersion(); + expect( + (service as unknown as { cachedCliVersion?: string }).cachedCliVersion, + ).toBe('1.2.3'); + const callsAfterFirst = vi.mocked(spawn).mock.calls.length; + await service.getVersion(); + expect(vi.mocked(spawn).mock.calls.length).toBe(callsAfterFirst); + }); + it('getVersion throws when command fails', async () => { mockSpawnExit(127, 'not found'); const service = new OpenSpecCliService(workspaceRoot); @@ -896,6 +911,8 @@ describe('CLI activation diagnostics', () => { const workspaceRoot = '/fake/workspace'; beforeEach(async () => { + OpenSpecCliService.resetVersionCacheForTests(); + resetOpenSpecCliVersionProbeSessionCache(); vi.clearAllMocks(); vi.mocked(spawn).mockReset(); // Reset vscode mock to default (empty cliPath) @@ -972,19 +989,23 @@ describe('CLI activation diagnostics', () => { await service.checkAvailability(false); expect(service.getCliActivationDiagnostic()).toBeNull(); + OpenSpecCliService.resetVersionCacheForTests(); + // Now make the actual spawn fail for subsequent commands vi.mocked(spawn).mockImplementation((command: string, _args: readonly string[]) => { if (command === '/usr/local/bin/openspec') { - return createSpawnErrorProcess('spawn /usr/local/bin/openspec ENOENT') as any; + return createSpawnErrorProcess('Failed to spawn openspec: spawn /usr/local/bin/openspec ENOENT') as any; } - return createSpawnErrorProcess(`spawn ${command} ENOENT`) as any; + return createSpawnErrorProcess(`Failed to spawn openspec: spawn ${command} ENOENT`) as any; }); - await expect(service.getVersion()).rejects.toThrow(); + const failingService = new OpenSpecCliService(workspaceRoot); + failingService.getResolver().clearCache(); + await expect(failingService.getVersion()).rejects.toThrow(); - const diagnostic = service.getCliActivationDiagnostic(); - expect(diagnostic?.category).toBe('spawn-failed'); - expect(diagnostic?.normalizedMessage).toContain('enoent'); + const diagnostic = failingService.getCliActivationDiagnostic(); + expect(['spawn-failed', 'cli-not-found']).toContain(diagnostic?.category); + expect(diagnostic?.normalizedMessage?.toLowerCase()).toMatch(/enoent|could not be resolved/); }); it('stores permission-denied diagnostic for EACCES errors', async () => { @@ -1145,6 +1166,8 @@ describe('argsPrefix and scope support', () => { const workspaceRoot = '/fake/workspace'; beforeEach(() => { + OpenSpecCliService.resetVersionCacheForTests(); + resetOpenSpecCliVersionProbeSessionCache(); vi.mocked(spawn).mockReset(); }); @@ -1202,6 +1225,28 @@ describe('argsPrefix and scope support', () => { expect(spawnCalls[1].args).toEqual(['list', '--json']); }); + it('spawns --version once across several CLI refreshes (multiple runJson calls)', async () => { + resetOpenSpecCliVersionProbeSessionCache(); + OpenSpecCliService.resetVersionCacheForTests(); + const spawnCalls: Array<{ command: string; args: readonly string[] }> = []; + vi.mocked(spawn).mockImplementation((command: string, args: readonly string[], _options?: unknown) => { + spawnCalls.push({ command, args }); + if (args[0] === '--version') { + return createSpawnSuccessProcess('1.3.1') as never; + } + return createSpawnSuccessProcess(JSON.stringify({ ok: true })) as never; + }); + + const service = new OpenSpecCliService(workspaceRoot); + await service.runJson(['change', 'show', 'demo', '--json']); + await service.runJson(['change', 'show', 'demo', '--json']); + await service.runJson(['list', '--json']); + + const versionCalls = spawnCalls.filter((c) => c.args[0] === '--version'); + expect(versionCalls).toHaveLength(1); + expect(spawnCalls.length).toBe(4); + }); + it('runJson prepends argsPrefix in local source mode', async () => { // Simulate local source mode by mocking spawn to expect node + bin/openspec.js as prefix let capturedCommand = ''; @@ -1391,6 +1436,8 @@ describe('Windows shell-free spawning', () => { const workspaceRoot = '/fake/workspace'; beforeEach(() => { + OpenSpecCliService.resetVersionCacheForTests(); + resetOpenSpecCliVersionProbeSessionCache(); vi.mocked(spawn).mockReset(); }); diff --git a/test/extension/services/openspecCliResolver.test.ts b/test/extension/services/openspecCliResolver.test.ts index c1bf811..31f86e4 100644 --- a/test/extension/services/openspecCliResolver.test.ts +++ b/test/extension/services/openspecCliResolver.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { spawn } from 'child_process'; -import { OpenSpecCliResolutionError, OpenSpecCliResolver } from '@extension/services/openspecCliResolver'; +import { + OpenSpecCliResolutionError, + OpenSpecCliResolver, + resetOpenSpecCliVersionProbeSessionCache, +} from '@extension/services/openspecCliResolver'; let cliPath = ''; let cliMode = 'auto'; @@ -68,6 +72,7 @@ describe('OpenSpecCliResolver', () => { cliMode = 'auto'; localOpenSpecSourcePath = ''; calls.length = 0; + resetOpenSpecCliVersionProbeSessionCache(); vi.mocked(spawn).mockReset(); vi.unstubAllEnvs(); }); @@ -226,6 +231,24 @@ describe('OpenSpecCliResolver', () => { expect(calls.map((c) => c.command)).toEqual(['/first/openspec', '/second/openspec']); }); + + it('probes --version once per CLI path across resolver instances and repeated resolveRuntime calls', async () => { + vi.mocked(spawn).mockImplementation((command: string, args: string[]) => { + calls.push({ command, args }); + return createProcess('1.3.1') as any; + }); + + const resolverA = new OpenSpecCliResolver('/workspace'); + const resolverB = new OpenSpecCliResolver('/other-root'); + await resolverA.resolveRuntime(); + await resolverB.resolveRuntime(); + await resolverA.resolveRuntime(); + await resolverB.resolve(); + + const versionCalls = calls.filter((c) => c.args[0] === '--version'); + expect(versionCalls).toHaveLength(1); + expect(versionCalls[0].command).toBe('openspec'); + }); }); describe('OpenSpecCliResolver runtime mode', () => { @@ -236,6 +259,7 @@ describe('OpenSpecCliResolver runtime mode', () => { cliMode = 'auto'; localOpenSpecSourcePath = ''; calls.length = 0; + resetOpenSpecCliVersionProbeSessionCache(); vi.mocked(spawn).mockReset(); }); diff --git a/test/extension/services/taskExecutorService.test.ts b/test/extension/services/taskExecutorService.test.ts index 774dfaa..4f452c9 100644 --- a/test/extension/services/taskExecutorService.test.ts +++ b/test/extension/services/taskExecutorService.test.ts @@ -37,6 +37,21 @@ vi.mock('@extension/utils/logger', () => ({ }, })); +vi.mock('@extension/utils/workspaceFolders', () => ({ + isPathInWorkspaceFolders: () => true, +})); + +vi.mock('@extension/services/workflowAgentLaunch', () => ({ + buildResolvedLaunchPayload: () => ({ + command: '/opsx-apply demo-change', + target: 'cursor', + }), +})); + +vi.mock('@extension/services/workflowClipboardNotify', () => ({ + notifyWorkflowCommandCopied: vi.fn(), +})); + const getConfiguration = vi.mocked(vscode.workspace.getConfiguration); function mockConfig(values: Record) { @@ -90,7 +105,7 @@ describe('TaskExecutorService workflow command generation', () => { await service.execute('demo-change', 0, 'Task'); - expect(writeText).toHaveBeenCalledWith('/opsx:apply demo-change'); + expect(writeText).toHaveBeenCalledWith('/opsx-apply demo-change'); expect(fillChat).not.toHaveBeenCalled(); }); diff --git a/test/extension/services/workflowAgentLaunch.test.ts b/test/extension/services/workflowAgentLaunch.test.ts new file mode 100644 index 0000000..245c88c --- /dev/null +++ b/test/extension/services/workflowAgentLaunch.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { + launchWorkflowAgentCommand, + resetWorkflowLaunchDedupeForTests, +} from '@extension/services/workflowAgentLaunch'; + +const launchAgentPanelPrompt = vi.hoisted(() => vi.fn()); + +vi.mock('@extension/services/agentPanelLauncher', () => ({ + launchAgentPanelPrompt, +})); + +vi.mock('@extension/adapters', () => ({ + getCurrentAdapter: vi.fn(async () => ({ id: 'cursor', displayName: 'Cursor', fillChat: vi.fn(), executeTask: vi.fn(), isAvailable: vi.fn() })), + getAdapterById: vi.fn(), +})); + +vi.mock('vscode', () => ({ + env: { clipboard: { writeText: vi.fn() } }, + window: { showInformationMessage: vi.fn() }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((key: string) => { + if (key === 'agentAutoSubmit') return 'readOnly'; + if (key === 'workflowLaunchMode') return 'adapter'; + if (key === 'preferredAgentAdapter') return 'cursor'; + if (key === 'cursorLaunchMode') return 'agentPanel'; + if (key === 'cursorAgentModel') return 'auto'; + return undefined; + }), + inspect: vi.fn(() => undefined), + })), + }, +})); + +describe('launchWorkflowAgentCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetWorkflowLaunchDedupeForTests(); + vi.mocked(vscode.env.appName as unknown as string); + Object.defineProperty(vscode.env, 'appName', { value: 'Cursor', configurable: true }); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string) => { + if (key === 'agentAutoSubmit') return 'readOnly'; + if (key === 'workflowLaunchMode') return 'adapter'; + if (key === 'preferredAgentAdapter') return 'cursor'; + if (key === 'cursorLaunchMode') return 'agentPanel'; + if (key === 'cursorAgentModel') return 'auto'; + return undefined; + }), + inspect: vi.fn(() => undefined), + } as ReturnType); + launchAgentPanelPrompt.mockResolvedValue({ + success: true, + layer: 'composerCreateNew', + outcome: 'started', + }); + }); + + it('auto-submits read-only verify actions through composer layer', async () => { + const result = await launchWorkflowAgentCommand({ + action: 'verify', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); + + expect(launchAgentPanelPrompt).toHaveBeenCalledWith({ + text: '/opsx-verify demo-change', + autoSubmit: true, + }); + expect(result.target).toBe('agentPanel'); + }); + + it('does not auto-submit apply actions in readOnly mode', async () => { + await launchWorkflowAgentCommand({ + action: 'apply', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); + + expect(launchAgentPanelPrompt).toHaveBeenCalledWith({ + text: '/opsx-apply demo-change', + autoSubmit: false, + }); + }); + + it('does not dedupe a retry after a failed agentCli launch', async () => { + const { getAdapterById } = await import('@extension/adapters'); + const { cursorAdapter } = await import('@extension/adapters/cursor-adapter'); + const configGet = vi.fn((key: string) => { + if (key === 'cursorLaunchMode') return 'agentCli'; + if (key === 'workflowLaunchMode') return 'adapter'; + if (key === 'preferredAgentAdapter') return 'cursor'; + if (key === 'agentAutoSubmit') return 'readOnly'; + if (key === 'cursorAgentModel') return 'auto'; + return undefined; + }); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: configGet, + inspect: vi.fn(() => undefined), + } as ReturnType); + vi.mocked(getAdapterById).mockResolvedValue({ + id: 'cursor', + displayName: 'Cursor', + fillChat: vi.fn(), + executeTask: vi.fn(), + isAvailable: vi.fn(), + }); + const executeTask = vi.spyOn(cursorAdapter, 'executeTask') + .mockResolvedValueOnce({ success: false, adapterId: 'cursor', message: 'spawn agent ENOENT' }) + .mockResolvedValueOnce({ success: false, adapterId: 'cursor', message: 'spawn agent ENOENT' }); + + const request = { + action: 'apply' as const, + changeName: 'demo-change', + workspaceRoot: '/workspace', + }; + await launchWorkflowAgentCommand(request); + await launchWorkflowAgentCommand(request); + + expect(executeTask).toHaveBeenCalledTimes(2); + executeTask.mockRestore(); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string) => { + if (key === 'agentAutoSubmit') return 'readOnly'; + if (key === 'workflowLaunchMode') return 'adapter'; + if (key === 'preferredAgentAdapter') return 'cursor'; + if (key === 'cursorLaunchMode') return 'agentPanel'; + if (key === 'cursorAgentModel') return 'auto'; + return undefined; + }), + inspect: vi.fn(() => undefined), + } as ReturnType); + }); + + it('does not dedupe rapid repeat cursorLaunchMode=clipboard copies', async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string) => { + if (key === 'agentAutoSubmit') return 'readOnly'; + if (key === 'workflowLaunchMode') return 'adapter'; + if (key === 'preferredAgentAdapter') return 'cursor'; + if (key === 'cursorLaunchMode') return 'clipboard'; + if (key === 'cursorAgentModel') return 'auto'; + return undefined; + }), + inspect: vi.fn((key: string) => ( + key === 'cursorLaunchMode' ? { globalValue: 'clipboard' } : undefined + )), + } as ReturnType); + + const request = { + action: 'verify' as const, + changeName: 'demo-change', + workspaceRoot: '/workspace', + }; + await launchWorkflowAgentCommand(request); + await launchWorkflowAgentCommand(request); + + expect(vscode.env.clipboard.writeText).toHaveBeenCalledTimes(2); + expect(launchAgentPanelPrompt).not.toHaveBeenCalled(); + }); + + it('does not dedupe rapid repeat copy-only launches', async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string) => { + if (key === 'agentAutoSubmit') return 'readOnly'; + if (key === 'workflowLaunchMode') return 'clipboard'; + if (key === 'preferredAgentAdapter') return 'clipboard'; + if (key === 'cursorLaunchMode') return 'clipboard'; + if (key === 'cursorAgentModel') return 'auto'; + return undefined; + }), + inspect: vi.fn((key: string) => ( + key === 'workflowLaunchMode' || key === 'preferredAgentAdapter' + ? { globalValue: 'clipboard' } + : undefined + )), + } as ReturnType); + + const request = { + action: 'apply' as const, + changeName: 'demo-change', + workspaceRoot: '/workspace', + }; + await launchWorkflowAgentCommand(request); + await launchWorkflowAgentCommand(request); + + expect(vscode.env.clipboard.writeText).toHaveBeenCalledTimes(2); + expect(launchAgentPanelPrompt).not.toHaveBeenCalled(); + }); + + it('dedupes rapid repeat launches for the same change and action', async () => { + const request = { + action: 'verify' as const, + changeName: 'demo-change', + workspaceRoot: '/workspace', + }; + await launchWorkflowAgentCommand(request); + await launchWorkflowAgentCommand(request); + + expect(launchAgentPanelPrompt).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/extension/services/workflowLaunchConfig.test.ts b/test/extension/services/workflowLaunchConfig.test.ts index b6de44a..d3453e7 100644 --- a/test/extension/services/workflowLaunchConfig.test.ts +++ b/test/extension/services/workflowLaunchConfig.test.ts @@ -46,14 +46,14 @@ describe('workflow launch config', () => { }); }); - it('defaults Cursor hosts to launch mode with deeplink when settings are missing', () => { + it('defaults Cursor hosts to launch mode with agentPanel when settings are missing', () => { vi.mocked(vscode.env).appName = 'Cursor'; mockConfig({}); expect(getWorkflowLaunchConfig()).toEqual({ workflowLaunchMode: 'adapter', preferredAgentAdapter: 'cursor', - cursorLaunchMode: 'deeplink', + cursorLaunchMode: 'agentPanel', cursorAgentModel: 'auto', cursorLaunchModeExplicit: false, }); @@ -109,6 +109,21 @@ describe('workflow launch config', () => { }); }); + it('migrates legacy explicit deeplink on Cursor to adapter agent panel (R3)', () => { + vi.mocked(vscode.env).appName = 'Cursor'; + mockConfig({ + cursorLaunchMode: 'deeplink', + __explicitCursorLaunchMode: true, + }); + + expect(getWorkflowLaunchConfig()).toMatchObject({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, + }); + }); + it('falls back to legacy agentModel for Cursor CLI model', () => { mockConfig({ agentModel: 'legacy-model', diff --git a/test/extension/services/workflowWebviewRegistry.test.ts b/test/extension/services/workflowWebviewRegistry.test.ts new file mode 100644 index 0000000..75cdd10 --- /dev/null +++ b/test/extension/services/workflowWebviewRegistry.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + broadcastWorkflowActionReceipt, + registerWorkflowReceiptWebview, + resetWorkflowReceiptWebviewsForTests, +} from '@extension/services/workflowWebviewRegistry'; + +describe('workflowWebviewRegistry', () => { + it('broadcasts workflow receipts to every registered webview', () => { + resetWorkflowReceiptWebviewsForTests(); + const a = { postMessage: vi.fn() }; + const b = { postMessage: vi.fn() }; + registerWorkflowReceiptWebview(a as never); + registerWorkflowReceiptWebview(b as never); + + broadcastWorkflowActionReceipt({ + requestId: 'r1', + changeName: 'demo', + bindingKey: 'bk', + action: 'apply', + target: 'cursor', + status: 'delivered', + }); + + expect(a.postMessage).toHaveBeenCalledTimes(1); + expect(b.postMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/extension/utils/workspaceFolders.symlink.test.ts b/test/extension/utils/workspaceFolders.symlink.test.ts new file mode 100644 index 0000000..da88bda --- /dev/null +++ b/test/extension/utils/workspaceFolders.symlink.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, symlink } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +vi.mock('vscode', () => ({ + workspace: { + get workspaceFolders() { + return (globalThis as { __testWorkspaceFolders?: unknown[] }).__testWorkspaceFolders ?? []; + }, + }, +})); + +import { isPathInWorkspaceFolders } from '@extension/utils/workspaceFolders'; + +describe('isPathInWorkspaceFolders (symlink integration)', () => { + it('accepts binding realpath when workspace folder is a symlink', async () => { + const base = await mkdtemp(path.join(os.tmpdir(), 'openspec-wsf-')); + const realRoot = path.join(base, 'ws2'); + await mkdir(realRoot, { recursive: true }); + const linkRoot = path.join(base, 'ws2-link'); + await symlink(realRoot, linkRoot, 'dir'); + + (globalThis as { __testWorkspaceFolders?: unknown[] }).__testWorkspaceFolders = [ + { uri: { fsPath: linkRoot }, name: 'ws2-link', index: 0 }, + ]; + + expect(isPathInWorkspaceFolders(realRoot)).toBe(true); + expect(isPathInWorkspaceFolders(path.join(realRoot, 'openspec'))).toBe(true); + }); +}); diff --git a/test/extension/utils/workspacePathCompare.test.ts b/test/extension/utils/workspacePathCompare.test.ts new file mode 100644 index 0000000..1e1154b --- /dev/null +++ b/test/extension/utils/workspacePathCompare.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, symlink } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { isPathUnderWorkspaceFolder, normalizePathForComparison } from '@extension/utils/workspacePathCompare'; +describe('workspacePathCompare', () => { + it('treats symlinked workspace folder and realpath binding root as the same', async () => { + const base = await mkdtemp(path.join(os.tmpdir(), 'openspec-ws-')); + const realRoot = path.join(base, 'ws2'); + await mkdir(realRoot, { recursive: true }); + const linkRoot = path.join(base, 'ws2-link'); + await symlink(realRoot, linkRoot, 'dir'); + + const bindingRoot = normalizePathForComparison(realRoot); + expect(isPathUnderWorkspaceFolder(linkRoot, bindingRoot)).toBe(true); + expect(isPathUnderWorkspaceFolder(linkRoot, path.join(bindingRoot, 'openspec'))).toBe(true); + }); + + it('normalizes /tmp vs /private/tmp style paths on the same volume', async () => { + const base = await mkdtemp(path.join(os.tmpdir(), 'openspec-private-tmp-')); + const nested = path.join(base, 'nested'); + await mkdir(nested, { recursive: true }); + expect( + isPathUnderWorkspaceFolder(base, nested), + ).toBe(true); + expect(normalizePathForComparison(base)).toBe(normalizePathForComparison(base)); + }); +}); diff --git a/test/shared/agentAutoSubmit.test.ts b/test/shared/agentAutoSubmit.test.ts new file mode 100644 index 0000000..3b9e8b6 --- /dev/null +++ b/test/shared/agentAutoSubmit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { + isReadOnlyWorkflowAction, + shouldAutoSubmitWorkflowAction, +} from '../../src/shared/agentAutoSubmit'; + +describe('agentAutoSubmit', () => { + it('treats explore and verify as read-only', () => { + expect(isReadOnlyWorkflowAction('explore')).toBe(true); + expect(isReadOnlyWorkflowAction('verify')).toBe(true); + expect(isReadOnlyWorkflowAction('apply')).toBe(false); + expect(isReadOnlyWorkflowAction('archive')).toBe(false); + }); + + it('respects auto-submit mode', () => { + expect(shouldAutoSubmitWorkflowAction('verify', 'never')).toBe(false); + expect(shouldAutoSubmitWorkflowAction('verify', 'readOnly')).toBe(true); + expect(shouldAutoSubmitWorkflowAction('apply', 'readOnly')).toBe(false); + expect(shouldAutoSubmitWorkflowAction('apply', 'always')).toBe(true); + }); +}); diff --git a/test/shared/executorLaunchPresentation.test.ts b/test/shared/executorLaunchPresentation.test.ts index 780596b..afad2b7 100644 --- a/test/shared/executorLaunchPresentation.test.ts +++ b/test/shared/executorLaunchPresentation.test.ts @@ -36,7 +36,7 @@ describe('buildExecutorLaunchPresentation', () => { expect(presentation.uiWorkflowLaunchConfig.effectiveAdapterId).toBe('cursor'); expect(getTaskNextButtonLabel(presentation.uiWorkflowLaunchConfig)).toBe('Next with Agent'); expect(getWorkflowActionButtonLabel('Continue planning', presentation.uiWorkflowLaunchConfig)).toBe('Open Cursor · Continue planning'); - expect(getWorkflowLaunchModeHint(presentation.uiWorkflowLaunchConfig)).toBe('Runs in Cursor'); + expect(getWorkflowLaunchModeHint(presentation.uiWorkflowLaunchConfig)).toBe('Opens Agent panel'); }); it('cursor executor with both adapters available resolves to Next', () => { @@ -97,6 +97,6 @@ describe('b663c24 regression: settings cursor + runtime clipboard only', () => { expect(getWorkflowActionButtonLabel('Continue planning', presentation.uiWorkflowLaunchConfig)).toBe( 'Open Cursor · Continue planning', ); - expect(getWorkflowLaunchModeHint(presentation.uiWorkflowLaunchConfig)).toBe('Runs in Cursor'); + expect(getWorkflowLaunchModeHint(presentation.uiWorkflowLaunchConfig)).toBe('Opens Agent panel'); }); }); diff --git a/test/shared/shouldUseAgentWorkflowLabels.test.ts b/test/shared/shouldUseAgentWorkflowLabels.test.ts new file mode 100644 index 0000000..5600736 --- /dev/null +++ b/test/shared/shouldUseAgentWorkflowLabels.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { shouldUseAgentWorkflowLabels, toWorkflowLaunchConfigView } from '../../src/shared/workflowLaunchConfig'; + +describe('shouldUseAgentWorkflowLabels', () => { + it('returns false when cursorLaunchMode is explicit clipboard on Cursor adapter', () => { + const view = toWorkflowLaunchConfigView({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(shouldUseAgentWorkflowLabels({ ...view, effectiveAdapterId: 'cursor' })).toBe(false); + }); + + it('returns true for VS Code Chat when cursorLaunchMode defaults to clipboard', () => { + const view = toWorkflowLaunchConfigView({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-copilot', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(shouldUseAgentWorkflowLabels({ ...view, effectiveAdapterId: 'vscode-copilot' })).toBe(true); + }); +}); diff --git a/test/shared/workflowCommand.test.ts b/test/shared/workflowCommand.test.ts index 726366f..62cdf66 100644 --- a/test/shared/workflowCommand.test.ts +++ b/test/shared/workflowCommand.test.ts @@ -56,7 +56,7 @@ describe('buildWorkflowCommand', () => { expect(getWorkflowCommandTargetForAdapter(null)).toBe('unknown'); }); - it('builds clipboard launch payload regardless of preferred adapter in clipboard mode', () => { + it('uses hyphen Cursor commands when clipboard mode passes cursor adapter hint', () => { expect( buildWorkflowLaunchPayload({ action: 'apply', @@ -68,8 +68,8 @@ describe('buildWorkflowCommand', () => { action: 'apply', changeName: 'demo-change', workflowLaunchMode: 'clipboard', - target: 'clipboard', - command: '/opsx:apply demo-change', + target: 'cursor', + command: '/opsx-apply demo-change', }); }); diff --git a/test/shared/workflowLaunchConfig.matrix.test.ts b/test/shared/workflowLaunchConfig.matrix.test.ts new file mode 100644 index 0000000..f1b28e5 --- /dev/null +++ b/test/shared/workflowLaunchConfig.matrix.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { + getEffectiveWorkflowAdapterId, + isCopyOnlyWorkflowMode, + resolveWorkflowLaunchConfig, + toWorkflowLaunchConfigView, + type WorkflowLaunchConfigWithExplicit, +} from '../../src/shared/workflowLaunchConfig'; + +const defaults: WorkflowLaunchConfigWithExplicit = { + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, +}; + +type MatrixRow = { + name: string; + input: Partial; + host: 'cursor' | 'vscode'; + expect: { + workflowLaunchMode: 'clipboard' | 'adapter'; + preferredAgentAdapter: string; + cursorLaunchMode: string; + effectiveAdapterId: string | null; + copyOnly: boolean; + }; +}; + +const matrix: MatrixRow[] = [ + { + name: 'Cursor unset → agent panel defaults', + input: {}, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + effectiveAdapterId: 'cursor', + copyOnly: false, + }, + }, + { + name: 'VS Code unset → copy-only defaults', + input: {}, + host: 'vscode', + expect: { + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'clipboard', + effectiveAdapterId: 'clipboard', + copyOnly: true, + }, + }, + { + name: 'VS Code adapter + vscode-chat (implicit cursorLaunchMode=clipboard)', + input: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + }, + host: 'vscode', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + cursorLaunchMode: 'clipboard', + effectiveAdapterId: 'vscode-chat', + copyOnly: false, + }, + }, + { + name: 'Cursor legacy workspace deeplink only (R3)', + input: { + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + effectiveAdapterId: 'cursor', + copyOnly: false, + }, + }, + { + name: 'Cursor legacy chatCommand explicit (normalized to agentPanel on read)', + input: { + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + effectiveAdapterId: 'cursor', + copyOnly: false, + }, + }, + { + name: 'Cursor explicit cursorLaunchMode=clipboard (copy-only launch)', + input: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'clipboard', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'clipboard', + effectiveAdapterId: 'cursor', + copyOnly: true, + }, + }, + { + name: 'Cursor explicit copy-only workflowLaunchMode', + input: { + workflowLaunchModeExplicit: true, + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'agentPanel', + effectiveAdapterId: 'clipboard', + copyOnly: true, + }, + }, + { + name: 'Cursor explicit clipboard adapter overrides legacy cursor mode', + input: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'clipboard', + preferredAgentAdapterExplicit: true, + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'agentPanel', + effectiveAdapterId: 'clipboard', + copyOnly: true, + }, + }, + { + name: 'Cursor explicit agentCli', + input: { + cursorLaunchMode: 'agentCli', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentCli', + effectiveAdapterId: 'cursor', + copyOnly: false, + }, + }, + { + name: 'Cursor explicit adapter + mode (regression)', + input: { + workflowLaunchMode: 'adapter', + workflowLaunchModeExplicit: true, + preferredAgentAdapter: 'cursor', + preferredAgentAdapterExplicit: true, + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + effectiveAdapterId: 'cursor', + copyOnly: false, + }, + }, + { + name: 'Cursor explicit vscode-copilot adapter', + input: { + workflowLaunchMode: 'adapter', + workflowLaunchModeExplicit: true, + preferredAgentAdapter: 'vscode-copilot', + preferredAgentAdapterExplicit: true, + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, + }, + host: 'cursor', + expect: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-copilot', + cursorLaunchMode: 'agentPanel', + effectiveAdapterId: 'vscode-copilot', + copyOnly: false, + }, + }, +]; + +describe('workflow launch config matrix (host × settings → route)', () => { + it.each(matrix)('$name', ({ input, host, expect: expected }) => { + const resolved = resolveWorkflowLaunchConfig( + { ...defaults, ...input }, + { isCursorHost: host === 'cursor' }, + ); + const view = toWorkflowLaunchConfigView(resolved); + + expect(resolved.workflowLaunchMode).toBe(expected.workflowLaunchMode); + expect(resolved.preferredAgentAdapter).toBe(expected.preferredAgentAdapter); + expect(resolved.cursorLaunchMode).toBe(expected.cursorLaunchMode); + expect(getEffectiveWorkflowAdapterId(resolved)).toBe(expected.effectiveAdapterId); + expect(view.effectiveAdapterId).toBe(expected.effectiveAdapterId); + expect(isCopyOnlyWorkflowMode(view)).toBe(expected.copyOnly); + }); +}); diff --git a/test/shared/workflowLaunchConfig.resolve.test.ts b/test/shared/workflowLaunchConfig.resolve.test.ts index bc86517..57dd299 100644 --- a/test/shared/workflowLaunchConfig.resolve.test.ts +++ b/test/shared/workflowLaunchConfig.resolve.test.ts @@ -16,13 +16,13 @@ const baseConfig: WorkflowLaunchConfigWithExplicit = { }; describe('resolveWorkflowLaunchConfig', () => { - it('defaults Cursor hosts to launch mode with deeplink when settings are unset', () => { + it('defaults Cursor hosts to launch mode with agentPanel when settings are unset', () => { const resolved = resolveWorkflowLaunchConfig(baseConfig, { isCursorHost: true }); expect(resolved).toMatchObject({ workflowLaunchMode: 'adapter', preferredAgentAdapter: 'cursor', - cursorLaunchMode: 'deeplink', + cursorLaunchMode: 'agentPanel', }); expect(getEffectiveWorkflowAdapterId(resolved)).toBe('cursor'); }); @@ -54,7 +54,7 @@ describe('resolveWorkflowLaunchConfig', () => { it('never smart-defaults agentCli; only explicit cursorLaunchMode may select it', () => { const resolved = resolveWorkflowLaunchConfig(baseConfig, { isCursorHost: true }); - expect(resolved.cursorLaunchMode).toBe('deeplink'); + expect(resolved.cursorLaunchMode).toBe('agentPanel'); const explicitAgentCli = resolveWorkflowLaunchConfig( { @@ -68,18 +68,22 @@ describe('resolveWorkflowLaunchConfig', () => { expect(explicitAgentCli.cursorLaunchMode).toBe('agentCli'); }); - it('preserves non-clipboard cursor launch modes set in configuration reads', () => { + it('upgrades legacy explicit cursor launch modes without explicit copy-only', () => { const resolved = resolveWorkflowLaunchConfig( { ...baseConfig, - workflowLaunchMode: 'adapter', - preferredAgentAdapter: 'cursor', - cursorLaunchMode: 'chatCommand', + cursorLaunchMode: 'agentPanel', + cursorLaunchModeExplicit: true, }, { isCursorHost: true }, ); - expect(resolved.cursorLaunchMode).toBe('chatCommand'); + expect(resolved).toMatchObject({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + }); + expect(getEffectiveWorkflowAdapterId(resolved)).toBe('cursor'); }); it('treats explicit clipboard executor as copy-only even with explicit cursor launch mode', () => { @@ -106,7 +110,7 @@ describe('resolveWorkflowLaunchConfig', () => { workflowLaunchModeExplicit: true, preferredAgentAdapter: 'vscode-copilot', preferredAgentAdapterExplicit: true, - cursorLaunchMode: 'chatCommand', + cursorLaunchMode: 'agentPanel', cursorLaunchModeExplicit: true, }, { isCursorHost: true }, @@ -115,7 +119,7 @@ describe('resolveWorkflowLaunchConfig', () => { expect(resolved).toMatchObject({ workflowLaunchMode: 'adapter', preferredAgentAdapter: 'vscode-copilot', - cursorLaunchMode: 'chatCommand', + cursorLaunchMode: 'agentPanel', }); }); }); diff --git a/test/shared/workflowLaunchConfig.test.ts b/test/shared/workflowLaunchConfig.test.ts index e8adea1..5dee23b 100644 --- a/test/shared/workflowLaunchConfig.test.ts +++ b/test/shared/workflowLaunchConfig.test.ts @@ -1,10 +1,22 @@ import { describe, expect, it } from 'vitest'; import { isCopyOnlyWorkflowMode, + resolveWorkflowLaunchConfig, shouldUseAgentWorkflowLabels, toWorkflowLaunchConfigView, + type WorkflowLaunchConfigWithExplicit, } from '../../src/shared/workflowLaunchConfig'; +const packageDefaults: WorkflowLaunchConfigWithExplicit = { + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, +}; + describe('workflow launch label intent', () => { it('uses agent labels for adapter mode with non-clipboard preferred adapter', () => { const config = toWorkflowLaunchConfigView({ @@ -29,3 +41,66 @@ describe('workflow launch label intent', () => { expect(shouldUseAgentWorkflowLabels(config)).toBe(false); }); }); + +describe('copy-only vs implicit cursorLaunchMode=clipboard (regression .17)', () => { + it('VS Code adapter + vscode-chat with package-default cursorLaunchMode is not copy-only', () => { + const view = toWorkflowLaunchConfigView( + resolveWorkflowLaunchConfig( + { + ...packageDefaults, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + }, + { isCursorHost: false }, + ), + ); + expect(view.cursorLaunchMode).toBe('clipboard'); + expect(view.effectiveAdapterId).toBe('vscode-chat'); + expect(isCopyOnlyWorkflowMode(view)).toBe(false); + expect(shouldUseAgentWorkflowLabels(view)).toBe(true); + }); + + it('Cursor package defaults resolve agentPanel and are not copy-only', () => { + const view = toWorkflowLaunchConfigView( + resolveWorkflowLaunchConfig(packageDefaults, { isCursorHost: true }), + ); + expect(view.cursorLaunchMode).toBe('agentPanel'); + expect(view.effectiveAdapterId).toBe('cursor'); + expect(isCopyOnlyWorkflowMode(view)).toBe(false); + }); + + it('explicit cursorLaunchMode=clipboard on Cursor is copy-only', () => { + const view = toWorkflowLaunchConfigView( + resolveWorkflowLaunchConfig( + { + ...packageDefaults, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'clipboard', + cursorLaunchModeExplicit: true, + }, + { isCursorHost: true }, + ), + ); + expect(isCopyOnlyWorkflowMode(view)).toBe(true); + expect(shouldUseAgentWorkflowLabels(view)).toBe(false); + }); + + it('explicit cursorLaunchMode=clipboard with vscode-chat adapter is not copy-only', () => { + const view = toWorkflowLaunchConfigView( + resolveWorkflowLaunchConfig( + { + ...packageDefaults, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + preferredAgentAdapterExplicit: true, + cursorLaunchMode: 'clipboard', + cursorLaunchModeExplicit: true, + }, + { isCursorHost: false }, + ), + ); + expect(isCopyOnlyWorkflowMode(view)).toBe(false); + expect(shouldUseAgentWorkflowLabels(view)).toBe(true); + }); +}); diff --git a/test/shared/workflowLaunchCopyOnlyParity.test.ts b/test/shared/workflowLaunchCopyOnlyParity.test.ts new file mode 100644 index 0000000..bcfeb07 --- /dev/null +++ b/test/shared/workflowLaunchCopyOnlyParity.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { buildExecutorLaunchPresentation } from '../../src/shared/executorLaunchPresentation'; +import { + isCopyOnlyWorkflowMode, + isHostWorkflowLaunchCopyOnly, + resolveWorkflowLaunchConfig, + toWorkflowLaunchConfigView, + type WorkflowLaunchConfigWithExplicit, +} from '../../src/shared/workflowLaunchConfig'; + +const base: WorkflowLaunchConfigWithExplicit = { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, +}; + +type CopyOnlyCase = { + name: string; + input: Partial; + host: 'cursor' | 'vscode'; + executorId: string; +}; + +const copyOnlyCases: CopyOnlyCase[] = [ + { + name: 'workflowLaunchMode=clipboard with vscode-chat executor', + input: { + workflowLaunchMode: 'clipboard', + workflowLaunchModeExplicit: true, + preferredAgentAdapter: 'vscode-chat', + preferredAgentAdapterExplicit: true, + }, + host: 'vscode', + executorId: 'vscode-chat', + }, + { + name: 'workflowLaunchMode=clipboard with cursor executor', + input: { + workflowLaunchMode: 'clipboard', + workflowLaunchModeExplicit: true, + preferredAgentAdapter: 'cursor', + preferredAgentAdapterExplicit: true, + }, + host: 'cursor', + executorId: 'cursor', + }, + { + name: 'adapter + preferredAgentAdapter=clipboard', + input: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'clipboard', + preferredAgentAdapterExplicit: true, + }, + host: 'cursor', + executorId: 'cursor', + }, + { + name: 'cursorLaunchMode=clipboard', + input: { cursorLaunchMode: 'clipboard', cursorLaunchModeExplicit: true }, + host: 'cursor', + executorId: 'cursor', + }, +]; + +const notCopyOnlyCases: CopyOnlyCase[] = [ + { + name: 'VS Code adapter + vscode-chat with implicit cursorLaunchMode=clipboard', + input: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + }, + host: 'vscode', + executorId: 'vscode-chat', + }, + { + name: 'explicit cursorLaunchMode=clipboard with vscode-chat adapter', + input: { + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + preferredAgentAdapterExplicit: true, + cursorLaunchMode: 'clipboard', + cursorLaunchModeExplicit: true, + }, + host: 'vscode', + executorId: 'vscode-chat', + }, +]; + +describe('copy-only parity (host settings vs webview UI override)', () => { + it.each(copyOnlyCases)('$name', ({ input, host, executorId }) => { + const hostSettings = toWorkflowLaunchConfigView( + resolveWorkflowLaunchConfig({ ...base, ...input }, { isCursorHost: host === 'cursor' }), + ); + expect(isCopyOnlyWorkflowMode(hostSettings)).toBe(true); + expect(isHostWorkflowLaunchCopyOnly(hostSettings)).toBe(true); + + const presentation = buildExecutorLaunchPresentation( + hostSettings, + [ + { id: 'clipboard', displayName: 'Clipboard' }, + { id: executorId, displayName: executorId }, + ], + executorId, + ); + + expect(isHostWorkflowLaunchCopyOnly(presentation.workflowLaunchConfig)).toBe(true); + if (hostSettings.workflowLaunchMode === 'clipboard') { + expect(isCopyOnlyWorkflowMode(presentation.uiWorkflowLaunchConfig)).toBe(false); + } + }); + + it.each(notCopyOnlyCases)('not copy-only: $name', ({ input, host, executorId }) => { + const hostSettings = toWorkflowLaunchConfigView( + resolveWorkflowLaunchConfig({ ...base, ...input }, { isCursorHost: host === 'cursor' }), + ); + expect(isCopyOnlyWorkflowMode(hostSettings)).toBe(false); + expect(isHostWorkflowLaunchCopyOnly(hostSettings)).toBe(false); + + const presentation = buildExecutorLaunchPresentation( + hostSettings, + [ + { id: 'clipboard', displayName: 'Clipboard' }, + { id: executorId, displayName: executorId }, + ], + executorId, + ); + + expect(isHostWorkflowLaunchCopyOnly(presentation.workflowLaunchConfig)).toBe(false); + expect(isCopyOnlyWorkflowMode(presentation.workflowLaunchConfig)).toBe( + isCopyOnlyWorkflowMode(hostSettings), + ); + }); +}); diff --git a/test/webview/components/changeCard.test.tsx b/test/webview/components/changeCard.test.tsx index e6a4e81..f9af58a 100644 --- a/test/webview/components/changeCard.test.tsx +++ b/test/webview/components/changeCard.test.tsx @@ -389,14 +389,14 @@ describe('ChangeCard badge and action consistency', () => { }); }); -describe('Dashboard ready-to-verify Verify & Archive path', () => { - it('routes verify/archive through openChangeDetailInEditor with scopeId', () => { +describe('Dashboard ready-to-verify Verify path', () => { + it('routes verify through unified launchWorkflowAction with scopeId', () => { const source = readFileSync( path.resolve(__dirname, '../../../src/webview/components/Dashboard.tsx'), 'utf8' ); - expect(source).toContain("action === 'verify' || action === 'archive'"); - expect(source).toContain("openChangeDetailInEditor(changeName, 'verifyArchive', action, state.data?.scope?.id)"); + expect(source).toContain('launchWorkflowAction'); + expect(source).not.toContain("openChangeDetailInEditor(changeName, 'verifyArchive', action, state.data?.scope?.id)"); expect(source).toContain('getDashboardActionScopeId(projectSidebar, state.data?.scope?.id)'); }); diff --git a/test/webview/components/changeDetailRouting.test.ts b/test/webview/components/changeDetailRouting.test.ts index 4430500..d3a60fd 100644 --- a/test/webview/components/changeDetailRouting.test.ts +++ b/test/webview/components/changeDetailRouting.test.ts @@ -36,9 +36,9 @@ describe('ChangeDetail workflow routing', () => { expect(source).not.toContain('const ALL_TABS'); }); - it('routes Verify and Archive through the dedicated interactive tab', () => { + it('routes Verify and Archive through unified workflow launch', () => { expect(source).toContain("action === 'verify' || action === 'archive'"); - expect(source).toContain('setPendingInteractiveAction(action)'); + expect(source).toContain('handleLaunchWorkflow(action'); expect(source).toContain('onAction={handleResolvedAction}'); }); @@ -49,12 +49,28 @@ describe('ChangeDetail workflow routing', () => { }); it('keeps correlated receipt state local and ignores stale requests', () => { - expect(source).toContain("msg.type === 'workflowActionReceipt'"); - expect(source).toContain('msg.bindingKey === workflowSnapshot.bindingKey'); - expect(source).toContain('workflowReceipt?.requestId === msg.requestId'); + expect(source).toContain("data.type === 'workflowActionReceipt'"); + expect(source).toContain('data.bindingKey === workflowSnapshot.bindingKey'); + expect(source).toContain('workflowLaunchPending.handleReceipt'); expect(source).toContain('receiptStatus={workflowReceipt?.status}'); }); + it('restores the pre-launch workflow receipt when host dedupes a double-click launch', () => { + expect(source).toContain('workflowReceiptBeforeLaunchRef'); + expect(source).toContain('workflowReceiptBeforeLaunchRef.current = workflowReceipt'); + const suppressBlock = source.slice( + source.indexOf('data.suppressPriorityAttention === true && data.status === \'completed\''), + source.indexOf('} else {', source.indexOf('data.suppressPriorityAttention === true')), + ); + expect(suppressBlock).toContain('setWorkflowReceipt(workflowReceiptBeforeLaunchRef.current)'); + }); + + it('dedupes the initial artifact fetch when navigation snapshot re-renders the same tab', () => { + expect(source).toContain('initialArtifactFetchKeyRef'); + expect(source).toContain('initialArtifactFetchKeyRef.current === fetchIdentity'); + expect(source).toContain('initialArtifactFetchKeyRef.current = fetchIdentity'); + }); + it('routes artifact create with AI through workflow launch instead of direct command manager calls', () => { expect(source).not.toContain('sendMessage.requestCreateArtifact'); expect(source).toContain("handleLaunchWorkflow('continue')"); @@ -66,20 +82,54 @@ describe('ChangeDetail workflow routing', () => { }); it('loads bound task progress before resolving Archive Now on the Verify & Archive tab', () => { - expect(source).toContain("sendMessage.getArtifactContent(changeName, 'tasks', scopeId)"); + expect(source).toContain("requestArtifact('tasks')"); + expect(source).toContain('artifactFetchCoordinatorKey'); + expect(source).toContain('completeArtifactFetchAndStoreContent'); + expect(source).toContain('dispatchExtensionMessageRef'); + expect(source).toContain('handleTasksArtifactInvalidated'); + expect(source).toContain('isArchiveNowAllowed'); + expect(source).toContain('executorLaunchConfigKey'); + expect(source).toContain('setWorkflowReceipt(null)'); + }); + + it('sets ActionBar pending UI before registerLaunch for agent launches (not copy-only)', () => { + const start = source.indexOf('const handleLaunchWorkflow = ('); + const end = source.indexOf('}, [activeTab, pendingInteractiveAction]);', start); + const block = source.slice(start, end); + expect(block).toContain('copyOnlyWorkflowLaunch'); + expect(block.indexOf('registerLaunch(changeName, workflowSnapshot.bindingKey)')).toBeLessThan( + block.indexOf('if (!copyOnlyWorkflowLaunch)'), + ); + const agentBlock = block.slice(block.indexOf('if (!copyOnlyWorkflowLaunch)')); + expect(agentBlock).toContain('setPendingLaunchAction(action)'); + }); + + it('skips Launching pending state for copy-only workflow launches', () => { + expect(source).toContain('isHostWorkflowLaunchCopyOnly'); + expect(source).toContain('hostWorkflowLaunchConfig'); + expect(source).toContain('if (!copyOnlyWorkflowLaunch)'); + }); + + it('keeps Archive Now gated until tasks refetch completes (not cleared by fallback timer)', () => { + const timeoutBlock = source.slice( + source.indexOf('if (!verifyArchiveTasksLoading) return'), + source.indexOf('}, [verifyArchiveTasksLoading, changeName, scopeId]'), + ); + expect(timeoutBlock).toContain("requestArtifact('tasks')"); + expect(timeoutBlock).not.toContain('setVerifyArchiveTasksLoading(false)'); }); it('keeps interactive review separate from resolver-gated direct archive', () => { expect(source).toContain('canArchiveNow'); expect(source).toContain('onArchiveNow'); expect(source).toContain('sendMessage.archiveChange(changeName, scopeId)'); - expect(source).toContain('runInteractiveWorkflow(changeName, action, scopeId)'); + expect(source).toContain('handleLaunchWorkflow(action'); const interactiveHandler = source.slice( source.indexOf('const handleResolvedAction'), source.indexOf('const handleConfirmTaskToggle') ); - expect(interactiveHandler).toContain('setPendingInteractiveAction(action)'); + expect(interactiveHandler).toContain("handleLaunchWorkflow(action, 'actionBar')"); expect(interactiveHandler).not.toContain('archiveChange'); const panel = source.slice( @@ -93,7 +143,7 @@ describe('ChangeDetail workflow routing', () => { it('switches the detail view to archived read-only state after direct archive succeeds', () => { expect(source).toContain("const [archivedLocally, setArchivedLocally] = useState(false)"); - expect(source).toContain("msg.type === 'dashboardData'"); + expect(source).toContain("data.type === 'dashboardData'"); expect(source).toContain('setArchivedLocally(true)'); expect(source).toContain('const isArchived = archivedLocally || changeName.startsWith(\'archive:\')'); expect(source).not.toContain("msg.type === 'archiveCompleted'"); @@ -138,7 +188,7 @@ describe('ChangeDetail workflow routing', () => { }); it('accepts executorLaunchPresentation from setContext and standalone messages', () => { - expect(source).toContain("msg.type === 'setContext'"); - expect(source).toContain('msg.executorLaunchPresentation'); + expect(source).toContain("data.type === 'setContext'"); + expect(source).toContain('data.executorLaunchPresentation'); }); }); diff --git a/test/webview/components/changesSection.test.tsx b/test/webview/components/changesSection.test.tsx index 674e684..e924402 100644 --- a/test/webview/components/changesSection.test.tsx +++ b/test/webview/components/changesSection.test.tsx @@ -13,6 +13,7 @@ import { } from '../../../src/webview/state/changesViewState'; import { buildChangeListItems } from '../../../src/webview/types/changeList'; import { buildVisibleChangePage } from '../../../src/webview/utils/changeListPipeline'; +import { getWorkflowBindingKey } from '../../../src/shared/changeWorkflow'; const HOST_COUNTS: ChangeStatusCounts = { all: 17, @@ -489,6 +490,53 @@ describe('Task 5.5 root-scoped archived isolation', () => { }); }); +describe('Project Sidebar workflow failure tooltips', () => { + const projectBinding = { + projectId: 'ws2', + commandCwd: '/fixtures/ws2', + rootPath: '/fixtures/ws2', + rootSource: 'declared' as const, + }; + const bindingKey = getWorkflowBindingKey(projectBinding); + + it('shows failed launch reason on card buttons when change has no workflowSnapshot', () => { + const change = makeChange('apply-a', 'applying', { + completedTasks: 1, + totalTasks: 3, + workflowSnapshot: { + changeName: 'apply-a', + schema: 'custom', + bindingKey: bindingKey, + artifacts: [{ + id: 'proposal', + status: 'ready', + requires: [], + missingDeps: [], + outputPath: 'proposal.md', + existingOutputPaths: ['proposal.md'], + }], + }, + }); + const html = renderSection({ + changes: [change], + archivedItems: [], + changeStatusCounts: { ...HOST_COUNTS, all: 1, applying: 1 }, + projectBinding, + workflowActionReceipts: [{ + requestId: 'req-1', + changeName: 'apply-a', + bindingKey, + action: 'apply', + target: 'cursor', + status: 'failed', + message: 'spawn agent ENOENT', + }], + onLaunchWorkflow: vi.fn(), + }); + expect(html).toContain('spawn agent ENOENT'); + }); +}); + describe('Task 7 root-scoped view state clamp', () => { const manyPlanning = Array.from({ length: 12 }, (_, i) => makeChange(`plan-${String(i).padStart(2, '0')}`, 'planning') diff --git a/test/webview/components/dashboard.test.tsx b/test/webview/components/dashboard.test.tsx index 85db310..6d76302 100644 --- a/test/webview/components/dashboard.test.tsx +++ b/test/webview/components/dashboard.test.tsx @@ -316,7 +316,7 @@ describe('Dashboard recommended action rail', () => { expect(html).toMatch(/data-priority-row="recommended-change"[^>]*data-priority-status="[^"]+"/); expect(html).toMatch(/data-change-name="attention-change"[^>]*class="[^"]*truncate/); expect(html).toContain('Review'); - expect(html).toMatch(/Verify(?: &| &) Archive/); + expect(html).toContain('Copy Verify'); expect(html).toContain('Copy Continue planning'); expect(html).not.toContain('Archive Now'); expect(html).not.toMatch(/data-priority-cta="[^"]+"[^>]*tabindex="-1"/); diff --git a/test/webview/components/verifyArchivePanel.test.ts b/test/webview/components/verifyArchivePanel.test.ts index dd9d78c..36f0809 100644 --- a/test/webview/components/verifyArchivePanel.test.ts +++ b/test/webview/components/verifyArchivePanel.test.ts @@ -1,9 +1,17 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import { setLocale, t } from '../../../src/i18n'; +import { setLocale } from '../../../src/i18n'; import { VerifyArchivePanel } from '../../../src/webview/components/VerifyArchivePanel'; -import { sendMessage } from '../../../src/webview/types/messages'; + +const agentPanelLaunchConfig = { + workflowLaunchMode: 'adapter' as const, + preferredAgentAdapter: 'cursor' as const, + cursorLaunchMode: 'agentPanel' as const, + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + effectiveAdapterId: 'cursor' as const, +}; describe('VerifyArchivePanel', () => { it('renders run controls for verify and archive', () => { @@ -11,16 +19,13 @@ describe('VerifyArchivePanel', () => { const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { isArchived: false, - sessions: {}, + workflowLaunchConfig: agentPanelLaunchConfig, onRun: () => undefined, - onReveal: () => undefined, - onStop: () => undefined, - onClear: () => undefined, }) ); - expect(html).toContain('Run Verify'); - expect(html).toContain('Review & Archive'); + expect(html).toContain('Open Cursor · Verify'); + expect(html).toContain('Open Cursor · Archive'); expect(html).toContain('Archive Now'); }); @@ -31,11 +36,7 @@ describe('VerifyArchivePanel', () => { isArchived: false, canArchiveNow: false, archiveNowDisabledReason: 'Complete all required tasks before archiving directly.', - sessions: {}, onRun: () => undefined, - onReveal: () => undefined, - onStop: () => undefined, - onClear: () => undefined, onArchiveNow: () => undefined, }) ); @@ -52,11 +53,7 @@ describe('VerifyArchivePanel', () => { isArchived: true, canArchiveNow: false, archiveNowDisabledReason: 'Archived changes are read-only.', - sessions: {}, onRun: () => undefined, - onReveal: () => undefined, - onStop: () => undefined, - onClear: () => undefined, onArchiveNow: () => undefined, }) ); @@ -65,115 +62,31 @@ describe('VerifyArchivePanel', () => { expect(html).toContain('Archived changes are read-only.'); }); - it('renders reveal, stop, and clear controls for an existing session', () => { - const html = renderToStaticMarkup( - React.createElement(VerifyArchivePanel, { - isArchived: false, - sessions: { - verify: { - action: 'verify', - status: 'running', - terminalName: 'OpenSpec Verify: demo-change', - lastCommand: "agent --workspace '/workspace' --model 'auto' '/opsx-verify' 'demo-change'", - }, - }, - onRun: () => undefined, - onReveal: () => undefined, - onStop: () => undefined, - onClear: () => undefined, - }) - ); - - expect(html).toContain('Reveal Terminal'); - expect(html).toContain('Clear Session'); - expect(html).toContain('OpenSpec Verify: demo-change'); - }); - it('disables archive when the change is already archived', () => { const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { isArchived: true, - sessions: {}, + workflowLaunchConfig: agentPanelLaunchConfig, onRun: () => undefined, - onReveal: () => undefined, - onStop: () => undefined, - onClear: () => undefined, }) ); expect(html).toContain('disabled'); - expect(html).toContain('Review & Archive'); - }); - - it('renders the session start time for a running session (spec: start time MUST be visible)', () => { - setLocale('en'); - const startedAt = 1717300000000; // fixed epoch ms - const html = renderToStaticMarkup( - React.createElement(VerifyArchivePanel, { - isArchived: false, - sessions: { - verify: { - action: 'verify', - status: 'running', - terminalName: 'OpenSpec Verify: demo-change', - lastCommand: "agent --workspace '/workspace' --model 'auto' '/opsx-verify' 'demo-change'", - startedAt, - }, - }, - onRun: () => undefined, - onReveal: () => undefined, - onStop: () => undefined, - onClear: () => undefined, - }) - ); - - // The "Started {time}" label prefix must be present, and the formatted - // time string must appear (locale-aware, so only assert the label prefix - // and that the placeholder token was substituted). - const label = t('verifyArchive.startedAt', { time: '__TIME__' }); - expect(label.startsWith('Started ')).toBe(true); - expect(html).toContain('Started '); - // The raw placeholder must NOT leak through. - expect(html).not.toContain('{time}'); + expect(html).toContain('Open Cursor · Archive'); }); - it('does not render a start time when the session is not running', () => { + it('describes Agent panel launch hints instead of terminal session controls', () => { setLocale('en'); const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { isArchived: false, - sessions: { - verify: { - action: 'verify', - status: 'error', - message: 'Cursor Agent CLI not found.', - }, - }, onRun: () => undefined, - onReveal: () => undefined, - onStop: () => undefined, - onClear: () => undefined, }) ); - expect(html).not.toContain('Started '); - }); -}); - -describe('Ready to Verify launch path (Dashboard → Verify & Archive)', () => { - it('openChangeDetailInEditor carries verifyArchive tab, interactiveAction, and scopeId', () => { - const message = sendMessage.openChangeDetailInEditor( - 'ready-change', - 'verifyArchive', - 'verify', - 'store:team-plans' - ); - expect(message).toEqual({ - type: 'openChangeDetailInEditor', - changeName: 'ready-change', - initialTab: 'verifyArchive', - interactiveAction: 'verify', - scopeId: 'store:team-plans', - }); + expect(html).toContain('/opsx:verify my-change'); + expect(html).toContain('/opsx:archive my-change'); + expect(html).not.toContain('Reveal Terminal'); + expect(html).not.toContain('Clear Session'); }); }); diff --git a/test/webview/hooks/useWorkflowLaunchPending.test.ts b/test/webview/hooks/useWorkflowLaunchPending.test.ts new file mode 100644 index 0000000..b6f9a97 --- /dev/null +++ b/test/webview/hooks/useWorkflowLaunchPending.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'fs'; +import path from 'path'; + +const source = readFileSync( + path.resolve(__dirname, '../../../src/webview/hooks/useWorkflowLaunchPending.ts'), + 'utf8', +); + +describe('useWorkflowLaunchPending paint anchoring', () => { + it('starts the min-visible clock after two requestAnimationFrame callbacks', () => { + const registerBlock = source.slice( + source.indexOf('const registerLaunch = useCallback('), + source.indexOf('const handleReceipt = useCallback('), + ); + expect(registerBlock).toContain('window.requestAnimationFrame'); + expect(registerBlock.match(/window\.requestAnimationFrame/g)?.length).toBeGreaterThanOrEqual(2); + expect(registerBlock).not.toMatch(/pendingStartedAtRef\.current\.set\(key, Date\.now\(\)\);\s*\n\s*const existing/); + }); + + it('defers min-visible clear until paint anchor when receipt arrives early', () => { + const receiptBlock = source.slice( + source.indexOf('const handleReceipt = useCallback('), + source.indexOf('useEffect(() => () => {'), + ); + expect(receiptBlock).toContain('clearAfterMinVisible'); + expect(receiptBlock).toContain('requestAnimationFrame'); + }); +}); diff --git a/test/webview/utils/artifactFetchCoordinator.test.ts b/test/webview/utils/artifactFetchCoordinator.test.ts new file mode 100644 index 0000000..f6cca07 --- /dev/null +++ b/test/webview/utils/artifactFetchCoordinator.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArtifactFetchCoordinator } from '../../../src/webview/utils/artifactFetchCoordinator'; + +describe('ArtifactFetchCoordinator', () => { + it('does not run overlapping fetches for the same key', () => { + vi.useFakeTimers(); + const runs: string[] = []; + const coordinator = new ArtifactFetchCoordinator({ + debounceMs: 100, + isVisible: () => true, + }); + + coordinator.schedule('tasks', () => runs.push('tasks')); + vi.advanceTimersByTime(100); + expect(runs).toEqual(['tasks']); + expect(coordinator.isInFlight('tasks')).toBe(true); + + coordinator.schedule('tasks', () => runs.push('tasks-again')); + vi.advanceTimersByTime(200); + expect(runs).toEqual(['tasks']); + + coordinator.complete('tasks'); + expect(runs).toEqual(['tasks', 'tasks-again']); + coordinator.complete('tasks'); + + coordinator.schedule('tasks', () => runs.push('tasks-third')); + vi.advanceTimersByTime(100); + expect(runs).toEqual(['tasks', 'tasks-again', 'tasks-third']); + + vi.useRealTimers(); + }); + + it('marks dirty when hidden and refetches when the panel becomes visible', () => { + vi.useFakeTimers(); + let visible = false; + const runs: string[] = []; + const coordinator = new ArtifactFetchCoordinator({ + debounceMs: 50, + isVisible: () => visible, + }); + + coordinator.schedule('tasks', () => runs.push('tasks')); + expect(runs).toEqual([]); + + visible = true; + coordinator.setPanelVisible(true); + vi.advanceTimersByTime(50); + expect(runs).toEqual(['tasks']); + + vi.useRealTimers(); + }); + + it('refetches after invalidation while a fetch is in flight', () => { + vi.useFakeTimers(); + const runs: string[] = []; + const coordinator = new ArtifactFetchCoordinator({ + debounceMs: 50, + isVisible: () => true, + }); + + coordinator.schedule('tasks', () => runs.push('tasks')); + vi.advanceTimersByTime(50); + expect(runs).toEqual(['tasks']); + + coordinator.schedule('tasks', () => runs.push('tasks-again')); + vi.advanceTimersByTime(50); + expect(runs).toEqual(['tasks']); + + coordinator.complete('tasks'); + expect(runs).toEqual(['tasks', 'tasks-again']); + + vi.useRealTimers(); + }); + + it('forceRelease clears in-flight so a fallback retry can schedule', () => { + vi.useFakeTimers(); + const fetchKey = 'scope::tasks'; + const runs: string[] = []; + const coordinator = new ArtifactFetchCoordinator({ debounceMs: 10, isVisible: () => true }); + + coordinator.schedule(fetchKey, () => runs.push('initial')); + vi.advanceTimersByTime(10); + expect(coordinator.isInFlight(fetchKey)).toBe(true); + + coordinator.forceRelease(fetchKey); + expect(coordinator.isInFlight(fetchKey)).toBe(false); + + coordinator.schedule(fetchKey, () => runs.push('retry')); + vi.advanceTimersByTime(10); + expect(runs).toEqual(['initial', 'retry']); + + vi.useRealTimers(); + }); +}); diff --git a/test/webview/utils/artifactFetchKeys.test.ts b/test/webview/utils/artifactFetchKeys.test.ts new file mode 100644 index 0000000..bc1b9c1 --- /dev/null +++ b/test/webview/utils/artifactFetchKeys.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + artifactContentCacheKey, + artifactFetchCoordinatorKey, +} from '../../../src/webview/utils/artifactFetchKeys'; +import { ArtifactFetchCoordinator } from '../../../src/webview/utils/artifactFetchCoordinator'; + +describe('artifactFetchKeys', () => { + it('uses a stable coordinator key for tasks regardless of output path', () => { + expect(artifactFetchCoordinatorKey('scope-1', 'tasks')).toBe('scope-1::tasks'); + expect(artifactFetchCoordinatorKey('scope-1', 'tasks')).toBe( + artifactFetchCoordinatorKey('scope-1', 'tasks', 'ignored'), + ); + expect(artifactContentCacheKey('scope-1', 'tasks')).toBe('scope-1::tasks'); + expect(artifactContentCacheKey('scope-1', 'tasks', 'openspec/changes/x/tasks.md')).toBe( + 'scope-1::tasks:openspec/changes/x/tasks.md', + ); + }); +}); + +describe('ArtifactFetchCoordinator with stable fetch keys', () => { + it('clears in-flight when complete uses coordinator key after response path differs', () => { + vi.useFakeTimers(); + const scopeId = 'scope-1'; + const fetchKey = artifactFetchCoordinatorKey(scopeId, 'tasks'); + const responsePath = 'openspec/changes/demo/tasks.md'; + const runs: string[] = []; + const coordinator = new ArtifactFetchCoordinator({ debounceMs: 1, isVisible: () => true }); + + coordinator.schedule(fetchKey, () => runs.push('initial')); + vi.advanceTimersByTime(1); + expect(runs).toEqual(['initial']); + expect(coordinator.isInFlight(fetchKey)).toBe(true); + + const storageKey = artifactContentCacheKey(scopeId, 'tasks', responsePath); + expect(storageKey).not.toBe(fetchKey); + coordinator.complete(fetchKey); + expect(coordinator.isInFlight(fetchKey)).toBe(false); + + coordinator.schedule(fetchKey, () => runs.push('after-invalidation')); + vi.advanceTimersByTime(1); + expect(runs).toEqual(['initial', 'after-invalidation']); + vi.useRealTimers(); + }); + + it('refetches immediately on invalidation after first fetch completes', () => { + vi.useFakeTimers(); + const fetchKey = artifactFetchCoordinatorKey('ws', 'tasks'); + const runs: string[] = []; + const coordinator = new ArtifactFetchCoordinator({ + debounceMs: 50, + isVisible: () => true, + }); + + coordinator.schedule(fetchKey, () => runs.push('open-va')); + vi.advanceTimersByTime(50); + expect(runs).toEqual(['open-va']); + coordinator.complete(fetchKey); + + coordinator.schedule(fetchKey, () => runs.push('invalidated')); + vi.advanceTimersByTime(50); + expect(runs).toEqual(['open-va', 'invalidated']); + + vi.useRealTimers(); + }); +}); diff --git a/test/webview/utils/changeBindingKey.test.ts b/test/webview/utils/changeBindingKey.test.ts new file mode 100644 index 0000000..d32dc60 --- /dev/null +++ b/test/webview/utils/changeBindingKey.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { getWorkflowBindingKey } from '../../../src/shared/changeWorkflow'; +import { resolveChangeBindingKey } from '../../../src/webview/utils/changeBindingKey'; +import type { ChangeInfo } from '../../../src/webview/types/messages'; + +describe('resolveChangeBindingKey', () => { + const projectBinding = { + projectId: 'ws2', + commandCwd: '/fixtures/ws2', + rootPath: '/fixtures/ws2', + rootSource: 'declared' as const, + }; + + it('prefers workflowSnapshot.bindingKey when present', () => { + const change = { + name: 'gamma', + workflowSnapshot: { bindingKey: 'snapshot-key' }, + } as ChangeInfo; + expect(resolveChangeBindingKey(change, projectBinding)).toBe('snapshot-key'); + }); + + it('derives binding key from projectBinding when snapshot is absent', () => { + const change = { name: 'gamma' } as ChangeInfo; + expect(resolveChangeBindingKey(change, projectBinding)).toBe(getWorkflowBindingKey(projectBinding)); + }); +}); diff --git a/test/webview/utils/changeDetailArchiveGating.test.ts b/test/webview/utils/changeDetailArchiveGating.test.ts new file mode 100644 index 0000000..a4eb444 --- /dev/null +++ b/test/webview/utils/changeDetailArchiveGating.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isArchiveNowAllowed } from '../../../src/webview/utils/changeDetailArchiveGating'; + +describe('changeDetailArchiveGating', () => { + it('never enables Archive Now while task progress refetch is pending', () => { + expect(isArchiveNowAllowed(true, true, false)).toBe(false); + expect(isArchiveNowAllowed(true, false, false)).toBe(true); + expect(isArchiveNowAllowed(false, false, false)).toBe(false); + expect(isArchiveNowAllowed(true, false, true)).toBe(false); + }); + + it('blocks Archive Now when resolver would allow but counts are stale during loading', () => { + const staleShowsComplete = { completedTasks: 3, totalTasks: 3 }; + const resolverWouldAllow = staleShowsComplete.completedTasks >= staleShowsComplete.totalTasks; + expect(resolverWouldAllow).toBe(true); + expect(isArchiveNowAllowed(resolverWouldAllow, true, false)).toBe(false); + }); +}); diff --git a/test/webview/utils/changeDetailArtifactInvalidation.test.ts b/test/webview/utils/changeDetailArtifactInvalidation.test.ts new file mode 100644 index 0000000..c595377 --- /dev/null +++ b/test/webview/utils/changeDetailArtifactInvalidation.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArtifactFetchCoordinator } from '../../../src/webview/utils/artifactFetchCoordinator'; +import { + completeArtifactFetchAndStoreContent, + handleTasksArtifactInvalidated, + tasksFetchCoordinatorKey, +} from '../../../src/webview/utils/changeDetailArtifactInvalidation'; +import { artifactContentCacheKey } from '../../../src/webview/utils/artifactFetchKeys'; + +describe('changeDetailArtifactInvalidation', () => { + it('refetches tasks three times in a row on V&A without clearing a stuck in-flight slot', () => { + vi.useFakeTimers(); + const scopeId = 'scope-ws2'; + const fetchKey = tasksFetchCoordinatorKey(scopeId); + const coordinator = new ArtifactFetchCoordinator({ debounceMs: 50, isVisible: () => true }); + const contentCache = new Map(); + const refetches: number[] = []; + let verifyArchiveLoadingCount = 0; + + const scheduleTasksRefetch = () => { + coordinator.schedule(fetchKey, () => { + refetches.push(refetches.length + 1); + }); + }; + + coordinator.schedule(fetchKey, () => refetches.push(0)); + vi.advanceTimersByTime(50); + expect(refetches).toEqual([0]); + expect(coordinator.isInFlight(fetchKey)).toBe(true); + + const path = 'openspec/changes/demo/tasks.md'; + completeArtifactFetchAndStoreContent({ + scopeId, + artifactType: 'tasks', + artifactPath: path, + content: '- [x] a\n- [ ] b', + coordinator, + contentCache, + }); + expect(coordinator.isInFlight(fetchKey)).toBe(false); + expect(contentCache.get(artifactContentCacheKey(scopeId, 'tasks', path))).toBeDefined(); + + for (let edit = 1; edit <= 3; edit += 1) { + handleTasksArtifactInvalidated({ + scopeId, + coordinator, + contentCache, + resetVerifyArchiveLoadedFlag: () => undefined, + markVerifyArchiveLoading: () => { + verifyArchiveLoadingCount += 1; + }, + scheduleTasksRefetch, + }); + vi.advanceTimersByTime(50); + expect(refetches).toHaveLength(edit + 1); + expect(coordinator.isInFlight(fetchKey)).toBe(true); + completeArtifactFetchAndStoreContent({ + scopeId, + artifactType: 'tasks', + artifactPath: path, + content: `- [x] edit-${edit}`, + coordinator, + contentCache, + }); + expect(coordinator.isInFlight(fetchKey)).toBe(false); + } + + expect(verifyArchiveLoadingCount).toBe(3); + vi.useRealTimers(); + }); + + it('recovers when complete used fetch key but an old path key left in-flight', () => { + vi.useFakeTimers(); + const scopeId = 'scope-1'; + const fetchKey = tasksFetchCoordinatorKey(scopeId); + const pathKey = artifactContentCacheKey(scopeId, 'tasks', 'tasks.md'); + const coordinator = new ArtifactFetchCoordinator({ debounceMs: 10, isVisible: () => true }); + const runs: string[] = []; + + coordinator.schedule(fetchKey, () => runs.push('initial')); + vi.advanceTimersByTime(10); + coordinator.complete(pathKey); + expect(coordinator.isInFlight(fetchKey)).toBe(true); + + handleTasksArtifactInvalidated({ + scopeId, + coordinator, + contentCache: new Map(), + resetVerifyArchiveLoadedFlag: () => undefined, + markVerifyArchiveLoading: () => undefined, + scheduleTasksRefetch: () => { + coordinator.schedule(fetchKey, () => runs.push('after-invalidate')); + }, + }); + vi.advanceTimersByTime(10); + expect(runs).toEqual(['initial', 'after-invalidate']); + + vi.useRealTimers(); + }); +}); diff --git a/test/webview/utils/changeDetailTasksFetch.test.ts b/test/webview/utils/changeDetailTasksFetch.test.ts new file mode 100644 index 0000000..af74adb --- /dev/null +++ b/test/webview/utils/changeDetailTasksFetch.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { isArchiveNowAllowed } from '../../../src/webview/utils/changeDetailArchiveGating'; +import { + isArchiveNowBlockedByTasksProgress, + readArtifactCacheStale, + shouldCompleteVerifyArchiveTasksFetch, + shouldShowActiveTabLoadingForArtifactFetch, +} from '../../../src/webview/utils/changeDetailTasksFetch'; + +describe('changeDetailTasksFetch', () => { + it('completes task progress loading for empty tasks.md content', () => { + expect(shouldCompleteVerifyArchiveTasksFetch({ + artifactType: 'tasks', + content: '', + cacheStale: false, + })).toBe(true); + }); + + it('does not complete while cache metadata is stale', () => { + expect(shouldCompleteVerifyArchiveTasksFetch({ + artifactType: 'tasks', + content: '- [x] done', + cacheStale: true, + })).toBe(false); + expect(readArtifactCacheStale({ stale: true, source: 'memory' })).toBe(true); + }); + + it('does not put the proposal tab into loading when only tasks refetch', () => { + expect(shouldShowActiveTabLoadingForArtifactFetch('proposal', 'tasks')).toBe(false); + expect(shouldShowActiveTabLoadingForArtifactFetch('tasks', 'tasks')).toBe(true); + }); + + it('blocks Archive Now when tasks progress failed to load', () => { + expect(isArchiveNowBlockedByTasksProgress({ + tasksProgressError: 'EACCES: permission denied', + tasksProgressUnknown: true, + verifyArchiveTasksLoading: false, + })).toBe(true); + expect(isArchiveNowAllowed(true, false, true)).toBe(false); + expect(isArchiveNowAllowed(true, false, false)).toBe(true); + }); +}); diff --git a/test/webview/utils/executorUiLaunchConfig.test.ts b/test/webview/utils/executorUiLaunchConfig.test.ts index 30cbe88..b3cc33c 100644 --- a/test/webview/utils/executorUiLaunchConfig.test.ts +++ b/test/webview/utils/executorUiLaunchConfig.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { setLocale } from '../../../src/i18n'; import type { WorkflowLaunchConfigView } from '../../../src/shared/workflowLaunchConfig'; +import { buildExecutorLaunchPresentation } from '../../../src/shared/executorLaunchPresentation'; import { getExecutorUiModeLabel, + normalizeExecutorPresentation, resolveExecutorSelectValue, resolveExecutorUiLaunchConfig, shouldPersistNormalizedExecutor, @@ -10,6 +12,7 @@ import { WEBVIEW_EXECUTOR_NORMALIZE_MARKER, WEBVIEW_EXECUTOR_UI_BUILD_MARKER, } from '../../../src/webview/utils/executorUiLaunchConfig'; +import { getExampleWorkflowCommand } from '../../../src/webview/utils/workflowLaunchLabels'; import { getTaskNextButtonLabel } from '../../../src/webview/utils/taskNextButtonLabels'; import { getWorkflowActionButtonLabel, getWorkflowLaunchModeHint } from '../../../src/webview/utils/workflowLaunchLabels'; @@ -104,7 +107,7 @@ describe('executorUiLaunchConfig', () => { }); expect(getExecutorUiModeLabel(uiConfig)).toBe('cursor'); - expect(getWorkflowLaunchModeHint(uiConfig)).toBe('Runs in Cursor'); + expect(getWorkflowLaunchModeHint(uiConfig)).toBe('Opens Agent panel'); }); it('resolves select value to clipboard when currentId is illegal', () => { @@ -113,4 +116,46 @@ describe('executorUiLaunchConfig', () => { expect(shouldPersistNormalizedExecutor('clipboard', { ...clipboardOnlyAdapters, currentId: 'clipboard' })).toBeNull(); }); + it('preserves isCursorHost when ChangeDetail applies executorLaunchPresentation refresh', () => { + const copyOnlyConfig: WorkflowLaunchConfigView = { + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + effectiveAdapterId: 'clipboard', + }; + const incoming = buildExecutorLaunchPresentation( + copyOnlyConfig, + [{ id: 'clipboard', displayName: 'Clipboard' }], + 'clipboard', + ); + const message = { ...incoming, isCursorHost: true }; + + const withoutHostFlag = normalizeExecutorPresentation({ + agentAdapters: message.agentAdapters, + workflowLaunchConfig: message.workflowLaunchConfig, + uiWorkflowLaunchConfig: message.uiWorkflowLaunchConfig, + }); + expect(withoutHostFlag.isCursorHost).toBeUndefined(); + expect( + getExampleWorkflowCommand('verify', withoutHostFlag.uiWorkflowLaunchConfig, { + isCursorHost: withoutHostFlag.isCursorHost === true, + }), + ).toBe('/opsx:verify my-change'); + + const withHostFlag = normalizeExecutorPresentation({ + agentAdapters: message.agentAdapters, + workflowLaunchConfig: message.workflowLaunchConfig, + uiWorkflowLaunchConfig: message.uiWorkflowLaunchConfig, + isCursorHost: message.isCursorHost === true, + }); + expect(withHostFlag.isCursorHost).toBe(true); + expect( + getExampleWorkflowCommand('verify', withHostFlag.uiWorkflowLaunchConfig, { + isCursorHost: withHostFlag.isCursorHost === true, + }), + ).toBe('/opsx-verify my-change'); + }); + }); diff --git a/test/webview/utils/workflowLaunchLabelMatrix.test.ts b/test/webview/utils/workflowLaunchLabelMatrix.test.ts new file mode 100644 index 0000000..1d620b0 --- /dev/null +++ b/test/webview/utils/workflowLaunchLabelMatrix.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setLocale } from '../../../src/i18n'; +import { + getWorkflowActionButtonLabel, + getWorkflowLaunchModeHint, +} from '../../../src/webview/utils/workflowLaunchLabels'; +import { + getEffectiveWorkflowAdapterId, + toWorkflowLaunchConfigView, + type WorkflowLaunchConfigWithExplicit, +} from '../../../src/shared/workflowLaunchConfig'; + +function viewFrom(config: WorkflowLaunchConfigWithExplicit) { + const base = toWorkflowLaunchConfigView(config); + return { ...base, effectiveAdapterId: getEffectiveWorkflowAdapterId(config) }; +} + +/** Regression matrix: Cursor/VS Code host configs M1–M10 from real-device retest oracle. */ +describe('workflow launch label matrix (host × M1–M10)', () => { + beforeEach(() => setLocale('en')); + + it('M1 default Cursor agent panel uses Open Cursor · Apply', () => { + const config = viewFrom({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Open Cursor · Apply'); + expect(getWorkflowLaunchModeHint(config)).toBe('Opens Agent panel'); + }); + + it('M4 clipboard uses Copy Apply', () => { + const config = viewFrom({ + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Copy Apply'); + expect(getWorkflowLaunchModeHint(config)).toBe('Copies command'); + }); + + it('M6 agentCli uses Run Agent · Apply', () => { + const config = viewFrom({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentCli', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Run Agent · Apply'); + }); + + it('M7 explicit Cursor clipboard uses Copy Apply', () => { + const config = viewFrom({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Copy Apply'); + }); + + it('M8 agent panel matches M1 Open Cursor wording', () => { + const config = viewFrom({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Verify', config)).toBe('Open Cursor · Verify'); + }); + + it('M8d deeplink uses Open Cursor · Apply', () => { + const config = viewFrom({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'deeplink', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Open Cursor · Apply'); + }); + + it('M10 workflow clipboard uses Copy Verify', () => { + const config = viewFrom({ + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'agentPanel', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: true, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Verify', config)).toBe('Copy Verify'); + }); + + it('VS Code Chat default (adapter vscode-copilot, cursorLaunchMode default clipboard) uses Launch · Apply not Copy', () => { + const config = viewFrom({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-copilot', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + workflowLaunchModeExplicit: false, + preferredAgentAdapterExplicit: false, + }); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Launch · Apply'); + expect(getWorkflowLaunchModeHint(config)).toBe('Launch via adapter'); + expect(getWorkflowLaunchModeHint(config)).not.toBe('Copies command'); + }); +}); diff --git a/test/webview/utils/workflowLaunchLabels.test.ts b/test/webview/utils/workflowLaunchLabels.test.ts index 5d914b1..f846b9f 100644 --- a/test/webview/utils/workflowLaunchLabels.test.ts +++ b/test/webview/utils/workflowLaunchLabels.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { setLocale } from '../../../src/i18n'; import { + getExampleWorkflowCommand, + getVerifyArchiveDescription, + getVerifyArchiveHint, getWorkflowActionButtonLabel, getWorkflowLaunchModeHint, type WorkflowLaunchConfigView, @@ -33,6 +36,8 @@ describe('workflow launch labels', () => { expect( getWorkflowActionButtonLabel('Apply', { ...baseConfig, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', cursorLaunchMode: 'agentCli', cursorLaunchModeExplicit: true, effectiveAdapterId: 'cursor', @@ -58,7 +63,7 @@ describe('workflow launch labels', () => { preferredAgentAdapter: 'cursor', cursorLaunchMode: 'deeplink', }), - ).toBe('Runs in Cursor'); + ).toBe('Opens Agent panel'); }); it('shows Chat wording for Cursor chat command routing', () => { @@ -73,12 +78,45 @@ describe('workflow launch labels', () => { ).toBe('Open Chat · Verify'); }); + it('formats Verify & Archive copy description with IDE-specific command syntax', () => { + const desc = getVerifyArchiveDescription(baseConfig, { isCursorHost: false }); + expect(desc).toContain('/opsx:verify my-change'); + expect(desc).toContain('/opsx:archive my-change'); + expect(desc).not.toContain('/opsx-verify'); + + const cursorCopy: WorkflowLaunchConfigView = { + ...baseConfig, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'clipboard', + cursorLaunchModeExplicit: true, + effectiveAdapterId: 'cursor', + }; + const cursorDesc = getVerifyArchiveDescription(cursorCopy, { isCursorHost: true }); + expect(cursorDesc).toContain('/opsx-verify my-change'); + expect(cursorDesc).toContain('/opsx-archive my-change'); + }); + + it('formats Verify & Archive hints with colon commands for VS Code Chat', () => { + const config: WorkflowLaunchConfigView = { + ...baseConfig, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + effectiveAdapterId: 'vscode-chat', + }; + expect(getExampleWorkflowCommand('verify', config)).toBe('/opsx:verify my-change'); + expect(getVerifyArchiveHint('verify', config)).toContain('/opsx:verify my-change'); + expect(getVerifyArchiveHint('verify', config)).toContain('Chat'); + expect(getVerifyArchiveHint('verify', config)).not.toContain('/opsx-verify'); + }); + it('shows generic launch wording for non-Cursor adapters', () => { expect( getWorkflowActionButtonLabel('Apply', { ...baseConfig, workflowLaunchMode: 'adapter', preferredAgentAdapter: 'vscode-copilot', + cursorLaunchMode: 'clipboard', effectiveAdapterId: 'vscode-copilot', }), ).toBe('Launch · Apply'); @@ -87,6 +125,7 @@ describe('workflow launch labels', () => { ...baseConfig, workflowLaunchMode: 'adapter', preferredAgentAdapter: 'vscode-copilot', + cursorLaunchMode: 'clipboard', effectiveAdapterId: 'vscode-copilot', }), ).toBe('Launch via adapter'); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..368b14b --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "out", "test"] +} diff --git a/vitest.config.ts b/vitest.config.ts index faf74f4..1254d67 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.test.{ts,tsx}'], globals: true, + pool: 'forks', + maxWorkers: 1, + fileParallelism: false, }, resolve: { alias: {