From e5ee1d6782df99fc620377cfb49bc1b54c02af9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 04:31:14 +0000 Subject: [PATCH 01/19] fix(workflow): post-merge agent panel polish (0.2.5-agent-panel.1) - Honor openspec.agentAutoSubmit in VS Code Chat via isPartialQuery - Prefill hint toast with settings shortcut and global suppress - Parse tasks.md for dashboard counts and patch sidebar on file watch - Localize zh workflow labels; use change name in command hints - Cursor composer focus best-effort; document in CURSOR_COMPOSER_FOCUS.md Co-authored-by: RandyZhang --- CHANGELOG.md | 15 ++++ docs/CURSOR_COMPOSER_FOCUS.md | 21 ++++++ package.json | 10 +-- package.nls.json | 6 ++ package.nls.zh-cn.json | 6 ++ src/extension/extension.ts | 2 + .../services/agentLaunchPrefillNotify.ts | 34 +++++++++ src/extension/services/agentPanelLauncher.ts | 69 +++++++++++++++--- .../services/changeTaskProgressFromFile.ts | 49 +++++++++++++ src/extension/services/dataManager.ts | 47 ++++++++++++- src/extension/services/workflowAgentLaunch.ts | 17 +++-- src/i18n/locales/en.json | 17 +++++ src/i18n/locales/zh-cn.json | 19 ++++- src/shared/chatOpenLaunchArgs.ts | 14 ++++ src/webview/components/ActionBar.tsx | 11 ++- src/webview/components/ChangeDetail.tsx | 1 + src/webview/components/VerifyArchivePanel.tsx | 10 ++- src/webview/utils/workflowLaunchLabels.ts | 70 +++++++++++++------ test/extension/packageConfiguration.test.ts | 1 + .../services/agentLaunchPrefillNotify.test.ts | 49 +++++++++++++ .../services/agentPanelLauncher.test.ts | 46 +++++++++++- .../changeTaskProgressFromFile.test.ts | 33 +++++++++ .../services/workflowAgentLaunch.test.ts | 22 ++++++ test/shared/chatOpenLaunchArgs.test.ts | 20 ++++++ .../shared/executorLaunchPresentation.test.ts | 8 +-- .../changeDetailExecutorSync.test.tsx | 2 +- .../components/verifyArchivePanel.test.ts | 32 +++++++-- .../utils/executorUiLaunchConfig.test.ts | 8 +-- .../resolveUiWorkflowLaunchConfig.test.ts | 2 +- .../utils/workflowLaunchLabelMatrix.test.ts | 12 ++-- .../utils/workflowLaunchLabels.test.ts | 14 ++-- 31 files changed, 593 insertions(+), 74 deletions(-) create mode 100644 docs/CURSOR_COMPOSER_FOCUS.md create mode 100644 package.nls.json create mode 100644 package.nls.zh-cn.json create mode 100644 src/extension/services/agentLaunchPrefillNotify.ts create mode 100644 src/extension/services/changeTaskProgressFromFile.ts create mode 100644 src/shared/chatOpenLaunchArgs.ts create mode 100644 test/extension/services/agentLaunchPrefillNotify.test.ts create mode 100644 test/extension/services/changeTaskProgressFromFile.test.ts create mode 100644 test/shared/chatOpenLaunchArgs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c85107..3b3926e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.5-agent-panel.1] - 2026-10-09 + +### Changed + +- `openspec.agentAutoSubmit` now applies to VS Code Chat (`workbench.action.chat.open` with `isPartialQuery: false` to submit) as well as Cursor; prefill-only launches show a one-shot hint with a link to the setting. +- Dashboard task counts are derived from `tasks.md` parsing (CLI-aligned) for faster updates after edits; sidebar receives immediate patches on file watch. +- Localized workflow action labels in zh-CN; copy/command hints use the current change name instead of `my-change`. +- Dedupe toast uses “just launched” semantics; Agent panel button wording clarified. + +### Fixed + +- Cursor: best-effort focus on new composer after `composer.createNew` (see `docs/CURSOR_COMPOSER_FOCUS.md`). + +## [0.2.5-agent-panel.0] - 2026-10-08 + ### 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. diff --git a/docs/CURSOR_COMPOSER_FOCUS.md b/docs/CURSOR_COMPOSER_FOCUS.md new file mode 100644 index 0000000..4384324 --- /dev/null +++ b/docs/CURSOR_COMPOSER_FOCUS.md @@ -0,0 +1,21 @@ +# Cursor composer focus after restart + +## Symptom + +After Cursor restarts, the first workflow launch via `composer.createNew` can leave a **restored previous chat** in the foreground while the new Agent run opens in a background tab. + +## Investigation + +- Primary launch uses `composer.createNew` with `openInNewTab: true`, `view: 'pane'`, and `unifiedMode: 'agent'`. That creates a new composer, but Cursor may not switch the visible tab to it when session restore replays an older composer. +- Public, stable Cursor commands for “focus composer by id” are not documented in the VS Code API. Runtime detection may expose ids such as `composer.openComposer` or `composer.focusComposer` on some builds; they are not guaranteed across versions. +- A follow-up `workbench.action.chat.open` would prefill/focus chat but can fight with `createNew` and must not await the agent turn. + +## Mitigation in the extension + +1. After `composer.createNew`, best-effort focus: if `composer.getOrderedSelectedComposerIds` shows a new id, try known focus commands when present (`composer.openComposer`, `composer.focusComposer`, `composer.selectComposer`) with that id — fire-and-forget, no await on the turn. +2. If pane growth is not confirmed within the background check window, show the existing “panel not confirmed” warning. +3. When focus commands are unavailable or ids do not change (restore race), show a one-shot hint that the new run may be in another Agent tab (`agentLaunch.cursorNewTabHint`). + +## Reliable fix + +No fully reliable, version-stable focus API was found without Cursor-internal hooks. Tab focus behavior should be treated as host-dependent until Cursor documents a supported focus command. diff --git a/package.json b/package.json index 921ee51..be8fb77 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.4-agent-panel.18", + "version": "0.2.5-agent-panel.1", "publisher": "randysss", "license": "MIT", "engines": { @@ -109,11 +109,11 @@ "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.", + "description": "%configuration.openspec.agentAutoSubmit.description%", "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)." + "%configuration.openspec.agentAutoSubmit.enum.never%", + "%configuration.openspec.agentAutoSubmit.enum.readOnly%", + "%configuration.openspec.agentAutoSubmit.enum.always%" ] }, "openspec.cursorLaunchMode": { diff --git a/package.nls.json b/package.nls.json new file mode 100644 index 0000000..ccd3484 --- /dev/null +++ b/package.nls.json @@ -0,0 +1,6 @@ +{ + "configuration.openspec.agentAutoSubmit.description": "When launching workflow commands into the IDE Agent panel (VS Code Chat/Copilot or Cursor), control whether the command is sent automatically or only prefilled. Applies to both VS Code and Cursor. never: always prefill; readOnly (default): auto-send explore and verify only; always: auto-send every workflow action including apply and archive. If auto-send is unsupported in your editor version, the extension prefills instead.", + "configuration.openspec.agentAutoSubmit.enum.never": "Never auto-send — always prefill the Agent input and wait for Enter.", + "configuration.openspec.agentAutoSubmit.enum.readOnly": "Auto-send read-only workflow commands (explore, verify) in VS Code and Cursor.", + "configuration.openspec.agentAutoSubmit.enum.always": "Auto-send every workflow command (apply, archive, etc.) in VS Code and Cursor." +} diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json new file mode 100644 index 0000000..113b754 --- /dev/null +++ b/package.nls.zh-cn.json @@ -0,0 +1,6 @@ +{ + "configuration.openspec.agentAutoSubmit.description": "在 VS Code Chat/Copilot 或 Cursor 的 Agent 面板中启动工作流命令时,控制是自动发送还是仅预填。适用于 VS Code 与 Cursor。never:始终预填;readOnly(默认):仅对 explore、verify 自动发送;always:对所有工作流动作(含 apply、archive)自动发送。若当前编辑器版本不支持自动发送,扩展会回退为预填。", + "configuration.openspec.agentAutoSubmit.enum.never": "从不自动发送 — 始终预填 Agent 输入框,按 Enter 发送。", + "configuration.openspec.agentAutoSubmit.enum.readOnly": "在 VS Code 与 Cursor 中仅对只读工作流(explore、verify)自动发送。", + "configuration.openspec.agentAutoSubmit.enum.always": "在 VS Code 与 Cursor 中对所有工作流命令(含 apply、archive)自动发送。" +} diff --git a/src/extension/extension.ts b/src/extension/extension.ts index b97fe20..b4f70c6 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -10,6 +10,7 @@ import { OpenSpecCacheService } from './services/openSpecCacheService'; import { createProjectContext, ProjectDataGateway } from './services/projectDataGateway'; import { setLocale, t } from '../i18n'; import { AgentUnavailableViewProvider } from './providers/agentUnavailableViewProvider'; +import { configureAgentLaunchPrefillNotify } from './services/agentLaunchPrefillNotify'; let dataManager: DataManager | null = null; @@ -89,6 +90,7 @@ export async function activate(context: vscode.ExtensionContext) { extensionVersion: context.extension.packageJSON.version ?? '0.0.0', }); dataManager = new DataManager(workspaceRoot, { cacheService, projectRoots }); + configureAgentLaunchPrefillNotify(context); await dataManager.initialize(); let dashboardViewProviderRef: DashboardViewProvider | null = null; diff --git a/src/extension/services/agentLaunchPrefillNotify.ts b/src/extension/services/agentLaunchPrefillNotify.ts new file mode 100644 index 0000000..320649d --- /dev/null +++ b/src/extension/services/agentLaunchPrefillNotify.ts @@ -0,0 +1,34 @@ +import * as vscode from 'vscode'; +import { t } from '../../i18n'; + +const GLOBAL_SUPPRESS_KEY = 'openspec.suppressAgentPrefillLaunchHint'; + +let extensionContext: vscode.ExtensionContext | undefined; + +export function configureAgentLaunchPrefillNotify(context: vscode.ExtensionContext): void { + extensionContext = context; +} + +export function resetAgentLaunchPrefillNotifyForTests(): void { + extensionContext = undefined; +} + +export async function notifyAgentPrefillLaunchHint(): Promise { + if (extensionContext?.globalState.get(GLOBAL_SUPPRESS_KEY)) { + return; + } + + const openSettingsLabel = t('agentLaunch.prefillHintOpenSettings'); + const dontShowLabel = t('agentLaunch.prefillHintDontShowAgain'); + const picked = await vscode.window.showInformationMessage( + t('agentLaunch.prefillHint'), + openSettingsLabel, + dontShowLabel, + ); + + if (picked === openSettingsLabel) { + void vscode.commands.executeCommand('workbench.action.openSettings', 'openspec.agentAutoSubmit'); + } else if (picked === dontShowLabel && extensionContext) { + await extensionContext.globalState.update(GLOBAL_SUPPRESS_KEY, true); + } +} diff --git a/src/extension/services/agentPanelLauncher.ts b/src/extension/services/agentPanelLauncher.ts index 8580166..e581204 100644 --- a/src/extension/services/agentPanelLauncher.ts +++ b/src/extension/services/agentPanelLauncher.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { buildChatOpenLaunchArgs } from '../../shared/chatOpenLaunchArgs'; import { buildCursorPromptDeeplink } from './cursorDeeplink'; import { logger } from '../utils/logger'; import { t } from '../../i18n'; @@ -79,6 +80,33 @@ function composerIdsIncreased(before: string[] | undefined, after: string[] | un return after.length > before.length; } +function findNewComposerId(before: string[] | undefined, after: string[] | undefined): string | undefined { + if (!after?.length) return undefined; + if (!before?.length) return after[after.length - 1]; + const beforeSet = new Set(before); + const added = after.filter((id) => !beforeSet.has(id)); + return added[added.length - 1] ?? (after.length > before.length ? after[after.length - 1] : undefined); +} + +const COMPOSER_FOCUS_COMMANDS = [ + 'composer.openComposer', + 'composer.focusComposer', + 'composer.selectComposer', +] as const; + +function tryFocusComposerById( + composerId: string, + cmds: Set, + executeCommand: NonNullable, +): void { + for (const command of COMPOSER_FOCUS_COMMANDS) { + if (!cmds.has(command)) continue; + void executeCommand(command, { composerId }).then(undefined, () => undefined); + void executeCommand(command, composerId).then(undefined, () => undefined); + return; + } +} + async function tryCursorComposerCreateNew( request: AgentPanelLaunchRequest, deps: AgentPanelLauncherDeps, @@ -132,6 +160,13 @@ async function tryCursorComposerCreateNew( } void (async () => { + await sleep(100); + const earlyIds = await readComposerPaneIds(executeCommand, cmds); + const newComposerId = findNewComposerId(beforeIds, earlyIds); + if (newComposerId) { + tryFocusComposerById(newComposerId, cmds, executeCommand); + } + await sleep(COMPOSER_SUCCESS_WAIT_MS); const afterIds = await readComposerPaneIds(executeCommand, cmds); if (!composerIdsIncreased(beforeIds, afterIds)) { @@ -143,6 +178,12 @@ async function tryCursorComposerCreateNew( ? t('agentLaunch.submitNotConfirmed') : t('agentLaunch.panelNotConfirmed'), ); + void vscode.window.showInformationMessage?.(t('agentLaunch.cursorNewTabHint')); + } else { + const confirmedNewId = findNewComposerId(beforeIds, afterIds); + if (confirmedNewId) { + tryFocusComposerById(confirmedNewId, cmds, executeCommand); + } } })(); @@ -164,14 +205,15 @@ async function tryCursorChatOpen( const executeCommand = deps.executeCommand ?? vscode.commands.executeCommand.bind(vscode.commands); try { - await executeCommand('workbench.action.chat.open', { query: request.text }); + const chatArgs = buildChatOpenLaunchArgs(request.text, request.autoSubmit); + await executeCommand('workbench.action.chat.open', chatArgs); if (cmds.has('composerMode.agent')) { void executeCommand('composerMode.agent').then(undefined, () => undefined); } return { success: true, layer: 'chatOpen', - outcome: 'prefilled', + outcome: request.autoSubmit ? 'submitted' : 'prefilled', }; } catch (error) { logger.warn('workbench.action.chat.open failed on Cursor', error as Error); @@ -208,18 +250,29 @@ async function tryVsCodeChatOpen( } const executeCommand = deps.executeCommand ?? vscode.commands.executeCommand.bind(vscode.commands); + const chatArgs = buildChatOpenLaunchArgs(request.text, request.autoSubmit); try { - await executeCommand('workbench.action.chat.open', { - query: request.text, - isPartialQuery: true, - mode: 'agent', - }); + await executeCommand('workbench.action.chat.open', chatArgs); return { success: true, layer: 'vscodeChat', - outcome: 'prefilled', + outcome: request.autoSubmit ? 'submitted' : 'prefilled', }; } catch (error) { + if (request.autoSubmit) { + logger.warn('workbench.action.chat.open submit failed; falling back to prefill', error as Error); + try { + await executeCommand('workbench.action.chat.open', buildChatOpenLaunchArgs(request.text, false)); + return { + success: true, + layer: 'vscodeChat', + outcome: 'prefilled', + }; + } catch (fallbackError) { + logger.warn('workbench.action.chat.open prefill fallback failed on VS Code', fallbackError as Error); + return undefined; + } + } logger.warn('workbench.action.chat.open failed on VS Code', error as Error); return undefined; } diff --git a/src/extension/services/changeTaskProgressFromFile.ts b/src/extension/services/changeTaskProgressFromFile.ts new file mode 100644 index 0000000..2a75115 --- /dev/null +++ b/src/extension/services/changeTaskProgressFromFile.ts @@ -0,0 +1,49 @@ +import { countTaskProgress } from '../../shared/taskMarkdown'; +import { enrichChangeWithLifecycle } from '../../shared/changeLifecycle'; +import type { IOpenSpecContentAccess } from './contentAccess'; +import type { ChangeInfo } from './types'; + +export function taskProgressFromTasksMarkdown(content: string): { + completedTasks: number; + totalTasks: number; + status: ChangeInfo['status']; +} { + const { completed, total } = countTaskProgress(content); + const status: ChangeInfo['status'] = total === 0 + ? 'draft' + : completed === total + ? 'complete' + : 'in-progress'; + return { completedTasks: completed, totalTasks: total, status }; +} + +export function applyTaskProgressToChange( + change: ChangeInfo, + progress: { completedTasks: number; totalTasks: number; status: ChangeInfo['status'] }, +): ChangeInfo { + return enrichChangeWithLifecycle({ + ...change, + completedTasks: progress.completedTasks, + totalTasks: progress.totalTasks, + status: progress.status, + }); +} + +/** Overlay CLI-reported task counts with tasks.md parsing (P0-A aligned with shared/taskMarkdown). */ +export async function overlayChangeTaskProgressFromFiles( + changes: ChangeInfo[], + contentAccess: IOpenSpecContentAccess, +): Promise { + return Promise.all(changes.map(async (change) => { + try { + const content = await contentAccess.readArtifact(change.name, 'tasks'); + if (!content.trim()) { + return change; + } + const progress = taskProgressFromTasksMarkdown(content); + return applyTaskProgressToChange(change, progress); + } catch { + return change; + } + })); +} diff --git a/src/extension/services/dataManager.ts b/src/extension/services/dataManager.ts index dabce39..7e565a6 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -22,6 +22,11 @@ import { enrichChangeWithLifecycle, type ChangeStatusCounts, } from '../../shared/changeLifecycle'; +import { + applyTaskProgressToChange, + overlayChangeTaskProgressFromFiles, + taskProgressFromTasksMarkdown, +} from './changeTaskProgressFromFile'; export interface ScopeInfo { id: string; @@ -539,6 +544,10 @@ export class DataManager { this.contentAccess.autoCompleteParents(tasksChangeName).catch((err) => logger.warn('autoCompleteParents after tasks.md change', err as Error) ); + const scope = this.resolveScopeForRoot(this.watchedProjectRoot); + void this.patchCachedTaskProgressFromFile(tasksChangeName, scope).catch((err) => + logger.warn('patchCachedTaskProgressFromFile after tasks.md change', err as Error) + ); } // Detect which artifact changed and compute downstream invalidations @@ -833,7 +842,11 @@ export class DataManager { services.stateReader.listSpecs(), services.stateReader.listArchivedChanges(), ]); - const changesWithLifecycle = rawChanges.map((change) => enrichChangeWithLifecycle(change)); + const rawWithFileTaskProgress = await overlayChangeTaskProgressFromFiles( + rawChanges, + services.contentAccess, + ); + const changesWithLifecycle = rawWithFileTaskProgress.map((change) => enrichChangeWithLifecycle(change)); const changes = await this.enrichChangesWithProposalWhy(changesWithLifecycle, services.contentAccess); const changeStatusCounts = buildChangeStatusCounts(changes, archivedChanges); @@ -1330,6 +1343,38 @@ export class DataManager { await fs.promises.writeFile(filePath, YAML.stringify(data), 'utf8'); } + private async patchCachedTaskProgressFromFile( + changeName: string, + scope?: OpenSpecScope, + ): Promise { + if (!this.cachedData || !this.isCurrentScope(scope)) { + return; + } + const services = this.getScopedServices(scope); + let content: string; + try { + content = await services.contentAccess.readArtifact(changeName, 'tasks'); + } catch { + return; + } + const progress = taskProgressFromTasksMarkdown(content); + const index = this.cachedData.changes.findIndex((c) => c.name === changeName); + if (index < 0) { + return; + } + const updatedChange = applyTaskProgressToChange(this.cachedData.changes[index], progress); + const changes = [...this.cachedData.changes]; + changes[index] = enrichChangeWithLifecycle(updatedChange); + const data: DashboardData = { + ...this.cachedData, + changes, + changeStatusCounts: buildChangeStatusCounts(changes, this.cachedData.archivedChanges), + lastRefresh: Date.now(), + }; + this.cachedData = data; + this.notifyRefresh(data); + } + /** * Register refresh callback */ diff --git a/src/extension/services/workflowAgentLaunch.ts b/src/extension/services/workflowAgentLaunch.ts index 4da8791..53f6d84 100644 --- a/src/extension/services/workflowAgentLaunch.ts +++ b/src/extension/services/workflowAgentLaunch.ts @@ -25,6 +25,7 @@ import { } from './agentPanelLauncher'; import { isCursorHost } from '../utils/isCursorHost'; import { notifyWorkflowCommandCopied } from './workflowClipboardNotify'; +import { notifyAgentPrefillLaunchHint } from './agentLaunchPrefillNotify'; export type WorkflowAgentLaunchTarget = | 'clipboard' @@ -102,7 +103,7 @@ function notifyLaunchResult( command: string, ): void { if (result.outcome === 'deduped') { - void vscode.window.showInformationMessage(t('workflow.launchDeduped')); + void vscode.window.showInformationMessage(t('workflow.launchJustLaunched')); return; } if (result.target === 'clipboard' || result.outcome === 'copied') { @@ -113,8 +114,16 @@ function notifyLaunchResult( void vscode.window.showInformationMessage(t('agentLaunch.deeplinkPrefilled', { command })); return; } - if (result.layer === 'vscodeChat' || result.target === 'externalAdapter') { - void vscode.window.showInformationMessage(t('agentLaunch.prefilledChat', { command })); + if (result.outcome === 'prefilled') { + void notifyAgentPrefillLaunchHint(); + return; + } + if (result.outcome === 'submitted' || result.layer === 'vscodeChat') { + void vscode.window.showInformationMessage(t('agentLaunch.openedPanel', { command })); + return; + } + if (result.target === 'externalAdapter') { + void notifyAgentPrefillLaunchHint(); return; } void vscode.window.showInformationMessage(t('agentLaunch.openedPanel', { command })); @@ -147,7 +156,7 @@ function buildDedupedLaunchResult(request: WorkflowAgentLaunchRequest): Workflow target: resolveLaunchTargetFromPayload(payload, view), layer: undefined, outcome: 'deduped', - message: t('workflow.launchDeduped'), + message: t('workflow.launchJustLaunched'), }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index faa2c47..5ff4a75 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -56,6 +56,16 @@ "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.launchJustLaunched": "This workflow was just launched — check the Agent panel.", + "workflow.action.verify": "Verify", + "workflow.action.archive": "Archive", + "workflow.action.apply": "Apply", + "workflow.action.explore": "Explore", + "workflow.action.propose": "Propose", + "workflow.action.continue": "Continue", + "workflow.action.continuePlanning": "Continue planning", + "workflow.action.ff": "FF", + "workflow.action.syncSpecs": "Sync Specs", "workflow.commandCopied": "Copied to clipboard: {command}", "workflow.retryLaunch": "Retry", "workflow.openSettings": "Open Settings", @@ -65,17 +75,20 @@ "workflow.launching": "Launching…", "workflow.button.copy": "Copy {action}", "workflow.button.openCursor": "Open Cursor · {action}", + "workflow.button.openAgentPanel": "Open Agent panel · {action}", "workflow.button.openChat": "Open Chat · {action}", "workflow.button.runAgent": "Run Agent · {action}", "workflow.button.launch": "Launch · {action}", "workflow.title.copy": "{action}: copy command to clipboard", "workflow.title.openCursor": "{action}: copy command and open Cursor prompt", + "workflow.title.openAgentPanel": "{action}: copy command and open the Agent panel", "workflow.title.openChat": "{action}: copy command and open Cursor Chat", "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": "Opens Agent panel", "workflow.modeHint.executableCursorCli": "Runs Cursor Agent CLI (may modify files)", + "workflow.modeHint.executableAgentCli": "Headless Agent CLI (may modify files)", "workflow.modeHint.executableGeneric": "Launch via adapter", "workflow.highImpact": "High-impact actions", "workflow.receiptPending": "Pending", @@ -232,6 +245,10 @@ "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.", + "agentLaunch.prefillHint": "Prefilled in the Agent chat — press Enter to send.", + "agentLaunch.prefillHintOpenSettings": "Auto-send settings", + "agentLaunch.prefillHintDontShowAgain": "Don't show again", + "agentLaunch.cursorNewTabHint": "The new Agent run may have opened in another tab — switch tabs if you still see an older chat.", "dashboard.loading": "Loading OpenSpec data...", "dashboard.loadFailed": "Failed to load data. Try refreshing.", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 4f1752c..f65a0a7 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -58,6 +58,16 @@ "workflow.bindingValidationFailed": "无法校验此工作流对应的 Change 根目录。", "workflow.bindingRequestIncomplete": "工作流请求缺少绑定信息。请刷新后重试。", "workflow.launchDeduped": "该 change 的工作流已在启动中。", + "workflow.launchJustLaunched": "该工作流刚刚已启动,请在 Agent 面板中查看。", + "workflow.action.verify": "验证", + "workflow.action.archive": "归档", + "workflow.action.apply": "实施", + "workflow.action.explore": "探索", + "workflow.action.propose": "提案", + "workflow.action.continue": "继续", + "workflow.action.continuePlanning": "继续规划", + "workflow.action.ff": "FF", + "workflow.action.syncSpecs": "同步 Spec", "workflow.commandCopied": "已复制到剪贴板:{command}", "workflow.retryLaunch": "重试", "workflow.openSettings": "打开设置", @@ -65,19 +75,22 @@ "workflow.workspaceRebound": "工作区文件夹已变化,OpenSpec 已重新绑定到当前项目根目录。", "workflow.launchHelp": "工作流按钮会在 IDE Agent 面板预填 `/opsx-*` 命令。若只需复制,请在设置中选择「仅复制」。", "workflow.launching": "启动中…", - "workflow.button.copy": "复制{action}", + "workflow.button.copy": "复制 {action}", "workflow.button.openCursor": "在 Cursor 打开 · {action}", + "workflow.button.openAgentPanel": "打开 Agent 面板 · {action}", "workflow.button.openChat": "打开聊天 · {action}", "workflow.button.runAgent": "运行 Agent · {action}", "workflow.button.launch": "启动 · {action}", "workflow.title.copy": "{action}:复制命令到剪贴板", "workflow.title.openCursor": "{action}:复制命令并打开 Cursor 提示", + "workflow.title.openAgentPanel": "{action}:复制命令并打开 Agent 面板", "workflow.title.openChat": "{action}:复制命令并打开 Cursor 聊天", "workflow.title.runAgent": "{action}:复制命令并运行 Cursor Agent CLI", "workflow.title.launch": "{action}:通过所选适配器启动", "workflow.modeHint.copyOnly": "仅复制命令", "workflow.modeHint.executableCursor": "打开 Agent 面板", "workflow.modeHint.executableCursorCli": "运行 Cursor Agent CLI(可能修改文件)", + "workflow.modeHint.executableAgentCli": "Headless Agent CLI(可能修改文件)", "workflow.modeHint.executableGeneric": "通过适配器启动", "workflow.highImpact": "高影响动作", "workflow.receiptPending": "等待中", @@ -234,6 +247,10 @@ "agentLaunch.copied": "已复制到剪贴板,请粘贴到 Agent:{command}", "agentLaunch.agentCliStarted": "正在以 headless Agent CLI 运行:{command}", "agentLaunch.failed": "无法在 Agent 中启动该工作流。", + "agentLaunch.prefillHint": "已填入 Agent 输入框,按 Enter 发送", + "agentLaunch.prefillHintOpenSettings": "自动发送设置", + "agentLaunch.prefillHintDontShowAgain": "不再提示", + "agentLaunch.cursorNewTabHint": "新的 Agent 运行可能已在其他标签页打开;若仍看到旧对话,请切换标签页。", "dashboard.loading": "正在加载 OpenSpec 数据...", "dashboard.loadFailed": "加载数据失败,请尝试刷新。", diff --git a/src/shared/chatOpenLaunchArgs.ts b/src/shared/chatOpenLaunchArgs.ts new file mode 100644 index 0000000..b8d2a7f --- /dev/null +++ b/src/shared/chatOpenLaunchArgs.ts @@ -0,0 +1,14 @@ +/** VS Code / Copilot `workbench.action.chat.open` options (isPartialQuery: false submits). */ +export interface ChatOpenLaunchArgs { + query: string; + isPartialQuery: boolean; + mode?: 'agent'; +} + +export function buildChatOpenLaunchArgs(text: string, autoSubmit: boolean): ChatOpenLaunchArgs { + return { + query: text, + isPartialQuery: !autoSubmit, + mode: 'agent', + }; +} diff --git a/src/webview/components/ActionBar.tsx b/src/webview/components/ActionBar.tsx index d86d84f..2b1dc45 100644 --- a/src/webview/components/ActionBar.tsx +++ b/src/webview/components/ActionBar.tsx @@ -113,8 +113,15 @@ export const ActionBar: React.FC = ({ launching: pendingAction === recommendedAction.action, })} - {launchModeHint && pendingAction !== recommendedAction.action && ( - + {launchModeHint && ( + {launchModeHint} )} diff --git a/src/webview/components/ChangeDetail.tsx b/src/webview/components/ChangeDetail.tsx index 52da4d4..1d8802c 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -1025,6 +1025,7 @@ export const ChangeDetail: React.FC = ({ {activeTab === 'verifyArchive' ? (
= ({ + changeName, isArchived, canArchiveNow = false, archiveNowDisabledReason, @@ -86,11 +88,12 @@ export const VerifyArchivePanel: React.FC = ({ )}
{t('verifyArchive.title')}
-

{getVerifyArchiveDescription(workflowLaunchConfig, commandFormatRuntime)}

+

{getVerifyArchiveDescription(workflowLaunchConfig, commandFormatRuntime, changeName)}

= ({ = ({ export const WorkflowActionCard: React.FC<{ action: InteractiveWorkflowAction; + changeName: string; disabled: boolean; disabledMessage?: string; launching?: boolean; @@ -156,6 +161,7 @@ export const WorkflowActionCard: React.FC<{ onRun: (action: InteractiveWorkflowAction) => void; }> = ({ action, + changeName, disabled, disabledMessage, launching = false, @@ -166,7 +172,7 @@ export const WorkflowActionCard: React.FC<{ const isVerify = action === 'verify'; const title = isVerify ? t('verifyArchive.verifyTitle') : t('verifyArchive.reviewArchiveTitle'); const runLabel = getVerifyArchiveRunLabel(action, workflowLaunchConfig, { launching }); - const hint = getVerifyArchiveHint(action, workflowLaunchConfig, commandFormatRuntime); + const hint = getVerifyArchiveHint(action, workflowLaunchConfig, commandFormatRuntime, changeName); return (
diff --git a/src/webview/utils/workflowLaunchLabels.ts b/src/webview/utils/workflowLaunchLabels.ts index f79daf7..7fe8695 100644 --- a/src/webview/utils/workflowLaunchLabels.ts +++ b/src/webview/utils/workflowLaunchLabels.ts @@ -7,18 +7,37 @@ import { type WorkflowAction, } from '../../shared/workflowCommand'; -const VERIFY_ARCHIVE_EXAMPLE_CHANGE = 'my-change'; +export const NEUTRAL_CHANGE_PLACEHOLDER = ''; + +const ENGLISH_ACTION_LABEL_KEYS: Record[0]> = { + Verify: 'workflow.action.verify', + Archive: 'workflow.action.archive', + Apply: 'workflow.action.apply', + Explore: 'workflow.action.explore', + Propose: 'workflow.action.propose', + Continue: 'workflow.action.continue', + 'Continue planning': 'workflow.action.continuePlanning', + FF: 'workflow.action.ff', + 'Sync Specs': 'workflow.action.syncSpecs', +}; export type WorkflowCommandFormatRuntime = { isCursorHost?: boolean }; +export function localizeWorkflowActionDisplayLabel(englishLabel: string): string { + const key = ENGLISH_ACTION_LABEL_KEYS[englishLabel]; + return key ? t(key) : englishLabel; +} + export function getExampleWorkflowCommand( action: WorkflowAction, config?: WorkflowLaunchConfigView | null, runtime?: WorkflowCommandFormatRuntime, + changeName?: string, ): string { + const resolvedName = changeName?.trim() || NEUTRAL_CHANGE_PLACEHOLDER; return buildWorkflowCommand({ action, - changeName: VERIFY_ARCHIVE_EXAMPLE_CHANGE, + changeName: resolvedName, target: resolveWorkflowCommandTargetForUi(config ?? null, runtime), }); } @@ -34,50 +53,54 @@ export function getWorkflowActionButtonLabel( return t('workflow.launching'); } + const displayAction = localizeWorkflowActionDisplayLabel(actionLabel); + if (!config || !shouldUseAgentWorkflowLabels(config)) { - return t('workflow.button.copy', { action: actionLabel }); + return t('workflow.button.copy', { action: displayAction }); } if (config.effectiveAdapterId === 'cursor') { switch (config.cursorLaunchMode) { case 'agentCli': - return t('workflow.button.runAgent', { action: actionLabel }); + return t('workflow.button.runAgent', { action: displayAction }); case 'chatCommand': - return t('workflow.button.openChat', { action: actionLabel }); + return t('workflow.button.openChat', { action: displayAction }); case 'clipboard': - return t('workflow.button.copy', { action: actionLabel }); + return t('workflow.button.copy', { action: displayAction }); case 'deeplink': default: - return t('workflow.button.openCursor', { action: actionLabel }); + return t('workflow.button.openAgentPanel', { action: displayAction }); } } - return t('workflow.button.launch', { action: actionLabel }); + return t('workflow.button.launch', { action: displayAction }); } export function getWorkflowActionTitle( actionLabel: string, config?: WorkflowLaunchConfigView | null, ): string { + const displayAction = localizeWorkflowActionDisplayLabel(actionLabel); + if (!config || !shouldUseAgentWorkflowLabels(config)) { - return t('workflow.title.copy', { action: actionLabel }); + return t('workflow.title.copy', { action: displayAction }); } if (config.effectiveAdapterId === 'cursor') { switch (config.cursorLaunchMode) { case 'agentCli': - return t('workflow.title.runAgent', { action: actionLabel }); + return t('workflow.title.runAgent', { action: displayAction }); case 'chatCommand': - return t('workflow.title.openChat', { action: actionLabel }); + return t('workflow.title.openChat', { action: displayAction }); case 'clipboard': - return t('workflow.title.copy', { action: actionLabel }); + return t('workflow.title.copy', { action: displayAction }); case 'deeplink': default: - return t('workflow.title.openCursor', { action: actionLabel }); + return t('workflow.title.openAgentPanel', { action: displayAction }); } } - return t('workflow.title.launch', { action: actionLabel }); + return t('workflow.title.launch', { action: displayAction }); } export function getVerifyArchiveRunLabel( @@ -92,32 +115,34 @@ export function getVerifyArchiveRunLabel( function verifyArchiveDescriptionCopy( config: WorkflowLaunchConfigView | null | undefined, runtime?: WorkflowCommandFormatRuntime, + changeName?: string, ): string { return t('verifyArchive.descriptionCopy', { - verifyCommand: getExampleWorkflowCommand('verify', config, runtime), - archiveCommand: getExampleWorkflowCommand('archive', config, runtime), + verifyCommand: getExampleWorkflowCommand('verify', config, runtime, changeName), + archiveCommand: getExampleWorkflowCommand('archive', config, runtime, changeName), }); } export function getVerifyArchiveDescription( config?: WorkflowLaunchConfigView | null, runtime?: WorkflowCommandFormatRuntime, + changeName?: string, ): string { if (!config || !shouldUseAgentWorkflowLabels(config)) { - return verifyArchiveDescriptionCopy(config, runtime); + return verifyArchiveDescriptionCopy(config, runtime, changeName); } if (config.effectiveAdapterId === 'cursor') { if (config.cursorLaunchMode === 'agentCli') { return t('verifyArchive.descriptionAgentCli'); } if (config.cursorLaunchMode === 'clipboard') { - return verifyArchiveDescriptionCopy(config, runtime); + return verifyArchiveDescriptionCopy(config, runtime, changeName); } return t('verifyArchive.descriptionAgentPanel'); } return t('verifyArchive.descriptionChatAdapter', { - verifyCommand: getExampleWorkflowCommand('verify', config, runtime), - archiveCommand: getExampleWorkflowCommand('archive', config, runtime), + verifyCommand: getExampleWorkflowCommand('verify', config, runtime, changeName), + archiveCommand: getExampleWorkflowCommand('archive', config, runtime, changeName), }); } @@ -125,8 +150,9 @@ export function getVerifyArchiveHint( action: 'verify' | 'archive', config?: WorkflowLaunchConfigView | null, runtime?: WorkflowCommandFormatRuntime, + changeName?: string, ): string { - const command = getExampleWorkflowCommand(action, config, runtime); + const command = getExampleWorkflowCommand(action, config, runtime, changeName); if (!config || !shouldUseAgentWorkflowLabels(config)) { return action === 'verify' ? t('verifyArchive.verifyCopyHint', { command }) @@ -166,7 +192,7 @@ export function getWorkflowLaunchModeHint( } if (config.effectiveAdapterId === 'cursor') { if (config.cursorLaunchMode === 'agentCli') { - return t('workflow.modeHint.executableCursorCli'); + return t('workflow.modeHint.executableAgentCli'); } if (config.cursorLaunchMode === 'clipboard') { return t('workflow.modeHint.copyOnly'); diff --git a/test/extension/packageConfiguration.test.ts b/test/extension/packageConfiguration.test.ts index 41753a5..099cbf6 100644 --- a/test/extension/packageConfiguration.test.ts +++ b/test/extension/packageConfiguration.test.ts @@ -40,6 +40,7 @@ describe('OpenSpec package configuration', () => { type: 'string', enum: ['never', 'readOnly', 'always'], default: 'readOnly', + description: '%configuration.openspec.agentAutoSubmit.description%', }); expect(properties['openspec.cursorLaunchMode']).toMatchObject({ type: 'string', diff --git a/test/extension/services/agentLaunchPrefillNotify.test.ts b/test/extension/services/agentLaunchPrefillNotify.test.ts new file mode 100644 index 0000000..4fa3df1 --- /dev/null +++ b/test/extension/services/agentLaunchPrefillNotify.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { + configureAgentLaunchPrefillNotify, + notifyAgentPrefillLaunchHint, + resetAgentLaunchPrefillNotifyForTests, +} from '@extension/services/agentLaunchPrefillNotify'; + +vi.mock('vscode', () => ({ + window: { + showInformationMessage: vi.fn(async () => undefined), + }, + commands: { + executeCommand: vi.fn(), + }, +})); + +describe('agentLaunchPrefillNotify', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetAgentLaunchPrefillNotifyForTests(); + }); + + it('shows hint when not suppressed', async () => { + configureAgentLaunchPrefillNotify({ + globalState: { + get: vi.fn(() => false), + update: vi.fn(), + }, + } as unknown as vscode.ExtensionContext); + + await notifyAgentPrefillLaunchHint(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalled(); + }); + + it('skips hint when suppressed in globalState', async () => { + configureAgentLaunchPrefillNotify({ + globalState: { + get: vi.fn(() => true), + update: vi.fn(), + }, + } as unknown as vscode.ExtensionContext); + + await notifyAgentPrefillLaunchHint(); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/test/extension/services/agentPanelLauncher.test.ts b/test/extension/services/agentPanelLauncher.test.ts index 185f374..a59ac6a 100644 --- a/test/extension/services/agentPanelLauncher.test.ts +++ b/test/extension/services/agentPanelLauncher.test.ts @@ -19,6 +19,7 @@ vi.mock('vscode', () => ({ }, window: { showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), }, env: { openExternal: vi.fn(async () => false), @@ -104,6 +105,7 @@ describe('agentPanelLauncher', () => { ); expect(result.layer).toBe('vscodeChat'); + expect(result.outcome).toBe('prefilled'); expect(executeCommand).toHaveBeenCalledWith('workbench.action.chat.open', { query: '/opsx:apply demo', isPartialQuery: true, @@ -111,6 +113,44 @@ describe('agentPanelLauncher', () => { }); }); + it('submits on VS Code when autoSubmit is true', async () => { + const executeCommand = vi.fn(async () => undefined); + const result = await launchAgentPanelPrompt( + { text: '/opsx:verify demo', autoSubmit: true }, + { + isCursorHost: () => false, + getCommands: async () => new Set(['workbench.action.chat.open']), + executeCommand, + }, + ); + + expect(result.outcome).toBe('submitted'); + expect(executeCommand).toHaveBeenCalledWith('workbench.action.chat.open', { + query: '/opsx:verify demo', + isPartialQuery: false, + mode: 'agent', + }); + }); + + it('falls back to prefill when VS Code submit throws', async () => { + const executeCommand = vi.fn(async (_cmd: string, args?: { isPartialQuery?: boolean }) => { + if (args?.isPartialQuery === false) { + throw new Error('submit unsupported'); + } + }); + const result = await launchAgentPanelPrompt( + { text: '/opsx:verify demo', autoSubmit: true }, + { + isCursorHost: () => false, + getCommands: async () => new Set(['workbench.action.chat.open']), + executeCommand, + }, + ); + + expect(result.outcome).toBe('prefilled'); + expect(executeCommand).toHaveBeenCalledTimes(2); + }); + it('clipboard is the final fallback', async () => { const writeClipboard = vi.fn(async () => undefined); const result = await launchAgentPanelPrompt( @@ -227,6 +267,10 @@ describe('agentPanelLauncher', () => { expect(result.layer).toBe('chatOpen'); expect(executeCommand).toHaveBeenCalledWith('composer.createNew', expect.any(Object)); - expect(executeCommand).toHaveBeenCalledWith('workbench.action.chat.open', { query: '/opsx:apply demo' }); + expect(executeCommand).toHaveBeenCalledWith('workbench.action.chat.open', { + query: '/opsx:apply demo', + isPartialQuery: true, + mode: 'agent', + }); }); }); diff --git a/test/extension/services/changeTaskProgressFromFile.test.ts b/test/extension/services/changeTaskProgressFromFile.test.ts new file mode 100644 index 0000000..fc40bc2 --- /dev/null +++ b/test/extension/services/changeTaskProgressFromFile.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + overlayChangeTaskProgressFromFiles, + taskProgressFromTasksMarkdown, +} from '@extension/services/changeTaskProgressFromFile'; +import type { ChangeInfo } from '@extension/services/types'; + +describe('changeTaskProgressFromFile', () => { + it('counts tasks the same way as shared taskMarkdown', () => { + const content = `- [ ] one\n- [x] two\n- [~] three\n`; + expect(taskProgressFromTasksMarkdown(content)).toEqual({ + completedTasks: 1, + totalTasks: 3, + status: 'in-progress', + }); + }); + + it('overlays CLI change rows from tasks.md content', async () => { + const cliChange: ChangeInfo = { + name: 'demo', + completedTasks: 0, + totalTasks: 99, + lastModified: '2026-01-01', + status: 'draft', + }; + const contentAccess = { + readArtifact: async () => `- [x] done\n- [ ] todo\n`, + }; + const [updated] = await overlayChangeTaskProgressFromFiles([cliChange], contentAccess); + expect(updated.completedTasks).toBe(1); + expect(updated.totalTasks).toBe(2); + }); +}); diff --git a/test/extension/services/workflowAgentLaunch.test.ts b/test/extension/services/workflowAgentLaunch.test.ts index 245c88c..2893552 100644 --- a/test/extension/services/workflowAgentLaunch.test.ts +++ b/test/extension/services/workflowAgentLaunch.test.ts @@ -11,6 +11,12 @@ vi.mock('@extension/services/agentPanelLauncher', () => ({ launchAgentPanelPrompt, })); +const notifyAgentPrefillLaunchHint = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock('@extension/services/agentLaunchPrefillNotify', () => ({ + notifyAgentPrefillLaunchHint, +})); + vi.mock('@extension/adapters', () => ({ getCurrentAdapter: vi.fn(async () => ({ id: 'cursor', displayName: 'Cursor', fillChat: vi.fn(), executeTask: vi.fn(), isAvailable: vi.fn() })), getAdapterById: vi.fn(), @@ -190,6 +196,22 @@ describe('launchWorkflowAgentCommand', () => { expect(launchAgentPanelPrompt).not.toHaveBeenCalled(); }); + it('shows prefill hint when launch ends in prefill-only', async () => { + launchAgentPanelPrompt.mockResolvedValue({ + success: true, + layer: 'vscodeChat', + outcome: 'prefilled', + }); + + await launchWorkflowAgentCommand({ + action: 'apply', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); + + expect(notifyAgentPrefillLaunchHint).toHaveBeenCalled(); + }); + it('dedupes rapid repeat launches for the same change and action', async () => { const request = { action: 'verify' as const, diff --git a/test/shared/chatOpenLaunchArgs.test.ts b/test/shared/chatOpenLaunchArgs.test.ts new file mode 100644 index 0000000..2d98c21 --- /dev/null +++ b/test/shared/chatOpenLaunchArgs.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { buildChatOpenLaunchArgs } from '../../src/shared/chatOpenLaunchArgs'; + +describe('buildChatOpenLaunchArgs', () => { + it('prefills when autoSubmit is false', () => { + expect(buildChatOpenLaunchArgs('/opsx:verify demo', false)).toEqual({ + query: '/opsx:verify demo', + isPartialQuery: true, + mode: 'agent', + }); + }); + + it('submits when autoSubmit is true', () => { + expect(buildChatOpenLaunchArgs('/opsx:verify demo', true)).toEqual({ + query: '/opsx:verify demo', + isPartialQuery: false, + mode: 'agent', + }); + }); +}); diff --git a/test/shared/executorLaunchPresentation.test.ts b/test/shared/executorLaunchPresentation.test.ts index afad2b7..cb52351 100644 --- a/test/shared/executorLaunchPresentation.test.ts +++ b/test/shared/executorLaunchPresentation.test.ts @@ -35,7 +35,7 @@ describe('buildExecutorLaunchPresentation', () => { expect(presentation.agentAdapters.currentId).toBe('clipboard'); 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(getWorkflowActionButtonLabel('Continue planning', presentation.uiWorkflowLaunchConfig)).toBe('Open Agent panel · Continue planning'); expect(getWorkflowLaunchModeHint(presentation.uiWorkflowLaunchConfig)).toBe('Opens Agent panel'); }); @@ -51,7 +51,7 @@ describe('buildExecutorLaunchPresentation', () => { ); expect(getTaskNextButtonLabel(presentation.uiWorkflowLaunchConfig)).toBe('Next with Agent'); - expect(getWorkflowActionButtonLabel('Continue planning', presentation.uiWorkflowLaunchConfig)).toBe('Open Cursor · Continue planning'); + expect(getWorkflowActionButtonLabel('Continue planning', presentation.uiWorkflowLaunchConfig)).toBe('Open Agent panel · Continue planning'); }); }); @@ -86,7 +86,7 @@ describe('b663c24 regression: settings cursor + runtime clipboard only', () => { setLocale('en'); const settingsOnly = resolveUiWorkflowLaunchConfig(cursorSettingsConfig, null, []); expect(getTaskNextButtonLabel(settingsOnly)).toBe('Next with Agent'); - expect(getWorkflowActionButtonLabel('Continue planning', settingsOnly)).toBe('Open Cursor · Continue planning'); + expect(getWorkflowActionButtonLabel('Continue planning', settingsOnly)).toBe('Open Agent panel · Continue planning'); const presentation = buildExecutorLaunchPresentation( cursorSettingsConfig, @@ -95,7 +95,7 @@ describe('b663c24 regression: settings cursor + runtime clipboard only', () => { ); expect(getTaskNextButtonLabel(presentation.uiWorkflowLaunchConfig)).toBe('Next with Agent'); expect(getWorkflowActionButtonLabel('Continue planning', presentation.uiWorkflowLaunchConfig)).toBe( - 'Open Cursor · Continue planning', + 'Open Agent panel · Continue planning', ); expect(getWorkflowLaunchModeHint(presentation.uiWorkflowLaunchConfig)).toBe('Opens Agent panel'); }); diff --git a/test/webview/components/changeDetailExecutorSync.test.tsx b/test/webview/components/changeDetailExecutorSync.test.tsx index 9f13ace..1ab4b57 100644 --- a/test/webview/components/changeDetailExecutorSync.test.tsx +++ b/test/webview/components/changeDetailExecutorSync.test.tsx @@ -64,7 +64,7 @@ describe('Change detail executor-driven labels', () => { />, ); - expect(actionBarHtml).toContain('Open Cursor · Continue planning'); + expect(actionBarHtml).toContain('Open Agent panel · Continue planning'); expect(taskListHtml).toContain('Next with Agent'); expect(taskListHtml).not.toContain('>Copy<'); }); diff --git a/test/webview/components/verifyArchivePanel.test.ts b/test/webview/components/verifyArchivePanel.test.ts index 36f0809..8dd1327 100644 --- a/test/webview/components/verifyArchivePanel.test.ts +++ b/test/webview/components/verifyArchivePanel.test.ts @@ -13,19 +13,22 @@ const agentPanelLaunchConfig = { effectiveAdapterId: 'cursor' as const, }; +const changeName = 'demo-change'; + describe('VerifyArchivePanel', () => { it('renders run controls for verify and archive', () => { setLocale('en'); const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { + changeName, isArchived: false, workflowLaunchConfig: agentPanelLaunchConfig, onRun: () => undefined, }) ); - expect(html).toContain('Open Cursor · Verify'); - expect(html).toContain('Open Cursor · Archive'); + expect(html).toContain('Open Agent panel · Verify'); + expect(html).toContain('Open Agent panel · Archive'); expect(html).toContain('Archive Now'); }); @@ -33,6 +36,7 @@ describe('VerifyArchivePanel', () => { setLocale('en'); const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { + changeName, isArchived: false, canArchiveNow: false, archiveNowDisabledReason: 'Complete all required tasks before archiving directly.', @@ -50,6 +54,7 @@ describe('VerifyArchivePanel', () => { it('keeps both archive actions non-executable for archived changes', () => { const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { + changeName, isArchived: true, canArchiveNow: false, archiveNowDisabledReason: 'Archived changes are read-only.', @@ -65,6 +70,7 @@ describe('VerifyArchivePanel', () => { it('disables archive when the change is already archived', () => { const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { + changeName, isArchived: true, workflowLaunchConfig: agentPanelLaunchConfig, onRun: () => undefined, @@ -72,21 +78,37 @@ describe('VerifyArchivePanel', () => { ); expect(html).toContain('disabled'); - expect(html).toContain('Open Cursor · Archive'); + expect(html).toContain('Open Agent panel · Archive'); }); it('describes Agent panel launch hints instead of terminal session controls', () => { setLocale('en'); const html = renderToStaticMarkup( React.createElement(VerifyArchivePanel, { + changeName, isArchived: false, onRun: () => undefined, }) ); - expect(html).toContain('/opsx:verify my-change'); - expect(html).toContain('/opsx:archive my-change'); + expect(html).toContain('/opsx:verify demo-change'); + expect(html).toContain('/opsx:archive demo-change'); expect(html).not.toContain('Reveal Terminal'); expect(html).not.toContain('Clear Session'); }); + + it('localizes action labels in zh-CN', () => { + setLocale('zh-cn'); + const html = renderToStaticMarkup( + React.createElement(VerifyArchivePanel, { + changeName, + isArchived: false, + workflowLaunchConfig: agentPanelLaunchConfig, + onRun: () => undefined, + }) + ); + + expect(html).toContain('打开 Agent 面板 · 验证'); + expect(html).toContain('打开 Agent 面板 · 归档'); + }); }); diff --git a/test/webview/utils/executorUiLaunchConfig.test.ts b/test/webview/utils/executorUiLaunchConfig.test.ts index b3cc33c..e9cc836 100644 --- a/test/webview/utils/executorUiLaunchConfig.test.ts +++ b/test/webview/utils/executorUiLaunchConfig.test.ts @@ -57,7 +57,7 @@ describe('executorUiLaunchConfig', () => { expect(uiConfig.workflowLaunchMode).toBe('adapter'); expect(uiConfig.preferredAgentAdapter).toBe('cursor'); expect(getTaskNextButtonLabel(uiConfig)).toBe('Next with Agent'); - expect(getWorkflowActionButtonLabel('Continue planning', uiConfig)).toBe('Open Cursor · Continue planning'); + expect(getWorkflowActionButtonLabel('Continue planning', uiConfig)).toBe('Open Agent panel · Continue planning'); expect(getExecutorUiModeLabel(uiConfig)).toBe('cursor'); }); @@ -94,7 +94,7 @@ describe('executorUiLaunchConfig', () => { }); expect(getTaskNextButtonLabel(uiConfig)).toBe('Copy'); - expect(getWorkflowActionButtonLabel('Continue planning', uiConfig)).not.toContain('Open Cursor'); + expect(getWorkflowActionButtonLabel('Continue planning', uiConfig)).not.toContain('Open Agent panel'); expect(getWorkflowLaunchModeHint(uiConfig)).toBe('Copies command'); }); @@ -142,7 +142,7 @@ describe('executorUiLaunchConfig', () => { getExampleWorkflowCommand('verify', withoutHostFlag.uiWorkflowLaunchConfig, { isCursorHost: withoutHostFlag.isCursorHost === true, }), - ).toBe('/opsx:verify my-change'); + ).toBe('/opsx:verify '); const withHostFlag = normalizeExecutorPresentation({ agentAdapters: message.agentAdapters, @@ -155,7 +155,7 @@ describe('executorUiLaunchConfig', () => { getExampleWorkflowCommand('verify', withHostFlag.uiWorkflowLaunchConfig, { isCursorHost: withHostFlag.isCursorHost === true, }), - ).toBe('/opsx-verify my-change'); + ).toBe('/opsx-verify '); }); }); diff --git a/test/webview/utils/resolveUiWorkflowLaunchConfig.test.ts b/test/webview/utils/resolveUiWorkflowLaunchConfig.test.ts index ab11fbf..6b4cdc4 100644 --- a/test/webview/utils/resolveUiWorkflowLaunchConfig.test.ts +++ b/test/webview/utils/resolveUiWorkflowLaunchConfig.test.ts @@ -19,6 +19,6 @@ describe('resolveUiWorkflowLaunchConfig re-export', () => { setLocale('en'); const uiConfig = resolveUiWorkflowLaunchConfig(cursorLaunchConfig, 'clipboard'); expect(getTaskNextButtonLabel(uiConfig)).toBe('Next with Agent'); - expect(getWorkflowActionButtonLabel('Continue planning', uiConfig)).toBe('Open Cursor · Continue planning'); + expect(getWorkflowActionButtonLabel('Continue planning', uiConfig)).toBe('Open Agent panel · Continue planning'); }); }); diff --git a/test/webview/utils/workflowLaunchLabelMatrix.test.ts b/test/webview/utils/workflowLaunchLabelMatrix.test.ts index 1d620b0..a92d622 100644 --- a/test/webview/utils/workflowLaunchLabelMatrix.test.ts +++ b/test/webview/utils/workflowLaunchLabelMatrix.test.ts @@ -19,7 +19,7 @@ function viewFrom(config: WorkflowLaunchConfigWithExplicit) { describe('workflow launch label matrix (host × M1–M10)', () => { beforeEach(() => setLocale('en')); - it('M1 default Cursor agent panel uses Open Cursor · Apply', () => { + it('M1 default Cursor agent panel uses Open Agent panel · Apply', () => { const config = viewFrom({ workflowLaunchMode: 'adapter', preferredAgentAdapter: 'cursor', @@ -29,7 +29,7 @@ describe('workflow launch label matrix (host × M1–M10)', () => { workflowLaunchModeExplicit: false, preferredAgentAdapterExplicit: false, }); - expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Open Cursor · Apply'); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Open Agent panel · Apply'); expect(getWorkflowLaunchModeHint(config)).toBe('Opens Agent panel'); }); @@ -73,7 +73,7 @@ describe('workflow launch label matrix (host × M1–M10)', () => { expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Copy Apply'); }); - it('M8 agent panel matches M1 Open Cursor wording', () => { + it('M8 agent panel matches M1 Open Agent panel wording', () => { const config = viewFrom({ workflowLaunchMode: 'adapter', preferredAgentAdapter: 'cursor', @@ -83,10 +83,10 @@ describe('workflow launch label matrix (host × M1–M10)', () => { workflowLaunchModeExplicit: false, preferredAgentAdapterExplicit: false, }); - expect(getWorkflowActionButtonLabel('Verify', config)).toBe('Open Cursor · Verify'); + expect(getWorkflowActionButtonLabel('Verify', config)).toBe('Open Agent panel · Verify'); }); - it('M8d deeplink uses Open Cursor · Apply', () => { + it('M8d deeplink uses Open Agent panel · Apply', () => { const config = viewFrom({ workflowLaunchMode: 'adapter', preferredAgentAdapter: 'cursor', @@ -96,7 +96,7 @@ describe('workflow launch label matrix (host × M1–M10)', () => { workflowLaunchModeExplicit: false, preferredAgentAdapterExplicit: false, }); - expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Open Cursor · Apply'); + expect(getWorkflowActionButtonLabel('Apply', config)).toBe('Open Agent panel · Apply'); }); it('M10 workflow clipboard uses Copy Verify', () => { diff --git a/test/webview/utils/workflowLaunchLabels.test.ts b/test/webview/utils/workflowLaunchLabels.test.ts index f846b9f..f9c2a92 100644 --- a/test/webview/utils/workflowLaunchLabels.test.ts +++ b/test/webview/utils/workflowLaunchLabels.test.ts @@ -54,7 +54,7 @@ describe('workflow launch labels', () => { cursorLaunchMode: 'deeplink', effectiveAdapterId: 'cursor', }), - ).toBe('Open Cursor · Apply'); + ).toBe('Open Agent panel · Apply'); expect( getWorkflowLaunchModeHint({ ...baseConfig, @@ -80,8 +80,8 @@ describe('workflow launch labels', () => { 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).toContain('/opsx:verify '); + expect(desc).toContain('/opsx:archive '); expect(desc).not.toContain('/opsx-verify'); const cursorCopy: WorkflowLaunchConfigView = { @@ -93,8 +93,8 @@ describe('workflow launch labels', () => { effectiveAdapterId: 'cursor', }; const cursorDesc = getVerifyArchiveDescription(cursorCopy, { isCursorHost: true }); - expect(cursorDesc).toContain('/opsx-verify my-change'); - expect(cursorDesc).toContain('/opsx-archive my-change'); + expect(cursorDesc).toContain('/opsx-verify '); + expect(cursorDesc).toContain('/opsx-archive '); }); it('formats Verify & Archive hints with colon commands for VS Code Chat', () => { @@ -104,8 +104,8 @@ describe('workflow launch labels', () => { 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(getExampleWorkflowCommand('verify', config)).toBe('/opsx:verify '); + expect(getVerifyArchiveHint('verify', config, undefined, 'real-change')).toContain('/opsx:verify real-change'); expect(getVerifyArchiveHint('verify', config)).toContain('Chat'); expect(getVerifyArchiveHint('verify', config)).not.toContain('/opsx-verify'); }); From ff0734fee3624d6f4532968ee89a1c8a994f55e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 05:32:55 +0000 Subject: [PATCH 02/19] fix(workflow): QA agent-panel regressions and task progress (0.2.5-agent-panel.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove post-createNew composer focus; session hint toast instead - Light-patch tasks.md progress to sidebar/detail without CLI reload - Align task counts with OpenSpec CLI (ws2-zeta-markers fixture) - Prefill hint once per activation; V&A copy follows autoSubmit - Prefill-only receipt shows Prefilled / 已预填 Co-authored-by: RandyZhang --- CHANGELOG.md | 8 ++ docs/CURSOR_COMPOSER_FOCUS.md | 18 +-- package.json | 2 +- src/extension/extension.ts | 7 + .../providers/changeDetailPanelManager.ts | 12 ++ .../providers/dashboardViewProvider.ts | 75 ++++++++++- .../providers/launchWorkflowAction.ts | 8 +- .../providers/webviewMessageHandler.ts | 11 +- .../services/agentCursorSessionHint.ts | 16 +++ .../services/agentLaunchPrefillNotify.ts | 7 + src/extension/services/agentPanelLauncher.ts | 40 ------ .../services/changeTaskProgressFromFile.ts | 7 + src/extension/services/dataManager.ts | 122 ++++++++++++++---- src/extension/services/workflowAgentLaunch.ts | 4 + src/i18n/locales/en.json | 9 +- src/i18n/locales/zh-cn.json | 9 +- src/shared/changeTaskProgressPatch.ts | 19 +++ src/shared/changeWorkflow.ts | 1 + src/shared/openspecTaskProgress.ts | 33 +++++ src/shared/taskMarkdown.ts | 13 +- src/shared/workflowLaunchConfig.ts | 3 + src/webview/components/ActionBar.tsx | 1 + src/webview/components/ChangeDetail.tsx | 21 +++ src/webview/components/Dashboard.tsx | 2 + src/webview/context/AppContext.tsx | 24 ++++ src/webview/types/messages.ts | 3 +- src/webview/utils/applyTaskProgressPatch.ts | 27 ++++ src/webview/utils/workflowLaunchLabels.ts | 40 ++++-- test-fixtures/ws2-zeta-markers/tasks.md | 11 ++ .../providers/agentUnavailableHandler.test.ts | 5 + .../providers/dashboardViewProvider.test.ts | 69 +++++++--- .../services/agentLaunchPrefillNotify.test.ts | 14 ++ .../services/agentPanelLauncher.test.ts | 31 +++++ test/extension/services/dataManager.test.ts | 2 + .../services/workflowAgentLaunch.test.ts | 15 +++ test/shared/openspecTaskProgress.test.ts | 19 +++ .../utils/applyTaskProgressPatch.test.ts | 24 ++++ .../utils/workflowLaunchLabels.test.ts | 13 ++ 38 files changed, 623 insertions(+), 122 deletions(-) create mode 100644 src/extension/services/agentCursorSessionHint.ts create mode 100644 src/shared/changeTaskProgressPatch.ts create mode 100644 src/shared/openspecTaskProgress.ts create mode 100644 src/webview/utils/applyTaskProgressPatch.ts create mode 100644 test-fixtures/ws2-zeta-markers/tasks.md create mode 100644 test/shared/openspecTaskProgress.test.ts create mode 100644 test/webview/utils/applyTaskProgressPatch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3926e..cfb5582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.5-agent-panel.2] - 2026-10-09 + +### Fixed + +- **BLOCKER:** Removed post–`composer.createNew` `composer.openComposer` / focus calls that broke Cursor 3.21.16 Agent tabs; restored 0.2.4-agent-panel.18 launch behavior. +- Task progress: OpenSpec CLI–aligned checkbox counting (`ws2-zeta-markers` 3/8); immediate `changeTaskProgressPatch` to sidebar and detail on `tasks.md` edits; project-first sidebar no longer CLI-reloads on every dashboard refresh. +- Prefill hint at most once per activation; Verify/Archive hints follow `agentAutoSubmit`; prefill launches receipt status `prefilled` / 「已预填」. + ## [0.2.5-agent-panel.1] - 2026-10-09 ### Changed diff --git a/docs/CURSOR_COMPOSER_FOCUS.md b/docs/CURSOR_COMPOSER_FOCUS.md index 4384324..67885da 100644 --- a/docs/CURSOR_COMPOSER_FOCUS.md +++ b/docs/CURSOR_COMPOSER_FOCUS.md @@ -2,20 +2,20 @@ ## Symptom -After Cursor restarts, the first workflow launch via `composer.createNew` can leave a **restored previous chat** in the foreground while the new Agent run opens in a background tab. +After Cursor restarts, the first workflow launch via `composer.createNew` can leave a **restored previous chat** visible while the new Agent run opens in another tab. -## Investigation +## Investigation (2026-10-09 QA) -- Primary launch uses `composer.createNew` with `openInNewTab: true`, `view: 'pane'`, and `unifiedMode: 'agent'`. That creates a new composer, but Cursor may not switch the visible tab to it when session restore replays an older composer. -- Public, stable Cursor commands for “focus composer by id” are not documented in the VS Code API. Runtime detection may expose ids such as `composer.openComposer` or `composer.focusComposer` on some builds; they are not guaranteed across versions. -- A follow-up `workbench.action.chat.open` would prefill/focus chat but can fight with `createNew` and must not await the agent turn. +- Post–PR #25 `tryFocusComposerById` called `composer.openComposer` with `{ composerId }` ~100ms after `composer.createNew`. On Cursor 3.21.16 this **regressed** launch: tabs collapsed, blank “Loading Chat”, prefill lost, and errors such as `e.path.replace is not a function` inside `computeTabLabels` → `openEditor`. The object form was treated as an editor input. +- **Decision:** remove all post–`createNew` composer focus / `openComposer` calls. Launch behavior matches pre-focus builds (e.g. 0.2.4-agent-panel.18 / `bdeab81`). +- There is still no stable, public Cursor API to focus a composer by id without risking editor corruption. ## Mitigation in the extension -1. After `composer.createNew`, best-effort focus: if `composer.getOrderedSelectedComposerIds` shows a new id, try known focus commands when present (`composer.openComposer`, `composer.focusComposer`, `composer.selectComposer`) with that id — fire-and-forget, no await on the turn. -2. If pane growth is not confirmed within the background check window, show the existing “panel not confirmed” warning. -3. When focus commands are unavailable or ids do not change (restore race), show a one-shot hint that the new run may be in another Agent tab (`agentLaunch.cursorNewTabHint`). +1. **Launch:** only `composer.createNew` (or chat/deeplink fallbacks) — no follow-up focus commands. +2. **First launch per activation:** one non-modal toast (`agentLaunch.cursorFirstPanelTabHint`) on the first successful Cursor agent-panel launch after the extension activates in that window. +3. **Background pane check:** unchanged warning if pane count does not increase within 1.5s (`agentLaunch.panelNotConfirmed`). ## Reliable fix -No fully reliable, version-stable focus API was found without Cursor-internal hooks. Tab focus behavior should be treated as host-dependent until Cursor documents a supported focus command. +None without Cursor-supported focus API. Track Cursor releases for a documented “focus composer” command that accepts an id safely. diff --git a/package.json b/package.json index be8fb77..33ca022 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.5-agent-panel.1", + "version": "0.2.5-agent-panel.2", "publisher": "randysss", "license": "MIT", "engines": { diff --git a/src/extension/extension.ts b/src/extension/extension.ts index b4f70c6..8aa3c42 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -138,6 +138,13 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + dataManager.onTaskProgressPatch((patch) => { + changeDetailPanelManager.broadcastTaskProgressPatch(patch); + dashboardViewProvider.applyTaskProgressPatch(patch); + }) + ); + context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((event) => { const launchConfigChanged = diff --git a/src/extension/providers/changeDetailPanelManager.ts b/src/extension/providers/changeDetailPanelManager.ts index f1c3fcc..e758922 100644 --- a/src/extension/providers/changeDetailPanelManager.ts +++ b/src/extension/providers/changeDetailPanelManager.ts @@ -20,6 +20,7 @@ import { isWorkflowSnapshotBoundTo, type ChangeWorkflowSnapshot, } from '../../shared/changeWorkflow'; +import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; import type { OpenSpecScope } from '../services/openspecScope'; import type { OpenSpecRootBinding, ProjectContext } from '../services/types'; @@ -428,6 +429,17 @@ export class ChangeDetailPanelManager { * Notify an open panel that certain artifact caches should be invalidated. * Called by DataManager's onArtifactChanged subscriber when upstream files change. */ + public broadcastTaskProgressPatch(patch: ChangeTaskProgressPatch): void { + for (const [key, panel] of this.panels) { + if (!this.keyMatchesChange(key, patch.changeName)) continue; + try { + panel.webview.postMessage(patch); + } catch (err) { + logger.warn(`broadcastTaskProgressPatch: panel ${patch.changeName} may be disposed`, err as Error); + } + } + } + public notifyArtifactChanged(changeName: string, artifactTypes: string[], rootPath?: string): void { for (const [key, panel] of this.panels) { if (!this.keyMatchesChange(key, changeName)) continue; diff --git a/src/extension/providers/dashboardViewProvider.ts b/src/extension/providers/dashboardViewProvider.ts index c0513c2..7aba034 100644 --- a/src/extension/providers/dashboardViewProvider.ts +++ b/src/extension/providers/dashboardViewProvider.ts @@ -20,6 +20,8 @@ import type { ProjectSidebarData, ProjectSpecsExplorerData, } from '../../webview/types/messages'; +import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; +import { enrichChangeWithLifecycle } from '../../shared/changeLifecycle'; import { handleWebviewMessage, getWebviewContent, @@ -116,13 +118,84 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { this.skipNextProjectRefreshCallback = false; return; } - void this.reloadProjectSidebarData(); + this.mergeDashboardTaskProgressIntoProjectSidebar(data); return; } this.postDashboardData(data); }); } + public applyTaskProgressPatch(patch: ChangeTaskProgressPatch): void { + if (this.isProjectFirst()) { + this.patchProjectSidebarTaskProgress(patch); + } + const post = (webview: vscode.Webview | undefined) => { + webview?.postMessage(patch); + }; + post(this._view?.webview); + post(this.dashboardPanel?.webview); + for (const panel of this.explorerPanels.values()) { + post(panel.webview); + } + } + + private patchProjectSidebarTaskProgress(patch: ChangeTaskProgressPatch): void { + if (!this.cachedProjectSidebarData) return; + const index = this.cachedProjectSidebarData.changes.findIndex((c) => c.name === patch.changeName); + if (index < 0) return; + const current = this.cachedProjectSidebarData.changes[index]; + const updated = enrichChangeWithLifecycle({ + ...current, + completedTasks: patch.completedTasks, + totalTasks: patch.totalTasks, + status: patch.totalTasks === 0 + ? 'draft' + : patch.completedTasks === patch.totalTasks + ? 'complete' + : 'in-progress', + }); + const changes = [...this.cachedProjectSidebarData.changes]; + changes[index] = updated; + this.cachedProjectSidebarData = { + ...this.cachedProjectSidebarData, + changes, + }; + } + + private mergeDashboardTaskProgressIntoProjectSidebar(data: DashboardData): void { + if (!data?.changes?.length) { + return; + } + if (!this.cachedProjectSidebarData) { + void this.reloadProjectSidebarData(); + return; + } + const byName = new Map(data.changes.map((change) => [change.name, change])); + const changes = this.cachedProjectSidebarData.changes.map((change) => { + const fresh = byName.get(change.name); + if (!fresh) return change; + return enrichChangeWithLifecycle({ + ...change, + completedTasks: fresh.completedTasks, + totalTasks: fresh.totalTasks, + status: fresh.status, + lifecycleStatus: fresh.lifecycleStatus, + attention: fresh.attention, + }); + }); + this.cachedProjectSidebarData = { + ...this.cachedProjectSidebarData, + changes, + }; + this.publishProjectSnapshot( + this.cachedProjectSidebarData, + this._view?.webview, + 'sidebar', + { source: 'memory', stale: false }, + true, + ); + } + /** * Called when the view is first opened or becomes visible */ diff --git a/src/extension/providers/launchWorkflowAction.ts b/src/extension/providers/launchWorkflowAction.ts index 9753969..0cb1935 100644 --- a/src/extension/providers/launchWorkflowAction.ts +++ b/src/extension/providers/launchWorkflowAction.ts @@ -331,7 +331,13 @@ export async function processLaunchWorkflowAction( return; } if (launchResult.success) { - const status = launchResult.outcome === 'copied' ? 'copied' : 'delivered'; + const status = launchResult.outcome === 'copied' + ? 'copied' + : launchResult.outcome === 'prefilled' + ? 'prefilled' + : launchResult.outcome === 'submitted' + ? 'delivered' + : 'delivered'; postReceipt(receiptTarget, status, launchResult.message); } else { const reason = launchResult.message ?? t('agentLaunch.failed'); diff --git a/src/extension/providers/webviewMessageHandler.ts b/src/extension/providers/webviewMessageHandler.ts index 73bb70b..fe28015 100644 --- a/src/extension/providers/webviewMessageHandler.ts +++ b/src/extension/providers/webviewMessageHandler.ts @@ -1461,11 +1461,20 @@ export async function handleAgentUnavailableMessage( } } +function readAgentAutoSubmitModeForUi(): 'never' | 'readOnly' | 'always' { + const raw = vscode.workspace.getConfiguration('openspec').get('agentAutoSubmit'); + if (raw === 'never' || raw === 'always' || raw === 'readOnly') return raw; + return 'readOnly'; +} + export function getWorkflowLaunchConfigMessage() { const config = getWorkflowLaunchConfig(); return { type: 'workflowLaunchConfig' as const, - config: toWorkflowLaunchConfigView(config), + config: { + ...toWorkflowLaunchConfigView(config), + agentAutoSubmit: readAgentAutoSubmitModeForUi(), + }, isCursorHost: isCursorHost(), }; } diff --git a/src/extension/services/agentCursorSessionHint.ts b/src/extension/services/agentCursorSessionHint.ts new file mode 100644 index 0000000..8796e04 --- /dev/null +++ b/src/extension/services/agentCursorSessionHint.ts @@ -0,0 +1,16 @@ +import * as vscode from 'vscode'; +import { t } from '../../i18n'; +import type { AgentLaunchLayer } from './agentPanelLauncher'; + +let firstAgentPanelLaunchHintShown = false; + +export function resetAgentCursorSessionHintForTests(): void { + firstAgentPanelLaunchHintShown = false; +} + +export function maybeShowFirstCursorAgentPanelLaunchHint(layer: AgentLaunchLayer): void { + if (firstAgentPanelLaunchHintShown) return; + if (layer !== 'composerCreateNew' && layer !== 'chatOpen') return; + firstAgentPanelLaunchHintShown = true; + void vscode.window.showInformationMessage(t('agentLaunch.cursorFirstPanelTabHint')); +} diff --git a/src/extension/services/agentLaunchPrefillNotify.ts b/src/extension/services/agentLaunchPrefillNotify.ts index 320649d..32b7920 100644 --- a/src/extension/services/agentLaunchPrefillNotify.ts +++ b/src/extension/services/agentLaunchPrefillNotify.ts @@ -4,19 +4,26 @@ import { t } from '../../i18n'; const GLOBAL_SUPPRESS_KEY = 'openspec.suppressAgentPrefillLaunchHint'; let extensionContext: vscode.ExtensionContext | undefined; +let prefillHintShownThisActivation = false; export function configureAgentLaunchPrefillNotify(context: vscode.ExtensionContext): void { extensionContext = context; + prefillHintShownThisActivation = false; } export function resetAgentLaunchPrefillNotifyForTests(): void { extensionContext = undefined; + prefillHintShownThisActivation = false; } export async function notifyAgentPrefillLaunchHint(): Promise { if (extensionContext?.globalState.get(GLOBAL_SUPPRESS_KEY)) { return; } + if (prefillHintShownThisActivation) { + return; + } + prefillHintShownThisActivation = true; const openSettingsLabel = t('agentLaunch.prefillHintOpenSettings'); const dontShowLabel = t('agentLaunch.prefillHintDontShowAgain'); diff --git a/src/extension/services/agentPanelLauncher.ts b/src/extension/services/agentPanelLauncher.ts index e581204..4b3d0f2 100644 --- a/src/extension/services/agentPanelLauncher.ts +++ b/src/extension/services/agentPanelLauncher.ts @@ -80,33 +80,6 @@ function composerIdsIncreased(before: string[] | undefined, after: string[] | un return after.length > before.length; } -function findNewComposerId(before: string[] | undefined, after: string[] | undefined): string | undefined { - if (!after?.length) return undefined; - if (!before?.length) return after[after.length - 1]; - const beforeSet = new Set(before); - const added = after.filter((id) => !beforeSet.has(id)); - return added[added.length - 1] ?? (after.length > before.length ? after[after.length - 1] : undefined); -} - -const COMPOSER_FOCUS_COMMANDS = [ - 'composer.openComposer', - 'composer.focusComposer', - 'composer.selectComposer', -] as const; - -function tryFocusComposerById( - composerId: string, - cmds: Set, - executeCommand: NonNullable, -): void { - for (const command of COMPOSER_FOCUS_COMMANDS) { - if (!cmds.has(command)) continue; - void executeCommand(command, { composerId }).then(undefined, () => undefined); - void executeCommand(command, composerId).then(undefined, () => undefined); - return; - } -} - async function tryCursorComposerCreateNew( request: AgentPanelLaunchRequest, deps: AgentPanelLauncherDeps, @@ -160,13 +133,6 @@ async function tryCursorComposerCreateNew( } void (async () => { - await sleep(100); - const earlyIds = await readComposerPaneIds(executeCommand, cmds); - const newComposerId = findNewComposerId(beforeIds, earlyIds); - if (newComposerId) { - tryFocusComposerById(newComposerId, cmds, executeCommand); - } - await sleep(COMPOSER_SUCCESS_WAIT_MS); const afterIds = await readComposerPaneIds(executeCommand, cmds); if (!composerIdsIncreased(beforeIds, afterIds)) { @@ -178,12 +144,6 @@ async function tryCursorComposerCreateNew( ? t('agentLaunch.submitNotConfirmed') : t('agentLaunch.panelNotConfirmed'), ); - void vscode.window.showInformationMessage?.(t('agentLaunch.cursorNewTabHint')); - } else { - const confirmedNewId = findNewComposerId(beforeIds, afterIds); - if (confirmedNewId) { - tryFocusComposerById(confirmedNewId, cmds, executeCommand); - } } })(); diff --git a/src/extension/services/changeTaskProgressFromFile.ts b/src/extension/services/changeTaskProgressFromFile.ts index 2a75115..7b598e3 100644 --- a/src/extension/services/changeTaskProgressFromFile.ts +++ b/src/extension/services/changeTaskProgressFromFile.ts @@ -41,6 +41,13 @@ export async function overlayChangeTaskProgressFromFiles( return change; } const progress = taskProgressFromTasksMarkdown(content); + if ( + progress.totalTasks === 0 + && change.totalTasks > 0 + && (content.includes('[') || content.includes('- ')) + ) { + return change; + } return applyTaskProgressToChange(change, progress); } catch { return change; diff --git a/src/extension/services/dataManager.ts b/src/extension/services/dataManager.ts index 7e565a6..c57fe13 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -27,6 +27,7 @@ import { overlayChangeTaskProgressFromFiles, taskProgressFromTasksMarkdown, } from './changeTaskProgressFromFile'; +import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; export interface ScopeInfo { id: string; @@ -133,7 +134,9 @@ export class DataManager { private queuedRefresh: Promise | null = null; private queuedRefreshScope: OpenSpecScope | undefined; private refreshCallbacks: Set<(data: DashboardData) => void> = new Set(); + private taskProgressPatchCallbacks: Set<(patch: ChangeTaskProgressPatch) => void> = new Set(); private artifactChangedCallbacks: Set<(event: ArtifactChangedEvent) => void> = new Set(); + private tasksOnlyRefreshTimer: ReturnType | undefined; private cliAvailable = false; // Scope-aware additions @@ -545,8 +548,8 @@ export class DataManager { logger.warn('autoCompleteParents after tasks.md change', err as Error) ); const scope = this.resolveScopeForRoot(this.watchedProjectRoot); - void this.patchCachedTaskProgressFromFile(tasksChangeName, scope).catch((err) => - logger.warn('patchCachedTaskProgressFromFile after tasks.md change', err as Error) + void this.publishTaskProgressFromFile(tasksChangeName, scope).catch((err) => + logger.warn('publishTaskProgressFromFile after tasks.md change', err as Error) ); } @@ -574,13 +577,22 @@ export class DataManager { }); } - logger.info(`File changes detected (${events.length} events), refreshing...`); - void (async () => { - await this.invalidateDashboardCache(this.resolveScopeForRoot(this.watchedProjectRoot)); - await this.refresh(); - })().catch((error) => { - logger.warn('Failed to refresh after file changes', error as Error); + const onlyTasksMd = events.length > 0 && events.every((event) => { + const relative = path.relative(this.watchedProjectRoot, event.uri.fsPath).replace(/\\/g, '/'); + return /\/tasks\.md$/.test(relative); }); + + if (onlyTasksMd) { + this.scheduleTasksOnlyBackgroundRefresh(this.resolveScopeForRoot(this.watchedProjectRoot)); + } else { + logger.info(`File changes detected (${events.length} events), refreshing...`); + void (async () => { + await this.invalidateDashboardCache(this.resolveScopeForRoot(this.watchedProjectRoot)); + await this.refresh(); + })().catch((error) => { + logger.warn('Failed to refresh after file changes', error as Error); + }); + } }); this.warmDashboardData(); @@ -1343,13 +1355,25 @@ export class DataManager { await fs.promises.writeFile(filePath, YAML.stringify(data), 'utf8'); } - private async patchCachedTaskProgressFromFile( + private scheduleTasksOnlyBackgroundRefresh(scope?: OpenSpecScope): void { + if (this.tasksOnlyRefreshTimer) { + clearTimeout(this.tasksOnlyRefreshTimer); + } + this.tasksOnlyRefreshTimer = setTimeout(() => { + this.tasksOnlyRefreshTimer = undefined; + void (async () => { + await this.invalidateDashboardCache(scope); + await this.refresh(scope); + })().catch((error) => { + logger.warn('Failed deferred tasks-only refresh', error as Error); + }); + }, 2000); + } + + private async publishTaskProgressFromFile( changeName: string, scope?: OpenSpecScope, ): Promise { - if (!this.cachedData || !this.isCurrentScope(scope)) { - return; - } const services = this.getScopedServices(scope); let content: string; try { @@ -1357,22 +1381,68 @@ export class DataManager { } catch { return; } - const progress = taskProgressFromTasksMarkdown(content); - const index = this.cachedData.changes.findIndex((c) => c.name === changeName); - if (index < 0) { + if (!content.trim()) { return; } - const updatedChange = applyTaskProgressToChange(this.cachedData.changes[index], progress); - const changes = [...this.cachedData.changes]; - changes[index] = enrichChangeWithLifecycle(updatedChange); - const data: DashboardData = { - ...this.cachedData, - changes, - changeStatusCounts: buildChangeStatusCounts(changes, this.cachedData.archivedChanges), - lastRefresh: Date.now(), - }; - this.cachedData = data; - this.notifyRefresh(data); + + let progress = taskProgressFromTasksMarkdown(content); + if (this.cachedData && this.isCurrentScope(scope)) { + const existing = this.cachedData.changes.find((c) => c.name === changeName); + if ( + existing + && progress.totalTasks === 0 + && existing.totalTasks > 0 + && (content.includes('[') || content.includes('- ')) + ) { + progress = { + completedTasks: existing.completedTasks, + totalTasks: existing.totalTasks, + status: existing.status, + }; + } + } + + if (this.cachedData && this.isCurrentScope(scope)) { + const index = this.cachedData.changes.findIndex((c) => c.name === changeName); + if (index >= 0) { + const updatedChange = applyTaskProgressToChange(this.cachedData.changes[index], progress); + const changes = [...this.cachedData.changes]; + changes[index] = enrichChangeWithLifecycle(updatedChange); + this.cachedData = { + ...this.cachedData, + changes, + changeStatusCounts: buildChangeStatusCounts(changes, this.cachedData.archivedChanges), + lastRefresh: Date.now(), + }; + } + } + + this.notifyTaskProgressPatch({ + type: 'changeTaskProgressPatch', + changeName, + scopeId: scope?.id, + completedTasks: progress.completedTasks, + totalTasks: progress.totalTasks, + tasksContent: content, + revisedAt: Date.now(), + }); + } + + onTaskProgressPatch(callback: (patch: ChangeTaskProgressPatch) => void): vscode.Disposable { + this.taskProgressPatchCallbacks.add(callback); + return new vscode.Disposable(() => { + this.taskProgressPatchCallbacks.delete(callback); + }); + } + + private notifyTaskProgressPatch(patch: ChangeTaskProgressPatch): void { + for (const callback of this.taskProgressPatchCallbacks) { + try { + callback(patch); + } catch (error) { + logger.error('Error in taskProgressPatch callback', error as Error); + } + } } /** diff --git a/src/extension/services/workflowAgentLaunch.ts b/src/extension/services/workflowAgentLaunch.ts index 53f6d84..56dea77 100644 --- a/src/extension/services/workflowAgentLaunch.ts +++ b/src/extension/services/workflowAgentLaunch.ts @@ -26,6 +26,7 @@ import { import { isCursorHost } from '../utils/isCursorHost'; import { notifyWorkflowCommandCopied } from './workflowClipboardNotify'; import { notifyAgentPrefillLaunchHint } from './agentLaunchPrefillNotify'; +import { maybeShowFirstCursorAgentPanelLaunchHint } from './agentCursorSessionHint'; export type WorkflowAgentLaunchTarget = | 'clipboard' @@ -296,6 +297,9 @@ async function executeWorkflowAgentLaunch( outcome: panelResult.outcome, message: panelResult.message, }; + if (isCursorHost() && panelResult.layer) { + maybeShowFirstCursorAgentPanelLaunchHint(panelResult.layer); + } notifyLaunchResult(result, payload.command); return result; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5ff4a75..f9329e5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -93,6 +93,7 @@ "workflow.highImpact": "High-impact actions", "workflow.receiptPending": "Pending", "workflow.receiptDelivered": "Delivered", + "workflow.receiptPrefilled": "Prefilled", "workflow.receiptCopied": "Copied", "workflow.receiptFallback": "Fallback", "workflow.receiptFailed": "Failed", @@ -183,7 +184,13 @@ "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.verifyAgentPrefillHint": "Prefills {command} in the Agent panel — press Enter to send.", + "verifyArchive.verifyAgentSentHint": "Sends {command} in the Agent panel automatically.", "verifyArchive.archiveAgentHint": "Prefills {command} in the Agent panel (confirm before sending).", + "verifyArchive.archiveAgentPrefillHint": "Prefills {command} in the Agent panel — press Enter to send.", + "verifyArchive.archiveAgentSentHint": "Sends {command} in the Agent panel automatically.", + "verifyArchive.verifyChatSentHint": "Sends {command} in Chat automatically.", + "verifyArchive.archiveChatSentHint": "Sends {command} in Chat automatically.", "verifyArchive.verifyCopyHint": "Copies {command} to the clipboard.", "verifyArchive.archiveCopyHint": "Copies {command} to the clipboard.", "verifyArchive.verifyAgentCliHint": "Runs {command} via Cursor Agent CLI.", @@ -248,7 +255,7 @@ "agentLaunch.prefillHint": "Prefilled in the Agent chat — press Enter to send.", "agentLaunch.prefillHintOpenSettings": "Auto-send settings", "agentLaunch.prefillHintDontShowAgain": "Don't show again", - "agentLaunch.cursorNewTabHint": "The new Agent run may have opened in another tab — switch tabs if you still see an older chat.", + "agentLaunch.cursorFirstPanelTabHint": "Opened in a new Agent tab — if you still see an older chat, switch to the latest tab.", "dashboard.loading": "Loading OpenSpec data...", "dashboard.loadFailed": "Failed to load data. Try refreshing.", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index f65a0a7..660c854 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -95,6 +95,7 @@ "workflow.highImpact": "高影响动作", "workflow.receiptPending": "等待中", "workflow.receiptDelivered": "已投递", + "workflow.receiptPrefilled": "已预填", "workflow.receiptCopied": "已复制", "workflow.receiptFallback": "已回退", "workflow.receiptFailed": "失败", @@ -185,6 +186,12 @@ "verifyArchive.descriptionCopy": "Verify 与 Archive 与其他 Copy 操作相同,将 {verifyCommand} 与 {archiveCommand} 复制到剪贴板。", "verifyArchive.descriptionAdapter": "Verify 与 Archive 与 Apply、Continue 相同,通过已配置的 Chat 适配器启动。", "verifyArchive.verifyAgentHint": "在 Agent 面板打开 {command}。", + "verifyArchive.verifyAgentPrefillHint": "在 Agent 面板预填 {command},按 Enter 发送。", + "verifyArchive.verifyAgentSentHint": "在 Agent 面板自动发送 {command}。", + "verifyArchive.archiveAgentPrefillHint": "在 Agent 面板预填 {command},按 Enter 发送。", + "verifyArchive.archiveAgentSentHint": "在 Agent 面板自动发送 {command}。", + "verifyArchive.verifyChatSentHint": "在 Chat 中自动发送 {command}。", + "verifyArchive.archiveChatSentHint": "在 Chat 中自动发送 {command}。", "verifyArchive.archiveAgentHint": "在 Agent 面板预填 {command}(发送前请确认)。", "verifyArchive.verifyCopyHint": "将 {command} 复制到剪贴板。", "verifyArchive.archiveCopyHint": "将 {command} 复制到剪贴板。", @@ -250,7 +257,7 @@ "agentLaunch.prefillHint": "已填入 Agent 输入框,按 Enter 发送", "agentLaunch.prefillHintOpenSettings": "自动发送设置", "agentLaunch.prefillHintDontShowAgain": "不再提示", - "agentLaunch.cursorNewTabHint": "新的 Agent 运行可能已在其他标签页打开;若仍看到旧对话,请切换标签页。", + "agentLaunch.cursorFirstPanelTabHint": "已在新的 Agent 标签中打开;若前台仍是旧对话,请切换到最新标签。", "dashboard.loading": "正在加载 OpenSpec 数据...", "dashboard.loadFailed": "加载数据失败,请尝试刷新。", diff --git a/src/shared/changeTaskProgressPatch.ts b/src/shared/changeTaskProgressPatch.ts new file mode 100644 index 0000000..a3485fa --- /dev/null +++ b/src/shared/changeTaskProgressPatch.ts @@ -0,0 +1,19 @@ +export interface ChangeTaskProgressPatch { + type: 'changeTaskProgressPatch'; + changeName: string; + scopeId?: string; + completedTasks: number; + totalTasks: number; + tasksContent?: string; + revisedAt: number; +} + +export function isChangeTaskProgressPatch(value: unknown): value is ChangeTaskProgressPatch { + if (!value || typeof value !== 'object') return false; + const patch = value as Partial; + return patch.type === 'changeTaskProgressPatch' + && typeof patch.changeName === 'string' + && typeof patch.completedTasks === 'number' + && typeof patch.totalTasks === 'number' + && typeof patch.revisedAt === 'number'; +} diff --git a/src/shared/changeWorkflow.ts b/src/shared/changeWorkflow.ts index b37c1f3..ca0eaf2 100644 --- a/src/shared/changeWorkflow.ts +++ b/src/shared/changeWorkflow.ts @@ -31,6 +31,7 @@ export interface ChangeWorkflowSnapshot { export type WorkflowActionReceiptStatus = | 'delivered' + | 'prefilled' | 'copied' | 'fallback' | 'running' diff --git a/src/shared/openspecTaskProgress.ts b/src/shared/openspecTaskProgress.ts new file mode 100644 index 0000000..106b440 --- /dev/null +++ b/src/shared/openspecTaskProgress.ts @@ -0,0 +1,33 @@ +/** + * Task checkbox counting aligned with OpenSpec CLI (`utils/task-progress.js`). + * Used for dashboard progress only; UI task-line parsing may stay stricter for toggles. + */ + +const TASK_LINE_PATTERN = + /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s]?)\s*\](?![([])|\s+\])\s*(.*)/; + +export interface OpenSpecTaskLineCount { + done: boolean; + description: string; +} + +export function parseOpenSpecTaskLineForCount(line: string): OpenSpecTaskLineCount | null { + const match = line.replace(/\r$/, '').match(TASK_LINE_PATTERN); + if (!match) return null; + return { + done: (match[1] ?? '').toLowerCase() === 'x', + description: match[2].trim(), + }; +} + +export function countOpenSpecTaskProgress(content: string): { completed: number; total: number } { + let total = 0; + let completed = 0; + for (const line of content.split('\n')) { + const parsed = parseOpenSpecTaskLineForCount(line); + if (!parsed) continue; + total += 1; + if (parsed.done) completed += 1; + } + return { completed, total }; +} diff --git a/src/shared/taskMarkdown.ts b/src/shared/taskMarkdown.ts index 37b22af..ed7f696 100644 --- a/src/shared/taskMarkdown.ts +++ b/src/shared/taskMarkdown.ts @@ -1,5 +1,7 @@ /** Shared tasks.md line parsing — keep webview + extension host in sync. */ +import { countOpenSpecTaskProgress } from './openspecTaskProgress'; + export type TaskMarker = 'open' | 'done' | 'inProgress'; export interface ParsedTaskLine { @@ -37,14 +39,5 @@ export function parseTaskLine(line: string): ParsedTaskLine | null { } export function countTaskProgress(content: string): { completed: number; total: number } { - const lines = content.split('\n'); - let total = 0; - let completed = 0; - for (const line of lines) { - const parsed = parseTaskLine(line); - if (!parsed) continue; - total += 1; - if (parsed.done) completed += 1; - } - return { completed, total }; + return countOpenSpecTaskProgress(content); } diff --git a/src/shared/workflowLaunchConfig.ts b/src/shared/workflowLaunchConfig.ts index 45738d2..fb1a141 100644 --- a/src/shared/workflowLaunchConfig.ts +++ b/src/shared/workflowLaunchConfig.ts @@ -32,8 +32,11 @@ export interface WorkflowLaunchRuntimeContext { export type EffectiveWorkflowAdapterId = PreferredAgentAdapter | null; +export type AgentAutoSubmitMode = 'never' | 'readOnly' | 'always'; + export interface WorkflowLaunchConfigView extends WorkflowLaunchConfig { effectiveAdapterId: EffectiveWorkflowAdapterId; + agentAutoSubmit?: AgentAutoSubmitMode; } /** User explicitly chose copy-only via settings (package defaults do not count). */ diff --git a/src/webview/components/ActionBar.tsx b/src/webview/components/ActionBar.tsx index 2b1dc45..b2af23f 100644 --- a/src/webview/components/ActionBar.tsx +++ b/src/webview/components/ActionBar.tsx @@ -35,6 +35,7 @@ function getReceiptLabel(status: string): string { const labels: Record[0]> = { pending: 'workflow.receiptPending', delivered: 'workflow.receiptDelivered', + prefilled: 'workflow.receiptPrefilled', copied: 'workflow.receiptCopied', fallback: 'workflow.receiptFallback', failed: 'workflow.receiptFailed', diff --git a/src/webview/components/ChangeDetail.tsx b/src/webview/components/ChangeDetail.tsx index 1d8802c..83a1f42 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -548,6 +548,27 @@ export const ChangeDetail: React.FC = ({ })) { setVerifyArchiveTasksLoading(false); } + } else if (data.type === 'changeTaskProgressPatch' && data.changeName === changeName) { + if (data.scopeId && scopeId && data.scopeId !== scopeId) { + return; + } + setTasksProgressError(null); + setTasksProgressUnknown(false); + setCompletedTasks(data.completedTasks); + setTotalTasks(data.totalTasks); + setVerifyArchiveTasksLoading(false); + if (typeof data.tasksContent === 'string') { + if (activeTabRef.current === 'tasks' || activeTabRef.current === 'verifyArchive') { + setContent(data.tasksContent); + } + completeArtifactFetchAndStoreContent({ + scopeId, + artifactType: 'tasks', + content: data.tasksContent, + coordinator: artifactFetchCoordinatorRef.current, + contentCache: contentCacheRef.current, + }); + } } else if (data.type === 'workflowActionReceipt' && data.changeName === changeName && workflowSnapshot diff --git a/src/webview/components/Dashboard.tsx b/src/webview/components/Dashboard.tsx index 0cdfb50..81f3c6b 100644 --- a/src/webview/components/Dashboard.tsx +++ b/src/webview/components/Dashboard.tsx @@ -480,6 +480,8 @@ export const Dashboard: React.FC = () => { ...prev, [message.specId]: message.requirements ?? [], })); + } else if (message.type === 'changeTaskProgressPatch') { + dispatch({ type: 'PATCH_TASK_PROGRESS', payload: message }); } else if (message.type === 'workflowLaunchConfig') { setWorkflowLaunchConfig(message.config ?? null); setWorkflowLaunchIsCursorHost(message.isCursorHost === true); diff --git a/src/webview/context/AppContext.tsx b/src/webview/context/AppContext.tsx index d6beb97..30b68f6 100644 --- a/src/webview/context/AppContext.tsx +++ b/src/webview/context/AppContext.tsx @@ -10,6 +10,8 @@ import type { WebviewCacheMeta, } from '../types/messages'; import { adaptLegacyDashboardData } from '../types/legacyDashboardAdapter'; +import { applyTaskProgressPatchToChanges } from '../utils/applyTaskProgressPatch'; +import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; export type DashboardActivity = | { kind: 'idle' } @@ -49,6 +51,7 @@ export type AppAction = | { type: 'START_SCOPE_SWITCH'; scopeId: string } | { type: 'SET_DATA'; payload: DashboardData; cache?: WebviewCacheMeta } | { type: 'SET_PROJECT_SIDEBAR'; payload: ProjectSidebarData } + | { type: 'PATCH_TASK_PROGRESS'; payload: ChangeTaskProgressPatch } | { type: 'SET_PAGE_CONTEXT'; payload: ProjectPageContextMessage } | { type: 'CLEAR_PAGE_CONTEXT' } | { type: 'SET_ERROR'; payload: string } @@ -175,6 +178,27 @@ export function appReducer(state: AppState, action: AppAction): AppState { }; } + case 'PATCH_TASK_PROGRESS': { + const patch = action.payload; + const nextSidebar = state.projectSidebar + ? { + ...state.projectSidebar, + changes: applyTaskProgressPatchToChanges(state.projectSidebar.changes, patch), + } + : null; + const nextData = state.data + ? { + ...state.data, + changes: applyTaskProgressPatchToChanges(state.data.changes, patch), + } + : null; + return { + ...state, + projectSidebar: nextSidebar, + data: nextData, + }; + } + case 'SET_PROJECT_SIDEBAR': { const stale = action.payload.cache?.stale === true; return { diff --git a/src/webview/types/messages.ts b/src/webview/types/messages.ts index 8b985d9..7ff0cd5 100644 --- a/src/webview/types/messages.ts +++ b/src/webview/types/messages.ts @@ -166,6 +166,7 @@ export interface ArtifactOutputDescriptor { } export type ExtensionMessage = + | import('../../shared/changeTaskProgressPatch').ChangeTaskProgressPatch | { type: 'dashboardData'; data: DashboardData; debug?: boolean; cache?: WebviewCacheMeta } | { type: 'cacheStats'; stats: CacheStatsView } | { type: 'cacheActionResult'; action: CacheAction; success: boolean; message?: string } @@ -177,7 +178,7 @@ export type ExtensionMessage = bindingKey: string; action: WorkflowAction; target: WorkflowCommandTarget; - status: 'delivered' | 'copied' | 'fallback' | 'running' | 'completed' | 'failed'; + status: 'delivered' | 'prefilled' | 'copied' | 'fallback' | 'running' | 'completed' | 'failed'; message?: string; } | { diff --git a/src/webview/utils/applyTaskProgressPatch.ts b/src/webview/utils/applyTaskProgressPatch.ts new file mode 100644 index 0000000..aa6184c --- /dev/null +++ b/src/webview/utils/applyTaskProgressPatch.ts @@ -0,0 +1,27 @@ +import type { ChangeInfo } from '../types/messages'; +import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; + +export function applyTaskProgressPatchToChange( + change: ChangeInfo, + patch: ChangeTaskProgressPatch, +): ChangeInfo { + if (change.name !== patch.changeName) return change; + const status = patch.totalTasks === 0 + ? 'draft' + : patch.completedTasks === patch.totalTasks + ? 'complete' + : 'in-progress'; + return { + ...change, + completedTasks: patch.completedTasks, + totalTasks: patch.totalTasks, + status, + }; +} + +export function applyTaskProgressPatchToChanges( + changes: ChangeInfo[], + patch: ChangeTaskProgressPatch, +): ChangeInfo[] { + return changes.map((change) => applyTaskProgressPatchToChange(change, patch)); +} diff --git a/src/webview/utils/workflowLaunchLabels.ts b/src/webview/utils/workflowLaunchLabels.ts index 7fe8695..fd25e7f 100644 --- a/src/webview/utils/workflowLaunchLabels.ts +++ b/src/webview/utils/workflowLaunchLabels.ts @@ -6,6 +6,7 @@ import { resolveWorkflowCommandTargetForUi, type WorkflowAction, } from '../../shared/workflowCommand'; +import { shouldAutoSubmitWorkflowAction } from '../../shared/agentAutoSubmit'; export const NEUTRAL_CHANGE_PLACEHOLDER = ''; @@ -146,6 +147,14 @@ export function getVerifyArchiveDescription( }); } +function willAutoSubmitWorkflowAction( + action: WorkflowAction, + config?: WorkflowLaunchConfigView | null, +): boolean { + const mode = config?.agentAutoSubmit ?? 'readOnly'; + return shouldAutoSubmitWorkflowAction(action, mode); +} + export function getVerifyArchiveHint( action: 'verify' | 'archive', config?: WorkflowLaunchConfigView | null, @@ -153,6 +162,7 @@ export function getVerifyArchiveHint( changeName?: string, ): string { const command = getExampleWorkflowCommand(action, config, runtime, changeName); + const autoSubmit = willAutoSubmitWorkflowAction(action, config); if (!config || !shouldUseAgentWorkflowLabels(config)) { return action === 'verify' ? t('verifyArchive.verifyCopyHint', { command }) @@ -170,17 +180,29 @@ export function getVerifyArchiveHint( : t('verifyArchive.archiveCopyHint', { command }); } if (config.cursorLaunchMode === 'chatCommand') { - return action === 'verify' - ? t('verifyArchive.verifyChatHint', { command }) - : t('verifyArchive.archiveChatHint', { command }); + return autoSubmit + ? (action === 'verify' + ? t('verifyArchive.verifyAgentSentHint', { command }) + : t('verifyArchive.archiveAgentSentHint', { command })) + : (action === 'verify' + ? t('verifyArchive.verifyChatHint', { command }) + : t('verifyArchive.archiveChatHint', { command })); } - return action === 'verify' - ? t('verifyArchive.verifyAgentHint', { command }) - : t('verifyArchive.archiveAgentHint', { command }); + return autoSubmit + ? (action === 'verify' + ? t('verifyArchive.verifyAgentSentHint', { command }) + : t('verifyArchive.archiveAgentSentHint', { command })) + : (action === 'verify' + ? t('verifyArchive.verifyAgentPrefillHint', { command }) + : t('verifyArchive.archiveAgentPrefillHint', { command })); } - return action === 'verify' - ? t('verifyArchive.verifyChatHint', { command }) - : t('verifyArchive.archiveChatHint', { command }); + return autoSubmit + ? (action === 'verify' + ? t('verifyArchive.verifyChatSentHint', { command }) + : t('verifyArchive.archiveChatSentHint', { command })) + : (action === 'verify' + ? t('verifyArchive.verifyChatHint', { command }) + : t('verifyArchive.archiveChatHint', { command })); } export function getWorkflowLaunchModeHint( diff --git a/test-fixtures/ws2-zeta-markers/tasks.md b/test-fixtures/ws2-zeta-markers/tasks.md new file mode 100644 index 0000000..2fcf6a9 --- /dev/null +++ b/test-fixtures/ws2-zeta-markers/tasks.md @@ -0,0 +1,11 @@ +# ws2-zeta-markers — CLI-aligned checkbox mix + +- [x] Alpha done (hyphen) +* [ ] Beta open (asterisk) ++ [x] Gamma done (plus) +1. [ ] Delta open (ordered dot) +2) [x] Epsilon done (ordered paren) + - [ ] Zeta nested open +- [A](https://example.com/not-a-task) +- [ ] Eta open after link guard +- [~] Theta in-progress counts as not done diff --git a/test/extension/providers/agentUnavailableHandler.test.ts b/test/extension/providers/agentUnavailableHandler.test.ts index c6c3475..b9676cd 100644 --- a/test/extension/providers/agentUnavailableHandler.test.ts +++ b/test/extension/providers/agentUnavailableHandler.test.ts @@ -11,6 +11,11 @@ vi.mock('vscode', () => ({ clipboard: { writeText: vi.fn() }, openExternal: vi.fn(), }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((key: string) => (key === 'agentAutoSubmit' ? 'readOnly' : undefined)), + })), + }, window: { showInformationMessage: vi.fn(), }, diff --git a/test/extension/providers/dashboardViewProvider.test.ts b/test/extension/providers/dashboardViewProvider.test.ts index cb8090b..11db9be 100644 --- a/test/extension/providers/dashboardViewProvider.test.ts +++ b/test/extension/providers/dashboardViewProvider.test.ts @@ -294,7 +294,7 @@ describe('DashboardViewProvider', () => { })); }); - it('publishes one fresh Project snapshot to both open Project surfaces', async () => { + it('publishes merged task progress to both open Project surfaces on refresh', async () => { vi.useFakeTimers(); const fixture = makeProjectFixture(); const first = { @@ -305,9 +305,8 @@ describe('DashboardViewProvider', () => { projectSpecs: [], referencedStoreSpecs: [], }; - const second = { ...first, changes: [makeProjectChange('second-change')] }; const gateway = { - loadProjectSidebarData: vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second), + loadProjectSidebarData: vi.fn().mockResolvedValue(first), }; const sidebarPostMessage = vi.fn(); const panel = makeEditorPanel(); @@ -325,19 +324,33 @@ describe('DashboardViewProvider', () => { sidebarPostMessage.mockClear(); panel.webview.postMessage.mockClear(); - const refreshCallback = (dataManager.onRefresh as any).mock.calls[0]?.[0] as (() => void) | undefined; - refreshCallback?.(); + const refreshCallback = (dataManager.onRefresh as any).mock.calls[0]?.[0] as ((data: unknown) => void) | undefined; + refreshCallback?.(makeDashboardData({ + changeName: 'first-change', + lastRefresh: 2, + completedTasks: 1, + totalTasks: 3, + })); await vi.runAllTimersAsync(); - expect(sidebarPostMessage).toHaveBeenCalledWith(expect.objectContaining({ - type: 'setContext', - view: 'sidebar', - data: expect.objectContaining({ changes: [second.changes[0] ] }), + expect(gateway.loadProjectSidebarData).toHaveBeenCalledTimes(1); + const sidebarContext = sidebarPostMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(sidebarContext).toEqual(expect.objectContaining({ + data: expect.objectContaining({ + changes: [expect.objectContaining({ name: 'first-change', completedTasks: 1, totalTasks: 3 })], + }), })); - expect(panel.webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ - type: 'setContext', - view: 'dashboard', - data: expect.objectContaining({ changes: [second.changes[0] ] }), + const dashboardContext = panel.webview.postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'dashboard') + .at(-1); + expect(dashboardContext).toEqual(expect.objectContaining({ + data: expect.objectContaining({ + changes: [expect.objectContaining({ name: 'first-change', completedTasks: 1, totalTasks: 3 })], + }), })); }); @@ -651,16 +664,20 @@ describe('DashboardViewProvider', () => { function makeDashboardData({ changeName, lastRefresh, + completedTasks = 0, + totalTasks = 1, }: { changeName: string; lastRefresh: number; + completedTasks?: number; + totalTasks?: number; }) { return { changes: [ { name: changeName, - completedTasks: 0, - totalTasks: 1, + completedTasks, + totalTasks, lastModified: '2026-06-01T00:00:00.000Z', status: 'draft' as const, lifecycleStatus: 'planning' as const, @@ -3397,7 +3414,7 @@ describe('DashboardViewProvider', () => { })); }); - it('project refreshes use the Project loader for watcher and manual refresh', async () => { + it('project dashboard refresh merges task progress without reloading the Project loader', async () => { vi.useFakeTimers(); const fixture = makeProjectFixture(); const first = makeProjectChange('first-change'); @@ -3418,12 +3435,22 @@ describe('DashboardViewProvider', () => { postMessage.mockClear(); const refreshCallback = (dataManager.onRefresh as any).mock.calls[0]?.[0] as ((data: any) => void) | undefined; - refreshCallback?.(makeDashboardData({ changeName: 'legacy-refresh', lastRefresh: 3 })); + refreshCallback?.(makeDashboardData({ + changeName: first.name, + completedTasks: 1, + totalTasks: 3, + lastRefresh: 3, + })); await vi.runAllTimersAsync(); - expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ - type: 'setContext', - view: 'sidebar', - data: expect.objectContaining({ changes: [second] }), + expect(gateway.loadChanges).toHaveBeenCalledTimes(1); + const mergedSidebar = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(mergedSidebar).toEqual(expect.objectContaining({ + data: expect.objectContaining({ + changes: [expect.objectContaining({ name: first.name, completedTasks: 1, totalTasks: 3 })], + }), })); postMessage.mockClear(); diff --git a/test/extension/services/agentLaunchPrefillNotify.test.ts b/test/extension/services/agentLaunchPrefillNotify.test.ts index 4fa3df1..4bfbd3b 100644 --- a/test/extension/services/agentLaunchPrefillNotify.test.ts +++ b/test/extension/services/agentLaunchPrefillNotify.test.ts @@ -34,6 +34,20 @@ describe('agentLaunchPrefillNotify', () => { expect(vscode.window.showInformationMessage).toHaveBeenCalled(); }); + it('shows at most once per extension activation', async () => { + configureAgentLaunchPrefillNotify({ + globalState: { + get: vi.fn(() => false), + update: vi.fn(), + }, + } as unknown as vscode.ExtensionContext); + + await notifyAgentPrefillLaunchHint(); + await notifyAgentPrefillLaunchHint(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); + }); + it('skips hint when suppressed in globalState', async () => { configureAgentLaunchPrefillNotify({ globalState: { diff --git a/test/extension/services/agentPanelLauncher.test.ts b/test/extension/services/agentPanelLauncher.test.ts index a59ac6a..92f7c4a 100644 --- a/test/extension/services/agentPanelLauncher.test.ts +++ b/test/extension/services/agentPanelLauncher.test.ts @@ -242,6 +242,37 @@ describe('agentPanelLauncher', () => { expect(executeCommand.mock.calls.filter(([c]) => c === 'composer.createNew')).toHaveLength(2); }); + it('does not invoke composer focus or open commands after createNew', 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; + }); + + await launchAgentPanelPrompt( + { text: '/opsx-apply demo', autoSubmit: false }, + { + isCursorHost: () => true, + getCommands: async () => new Set([ + 'composer.createNew', + 'composer.getOrderedSelectedComposerIds', + 'composer.openComposer', + 'composer.focusComposer', + ]), + executeCommand, + sleep: async () => undefined, + }, + ); + + const commands = executeCommand.mock.calls.map(([command]) => command); + expect(commands).not.toContain('composer.openComposer'); + expect(commands).not.toContain('composer.focusComposer'); + expect(commands).not.toContain('composer.selectComposer'); + }); + 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']); diff --git a/test/extension/services/dataManager.test.ts b/test/extension/services/dataManager.test.ts index db6bfe1..632b28d 100644 --- a/test/extension/services/dataManager.test.ts +++ b/test/extension/services/dataManager.test.ts @@ -432,6 +432,8 @@ describe('DataManager dashboard data loading', () => { getScopedServices: scopedServices, }); + const { mkdirSync } = await import('fs'); + mkdirSync(`${projectRoot}/openspec/changes/same-name`, { recursive: true }); try { await (manager as any).executeTaskRequest('same-name', 0, 'Task', projectScope); await (manager as any).setTaskExecutionState('same-name', 0, true, projectScope); diff --git a/test/extension/services/workflowAgentLaunch.test.ts b/test/extension/services/workflowAgentLaunch.test.ts index 2893552..7348af0 100644 --- a/test/extension/services/workflowAgentLaunch.test.ts +++ b/test/extension/services/workflowAgentLaunch.test.ts @@ -196,6 +196,21 @@ describe('launchWorkflowAgentCommand', () => { expect(launchAgentPanelPrompt).not.toHaveBeenCalled(); }); + it('maps prefill-only panel launches to prefilled receipts', async () => { + // covered in launchWorkflowAction.test; keep launch path mock here + launchAgentPanelPrompt.mockResolvedValue({ + success: true, + layer: 'composerCreateNew', + outcome: 'prefilled', + }); + const result = await launchWorkflowAgentCommand({ + action: 'apply', + changeName: 'demo-change', + workspaceRoot: '/workspace', + }); + expect(result.outcome).toBe('prefilled'); + }); + it('shows prefill hint when launch ends in prefill-only', async () => { launchAgentPanelPrompt.mockResolvedValue({ success: true, diff --git a/test/shared/openspecTaskProgress.test.ts b/test/shared/openspecTaskProgress.test.ts new file mode 100644 index 0000000..f9a118d --- /dev/null +++ b/test/shared/openspecTaskProgress.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { countOpenSpecTaskProgress } from '../../src/shared/openspecTaskProgress'; + +describe('openspecTaskProgress (CLI-aligned)', () => { + it('matches ws2-zeta-markers fixture (3/8)', () => { + const content = readFileSync( + path.join(process.cwd(), 'test-fixtures/ws2-zeta-markers/tasks.md'), + 'utf-8', + ); + expect(countOpenSpecTaskProgress(content)).toEqual({ completed: 3, total: 8 }); + }); + + it('counts ordered and plus markers the legacy parser missed', () => { + const content = ['* [ ] star', '1. [x] ordered', '+ [ ] plus'].join('\n'); + expect(countOpenSpecTaskProgress(content)).toEqual({ completed: 1, total: 3 }); + }); +}); diff --git a/test/webview/utils/applyTaskProgressPatch.test.ts b/test/webview/utils/applyTaskProgressPatch.test.ts new file mode 100644 index 0000000..18d84a9 --- /dev/null +++ b/test/webview/utils/applyTaskProgressPatch.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { applyTaskProgressPatchToChanges } from '../../../src/webview/utils/applyTaskProgressPatch'; +import type { ChangeInfo } from '../../../src/webview/types/messages'; + +describe('applyTaskProgressPatch', () => { + it('updates matching change task counts', () => { + const changes = [{ + name: 'demo', + completedTasks: 0, + totalTasks: 2, + lastModified: '2026-01-01', + status: 'in-progress', + } as ChangeInfo]; + const next = applyTaskProgressPatchToChanges(changes, { + type: 'changeTaskProgressPatch', + changeName: 'demo', + completedTasks: 2, + totalTasks: 2, + revisedAt: 1, + }); + expect(next[0].completedTasks).toBe(2); + expect(next[0].status).toBe('complete'); + }); +}); diff --git a/test/webview/utils/workflowLaunchLabels.test.ts b/test/webview/utils/workflowLaunchLabels.test.ts index f9c2a92..ad0c325 100644 --- a/test/webview/utils/workflowLaunchLabels.test.ts +++ b/test/webview/utils/workflowLaunchLabels.test.ts @@ -110,6 +110,19 @@ describe('workflow launch labels', () => { expect(getVerifyArchiveHint('verify', config)).not.toContain('/opsx-verify'); }); + it('verify hint reflects readOnly auto-submit for verify', () => { + const config: WorkflowLaunchConfigView = { + ...baseConfig, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + effectiveAdapterId: 'cursor', + cursorLaunchMode: 'agentPanel', + agentAutoSubmit: 'readOnly', + }; + expect(getVerifyArchiveHint('verify', config, { isCursorHost: true }, 'demo')).toContain('automatically'); + expect(getVerifyArchiveHint('archive', config, { isCursorHost: true }, 'demo')).toContain('Enter'); + }); + it('shows generic launch wording for non-Cursor adapters', () => { expect( getWorkflowActionButtonLabel('Apply', { From 512646a83796ccd707af9860505fdc632c7ccfec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 05:44:14 +0000 Subject: [PATCH 03/19] fix(release): typecheck gate, zh copy, 0.2.5-agent-panel.3 - Narrow changeTaskProgressPatch; generic task-progress patch helper - check-typecheck-baseline.sh uses grep; no rg dependency - zh-CN sidebar/V&A/header strings; localize card action summary Co-authored-by: RandyZhang --- CHANGELOG.md | 8 ++++++ package.json | 2 +- scripts/check-typecheck-baseline.sh | 27 ++++++++++++++++-- src/i18n/locales/zh-cn.json | 28 +++++++++---------- src/webview/components/ChangeCard.tsx | 9 ++++-- src/webview/components/ChangeDetail.tsx | 16 ++++++----- src/webview/utils/applyTaskProgressPatch.ts | 14 ++++++---- test/scripts/check-typecheck-baseline.test.ts | 20 +++++++++++++ test/webview/components/changeCard.test.tsx | 26 +++++++++++++++++ 9 files changed, 117 insertions(+), 33 deletions(-) create mode 100644 test/scripts/check-typecheck-baseline.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb5582..883acae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.5-agent-panel.3] - 2026-10-09 + +### Fixed + +- Typecheck: narrow `changeTaskProgressPatch` messages in Change Detail; accept `readonly` change lists in task-progress patch helper. +- CI typecheck gate: `check-typecheck-baseline.sh` counts errors with `grep` (no `rg` dependency); fails loudly on missing tools or unparseable output. +- zh-CN: localized sidebar card action summaries, lifecycle labels, New Change, and Verify & Archive intro copy (slash commands unchanged). + ## [0.2.5-agent-panel.2] - 2026-10-09 ### Fixed diff --git a/package.json b/package.json index 33ca022..7b74b27 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.5-agent-panel.2", + "version": "0.2.5-agent-panel.3", "publisher": "randysss", "license": "MIT", "engines": { diff --git a/scripts/check-typecheck-baseline.sh b/scripts/check-typecheck-baseline.sh index 61739be..2296760 100755 --- a/scripts/check-typecheck-baseline.sh +++ b/scripts/check-typecheck-baseline.sh @@ -5,10 +5,31 @@ BASELINE="${TSC_BASELINE_ERROR_COUNT:-13}" LOG="$(mktemp)" trap 'rm -f "$LOG"' EXIT +if ! command -v npx >/dev/null 2>&1; then + echo "check-typecheck-baseline: required command 'npx' not found in PATH" >&2 + exit 1 +fi + +if ! command -v grep >/dev/null 2>&1; then + echo "check-typecheck-baseline: required command 'grep' not found in PATH" >&2 + exit 1 +fi + # 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}" +TSC_EXIT=0 +npx tsc --noEmit -p tsconfig.typecheck.json >"$LOG" 2>&1 || TSC_EXIT=$? + +if [ ! -s "$LOG" ] && [ "$TSC_EXIT" -ne 0 ]; then + echo "check-typecheck-baseline: tsc failed (exit $TSC_EXIT) but produced no output" >&2 + exit 1 +fi + +COUNT="$(grep -c 'error TS' "$LOG" 2>/dev/null || true)" +if ! [[ "${COUNT:-}" =~ ^[0-9]+$ ]]; then + echo "check-typecheck-baseline: could not parse TypeScript error count from tsc output" >&2 + cat "$LOG" >&2 + exit 1 +fi if [ "$COUNT" -gt "$BASELINE" ]; then echo "TypeScript error count $COUNT exceeds baseline $BASELINE" diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 660c854..b047eec 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -180,11 +180,11 @@ "verify.debugLabel": "Debug: 执行 IDE 命令", "verify.executed": "命令已执行。", "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.description": "验证与归档与实施、继续相同,在 IDE Agent 面板启动 `/opsx-verify` 与 `/opsx-archive`。", + "verifyArchive.descriptionAgentPanel": "验证与归档与实施、继续相同,通过 Cursor Agent 面板启动。", + "verifyArchive.descriptionAgentCli": "验证与归档与实施、继续相同,通过 Cursor Agent CLI 启动。", + "verifyArchive.descriptionCopy": "验证与归档与其他复制操作相同,将 {verifyCommand} 与 {archiveCommand} 复制到剪贴板。", + "verifyArchive.descriptionAdapter": "验证与归档与实施、继续相同,通过已配置的 Chat 适配器启动。", "verifyArchive.verifyAgentHint": "在 Agent 面板打开 {command}。", "verifyArchive.verifyAgentPrefillHint": "在 Agent 面板预填 {command},按 Enter 发送。", "verifyArchive.verifyAgentSentHint": "在 Agent 面板自动发送 {command}。", @@ -199,7 +199,7 @@ "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.descriptionChatAdapter": "验证与归档与实施、继续相同,通过 Chat 启动(例如 {verifyCommand}、{archiveCommand})。", "verifyArchive.verifyTitle": "验证与确认", "verifyArchive.archiveTitle": "Archive", "verifyArchive.runVerify": "Run Verify", @@ -267,7 +267,7 @@ "dashboard.changes": "Changes ({count})", "dashboard.emptyChanges": "此 Root 中没有 change。", "dashboard.emptyChangesInRoot": "{root} 中没有 change。", - "dashboard.createNew": "创建 New Change", + "dashboard.createNew": "新建 Change", "dashboard.specs": "Specs ({count})", "dashboard.emptySpecs": "此工作区尚未定义 specs。", "dashboard.emptySpecsInRoot": "{root} 中还没有定义 spec。", @@ -277,13 +277,13 @@ "dashboard.emptyPlanning": "没有 Planning change", "dashboard.emptyReadyToApply": "没有 Ready to Apply change", "dashboard.emptyApplying": "没有 Applying change", - "dashboard.emptyReadyToVerify": "没有 Ready to Verify change", + "dashboard.emptyReadyToVerify": "没有待验证的 change", "dashboard.emptySearchAndFilters": "没有匹配当前搜索和筛选条件的 change", "dashboard.lifecycleAll": "全部", - "dashboard.lifecyclePlanning": "Planning", - "dashboard.lifecycleReadyToApply": "Ready to Apply", - "dashboard.lifecycleApplying": "Applying", - "dashboard.lifecycleReadyToVerify": "Ready to Verify", + "dashboard.lifecyclePlanning": "规划中", + "dashboard.lifecycleReadyToApply": "待实施", + "dashboard.lifecycleApplying": "实施中", + "dashboard.lifecycleReadyToVerify": "待验证", "dashboard.lifecycleArchived": "已归档", "dashboard.lifecycleFilterLabel": "按生命周期状态筛选", "dashboard.needsAttention": "Needs Attention", @@ -346,7 +346,7 @@ "projectDashboard.summary": "项目摘要", "projectDashboard.totalChanges": "Change 总数", "projectDashboard.activeChanges": "Active Change", - "projectDashboard.readyToVerify": "待 Verify", + "projectDashboard.readyToVerify": "待验证", "projectDashboard.archived": "已归档", "projectDashboard.activeTasks": "Active 任务", "projectDashboard.completionRate": "Active 任务完成率", @@ -414,7 +414,7 @@ "header.refresh": "刷新", "header.loading": "加载中...", - "header.newChange": "+ New Change", + "header.newChange": "+ 新建 Change", "action.openInEditor": "在编辑器中打开", "action.refresh": "刷新", diff --git a/src/webview/components/ChangeCard.tsx b/src/webview/components/ChangeCard.tsx index 1051711..f1285b9 100644 --- a/src/webview/components/ChangeCard.tsx +++ b/src/webview/components/ChangeCard.tsx @@ -12,11 +12,12 @@ import type { OpenSpecRootBinding } from '../types/messages'; import { getWorkflowActionButtonLabel, getWorkflowActionTitle, + localizeWorkflowActionDisplayLabel, type WorkflowLaunchConfigView, } from '../utils/workflowLaunchLabels'; import { formatDateLabel, formatRelativeDateLabel } from '../utils/dateLabels'; -const LIFECYCLE_BADGE_KEYS: Record = { +const LIFECYCLE_BADGE_KEYS: Record[0]> = { planning: 'dashboard.lifecyclePlanning', 'ready-to-apply': 'dashboard.lifecycleReadyToApply', applying: 'dashboard.lifecycleApplying', @@ -205,7 +206,11 @@ export const ChangeCard: React.FC = ({ data-workflow-summary style={{ color: 'var(--vscode-descriptionForeground)' }} > - {resolvedActions.recommended?.label ?? t('dashboard.noRecommendedAction')} + + {resolvedActions.recommended + ? localizeWorkflowActionDisplayLabel(resolvedActions.recommended.label) + : t('dashboard.noRecommendedAction')} + {additionalActionCount > 0 && {t('dashboard.moreActions', { count: additionalActionCount })}} {resolvedActions.blocked.length > 0 && {t('dashboard.blockedCount', { count: resolvedActions.blocked.length })}} {resolvedActions.skipped.length > 0 && {t('dashboard.skippedCount', { count: resolvedActions.skipped.length })}} diff --git a/src/webview/components/ChangeDetail.tsx b/src/webview/components/ChangeDetail.tsx index 83a1f42..85155e6 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -58,6 +58,7 @@ import { shouldShowActiveTabLoadingForArtifactFetch, } from '../utils/changeDetailTasksFetch'; import type { WorkflowActionReceipt } from '../../shared/changeWorkflow'; +import { isChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; const MISSING_ARTIFACT_MESSAGE = t('artifact.missing'); @@ -548,23 +549,24 @@ export const ChangeDetail: React.FC = ({ })) { setVerifyArchiveTasksLoading(false); } - } else if (data.type === 'changeTaskProgressPatch' && data.changeName === changeName) { - if (data.scopeId && scopeId && data.scopeId !== scopeId) { + } else if (isChangeTaskProgressPatch(msg) && msg.changeName === changeName) { + const patch = msg; + if (patch.scopeId && scopeId && patch.scopeId !== scopeId) { return; } setTasksProgressError(null); setTasksProgressUnknown(false); - setCompletedTasks(data.completedTasks); - setTotalTasks(data.totalTasks); + setCompletedTasks(patch.completedTasks); + setTotalTasks(patch.totalTasks); setVerifyArchiveTasksLoading(false); - if (typeof data.tasksContent === 'string') { + if (typeof patch.tasksContent === 'string') { if (activeTabRef.current === 'tasks' || activeTabRef.current === 'verifyArchive') { - setContent(data.tasksContent); + setContent(patch.tasksContent); } completeArtifactFetchAndStoreContent({ scopeId, artifactType: 'tasks', - content: data.tasksContent, + content: patch.tasksContent, coordinator: artifactFetchCoordinatorRef.current, contentCache: contentCacheRef.current, }); diff --git a/src/webview/utils/applyTaskProgressPatch.ts b/src/webview/utils/applyTaskProgressPatch.ts index aa6184c..9ea0542 100644 --- a/src/webview/utils/applyTaskProgressPatch.ts +++ b/src/webview/utils/applyTaskProgressPatch.ts @@ -1,10 +1,12 @@ import type { ChangeInfo } from '../types/messages'; import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; -export function applyTaskProgressPatchToChange( - change: ChangeInfo, +type ChangeTaskProgressRow = Pick; + +export function applyTaskProgressPatchToChange( + change: T, patch: ChangeTaskProgressPatch, -): ChangeInfo { +): T { if (change.name !== patch.changeName) return change; const status = patch.totalTasks === 0 ? 'draft' @@ -19,9 +21,9 @@ export function applyTaskProgressPatchToChange( }; } -export function applyTaskProgressPatchToChanges( - changes: ChangeInfo[], +export function applyTaskProgressPatchToChanges( + changes: readonly T[], patch: ChangeTaskProgressPatch, -): ChangeInfo[] { +): T[] { return changes.map((change) => applyTaskProgressPatchToChange(change, patch)); } diff --git a/test/scripts/check-typecheck-baseline.test.ts b/test/scripts/check-typecheck-baseline.test.ts new file mode 100644 index 0000000..cd9e436 --- /dev/null +++ b/test/scripts/check-typecheck-baseline.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +describe('check-typecheck-baseline.sh', () => { + const script = readFileSync( + path.resolve(__dirname, '../../scripts/check-typecheck-baseline.sh'), + 'utf8', + ); + + it('counts TypeScript errors with grep (no ripgrep dependency)', () => { + expect(script).toMatch(/grep -c 'error TS'/); + expect(script).not.toMatch(/\brg\b/); + }); + + it('uses strict bash and validates the parsed count', () => { + expect(script).toContain('set -euo pipefail'); + expect(script).toContain('could not parse TypeScript error count'); + }); +}); diff --git a/test/webview/components/changeCard.test.tsx b/test/webview/components/changeCard.test.tsx index f9af58a..047f8ba 100644 --- a/test/webview/components/changeCard.test.tsx +++ b/test/webview/components/changeCard.test.tsx @@ -152,6 +152,32 @@ describe('ChangeCard', () => { }); }); +describe('ChangeCard zh-CN copy', () => { + beforeEach(() => setLocale('zh-cn')); + + it('localizes recommended action text in the workflow summary', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain(t('workflow.action.verify')); + expect(html).not.toContain('>Verify<'); + }); + + it('localizes Continue planning in the workflow summary', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain(t('workflow.action.continuePlanning')); + }); + + it('localizes ready-to-verify lifecycle badge', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain(t('dashboard.lifecycleReadyToVerify')); + }); +}); + describe('ChangeCard lifecycle badge', () => { beforeEach(() => { setLocale('en'); From 21c345ff2ed461769820366819f413b059bbdf96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 05:56:31 +0000 Subject: [PATCH 04/19] fix(release): typecheck gate hardening and zh copy (0.2.5-agent-panel.4) - Fail gate when tsc crashes without error TS diagnostics; baseline 12 - zh-CN Needs Attention, filter empties, archive verify, Proposal Why, V&A intro Co-authored-by: RandyZhang --- CHANGELOG.md | 7 ++ package.json | 2 +- scripts/check-typecheck-baseline.sh | 18 +++- src/i18n/locales/zh-cn.json | 92 +++++++++---------- test/i18n/i18n.test.ts | 4 +- test/scripts/check-typecheck-baseline.test.ts | 39 ++++++-- test/webview/components/header.test.tsx | 4 +- 7 files changed, 107 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 883acae..7dc0c06 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] +## [0.2.5-agent-panel.4] - 2026-10-09 + +### Fixed + +- Typecheck gate: fail when `tsc` exits non-zero but emits zero `error TS` lines; baseline lowered to **12**. +- zh-CN: Needs Attention filter/badge, lifecycle empty states, archive verify dialog, Proposal Why, V&A intro wording, and remaining English UI fragments. + ## [0.2.5-agent-panel.3] - 2026-10-09 ### Fixed diff --git a/package.json b/package.json index 7b74b27..6ca9239 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.5-agent-panel.3", + "version": "0.2.5-agent-panel.4", "publisher": "randysss", "license": "MIT", "engines": { diff --git a/scripts/check-typecheck-baseline.sh b/scripts/check-typecheck-baseline.sh index 2296760..cd60291 100755 --- a/scripts/check-typecheck-baseline.sh +++ b/scripts/check-typecheck-baseline.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -BASELINE="${TSC_BASELINE_ERROR_COUNT:-13}" +BASELINE="${TSC_BASELINE_ERROR_COUNT:-12}" LOG="$(mktemp)" trap 'rm -f "$LOG"' EXIT @@ -15,9 +15,17 @@ if ! command -v grep >/dev/null 2>&1; then exit 1 fi +run_tsc() { + if [ -n "${CHECK_TYPECHECK_TSC_INVOKE:-}" ]; then + bash -c "${CHECK_TYPECHECK_TSC_INVOKE}" + else + npx tsc --noEmit -p tsconfig.typecheck.json + fi +} + # Must match local: npx tsc --noEmit -p tsconfig.typecheck.json TSC_EXIT=0 -npx tsc --noEmit -p tsconfig.typecheck.json >"$LOG" 2>&1 || TSC_EXIT=$? +run_tsc >"$LOG" 2>&1 || TSC_EXIT=$? if [ ! -s "$LOG" ] && [ "$TSC_EXIT" -ne 0 ]; then echo "check-typecheck-baseline: tsc failed (exit $TSC_EXIT) but produced no output" >&2 @@ -31,6 +39,12 @@ if ! [[ "${COUNT:-}" =~ ^[0-9]+$ ]]; then exit 1 fi +if [ "$TSC_EXIT" -ne 0 ] && [ "$COUNT" -eq 0 ]; then + echo "check-typecheck-baseline: tsc exited $TSC_EXIT but no 'error TS' lines were counted" >&2 + cat "$LOG" >&2 + exit 1 +fi + if [ "$COUNT" -gt "$BASELINE" ]; then echo "TypeScript error count $COUNT exceeds baseline $BASELINE" cat "$LOG" diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index b047eec..6239f76 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -32,7 +32,7 @@ "task.warnPrompt": "以下任务尚未完成,是否仍要执行?\n{list}{more}", "task.continue": "继续执行", "task.cancel": "取消", - "task.noAdapter": "当前没有可用的执行者。请安装 Cursor 或使用剪贴板(Clipboard)适配器。", + "task.noAdapter": "当前没有可用的执行者。请安装 Cursor 或使用剪贴板适配器。", "task.processedVia": "已通过 {adapter} 处理", "task.executionFailed": "执行失败", "task.executor": "执行者:", @@ -54,7 +54,7 @@ "workflow.copiedCommand": "已复制命令:{command}", "workflow.noAdapterCopied": "当前没有可用执行者,已复制命令:{command}", "workflow.adapterFallback": "无法启动适配器,命令已复制。", - "workflow.bindingMismatch": "无法启动工作流:Change 根目录已变化。请刷新 Dashboard 后重试。", + "workflow.bindingMismatch": "无法启动工作流:Change 根目录已变化。请刷新仪表板后重试。", "workflow.bindingValidationFailed": "无法校验此工作流对应的 Change 根目录。", "workflow.bindingRequestIncomplete": "工作流请求缺少绑定信息。请刷新后重试。", "workflow.launchDeduped": "该 change 的工作流已在启动中。", @@ -136,9 +136,9 @@ "command.selectArchive": "选择要归档的 change", "command.archiveConfirm": "确认归档 change \"{name}\"?", "command.archive": "归档", - "command.archiveVerifyFirst": "先 Verify", - "command.archiveVerifySuggest": "建议在归档前先执行 Verify。现在去 Verify,或继续直接归档。", - "command.archiveVerifyHint": "Verify & Archive", + "command.archiveVerifyFirst": "先验证", + "command.archiveVerifySuggest": "建议在归档前先执行验证。现在去验证,或继续直接归档。", + "command.archiveVerifyHint": "验证并归档", "command.archiving": "正在归档 change: {name}...", "command.archived": "Change \"{name}\" 已归档", "command.archiveFailed": "归档 change 失败", @@ -173,18 +173,18 @@ "verify.completeness": "完整性:所有 tasks 完成,所有 requirements 有对应代码", "verify.correctness": "正确性:实现匹配 spec intent,边界情况已处理", "verify.coherence": "一致性:设计决策反映在代码中,命名约定一致", - "verify.run": "Run Verify", + "verify.run": "运行验证", "verify.commandIdEmpty": "Command ID 不能为空", - "verify.notInAllowlist": "该命令不在 Verify 白名单中,仅允许执行预定义命令用于调试。", + "verify.notInAllowlist": "该命令不在验证白名单中,仅允许执行预定义命令用于调试。", "verify.invalidJson": "参数不是合法 JSON", - "verify.debugLabel": "Debug: 执行 IDE 命令", + "verify.debugLabel": "调试:执行 IDE 命令", "verify.executed": "命令已执行。", "verifyArchive.title": "验证并归档此 change", - "verifyArchive.description": "验证与归档与实施、继续相同,在 IDE Agent 面板启动 `/opsx-verify` 与 `/opsx-archive`。", - "verifyArchive.descriptionAgentPanel": "验证与归档与实施、继续相同,通过 Cursor Agent 面板启动。", - "verifyArchive.descriptionAgentCli": "验证与归档与实施、继续相同,通过 Cursor Agent CLI 启动。", - "verifyArchive.descriptionCopy": "验证与归档与其他复制操作相同,将 {verifyCommand} 与 {archiveCommand} 复制到剪贴板。", - "verifyArchive.descriptionAdapter": "验证与归档与实施、继续相同,通过已配置的 Chat 适配器启动。", + "verifyArchive.description": "验证和归档与实施、继续一样,在 IDE Agent 面板启动 `/opsx-verify` 与 `/opsx-archive`。", + "verifyArchive.descriptionAgentPanel": "验证和归档与实施、继续一样,通过 Cursor Agent 面板启动。", + "verifyArchive.descriptionAgentCli": "验证和归档与实施、继续一样,通过 Cursor Agent CLI 启动。", + "verifyArchive.descriptionCopy": "验证和归档与其他复制操作一样,将 {verifyCommand} 与 {archiveCommand} 复制到剪贴板。", + "verifyArchive.descriptionAdapter": "验证和归档与实施、继续一样,通过已配置的 Chat 适配器启动。", "verifyArchive.verifyAgentHint": "在 Agent 面板打开 {command}。", "verifyArchive.verifyAgentPrefillHint": "在 Agent 面板预填 {command},按 Enter 发送。", "verifyArchive.verifyAgentSentHint": "在 Agent 面板自动发送 {command}。", @@ -199,18 +199,18 @@ "verifyArchive.archiveAgentCliHint": "通过 Cursor Agent CLI 运行 {command}。", "verifyArchive.verifyChatHint": "在 Chat 中预填 {command}(按 Enter 发送)。", "verifyArchive.archiveChatHint": "在 Chat 中预填 {command}(发送前请确认)。", - "verifyArchive.descriptionChatAdapter": "验证与归档与实施、继续相同,通过 Chat 启动(例如 {verifyCommand}、{archiveCommand})。", + "verifyArchive.descriptionChatAdapter": "验证和归档与实施、继续一样,通过 Chat 启动(例如 {verifyCommand}、{archiveCommand})。", "verifyArchive.verifyTitle": "验证与确认", - "verifyArchive.archiveTitle": "Archive", - "verifyArchive.runVerify": "Run Verify", - "verifyArchive.runArchive": "Run Archive", + "verifyArchive.archiveTitle": "归档", + "verifyArchive.runVerify": "运行验证", + "verifyArchive.runArchive": "运行归档", "verifyArchive.reviewArchiveTitle": "审查并归档", "verifyArchive.reviewArchive": "审查并归档", "verifyArchive.archiveNow": "立即归档", - "verifyArchive.revealTerminal": "Reveal Terminal", - "verifyArchive.stop": "Stop", - "verifyArchive.clearSession": "Clear Session", - "verifyArchive.archiveDisabledArchived": "已归档 change 为只读。仍可执行 Verify,但不能再次 Archive。", + "verifyArchive.revealTerminal": "显示终端", + "verifyArchive.stop": "停止", + "verifyArchive.clearSession": "清除会话", + "verifyArchive.archiveDisabledArchived": "已归档 change 为只读。仍可执行验证,但不能再次归档。", "verifyArchive.archiveDisabledIncomplete": "完成所有必需 artifact 和任务后才能使用“立即归档”。", "verifyArchive.running": "运行中", "verifyArchive.ready": "可开始", @@ -218,10 +218,10 @@ "verifyArchive.agentCliNotFound": "未找到 Cursor Agent CLI。", "verifyArchive.terminalCreateFailed": "无法启动 Agent 工作流会话。", "verifyArchive.managerUnavailable": "Agent 工作流会话不可用。", - "verifyArchive.archivedArchiveRejected": "已归档 change 为只读,不能再次执行 Archive。", + "verifyArchive.archivedArchiveRejected": "已归档 change 为只读,不能再次执行归档。", "verifyArchive.statusArchived": "已归档 change · 只读", "verifyArchive.statusTasks": "{artifacts} 个 artifact · 已完成 {completed}/{total} 个任务", - "verifyArchive.statusArtifacts": "已就绪 {count} 个 planning artifact", + "verifyArchive.statusArtifacts": "已就绪 {count} 个规划 artifact", "cursor.executing": "正在执行: {task}", "cursor.promptLength": "Prompt 长度: {length} 字符", @@ -264,8 +264,8 @@ "dashboard.retryLoad": "重试", "dashboard.staleData": "正在刷新,当前显示缓存数据...", "dashboard.refreshing": "正在刷新 OpenSpec 数据...", - "dashboard.changes": "Changes ({count})", - "dashboard.emptyChanges": "此 Root 中没有 change。", + "dashboard.changes": "Change ({count})", + "dashboard.emptyChanges": "此根目录中没有 change。", "dashboard.emptyChangesInRoot": "{root} 中没有 change。", "dashboard.createNew": "新建 Change", "dashboard.specs": "Specs ({count})", @@ -274,9 +274,9 @@ "dashboard.archived": "已归档", "dashboard.emptyArchived": "没有已归档 change", "dashboard.emptyArchivedInRoot": "{root} 中没有已归档 change", - "dashboard.emptyPlanning": "没有 Planning change", - "dashboard.emptyReadyToApply": "没有 Ready to Apply change", - "dashboard.emptyApplying": "没有 Applying change", + "dashboard.emptyPlanning": "没有规划中的 change", + "dashboard.emptyReadyToApply": "没有待实施的 change", + "dashboard.emptyApplying": "没有实施中的 change", "dashboard.emptyReadyToVerify": "没有待验证的 change", "dashboard.emptySearchAndFilters": "没有匹配当前搜索和筛选条件的 change", "dashboard.lifecycleAll": "全部", @@ -286,7 +286,7 @@ "dashboard.lifecycleReadyToVerify": "待验证", "dashboard.lifecycleArchived": "已归档", "dashboard.lifecycleFilterLabel": "按生命周期状态筛选", - "dashboard.needsAttention": "Needs Attention", + "dashboard.needsAttention": "需要关注", "dashboard.priorityNeedsAttention": "需要关注", "dashboard.priorityReadyToVerify": "可验证", "dashboard.priorityRecommended": "推荐动作", @@ -299,7 +299,7 @@ "dashboard.moreActions": "另有 {count} 个", "dashboard.blockedCount": "{count} 个已阻塞", "dashboard.skippedCount": "{count} 个已跳过", - "dashboard.needsAttentionFilterLabel": "Needs Attention 筛选", + "dashboard.needsAttentionFilterLabel": "需要关注筛选", "dashboard.moreFilters": "更多筛选", "dashboard.sortLabel": "排序", "dashboard.sortUpdatedDesc": "更新时间降序", @@ -319,42 +319,42 @@ "dashboard.statusInProgress": "进行中", "dashboard.statusDraft": "草稿", "dashboard.statusComplete": "已完成", - "dashboard.searchPlaceholder": "搜索 change、状态、artifact 或 Proposal Why", - "dashboard.searchLabel": "搜索 changes", + "dashboard.searchPlaceholder": "搜索 change、状态、artifact 或提案说明", + "dashboard.searchLabel": "搜索 change", "dashboard.searchEmpty": "没有匹配的 change。", "dashboard.searchNoResults": "没有匹配的 change。", - "dashboard.proposalWhy": "Proposal Why", + "dashboard.proposalWhy": "提案说明", "projectSidebar.currentProject": "当前项目", "projectSidebar.navigationLabel": "项目导航", "projectSidebar.rootContextLabel": "OpenSpec 根上下文", "projectSidebar.project": "项目", - "projectSidebar.allChanges": "全部 Changes", - "projectSidebar.specs": "Specs", - "projectSidebar.cardChangesSupporting": "浏览本地 Changes", - "projectSidebar.cardSpecsSupporting": "浏览本地 Specs", + "projectSidebar.allChanges": "全部 Change", + "projectSidebar.specs": "Spec", + "projectSidebar.cardChangesSupporting": "浏览本地 Change", + "projectSidebar.cardSpecsSupporting": "浏览本地 Spec", "projectSidebar.cardWorksetsSupporting": "浏览 Workset 项目", "projectSidebar.cardDashboardSupporting": "在编辑器中打开项目 Dashboard", - "projectSidebar.emptyActive": "暂无 active change", - "projectSidebar.projectSpecs": "项目 Specs", - "projectSidebar.emptyProjectSpecs": "暂无项目 Specs", - "projectSidebar.referencedStoreSpecs": "引用 Store Specs:{storeId}", - "projectSidebar.emptyStoreSpecs": "引用 Store {storeId} 中暂无 Specs", + "projectSidebar.emptyActive": "暂无进行中的 change", + "projectSidebar.projectSpecs": "项目 Spec", + "projectSidebar.emptyProjectSpecs": "暂无项目 Spec", + "projectSidebar.referencedStoreSpecs": "引用 Store Spec:{storeId}", + "projectSidebar.emptyStoreSpecs": "引用 Store {storeId} 中暂无 Spec", "projectSidebar.worksets": "浏览 Workset 项目", "projectSidebar.dashboard": "Dashboard", "projectSidebar.worksetsUnavailable": "暂无可信的 Workset 成员关系", "projectDashboard.title": "项目 Dashboard", "projectDashboard.summary": "项目摘要", "projectDashboard.totalChanges": "Change 总数", - "projectDashboard.activeChanges": "Active Change", + "projectDashboard.activeChanges": "进行中的 Change", "projectDashboard.readyToVerify": "待验证", "projectDashboard.archived": "已归档", - "projectDashboard.activeTasks": "Active 任务", - "projectDashboard.completionRate": "Active 任务完成率", + "projectDashboard.activeTasks": "进行中任务", + "projectDashboard.completionRate": "进行中任务完成率", "projectDashboard.lifecycle": "生命周期分布", "projectDashboard.lifecycleDistribution": "项目生命周期分布", "projectDashboard.artifactReadiness": "Artifact 就绪度", "projectDashboard.recentUpdates": "最近更新", - "projectDashboard.noArtifacts": "Active Change 尚未声明 artifact。", + "projectDashboard.noArtifacts": "进行中的 Change 尚未声明 artifact。", "projectDashboard.noRecentUpdates": "暂无最近更新。", "projectDashboard.loading": "正在加载项目 Dashboard...", "projectDashboard.stale": "正在刷新,当前显示缓存的项目数据...", diff --git a/test/i18n/i18n.test.ts b/test/i18n/i18n.test.ts index 5f81904..74aad56 100644 --- a/test/i18n/i18n.test.ts +++ b/test/i18n/i18n.test.ts @@ -92,8 +92,8 @@ describe('i18n', () => { expect(t('verify.completeness')).toContain('完整性'); expect(t('verify.correctness')).toContain('正确性'); expect(t('verify.coherence')).toContain('一致性'); - expect(t('verifyArchive.runVerify')).toBe('Run Verify'); - expect(t('verifyArchive.clearSession')).toBe('Clear Session'); + expect(t('verifyArchive.runVerify')).toBe('运行验证'); + expect(t('verifyArchive.clearSession')).toBe('清除会话'); expect(t('verifyArchive.reviewArchive')).toBe('审查并归档'); expect(t('verifyArchive.archiveNow')).toBe('立即归档'); expect(t('extension.emptyWorkspaceMessage')).toBe( diff --git a/test/scripts/check-typecheck-baseline.test.ts b/test/scripts/check-typecheck-baseline.test.ts index cd9e436..578df0c 100644 --- a/test/scripts/check-typecheck-baseline.test.ts +++ b/test/scripts/check-typecheck-baseline.test.ts @@ -1,20 +1,47 @@ import { readFileSync } from 'fs'; import path from 'path'; +import { spawnSync } from 'child_process'; import { describe, expect, it } from 'vitest'; describe('check-typecheck-baseline.sh', () => { - const script = readFileSync( - path.resolve(__dirname, '../../scripts/check-typecheck-baseline.sh'), - 'utf8', - ); + const scriptPath = path.resolve(__dirname, '../../scripts/check-typecheck-baseline.sh'); + const script = readFileSync(scriptPath, 'utf8'); it('counts TypeScript errors with grep (no ripgrep dependency)', () => { expect(script).toMatch(/grep -c 'error TS'/); expect(script).not.toMatch(/\brg\b/); }); - it('uses strict bash and validates the parsed count', () => { + it('uses strict bash, baseline 12, and rejects tsc crash without error TS lines', () => { expect(script).toContain('set -euo pipefail'); - expect(script).toContain('could not parse TypeScript error count'); + expect(script).toContain('TSC_BASELINE_ERROR_COUNT:-12'); + expect(script).toContain("no 'error TS' lines were counted"); + }); + + it('fails when tsc cannot start (non-zero exit, no error TS lines)', () => { + const result = spawnSync('bash', [scriptPath], { + env: { + ...process.env, + CHECK_TYPECHECK_TSC_INVOKE: 'echo "Cannot find module \'/tmp/typescript/bin/tsc\'" >&2; exit 1', + TSC_BASELINE_ERROR_COUNT: '12', + }, + encoding: 'utf8', + }); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("no 'error TS' lines were counted"); + expect(`${result.stdout}${result.stderr}`).toContain('Cannot find module'); + }); + + it('fails when tsc aborts without diagnostics (e.g. OOM exit 134)', () => { + const result = spawnSync('bash', [scriptPath], { + env: { + ...process.env, + CHECK_TYPECHECK_TSC_INVOKE: 'echo "Killed" >&2; exit 134', + TSC_BASELINE_ERROR_COUNT: '12', + }, + encoding: 'utf8', + }); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("no 'error TS' lines were counted"); }); }); diff --git a/test/webview/components/header.test.tsx b/test/webview/components/header.test.tsx index 287bd6a..48ce1bb 100644 --- a/test/webview/components/header.test.tsx +++ b/test/webview/components/header.test.tsx @@ -294,8 +294,8 @@ describe('Project-first Header', () => { ); expect(html).toContain('aria-label="项目导航"'); - expect(html).toContain('浏览本地 Changes'); - expect(html).toContain('浏览本地 Specs'); + expect(html).toContain('浏览本地 Change'); + expect(html).toContain('浏览本地 Spec'); expect(html).toContain('在编辑器中打开项目 Dashboard'); // The Worksets launcher keeps its browsing-for-current-Project accessible // name in zh-cn too (short visible label, descriptive accessible name). From 74b2bd7ee4e3ebcd93145d985cce61a62d3f22a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 06:14:12 +0000 Subject: [PATCH 05/19] Release 0.2.5-agent-panel.5: tasks.md perf, agentAutoSubmit UI, zh Dashboard, typecheck gate Co-authored-by: RandyZhang --- CHANGELOG.md | 10 ++ package.json | 2 +- scripts/check-typecheck-baseline.sh | 27 +++- src/extension/extension.ts | 3 +- .../providers/webviewMessageHandler.ts | 45 ++++-- src/extension/services/dataManager.ts | 138 ++++++++++-------- .../services/executorLaunchPresentation.ts | 5 +- src/extension/services/fileManager.ts | 7 +- src/extension/services/workflowAgentLaunch.ts | 16 +- .../services/workflowLaunchConfig.ts | 21 ++- src/i18n/locales/zh-cn.json | 32 ++-- src/shared/workflowLaunchConfig.ts | 4 +- src/webview/components/ChangeDetail.tsx | 12 +- .../utils/changeDetailArtifactInvalidation.ts | 4 +- .../providers/agentUnavailableHandler.test.ts | 9 ++ test/extension/services/dataManager.test.ts | 60 +++++++- .../executorLaunchPresentation.test.ts | 9 ++ .../services/workflowLaunchConfig.test.ts | 21 +++ test/i18n/i18n.test.ts | 6 +- test/scripts/check-typecheck-baseline.test.ts | 28 +++- test/shared/workflowLaunchConfig.test.ts | 14 ++ test/webview/components/dashboard.test.tsx | 2 +- test/webview/components/header.test.tsx | 2 +- .../changeDetailArtifactInvalidation.test.ts | 2 +- .../utils/workflowLaunchLabels.test.ts | 25 ++++ 25 files changed, 371 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc0c06..a95b649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.5-agent-panel.5] - 2026-10-09 + +### Fixed + +- Detail page: `tasks.md` edits push `changeTaskProgressPatch` (counts + content) without `artifactInvalidated` / loading flash; tasks artifact reads skip CLI list/status; tasks-only watcher events no longer trigger full dashboard CLI refresh. +- V&A hints: `agentAutoSubmit` included in executor launch presentation and refreshed on settings change; labels use host-effective mode (`never` / `readOnly` / `always`). +- Optional missing artifacts log at debug instead of error. +- zh-CN: keep **Dashboard** product label; lowercase **change** consistently. +- Typecheck gate: fail on abnormal tsc exit (≥128) or missing `Found N error(s)` summary even when partial `error TS` lines exist. + ## [0.2.5-agent-panel.4] - 2026-10-09 ### Fixed diff --git a/package.json b/package.json index 6ca9239..a382156 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.5-agent-panel.4", + "version": "0.2.5-agent-panel.5", "publisher": "randysss", "license": "MIT", "engines": { diff --git a/scripts/check-typecheck-baseline.sh b/scripts/check-typecheck-baseline.sh index cd60291..7be16a1 100755 --- a/scripts/check-typecheck-baseline.sh +++ b/scripts/check-typecheck-baseline.sh @@ -3,7 +3,6 @@ set -euo pipefail BASELINE="${TSC_BASELINE_ERROR_COUNT:-12}" LOG="$(mktemp)" -trap 'rm -f "$LOG"' EXIT if ! command -v npx >/dev/null 2>&1; then echo "check-typecheck-baseline: required command 'npx' not found in PATH" >&2 @@ -15,24 +14,31 @@ if ! command -v grep >/dev/null 2>&1; then exit 1 fi +strip_ansi() { + sed 's/\x1b\[[0-9;]*m//g' +} + run_tsc() { if [ -n "${CHECK_TYPECHECK_TSC_INVOKE:-}" ]; then bash -c "${CHECK_TYPECHECK_TSC_INVOKE}" else - npx tsc --noEmit -p tsconfig.typecheck.json + npx tsc --noEmit -p tsconfig.typecheck.json --pretty true fi } -# Must match local: npx tsc --noEmit -p tsconfig.typecheck.json +# Must match local: npx tsc --noEmit -p tsconfig.typecheck.json --pretty true TSC_EXIT=0 run_tsc >"$LOG" 2>&1 || TSC_EXIT=$? +PLAIN_LOG="$(mktemp)" +trap 'rm -f "$LOG" "$PLAIN_LOG"' EXIT +strip_ansi <"$LOG" >"$PLAIN_LOG" if [ ! -s "$LOG" ] && [ "$TSC_EXIT" -ne 0 ]; then echo "check-typecheck-baseline: tsc failed (exit $TSC_EXIT) but produced no output" >&2 exit 1 fi -COUNT="$(grep -c 'error TS' "$LOG" 2>/dev/null || true)" +COUNT="$(grep -c 'error TS' "$PLAIN_LOG" 2>/dev/null || true)" if ! [[ "${COUNT:-}" =~ ^[0-9]+$ ]]; then echo "check-typecheck-baseline: could not parse TypeScript error count from tsc output" >&2 cat "$LOG" >&2 @@ -45,6 +51,19 @@ if [ "$TSC_EXIT" -ne 0 ] && [ "$COUNT" -eq 0 ]; then exit 1 fi +if [ "$TSC_EXIT" -ne 0 ]; then + if [ "$TSC_EXIT" -ge 128 ]; then + echo "check-typecheck-baseline: tsc exited with abnormal signal/code $TSC_EXIT" >&2 + cat "$LOG" >&2 + exit 1 + fi + if ! grep -qE 'Found [0-9]+ error' "$PLAIN_LOG"; then + echo "check-typecheck-baseline: tsc exited $TSC_EXIT without a 'Found N error(s)' summary (possible crash)" >&2 + cat "$LOG" >&2 + exit 1 + fi +fi + if [ "$COUNT" -gt "$BASELINE" ]; then echo "TypeScript error count $COUNT exceeds baseline $BASELINE" cat "$LOG" diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 8aa3c42..304b37f 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -151,7 +151,8 @@ export async function activate(context: vscode.ExtensionContext) { event.affectsConfiguration('openspec.workflowLaunchMode') || event.affectsConfiguration('openspec.preferredAgentAdapter') || event.affectsConfiguration('openspec.cursorLaunchMode') || - event.affectsConfiguration('openspec.cursorAgentModel'); + event.affectsConfiguration('openspec.cursorAgentModel') || + event.affectsConfiguration('openspec.agentAutoSubmit'); if (!launchConfigChanged) return; logger.info('[workflow] launch configuration changed; updating webviews'); dashboardViewProvider.postWorkflowLaunchConfig(); diff --git a/src/extension/providers/webviewMessageHandler.ts b/src/extension/providers/webviewMessageHandler.ts index fe28015..26e2ad9 100644 --- a/src/extension/providers/webviewMessageHandler.ts +++ b/src/extension/providers/webviewMessageHandler.ts @@ -14,7 +14,7 @@ import type { import { t } from '../../i18n'; import { buildWorkflowLaunchPayload } from '../../shared/workflowCommand'; import type { WorkflowAction } from '../../shared/workflowCommand'; -import { getWorkflowLaunchConfig } from '../services/workflowLaunchConfig'; +import { getWorkflowLaunchConfigViewForUi } from '../services/workflowLaunchConfig'; import { launchWorkflowAgentCommand } from '../services/workflowAgentLaunch'; import { postExecutorLaunchPresentationFromHost } from '../services/executorLaunchPresentation'; import { @@ -22,7 +22,6 @@ import { } from '../services/interactiveAgentTerminalManager'; import { confirmDirectArchive } from '../commands/archiveConfirm'; import { formatBytes } from '../utils/formatBytes'; -import { toWorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; import { isCursorHost } from '../utils/isCursorHost'; import type { InteractiveWorkflowAction, @@ -680,6 +679,36 @@ export async function handleWebviewMessage( const artifactType = message.artifactId ?? message.artifactType; if (!changeName || !artifactType) break; const { rootPath, scope } = resolveScopeRoot(dataManager, message.scopeId, boundScope); + if ( + artifactType === 'tasks' + && !changeName.startsWith('archive:') + ) { + try { + const content = await dataManager.readArtifact(changeName, 'tasks', scope); + await dataManager.writeArtifactContentCache?.({ + changeName, + artifactType: 'tasks', + scope, + content, + }); + webview.postMessage({ + type: 'artifactContent', + changeName, + artifactType: 'tasks', + content, + cache: { source: 'fresh', stale: false }, + }); + } catch { + webview.postMessage({ + type: 'artifactContentError', + changeName, + artifactType: 'tasks', + message: t('artifact.missingShort'), + code: 'ARTIFACT_MISSING', + }); + } + break; + } if (!isPathInWorkspaceFolders(rootPath)) { webview.postMessage({ type: 'artifactContentError', @@ -1461,20 +1490,10 @@ export async function handleAgentUnavailableMessage( } } -function readAgentAutoSubmitModeForUi(): 'never' | 'readOnly' | 'always' { - const raw = vscode.workspace.getConfiguration('openspec').get('agentAutoSubmit'); - if (raw === 'never' || raw === 'always' || raw === 'readOnly') return raw; - return 'readOnly'; -} - export function getWorkflowLaunchConfigMessage() { - const config = getWorkflowLaunchConfig(); return { type: 'workflowLaunchConfig' as const, - config: { - ...toWorkflowLaunchConfigView(config), - agentAutoSubmit: readAgentAutoSubmitModeForUi(), - }, + config: getWorkflowLaunchConfigViewForUi(), isCursorHost: isCursorHost(), }; } diff --git a/src/extension/services/dataManager.ts b/src/extension/services/dataManager.ts index c57fe13..4bead2d 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -137,6 +137,7 @@ export class DataManager { private taskProgressPatchCallbacks: Set<(patch: ChangeTaskProgressPatch) => void> = new Set(); private artifactChangedCallbacks: Set<(event: ArtifactChangedEvent) => void> = new Set(); private tasksOnlyRefreshTimer: ReturnType | undefined; + private tasksOnlyRefreshPending: { changeName: string; scope?: OpenSpecScope } | undefined; private cliAvailable = false; // Scope-aware additions @@ -531,71 +532,85 @@ export class DataManager { // Start file watcher this.fileWatcher.start((events) => { - // Collect artifact-specific changes to notify open panels - const artifactChanges = new Map>(); - - for (const e of events) { - const relative = path.relative(this.watchedProjectRoot, e.uri.fsPath).replace(/\\/g, '/'); - - // tasks.md auto-complete parents - const archiveTasksMatch = relative.match(/^openspec\/changes\/archive\/([^/]+)\/tasks\.md$/); - const draftTasksMatch = relative.match(/^openspec\/changes\/(?!archive)([^/]+)\/tasks\.md$/); - const tasksChangeName = archiveTasksMatch - ? `archive:${archiveTasksMatch[1]}` - : draftTasksMatch ? draftTasksMatch[1] : null; - if (tasksChangeName) { - this.contentAccess.autoCompleteParents(tasksChangeName).catch((err) => - logger.warn('autoCompleteParents after tasks.md change', err as Error) - ); - const scope = this.resolveScopeForRoot(this.watchedProjectRoot); - void this.publishTaskProgressFromFile(tasksChangeName, scope).catch((err) => - logger.warn('publishTaskProgressFromFile after tasks.md change', err as Error) - ); - } + void this.handleFileWatcherEvents(events); + }); - // Detect which artifact changed and compute downstream invalidations - const parsed = this.parseArtifactFromPath(relative); - if (parsed) { - const { changeName, artifactType } = parsed; - if (!artifactChanges.has(changeName)) { - artifactChanges.set(changeName, new Set()); - } - // Invalidate the changed artifact itself + its downstream dependents - const invalidate = [artifactType, ...(ARTIFACT_DOWNSTREAM[artifactType] ?? [])]; - for (const t of invalidate) { - artifactChanges.get(changeName)!.add(t); - } + this.warmDashboardData(); + } + + private async handleFileWatcherEvents( + events: Array<{ uri: { fsPath: string } }>, + ): Promise { + const artifactChanges = new Map>(); + const tasksPatchedChanges = new Set(); + const scope = this.resolveScopeForRoot(this.watchedProjectRoot); + + for (const e of events) { + const relative = path.relative(this.watchedProjectRoot, e.uri.fsPath).replace(/\\/g, '/'); + + const archiveTasksMatch = relative.match(/^openspec\/changes\/archive\/([^/]+)\/tasks\.md$/); + const draftTasksMatch = relative.match(/^openspec\/changes\/(?!archive)([^/]+)\/tasks\.md$/); + const tasksChangeName = archiveTasksMatch + ? `archive:${archiveTasksMatch[1]}` + : draftTasksMatch ? draftTasksMatch[1] : null; + if (tasksChangeName) { + tasksPatchedChanges.add(tasksChangeName); + this.contentAccess.autoCompleteParents(tasksChangeName).catch((err) => + logger.warn('autoCompleteParents after tasks.md change', err as Error) + ); + try { + await this.publishTaskProgressFromFile(tasksChangeName, scope); + } catch (err) { + logger.warn('publishTaskProgressFromFile after tasks.md change', err as Error); } } - // Notify artifact-level change subscribers (e.g. open change detail panels) - for (const [changeName, types] of artifactChanges) { - this.notifyArtifactChanged({ - changeName, - artifactTypes: [...types], - rootPath: this.canonicalRootPath(this.watchedProjectRoot), - }); + const parsed = this.parseArtifactFromPath(relative); + if (parsed) { + const { changeName, artifactType } = parsed; + if (artifactType === 'tasks') { + continue; + } + if (!artifactChanges.has(changeName)) { + artifactChanges.set(changeName, new Set()); + } + const invalidate = [artifactType, ...(ARTIFACT_DOWNSTREAM[artifactType] ?? [])]; + for (const t of invalidate) { + artifactChanges.get(changeName)!.add(t); + } } + } - const onlyTasksMd = events.length > 0 && events.every((event) => { - const relative = path.relative(this.watchedProjectRoot, event.uri.fsPath).replace(/\\/g, '/'); - return /\/tasks\.md$/.test(relative); + for (const [changeName, types] of artifactChanges) { + this.notifyArtifactChanged({ + changeName, + artifactTypes: [...types], + rootPath: this.canonicalRootPath(this.watchedProjectRoot), }); + } - if (onlyTasksMd) { - this.scheduleTasksOnlyBackgroundRefresh(this.resolveScopeForRoot(this.watchedProjectRoot)); - } else { - logger.info(`File changes detected (${events.length} events), refreshing...`); - void (async () => { - await this.invalidateDashboardCache(this.resolveScopeForRoot(this.watchedProjectRoot)); - await this.refresh(); - })().catch((error) => { - logger.warn('Failed to refresh after file changes', error as Error); - }); - } + const onlyTasksMd = events.length > 0 && events.every((event) => { + const relative = path.relative(this.watchedProjectRoot, event.uri.fsPath).replace(/\\/g, '/'); + return /\/tasks\.md$/.test(relative); }); - this.warmDashboardData(); + if (onlyTasksMd) { + const changeName = [...tasksPatchedChanges].at(-1); + if (changeName) { + this.scheduleTasksOnlyBackgroundRefresh(changeName, scope); + } + return; + } + + if (events.length > 0) { + logger.info(`File changes detected (${events.length} events), refreshing...`); + try { + await this.invalidateDashboardCache(scope); + await this.refresh(scope); + } catch (error) { + logger.warn('Failed to refresh after file changes', error as Error); + } + } } /** @@ -1355,17 +1370,18 @@ export class DataManager { await fs.promises.writeFile(filePath, YAML.stringify(data), 'utf8'); } - private scheduleTasksOnlyBackgroundRefresh(scope?: OpenSpecScope): void { + private scheduleTasksOnlyBackgroundRefresh(changeName: string, scope?: OpenSpecScope): void { + this.tasksOnlyRefreshPending = { changeName, scope }; if (this.tasksOnlyRefreshTimer) { clearTimeout(this.tasksOnlyRefreshTimer); } this.tasksOnlyRefreshTimer = setTimeout(() => { this.tasksOnlyRefreshTimer = undefined; - void (async () => { - await this.invalidateDashboardCache(scope); - await this.refresh(scope); - })().catch((error) => { - logger.warn('Failed deferred tasks-only refresh', error as Error); + const pending = this.tasksOnlyRefreshPending; + this.tasksOnlyRefreshPending = undefined; + if (!pending) return; + void this.publishTaskProgressFromFile(pending.changeName, pending.scope).catch((error) => { + logger.warn('Failed deferred tasks-only reconcile', error as Error); }); }, 2000); } diff --git a/src/extension/services/executorLaunchPresentation.ts b/src/extension/services/executorLaunchPresentation.ts index 757e8c4..e1e6385 100644 --- a/src/extension/services/executorLaunchPresentation.ts +++ b/src/extension/services/executorLaunchPresentation.ts @@ -3,15 +3,14 @@ import { buildExecutorLaunchPresentation, type ExecutorLaunchPresentation, } from '../../shared/executorLaunchPresentation'; -import { toWorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; import type { DataManager } from './dataManager'; -import { getWorkflowLaunchConfig } from './workflowLaunchConfig'; +import { getWorkflowLaunchConfigViewForUi } from './workflowLaunchConfig'; import { isCursorHost } from '../utils/isCursorHost'; export async function createExecutorLaunchPresentation( dataManager: DataManager, ): Promise { - const workflowLaunchConfig = toWorkflowLaunchConfigView(getWorkflowLaunchConfig()); + const workflowLaunchConfig = getWorkflowLaunchConfigViewForUi(); const adapters = await dataManager.getAgentAdaptersInfo(); return { ...buildExecutorLaunchPresentation( diff --git a/src/extension/services/fileManager.ts b/src/extension/services/fileManager.ts index 38a8aeb..25dea06 100644 --- a/src/extension/services/fileManager.ts +++ b/src/extension/services/fileManager.ts @@ -89,7 +89,12 @@ export class FileManagerService implements IOpenSpecContentAccess { logger.debug(`Read artifact: ${changeName}/${artifactType}`); return content; } catch (error) { - logger.error(`Failed to read artifact: ${artifactPath}`, error as Error); + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') { + logger.debug(`Artifact not present: ${artifactPath}`); + } else { + logger.error(`Failed to read artifact: ${artifactPath}`, error as Error); + } throw error; } } diff --git a/src/extension/services/workflowAgentLaunch.ts b/src/extension/services/workflowAgentLaunch.ts index 56dea77..f1435ea 100644 --- a/src/extension/services/workflowAgentLaunch.ts +++ b/src/extension/services/workflowAgentLaunch.ts @@ -5,10 +5,7 @@ import { getWorkflowCommandTargetForAdapter, buildWorkflowCommand, } from '../../shared/workflowCommand'; -import { - type AgentAutoSubmitMode, - shouldAutoSubmitWorkflowAction, -} from '../../shared/agentAutoSubmit'; +import { shouldAutoSubmitWorkflowAction } from '../../shared/agentAutoSubmit'; import { isCopyOnlyWorkflowMode, shouldForceCursorWorkflowRoute, @@ -17,7 +14,7 @@ import { import { getCurrentAdapter, getAdapterById } from '../adapters'; import { cursorAdapter } from '../adapters/cursor-adapter'; import { t } from '../../i18n'; -import { getWorkflowLaunchConfig } from './workflowLaunchConfig'; +import { getAgentAutoSubmitMode, getWorkflowLaunchConfig } from './workflowLaunchConfig'; import { launchAgentPanelPrompt, type AgentLaunchLayer, @@ -91,13 +88,6 @@ 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, @@ -216,7 +206,7 @@ async function executeWorkflowAgentLaunch( ): Promise { const launchConfig = getWorkflowLaunchConfig(); const launchConfigView = toWorkflowLaunchConfigView(launchConfig); - const autoSubmit = shouldAutoSubmitWorkflowAction(request.action, readAgentAutoSubmitMode()); + const autoSubmit = shouldAutoSubmitWorkflowAction(request.action, getAgentAutoSubmitMode()); const clipboardPayload = buildResolvedLaunchPayload(request.action, request.changeName); diff --git a/src/extension/services/workflowLaunchConfig.ts b/src/extension/services/workflowLaunchConfig.ts index 750ba21..044d3c6 100644 --- a/src/extension/services/workflowLaunchConfig.ts +++ b/src/extension/services/workflowLaunchConfig.ts @@ -1,12 +1,14 @@ import * as vscode from 'vscode'; import type { + AgentAutoSubmitMode, CursorLaunchMode, PreferredAgentAdapter, WorkflowLaunchConfig, + WorkflowLaunchConfigView, WorkflowLaunchConfigWithExplicit, WorkflowLaunchMode, } from '../../shared/workflowLaunchConfig'; -import { resolveWorkflowLaunchConfig } from '../../shared/workflowLaunchConfig'; +import { resolveWorkflowLaunchConfig, toWorkflowLaunchConfigView } from '../../shared/workflowLaunchConfig'; import { isCursorHost } from '../utils/isCursorHost'; export type { CursorLaunchMode, @@ -98,3 +100,20 @@ export function getWorkflowLaunchConfig(): WorkflowLaunchConfig { isCursorHost: isCursorHost(), }); } + +/** Effective `openspec.agentAutoSubmit` (workspace folder → workspace → user → default). */ +export function getAgentAutoSubmitMode(resource?: vscode.Uri): AgentAutoSubmitMode { + const raw = vscode.workspace.getConfiguration('openspec', resource).get('agentAutoSubmit'); + if (raw === 'never' || raw === 'always' || raw === 'readOnly') { + return raw; + } + return 'readOnly'; +} + +/** Launch config pushed to webviews; includes the same auto-submit mode the host uses to launch. */ +export function getWorkflowLaunchConfigViewForUi(resource?: vscode.Uri): WorkflowLaunchConfigView { + return { + ...toWorkflowLaunchConfigView(getWorkflowLaunchConfig()), + agentAutoSubmit: getAgentAutoSubmitMode(resource), + }; +} diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 6239f76..a6ab4d9 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -54,8 +54,8 @@ "workflow.copiedCommand": "已复制命令:{command}", "workflow.noAdapterCopied": "当前没有可用执行者,已复制命令:{command}", "workflow.adapterFallback": "无法启动适配器,命令已复制。", - "workflow.bindingMismatch": "无法启动工作流:Change 根目录已变化。请刷新仪表板后重试。", - "workflow.bindingValidationFailed": "无法校验此工作流对应的 Change 根目录。", + "workflow.bindingMismatch": "无法启动工作流:change 根目录已变化。请刷新 Dashboard 后重试。", + "workflow.bindingValidationFailed": "无法校验此工作流对应的 change 根目录。", "workflow.bindingRequestIncomplete": "工作流请求缺少绑定信息。请刷新后重试。", "workflow.launchDeduped": "该 change 的工作流已在启动中。", "workflow.launchJustLaunched": "该工作流刚刚已启动,请在 Agent 面板中查看。", @@ -127,10 +127,10 @@ "command.enterName": "请输入 change 名称(按 Enter 确认或 Escape 取消)", "command.namePlaceholder": "例如 add-authentication", - "command.nameRequired": "Change 名称不能为空", + "command.nameRequired": "change 名称不能为空", "command.nameFormat": "仅允许小写字母、数字和连字符", "command.creating": "正在创建 change: {name}...", - "command.created": "Change \"{name}\" 已创建", + "command.created": "change \"{name}\" 已创建", "command.createFailed": "创建 change 失败", "command.noChanges": "没有可归档的 change", "command.selectArchive": "选择要归档的 change", @@ -140,12 +140,12 @@ "command.archiveVerifySuggest": "建议在归档前先执行验证。现在去验证,或继续直接归档。", "command.archiveVerifyHint": "验证并归档", "command.archiving": "正在归档 change: {name}...", - "command.archived": "Change \"{name}\" 已归档", + "command.archived": "change \"{name}\" 已归档", "command.archiveFailed": "归档 change 失败", "command.refreshing": "正在刷新 OpenSpec 数据...", "command.refreshed": "OpenSpec 数据已刷新", "command.refreshFailed": "刷新 OpenSpec 数据失败", - "command.dashboardFailed": "打开 OpenSpec 仪表板失败", + "command.dashboardFailed": "打开 OpenSpec Dashboard 失败", "command.copiedContinue": "已复制命令,请在 AI 对话中粘贴以生成 {artifact}", "command.copiedContinueGeneric": "已复制 /opsx:continue,请在 AI 对话中粘贴以生成对应 artifact", "command.promptProposal": "请创建 Proposal artifact(change 的需求提案文档)", @@ -264,10 +264,10 @@ "dashboard.retryLoad": "重试", "dashboard.staleData": "正在刷新,当前显示缓存数据...", "dashboard.refreshing": "正在刷新 OpenSpec 数据...", - "dashboard.changes": "Change ({count})", + "dashboard.changes": "change ({count})", "dashboard.emptyChanges": "此根目录中没有 change。", "dashboard.emptyChangesInRoot": "{root} 中没有 change。", - "dashboard.createNew": "新建 Change", + "dashboard.createNew": "新建 change", "dashboard.specs": "Specs ({count})", "dashboard.emptySpecs": "此工作区尚未定义 specs。", "dashboard.emptySpecsInRoot": "{root} 中还没有定义 spec。", @@ -294,7 +294,7 @@ "dashboard.priorityReasonNeedsAttention": "需要关注", "dashboard.priorityReasonReadyToVerify": "可以验证", "dashboard.priorityReasonRecommended": "推荐动作", - "dashboard.priorityActionAriaLabel": "{action}(Change:{name})", + "dashboard.priorityActionAriaLabel": "{action}(change:{name})", "dashboard.noRecommendedAction": "没有推荐动作", "dashboard.moreActions": "另有 {count} 个", "dashboard.blockedCount": "{count} 个已阻塞", @@ -328,9 +328,9 @@ "projectSidebar.navigationLabel": "项目导航", "projectSidebar.rootContextLabel": "OpenSpec 根上下文", "projectSidebar.project": "项目", - "projectSidebar.allChanges": "全部 Change", + "projectSidebar.allChanges": "全部 change", "projectSidebar.specs": "Spec", - "projectSidebar.cardChangesSupporting": "浏览本地 Change", + "projectSidebar.cardChangesSupporting": "浏览本地 change", "projectSidebar.cardSpecsSupporting": "浏览本地 Spec", "projectSidebar.cardWorksetsSupporting": "浏览 Workset 项目", "projectSidebar.cardDashboardSupporting": "在编辑器中打开项目 Dashboard", @@ -344,8 +344,8 @@ "projectSidebar.worksetsUnavailable": "暂无可信的 Workset 成员关系", "projectDashboard.title": "项目 Dashboard", "projectDashboard.summary": "项目摘要", - "projectDashboard.totalChanges": "Change 总数", - "projectDashboard.activeChanges": "进行中的 Change", + "projectDashboard.totalChanges": "change 总数", + "projectDashboard.activeChanges": "进行中的 change", "projectDashboard.readyToVerify": "待验证", "projectDashboard.archived": "已归档", "projectDashboard.activeTasks": "进行中任务", @@ -354,7 +354,7 @@ "projectDashboard.lifecycleDistribution": "项目生命周期分布", "projectDashboard.artifactReadiness": "Artifact 就绪度", "projectDashboard.recentUpdates": "最近更新", - "projectDashboard.noArtifacts": "进行中的 Change 尚未声明 artifact。", + "projectDashboard.noArtifacts": "进行中的 change 尚未声明 artifact。", "projectDashboard.noRecentUpdates": "暂无最近更新。", "projectDashboard.loading": "正在加载项目 Dashboard...", "projectDashboard.stale": "正在刷新,当前显示缓存的项目数据...", @@ -414,11 +414,11 @@ "header.refresh": "刷新", "header.loading": "加载中...", - "header.newChange": "+ 新建 Change", + "header.newChange": "+ 新建 change", "action.openInEditor": "在编辑器中打开", "action.refresh": "刷新", - "action.archiveChange": "归档 Change", + "action.archiveChange": "归档 change", "action.showInSidebar": "在侧边栏中显示", "time.today": "今天", diff --git a/src/shared/workflowLaunchConfig.ts b/src/shared/workflowLaunchConfig.ts index fb1a141..5df1c1e 100644 --- a/src/shared/workflowLaunchConfig.ts +++ b/src/shared/workflowLaunchConfig.ts @@ -124,7 +124,9 @@ export function resolveWorkflowLabelLaunchConfig( hostSettingsConfig: WorkflowLaunchConfigView, uiConfig: WorkflowLaunchConfigView, ): WorkflowLaunchConfigView { - return isHostWorkflowLaunchCopyOnly(hostSettingsConfig) ? hostSettingsConfig : uiConfig; + const base = isHostWorkflowLaunchCopyOnly(hostSettingsConfig) ? hostSettingsConfig : uiConfig; + const agentAutoSubmit = hostSettingsConfig.agentAutoSubmit ?? uiConfig.agentAutoSubmit; + return agentAutoSubmit === undefined ? base : { ...base, agentAutoSubmit }; } /** Label/intent: adapter mode with a non-clipboard preferred adapter uses Agent verbs. */ diff --git a/src/webview/components/ChangeDetail.tsx b/src/webview/components/ChangeDetail.tsx index 85155e6..b64a2c0 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -350,11 +350,17 @@ export const ChangeDetail: React.FC = ({ artifactType === 'specs' ? selectedSpecIdRef.current : undefined, ); - const requestArtifact = (artifactType: string, outputPath?: string) => { + const requestArtifact = ( + artifactType: string, + outputPath?: string, + options?: { background?: boolean }, + ) => { const fetchKey = resolveFetchKey(artifactType); const resolvedOutputPath = outputPath ?? selectedOutputPathsRef.current[artifactType]; artifactFetchCoordinatorRef.current.schedule(fetchKey, () => { - if (shouldShowActiveTabLoadingForArtifactFetch(activeTabRef.current, artifactType)) { + const showLoading = !options?.background + && shouldShowActiveTabLoadingForArtifactFetch(activeTabRef.current, artifactType); + if (showLoading) { setLoading(true); setError(null); setErrorCode(undefined); @@ -723,7 +729,7 @@ export const ChangeDetail: React.FC = ({ setVerifyArchiveTasksLoading(true); } }, - scheduleTasksRefetch: () => requestArtifact('tasks'), + scheduleTasksRefetch: () => requestArtifact('tasks', undefined, { background: true }), }); } else if (invalidated.includes(activeTabRef.current)) { initialArtifactFetchKeyRef.current = null; diff --git a/src/webview/utils/changeDetailArtifactInvalidation.ts b/src/webview/utils/changeDetailArtifactInvalidation.ts index 04dace0..86f203f 100644 --- a/src/webview/utils/changeDetailArtifactInvalidation.ts +++ b/src/webview/utils/changeDetailArtifactInvalidation.ts @@ -23,7 +23,7 @@ export function clearTasksContentCache( } } -/** Drop any stuck tasks fetch slot, clear cache, and schedule a refetch. */ +/** Drop any stuck tasks fetch slot and refetch without clearing visible content. */ export function handleTasksArtifactInvalidated(params: { scopeId: string | undefined; coordinator: ArtifactFetchCoordinator; @@ -34,8 +34,6 @@ export function handleTasksArtifactInvalidated(params: { }): void { params.resetVerifyArchiveLoadedFlag(); params.coordinator.complete(tasksFetchCoordinatorKey(params.scopeId)); - clearTasksContentCache(params.contentCache, params.scopeId); - params.markVerifyArchiveLoading(); params.scheduleTasksRefetch(); } diff --git a/test/extension/providers/agentUnavailableHandler.test.ts b/test/extension/providers/agentUnavailableHandler.test.ts index b9676cd..836e3ca 100644 --- a/test/extension/providers/agentUnavailableHandler.test.ts +++ b/test/extension/providers/agentUnavailableHandler.test.ts @@ -37,6 +37,15 @@ vi.mock('@extension/services/workflowLaunchConfig', () => ({ cursorAgentModel: 'auto', cursorLaunchModeExplicit: false, })), + getWorkflowLaunchConfigViewForUi: vi.fn(() => ({ + workflowLaunchMode: 'clipboard', + preferredAgentAdapter: 'clipboard', + cursorLaunchMode: 'clipboard', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + effectiveAdapterId: null, + agentAutoSubmit: 'readOnly', + })), })); describe('agent unavailable webview handler', () => { diff --git a/test/extension/services/dataManager.test.ts b/test/extension/services/dataManager.test.ts index 632b28d..96d6f18 100644 --- a/test/extension/services/dataManager.test.ts +++ b/test/extension/services/dataManager.test.ts @@ -471,15 +471,65 @@ describe('DataManager dashboard data loading', () => { await manager.initialize(); const watcherCallback = start.mock.calls[0][0]; - watcherCallback([{ + const patchSpy = vi.fn(); + manager.onTaskProgressPatch(patchSpy); + vi.spyOn(manager as any, 'publishTaskProgressFromFile').mockResolvedValue(undefined); + + await (manager as any).handleFileWatcherEvents([{ uri: { fsPath: '/tmp/openspec/changes/same-change/tasks.md' }, }]); - expect(artifactChanged).toHaveBeenCalledWith({ - changeName: 'same-change', - artifactTypes: ['tasks'], - rootPath: realpathSync('/tmp'), + expect(artifactChanged).not.toHaveBeenCalled(); + expect((manager as any).publishTaskProgressFromFile).toHaveBeenCalledWith( + 'same-change', + undefined, + ); + }); + + it('does not invoke CLI refresh on tasks.md-only watcher events', async () => { + vi.useFakeTimers(); + const manager = new DataManager('/tmp'); + const execOpenSpec = vi.fn(); + const start = vi.fn(); + Object.assign(manager as any, { + cliService: { + checkAvailability: vi.fn().mockResolvedValue(true), + getCliActivationDiagnostic: vi.fn().mockReturnValue(null), + getVersion: vi.fn().mockResolvedValue('1.0.0'), + execOpenSpec, + runJson: vi.fn(), + listChanges: vi.fn(), + getChangeStatus: vi.fn(), + }, + contentAccess: { + autoCompleteParents: vi.fn().mockResolvedValue(undefined), + readArtifact: vi.fn().mockResolvedValue('- [ ] Task\n'), + }, + fileWatcher: { start, stop: vi.fn() }, + cachedData: { + changes: [{ name: 'same-change', completedTasks: 0, totalTasks: 1, status: 'draft' }], + archivedChanges: [], + changeStatusCounts: { all: 1, planning: 1, readyToApply: 0, applying: 0, readyToVerify: 0, archived: 0, needsAttention: 0 }, + }, }); + vi.spyOn(manager as any, 'migrateExecutionStateFromGlobalFile').mockResolvedValue(undefined); + vi.spyOn(manager as any, 'warmDashboardData').mockImplementation(() => undefined); + vi.spyOn(manager as any, 'initializeScopeManager').mockResolvedValue(undefined); + const refreshSpy = vi.spyOn(manager, 'refresh').mockResolvedValue({} as any); + + await manager.initialize(); + const watcherCallback = start.mock.calls[0][0]; + await watcherCallback([{ + uri: { fsPath: '/tmp/openspec/changes/same-change/tasks.md' }, + }]); + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(execOpenSpec).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2500); + expect(refreshSpy).not.toHaveBeenCalled(); + expect(execOpenSpec).not.toHaveBeenCalled(); + vi.useRealTimers(); }); it('accepts an optional cache service dependency', () => { diff --git a/test/extension/services/executorLaunchPresentation.test.ts b/test/extension/services/executorLaunchPresentation.test.ts index 2226276..9ebc167 100644 --- a/test/extension/services/executorLaunchPresentation.test.ts +++ b/test/extension/services/executorLaunchPresentation.test.ts @@ -10,6 +10,15 @@ vi.mock('@extension/services/workflowLaunchConfig', () => ({ cursorAgentModel: 'auto', cursorLaunchModeExplicit: true, })), + getWorkflowLaunchConfigViewForUi: vi.fn(() => ({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'deeplink', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: true, + effectiveAdapterId: 'cursor', + agentAutoSubmit: 'readOnly', + })), })); describe('createExecutorLaunchPresentation', () => { diff --git a/test/extension/services/workflowLaunchConfig.test.ts b/test/extension/services/workflowLaunchConfig.test.ts index d3453e7..f09f81e 100644 --- a/test/extension/services/workflowLaunchConfig.test.ts +++ b/test/extension/services/workflowLaunchConfig.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; import { + getAgentAutoSubmitMode, getCursorAgentModel, getWorkflowLaunchConfig, + getWorkflowLaunchConfigViewForUi, } from '@extension/services/workflowLaunchConfig'; vi.mock('vscode', () => ({ @@ -131,4 +133,23 @@ describe('workflow launch config', () => { expect(getCursorAgentModel()).toBe('legacy-model'); }); + + it('reads agentAutoSubmit from the effective configuration scope', () => { + getConfiguration.mockReturnValue({ + get: vi.fn((key: string) => (key === 'agentAutoSubmit' ? 'never' : undefined)), + inspect: vi.fn(), + } as any); + + expect(getAgentAutoSubmitMode()).toBe('never'); + expect(getWorkflowLaunchConfigViewForUi().agentAutoSubmit).toBe('never'); + }); + + it('includes workspace-scoped agentAutoSubmit in the UI launch config view', () => { + getConfiguration.mockReturnValue({ + get: vi.fn((key: string) => (key === 'agentAutoSubmit' ? 'always' : undefined)), + inspect: vi.fn(), + } as any); + + expect(getWorkflowLaunchConfigViewForUi().agentAutoSubmit).toBe('always'); + }); }); diff --git a/test/i18n/i18n.test.ts b/test/i18n/i18n.test.ts index 74aad56..b83422b 100644 --- a/test/i18n/i18n.test.ts +++ b/test/i18n/i18n.test.ts @@ -84,7 +84,7 @@ describe('i18n', () => { expect(t('task.executing')).toBe('执行中...'); expect(t('action.openInEditor')).toBe('在编辑器中打开'); expect(t('action.refresh')).toBe('刷新'); - expect(t('action.archiveChange')).toBe('归档 Change'); + expect(t('action.archiveChange')).toBe('归档 change'); expect(t('dashboard.statusDraft')).toBe('草稿'); expect(t('dashboard.statusInProgress')).toBe('进行中'); expect(t('dashboard.statusComplete')).toBe('已完成'); @@ -103,7 +103,7 @@ describe('i18n', () => { }); it('supports parameter substitution in Chinese', () => { - expect(t('command.created', { name: 'test-change' })).toBe('Change "test-change" 已创建'); + expect(t('command.created', { name: 'test-change' })).toBe('change "test-change" 已创建'); expect(t('adapter.switched', { name: 'Clipboard' })).toBe('已切换执行者: Clipboard'); expect(t('time.daysAgo', { days: 3 })).toBe('3天前'); expect(t('time.weeksAgo', { weeks: 2 })).toBe('2周前'); @@ -353,7 +353,7 @@ describe('i18n', () => { expect((en as Record)['dashboard.priorityActionAriaLabel']) .toBe('{action} for Change {name}'); expect((zhCn as Record)['dashboard.priorityActionAriaLabel']) - .toBe('{action}(Change:{name})'); + .toBe('{action}(change:{name})'); }); }); }); diff --git a/test/scripts/check-typecheck-baseline.test.ts b/test/scripts/check-typecheck-baseline.test.ts index 578df0c..4fb00f1 100644 --- a/test/scripts/check-typecheck-baseline.test.ts +++ b/test/scripts/check-typecheck-baseline.test.ts @@ -42,6 +42,32 @@ describe('check-typecheck-baseline.sh', () => { encoding: 'utf8', }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain("no 'error TS' lines were counted"); + expect(`${result.stdout}${result.stderr}`).toMatch(/abnormal signal|no 'error TS' lines were counted/); + }); + + it('fails on abnormal exit even when partial error TS lines were emitted', () => { + const result = spawnSync('bash', [scriptPath], { + env: { + ...process.env, + CHECK_TYPECHECK_TSC_INVOKE: 'echo "error TS9999: partial crash" >&2; echo "Killed" >&2; exit 134', + TSC_BASELINE_ERROR_COUNT: '12', + }, + encoding: 'utf8', + }); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain('abnormal signal/code 134'); + }); + + it('fails when tsc exits non-zero without Found N errors summary', () => { + const result = spawnSync('bash', [scriptPath], { + env: { + ...process.env, + CHECK_TYPECHECK_TSC_INVOKE: 'echo "error TS9999: stray" >&2; exit 2', + TSC_BASELINE_ERROR_COUNT: '12', + }, + encoding: 'utf8', + }); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("without a 'Found N error(s)' summary"); }); }); diff --git a/test/shared/workflowLaunchConfig.test.ts b/test/shared/workflowLaunchConfig.test.ts index 5dee23b..33ae812 100644 --- a/test/shared/workflowLaunchConfig.test.ts +++ b/test/shared/workflowLaunchConfig.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { isCopyOnlyWorkflowMode, resolveWorkflowLaunchConfig, + resolveWorkflowLabelLaunchConfig, shouldUseAgentWorkflowLabels, toWorkflowLaunchConfigView, type WorkflowLaunchConfigWithExplicit, @@ -103,4 +104,17 @@ describe('copy-only vs implicit cursorLaunchMode=clipboard (regression .17)', () expect(isCopyOnlyWorkflowMode(view)).toBe(false); expect(shouldUseAgentWorkflowLabels(view)).toBe(true); }); + + it('keeps host agentAutoSubmit when resolving label launch config', () => { + const host = toWorkflowLaunchConfigView({ + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'cursor', + cursorLaunchMode: 'agentPanel', + cursorAgentModel: 'auto', + cursorLaunchModeExplicit: false, + }); + const ui = { ...host, agentAutoSubmit: 'readOnly' as const }; + const hostNever = { ...host, agentAutoSubmit: 'never' as const }; + expect(resolveWorkflowLabelLaunchConfig(hostNever, ui).agentAutoSubmit).toBe('never'); + }); }); diff --git a/test/webview/components/dashboard.test.tsx b/test/webview/components/dashboard.test.tsx index 6d76302..d6dcaa7 100644 --- a/test/webview/components/dashboard.test.tsx +++ b/test/webview/components/dashboard.test.tsx @@ -420,7 +420,7 @@ describe('Dashboard recommended action rail', () => { expect(html).toContain('aria-label="工作流优先级"'); expect(html).toContain('需要关注'); expect(html).toContain('可以验证'); - expect(html).toContain('aria-label="审查并归档(Change:attention-change)"'); + expect(html).toContain('aria-label="审查并归档(change:attention-change)"'); setLocale('en'); }); diff --git a/test/webview/components/header.test.tsx b/test/webview/components/header.test.tsx index 48ce1bb..a9acc7a 100644 --- a/test/webview/components/header.test.tsx +++ b/test/webview/components/header.test.tsx @@ -294,7 +294,7 @@ describe('Project-first Header', () => { ); expect(html).toContain('aria-label="项目导航"'); - expect(html).toContain('浏览本地 Change'); + expect(html).toContain('浏览本地 change'); expect(html).toContain('浏览本地 Spec'); expect(html).toContain('在编辑器中打开项目 Dashboard'); // The Worksets launcher keeps its browsing-for-current-Project accessible diff --git a/test/webview/utils/changeDetailArtifactInvalidation.test.ts b/test/webview/utils/changeDetailArtifactInvalidation.test.ts index c595377..0517f73 100644 --- a/test/webview/utils/changeDetailArtifactInvalidation.test.ts +++ b/test/webview/utils/changeDetailArtifactInvalidation.test.ts @@ -65,7 +65,7 @@ describe('changeDetailArtifactInvalidation', () => { expect(coordinator.isInFlight(fetchKey)).toBe(false); } - expect(verifyArchiveLoadingCount).toBe(3); + expect(verifyArchiveLoadingCount).toBe(0); vi.useRealTimers(); }); diff --git a/test/webview/utils/workflowLaunchLabels.test.ts b/test/webview/utils/workflowLaunchLabels.test.ts index ad0c325..3c20ffb 100644 --- a/test/webview/utils/workflowLaunchLabels.test.ts +++ b/test/webview/utils/workflowLaunchLabels.test.ts @@ -123,6 +123,31 @@ describe('workflow launch labels', () => { expect(getVerifyArchiveHint('archive', config, { isCursorHost: true }, 'demo')).toContain('Enter'); }); + it('verify hint reflects never auto-submit (prefill, not send)', () => { + const config: WorkflowLaunchConfigView = { + ...baseConfig, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + effectiveAdapterId: 'vscode-chat', + agentAutoSubmit: 'never', + }; + const verifyHint = getVerifyArchiveHint('verify', config, undefined, 'demo-change'); + expect(verifyHint).toContain('Enter'); + expect(verifyHint).not.toContain('automatically'); + }); + + it('verify hint reflects always auto-submit for verify and archive', () => { + const config: WorkflowLaunchConfigView = { + ...baseConfig, + workflowLaunchMode: 'adapter', + preferredAgentAdapter: 'vscode-chat', + effectiveAdapterId: 'vscode-chat', + agentAutoSubmit: 'always', + }; + expect(getVerifyArchiveHint('verify', config, undefined, 'demo-change')).toContain('automatically'); + expect(getVerifyArchiveHint('archive', config, undefined, 'demo-change')).toContain('automatically'); + }); + it('shows generic launch wording for non-Cursor adapters', () => { expect( getWorkflowActionButtonLabel('Apply', { From 736c35259c442d0f407faeaa06cedd0ae80289dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 06:14:17 +0000 Subject: [PATCH 06/19] test: assert typecheck gate uses pretty output and ANSI strip Co-authored-by: RandyZhang --- test/scripts/check-typecheck-baseline.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/scripts/check-typecheck-baseline.test.ts b/test/scripts/check-typecheck-baseline.test.ts index 4fb00f1..f15d26f 100644 --- a/test/scripts/check-typecheck-baseline.test.ts +++ b/test/scripts/check-typecheck-baseline.test.ts @@ -12,9 +12,11 @@ describe('check-typecheck-baseline.sh', () => { expect(script).not.toMatch(/\brg\b/); }); - it('uses strict bash, baseline 12, and rejects tsc crash without error TS lines', () => { + it('uses strict bash, baseline 12, pretty tsc output, and rejects tsc crash without error TS lines', () => { expect(script).toContain('set -euo pipefail'); expect(script).toContain('TSC_BASELINE_ERROR_COUNT:-12'); + expect(script).toContain('--pretty true'); + expect(script).toContain('strip_ansi'); expect(script).toContain("no 'error TS' lines were counted"); }); From 1feee4bb158b7a4477e695911e4923097ca06fd1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 06:38:30 +0000 Subject: [PATCH 07/19] Release 0.2.5-agent-panel.6: detail task patches by root, lifecycle reconcile Co-authored-by: RandyZhang --- CHANGELOG.md | 7 + package.json | 2 +- .../providers/changeDetailPanelManager.ts | 10 ++ src/extension/services/changeStatusFromCli.ts | 59 +++++++ src/extension/services/dataManager.ts | 156 +++++++++++++----- src/shared/changeTaskProgressPatch.ts | 6 + src/shared/changeTaskProgressScope.ts | 85 ++++++++++ src/shared/taskProgressLifecycleBoundary.ts | 30 ++++ src/webview/components/ChangeDetail.tsx | 9 +- src/webview/utils/applyTaskProgressPatch.ts | 29 +++- .../changeDetailPanelManager.test.ts | 55 ++++++ test/extension/services/dataManager.test.ts | 89 +++++++++- test/shared/changeTaskProgressScope.test.ts | 56 +++++++ .../taskProgressLifecycleBoundary.test.ts | 25 +++ .../utils/applyTaskProgressPatch.test.ts | 24 +++ 15 files changed, 591 insertions(+), 51 deletions(-) create mode 100644 src/extension/services/changeStatusFromCli.ts create mode 100644 src/shared/changeTaskProgressScope.ts create mode 100644 src/shared/taskProgressLifecycleBoundary.ts create mode 100644 test/shared/changeTaskProgressScope.test.ts create mode 100644 test/shared/taskProgressLifecycleBoundary.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a95b649..7ab0767 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] +## [0.2.5-agent-panel.6] - 2026-10-09 + +### Fixed + +- Detail task progress: match `changeTaskProgressPatch` by OpenSpec root + change name (project-bound `scopeId` no longer drops watcher patches); tasks-only watcher events send background `artifactInvalidated` safety net. +- Lifecycle after tasks.md: debounced single-change `getChangeStatus` when file counts cross a lifecycle boundary; empty/deleted `tasks.md` immediately publishes 0/0; ordinary checkbox edits stay CLI-free. + ## [0.2.5-agent-panel.5] - 2026-10-09 ### Fixed diff --git a/package.json b/package.json index a382156..1132184 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.5-agent-panel.5", + "version": "0.2.5-agent-panel.6", "publisher": "randysss", "license": "MIT", "engines": { diff --git a/src/extension/providers/changeDetailPanelManager.ts b/src/extension/providers/changeDetailPanelManager.ts index e758922..991bc8c 100644 --- a/src/extension/providers/changeDetailPanelManager.ts +++ b/src/extension/providers/changeDetailPanelManager.ts @@ -21,6 +21,7 @@ import { type ChangeWorkflowSnapshot, } from '../../shared/changeWorkflow'; import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; +import { changeTaskProgressPatchAppliesToPanel } from '../../shared/changeTaskProgressScope'; import type { OpenSpecScope } from '../services/openspecScope'; import type { OpenSpecRootBinding, ProjectContext } from '../services/types'; @@ -432,6 +433,15 @@ export class ChangeDetailPanelManager { public broadcastTaskProgressPatch(patch: ChangeTaskProgressPatch): void { for (const [key, panel] of this.panels) { if (!this.keyMatchesChange(key, patch.changeName)) continue; + const panelRootPath = this.panelRootPaths.get(key); + const panelScope = this.panelScopes.get(panel.webview); + if (!changeTaskProgressPatchAppliesToPanel(patch, { + changeName: patch.changeName, + scopeId: panelScope?.id, + planningRoot: panelRootPath, + })) { + continue; + } try { panel.webview.postMessage(patch); } catch (err) { diff --git a/src/extension/services/changeStatusFromCli.ts b/src/extension/services/changeStatusFromCli.ts new file mode 100644 index 0000000..583174c --- /dev/null +++ b/src/extension/services/changeStatusFromCli.ts @@ -0,0 +1,59 @@ +import { enrichChangeWithLifecycle } from '../../shared/changeLifecycle'; +import type { ChangeInfo } from './types'; +import type { OpenSpecScope } from './openspecScope'; +import type { OpenSpecCliService } from './openspecCli'; + +function mapCliArtifacts(raw: unknown): ChangeInfo['artifacts'] { + if (!Array.isArray(raw)) return []; + return raw.map((entry) => { + const artifact = entry as Record; + const statusRaw = typeof artifact.status === 'string' ? artifact.status : ''; + const status = statusRaw === 'complete' + ? 'done' + : (statusRaw === 'done' || statusRaw === 'ready' || statusRaw === 'blocked' + ? statusRaw + : 'blocked'); + return { + id: typeof artifact.id === 'string' ? artifact.id : '', + outputPath: typeof artifact.outputPath === 'string' + ? artifact.outputPath + : typeof artifact.path === 'string' + ? artifact.path + : '', + status, + }; + }); +} + +/** + * Refresh artifact graph for one change via `openspec status --change` (no list/doctor/context/workset). + */ +export async function reconcileChangeLifecycleFromCli(params: { + changeName: string; + scope: OpenSpecScope | undefined; + cli: OpenSpecCliService; + existing: ChangeInfo | undefined; + fileProgress: { completedTasks: number; totalTasks: number; status: ChangeInfo['status'] }; +}): Promise { + try { + const status = await params.cli.getChangeStatus(params.changeName, params.scope); + const artifacts = mapCliArtifacts(status.artifacts); + const base: ChangeInfo = { + name: params.changeName, + completedTasks: params.fileProgress.completedTasks, + totalTasks: params.fileProgress.totalTasks, + status: params.fileProgress.status, + lastModified: params.existing?.lastModified ?? new Date().toISOString(), + createdAt: params.existing?.createdAt, + artifacts, + workflowSnapshot: params.existing?.workflowSnapshot, + proposalWhySummary: params.existing?.proposalWhySummary, + proposalWhyFullText: params.existing?.proposalWhyFullText, + searchText: params.existing?.searchText, + lifecycleStatus: params.existing?.lifecycleStatus ?? 'planning', + }; + return enrichChangeWithLifecycle(base); + } catch { + return null; + } +} diff --git a/src/extension/services/dataManager.ts b/src/extension/services/dataManager.ts index 4bead2d..b93991f 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -28,6 +28,8 @@ import { taskProgressFromTasksMarkdown, } from './changeTaskProgressFromFile'; import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; +import { taskProgressCrossesLifecycleBoundary } from '../../shared/taskProgressLifecycleBoundary'; +import { reconcileChangeLifecycleFromCli } from './changeStatusFromCli'; export interface ScopeInfo { id: string; @@ -136,8 +138,8 @@ export class DataManager { private refreshCallbacks: Set<(data: DashboardData) => void> = new Set(); private taskProgressPatchCallbacks: Set<(patch: ChangeTaskProgressPatch) => void> = new Set(); private artifactChangedCallbacks: Set<(event: ArtifactChangedEvent) => void> = new Set(); - private tasksOnlyRefreshTimer: ReturnType | undefined; - private tasksOnlyRefreshPending: { changeName: string; scope?: OpenSpecScope } | undefined; + private tasksStatusReconcileTimer: ReturnType | undefined; + private tasksStatusReconcilePending: { changeName: string; scope?: OpenSpecScope } | undefined; private cliAvailable = false; // Scope-aware additions @@ -595,9 +597,12 @@ export class DataManager { }); if (onlyTasksMd) { - const changeName = [...tasksPatchedChanges].at(-1); - if (changeName) { - this.scheduleTasksOnlyBackgroundRefresh(changeName, scope); + for (const changeName of tasksPatchedChanges) { + this.notifyArtifactChanged({ + changeName, + artifactTypes: ['tasks'], + rootPath: this.canonicalRootPath(this.watchedProjectRoot), + }); } return; } @@ -1370,60 +1375,50 @@ export class DataManager { await fs.promises.writeFile(filePath, YAML.stringify(data), 'utf8'); } - private scheduleTasksOnlyBackgroundRefresh(changeName: string, scope?: OpenSpecScope): void { - this.tasksOnlyRefreshPending = { changeName, scope }; - if (this.tasksOnlyRefreshTimer) { - clearTimeout(this.tasksOnlyRefreshTimer); + private scheduleTasksStatusReconcile(changeName: string, scope?: OpenSpecScope): void { + this.tasksStatusReconcilePending = { changeName, scope }; + if (this.tasksStatusReconcileTimer) { + clearTimeout(this.tasksStatusReconcileTimer); } - this.tasksOnlyRefreshTimer = setTimeout(() => { - this.tasksOnlyRefreshTimer = undefined; - const pending = this.tasksOnlyRefreshPending; - this.tasksOnlyRefreshPending = undefined; + this.tasksStatusReconcileTimer = setTimeout(() => { + this.tasksStatusReconcileTimer = undefined; + const pending = this.tasksStatusReconcilePending; + this.tasksStatusReconcilePending = undefined; if (!pending) return; - void this.publishTaskProgressFromFile(pending.changeName, pending.scope).catch((error) => { - logger.warn('Failed deferred tasks-only reconcile', error as Error); + void this.reconcileChangeStatusAfterTasksBoundary(pending.changeName, pending.scope).catch((error) => { + logger.warn('Failed debounced tasks status reconcile', error as Error); }); - }, 2000); + }, 750); } - private async publishTaskProgressFromFile( + private async reconcileChangeStatusAfterTasksBoundary( changeName: string, scope?: OpenSpecScope, ): Promise { + if (!this.cliAvailable) return; const services = this.getScopedServices(scope); - let content: string; + const existing = this.cachedData?.changes.find((c) => c.name === changeName); + let content = ''; try { content = await services.contentAccess.readArtifact(changeName, 'tasks'); } catch { - return; - } - if (!content.trim()) { - return; - } - - let progress = taskProgressFromTasksMarkdown(content); - if (this.cachedData && this.isCurrentScope(scope)) { - const existing = this.cachedData.changes.find((c) => c.name === changeName); - if ( - existing - && progress.totalTasks === 0 - && existing.totalTasks > 0 - && (content.includes('[') || content.includes('- ')) - ) { - progress = { - completedTasks: existing.completedTasks, - totalTasks: existing.totalTasks, - status: existing.status, - }; - } + content = ''; } + const fileProgress = taskProgressFromTasksMarkdown(content); + const reconciled = await reconcileChangeLifecycleFromCli({ + changeName, + scope, + cli: services.cli, + existing, + fileProgress, + }); + if (!reconciled) return; if (this.cachedData && this.isCurrentScope(scope)) { const index = this.cachedData.changes.findIndex((c) => c.name === changeName); if (index >= 0) { - const updatedChange = applyTaskProgressToChange(this.cachedData.changes[index], progress); const changes = [...this.cachedData.changes]; - changes[index] = enrichChangeWithLifecycle(updatedChange); + changes[index] = reconciled; this.cachedData = { ...this.cachedData, changes, @@ -1433,17 +1428,90 @@ export class DataManager { } } + this.emitTaskProgressPatch(changeName, scope, reconciled, content); + } + + private emitTaskProgressPatch( + changeName: string, + scope: OpenSpecScope | undefined, + change: ChangeInfo, + tasksContent: string, + ): void { + const services = this.getScopedServices(scope); this.notifyTaskProgressPatch({ type: 'changeTaskProgressPatch', changeName, scopeId: scope?.id, - completedTasks: progress.completedTasks, - totalTasks: progress.totalTasks, - tasksContent: content, + changeRootPath: services.rootPath, + completedTasks: change.completedTasks, + totalTasks: change.totalTasks, + tasksContent, + lifecycleStatus: change.lifecycleStatus, + attention: change.attention, revisedAt: Date.now(), }); } + private async publishTaskProgressFromFile( + changeName: string, + scope?: OpenSpecScope, + ): Promise { + const services = this.getScopedServices(scope); + let content = ''; + let missingFile = false; + try { + content = await services.contentAccess.readArtifact(changeName, 'tasks'); + } catch { + missingFile = true; + content = ''; + } + + const progress = taskProgressFromTasksMarkdown(content); + const previous = this.cachedData?.changes.find((c) => c.name === changeName); + const boundaryCrossed = missingFile + || taskProgressCrossesLifecycleBoundary( + previous + ? { completedTasks: previous.completedTasks, totalTasks: previous.totalTasks } + : undefined, + progress, + ); + + let updatedChange: ChangeInfo | undefined; + if (this.cachedData && this.isCurrentScope(scope)) { + const index = this.cachedData.changes.findIndex((c) => c.name === changeName); + if (index >= 0) { + updatedChange = applyTaskProgressToChange(this.cachedData.changes[index], progress); + const changes = [...this.cachedData.changes]; + changes[index] = updatedChange; + this.cachedData = { + ...this.cachedData, + changes, + changeStatusCounts: buildChangeStatusCounts(changes, this.cachedData.archivedChanges), + lastRefresh: Date.now(), + }; + } + } + + if (updatedChange) { + this.emitTaskProgressPatch(changeName, scope, updatedChange, content); + } else { + this.notifyTaskProgressPatch({ + type: 'changeTaskProgressPatch', + changeName, + scopeId: scope?.id, + changeRootPath: services.rootPath, + completedTasks: progress.completedTasks, + totalTasks: progress.totalTasks, + tasksContent: content, + revisedAt: Date.now(), + }); + } + + if (boundaryCrossed) { + this.scheduleTasksStatusReconcile(changeName, scope); + } + } + onTaskProgressPatch(callback: (patch: ChangeTaskProgressPatch) => void): vscode.Disposable { this.taskProgressPatchCallbacks.add(callback); return new vscode.Disposable(() => { diff --git a/src/shared/changeTaskProgressPatch.ts b/src/shared/changeTaskProgressPatch.ts index a3485fa..fe4f328 100644 --- a/src/shared/changeTaskProgressPatch.ts +++ b/src/shared/changeTaskProgressPatch.ts @@ -1,10 +1,16 @@ +import type { ActiveChangeLifecycleStatus, ChangeAttention } from './changeLifecycle'; + export interface ChangeTaskProgressPatch { type: 'changeTaskProgressPatch'; changeName: string; scopeId?: string; + /** Canonical OpenSpec root for this change; used to match project-bound detail panels. */ + changeRootPath?: string; completedTasks: number; totalTasks: number; tasksContent?: string; + lifecycleStatus?: ActiveChangeLifecycleStatus; + attention?: ChangeAttention; revisedAt: number; } diff --git a/src/shared/changeTaskProgressScope.ts b/src/shared/changeTaskProgressScope.ts new file mode 100644 index 0000000..3157b2e --- /dev/null +++ b/src/shared/changeTaskProgressScope.ts @@ -0,0 +1,85 @@ +import type { ChangeTaskProgressPatch } from './changeTaskProgressPatch'; + +/** Normalize an OpenSpec root path for comparison (no realpath; safe in webview). */ +export function normalizeChangeRootPath(rootPath: string): string { + if (!rootPath) return rootPath; + let normalized = rootPath.replace(/\\/g, '/'); + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +export function changeRootPathsEqual(a: string | undefined, b: string | undefined): boolean { + if (!a || !b) return false; + return normalizeChangeRootPath(a) === normalizeChangeRootPath(b); +} + +/** Best-effort root path embedded in a scope id (local / declared / project-bound). */ +export function resolveRootPathFromScopeId(scopeId: string | undefined): string | undefined { + if (!scopeId) return undefined; + if (scopeId.startsWith('local:')) { + return scopeId.slice('local:'.length); + } + if (scopeId.startsWith('declared:')) { + return scopeId.slice('declared:'.length); + } + if (scopeId.startsWith('project:')) { + const withoutPrefix = scopeId.slice('project:'.length); + const firstColon = withoutPrefix.indexOf(':'); + if (firstColon === -1) return undefined; + const afterProjectId = withoutPrefix.slice(firstColon + 1); + const rootSourceMatch = afterProjectId.match( + /^(.+):(?:local|store|declared)(?::|$)/, + ); + if (rootSourceMatch?.[1]) { + return rootSourceMatch[1]; + } + const parts = afterProjectId.split(':'); + if (parts.length >= 2 && parts[0].startsWith('/')) { + return parts[0]; + } + return undefined; + } + return undefined; +} + +export interface TaskProgressPanelScope { + changeName: string; + scopeId?: string; + planningRoot?: string; +} + +export function resolvePanelChangeRoot(panel: TaskProgressPanelScope): string | undefined { + if (panel.planningRoot) return panel.planningRoot; + return resolveRootPathFromScopeId(panel.scopeId); +} + +export function resolvePatchChangeRoot(patch: ChangeTaskProgressPatch): string | undefined { + if (patch.changeRootPath) return patch.changeRootPath; + return resolveRootPathFromScopeId(patch.scopeId); +} + +/** + * True when a host task-progress patch targets the same change at the same OpenSpec root + * as the detail panel (project-bound scope ids may differ from watcher local/store ids). + */ +export function changeTaskProgressPatchAppliesToPanel( + patch: ChangeTaskProgressPatch, + panel: TaskProgressPanelScope, +): boolean { + if (patch.changeName !== panel.changeName) return false; + + const patchRoot = resolvePatchChangeRoot(patch); + const panelRoot = resolvePanelChangeRoot(panel); + + if (patchRoot && panelRoot) { + return changeRootPathsEqual(patchRoot, panelRoot); + } + + if (patch.scopeId && panel.scopeId && patch.scopeId === panel.scopeId) { + return true; + } + + return !patchRoot && !panelRoot; +} diff --git a/src/shared/taskProgressLifecycleBoundary.ts b/src/shared/taskProgressLifecycleBoundary.ts new file mode 100644 index 0000000..1b1fc0b --- /dev/null +++ b/src/shared/taskProgressLifecycleBoundary.ts @@ -0,0 +1,30 @@ +export interface TaskProgressCounts { + completedTasks: number; + totalTasks: number; +} + +/** True when tasks.md-derived counts may change derived lifecycle (needs one status reconcile). */ +export function taskProgressCrossesLifecycleBoundary( + before: TaskProgressCounts | undefined, + after: TaskProgressCounts, +): boolean { + const prev = before ?? { completedTasks: 0, totalTasks: 0 }; + + if ((prev.totalTasks === 0) !== (after.totalTasks === 0)) { + return true; + } + + const prevAllDone = prev.totalTasks > 0 && prev.completedTasks === prev.totalTasks; + const nextAllDone = after.totalTasks > 0 && after.completedTasks === after.totalTasks; + if (prevAllDone !== nextAllDone) { + return true; + } + + const prevSomeDone = prev.completedTasks > 0; + const nextSomeDone = after.completedTasks > 0; + if (prevSomeDone !== nextSomeDone) { + return true; + } + + return false; +} diff --git a/src/webview/components/ChangeDetail.tsx b/src/webview/components/ChangeDetail.tsx index b64a2c0..5ad305e 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -59,6 +59,7 @@ import { } from '../utils/changeDetailTasksFetch'; import type { WorkflowActionReceipt } from '../../shared/changeWorkflow'; import { isChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; +import { changeTaskProgressPatchAppliesToPanel } from '../../shared/changeTaskProgressScope'; const MISSING_ARTIFACT_MESSAGE = t('artifact.missing'); @@ -555,9 +556,9 @@ export const ChangeDetail: React.FC = ({ })) { setVerifyArchiveTasksLoading(false); } - } else if (isChangeTaskProgressPatch(msg) && msg.changeName === changeName) { + } else if (isChangeTaskProgressPatch(msg)) { const patch = msg; - if (patch.scopeId && scopeId && patch.scopeId !== scopeId) { + if (!changeTaskProgressPatchAppliesToPanel(patch, { changeName, scopeId, planningRoot })) { return; } setTasksProgressError(null); @@ -566,7 +567,9 @@ export const ChangeDetail: React.FC = ({ setTotalTasks(patch.totalTasks); setVerifyArchiveTasksLoading(false); if (typeof patch.tasksContent === 'string') { - if (activeTabRef.current === 'tasks' || activeTabRef.current === 'verifyArchive') { + const onTasksSurface = activeTabRef.current === 'tasks' + || activeTabRef.current === 'verifyArchive'; + if (onTasksSurface) { setContent(patch.tasksContent); } completeArtifactFetchAndStoreContent({ diff --git a/src/webview/utils/applyTaskProgressPatch.ts b/src/webview/utils/applyTaskProgressPatch.ts index 9ea0542..d62f987 100644 --- a/src/webview/utils/applyTaskProgressPatch.ts +++ b/src/webview/utils/applyTaskProgressPatch.ts @@ -1,7 +1,18 @@ import type { ChangeInfo } from '../types/messages'; import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; +import { enrichChangeWithLifecycle } from '../../shared/changeLifecycle'; -type ChangeTaskProgressRow = Pick; +type ChangeTaskProgressRow = Pick< + ChangeInfo, + | 'name' + | 'completedTasks' + | 'totalTasks' + | 'status' + | 'lastModified' + | 'artifacts' + | 'lifecycleStatus' + | 'attention' +>; export function applyTaskProgressPatchToChange( change: T, @@ -13,12 +24,26 @@ export function applyTaskProgressPatchToChange( : patch.completedTasks === patch.totalTasks ? 'complete' : 'in-progress'; - return { + const merged: T = { ...change, completedTasks: patch.completedTasks, totalTasks: patch.totalTasks, status, + ...(patch.lifecycleStatus ? { lifecycleStatus: patch.lifecycleStatus } : {}), + ...(patch.attention !== undefined ? { attention: patch.attention } : {}), }; + if (patch.lifecycleStatus) { + return merged; + } + if (change.artifacts && change.artifacts.length > 0) { + return enrichChangeWithLifecycle({ + ...change, + completedTasks: patch.completedTasks, + totalTasks: patch.totalTasks, + status, + }) as T; + } + return merged; } export function applyTaskProgressPatchToChanges( diff --git a/test/extension/providers/changeDetailPanelManager.test.ts b/test/extension/providers/changeDetailPanelManager.test.ts index e2ade1e..cf83334 100644 --- a/test/extension/providers/changeDetailPanelManager.test.ts +++ b/test/extension/providers/changeDetailPanelManager.test.ts @@ -177,6 +177,61 @@ describe('ChangeDetailPanelManager scope binding', () => { }); }); + it('broadcasts task progress patches only to panels at the matching OpenSpec root', () => { + const localRoot = '/workspace/openspec'; + const projectRoot = '/planning/project-a'; + const panels = [createPanel(), createPanel()]; + const localScope = { + id: `local:${localRoot}`, + label: 'Local', + rootPath: localRoot, + source: 'local', + capabilities: {}, + diagnostics: [], + }; + const projectBinding = { + projectId: '/projects/current', + commandCwd: '/projects/current', + rootPath: projectRoot, + rootSource: 'nearest', + }; + const dataManager = { + resolveScope: vi.fn((scopeId?: string) => ( + scopeId === localScope.id ? localScope : undefined + )), + getSelectedScope: vi.fn(() => localScope), + getDashboardData: vi.fn().mockResolvedValue({ changes: [], specs: [], lastRefresh: 1 }), + artifactExists: vi.fn().mockResolvedValue(true), + }; + vi.mocked(vscode.window.createWebviewPanel) + .mockReturnValueOnce(panels[0] as any) + .mockReturnValueOnce(panels[1] as any); + + const manager = new ChangeDetailPanelManager(dataManager as any, '/ext', {} as any); + manager.open('same-change', { scopeId: localScope.id }); + manager.open('same-change', { + project: { id: 'p', label: 'P', projectPath: '/projects/current' }, + binding: projectBinding, + }); + panels.forEach((panel) => panel.webview.postMessage.mockClear()); + + manager.broadcastTaskProgressPatch({ + type: 'changeTaskProgressPatch', + changeName: 'same-change', + scopeId: localScope.id, + changeRootPath: localRoot, + completedTasks: 2, + totalTasks: 3, + revisedAt: Date.now(), + }); + + expect(panels[0].webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'changeTaskProgressPatch', + completedTasks: 2, + })); + expect(panels[1].webview.postMessage).not.toHaveBeenCalled(); + }); + it('invalidates only the same-named Project-bound panel with the matching root', async () => { const project = { id: '/projects/current', diff --git a/test/extension/services/dataManager.test.ts b/test/extension/services/dataManager.test.ts index 96d6f18..bd1a914 100644 --- a/test/extension/services/dataManager.test.ts +++ b/test/extension/services/dataManager.test.ts @@ -479,7 +479,11 @@ describe('DataManager dashboard data loading', () => { uri: { fsPath: '/tmp/openspec/changes/same-change/tasks.md' }, }]); - expect(artifactChanged).not.toHaveBeenCalled(); + expect(artifactChanged).toHaveBeenCalledWith(expect.objectContaining({ + changeName: 'same-change', + artifactTypes: ['tasks'], + rootPath: '/tmp', + })); expect((manager as any).publishTaskProgressFromFile).toHaveBeenCalledWith( 'same-change', undefined, @@ -532,6 +536,89 @@ describe('DataManager dashboard data loading', () => { vi.useRealTimers(); }); + it('publishes zero task counts when tasks.md is empty', async () => { + const manager = new DataManager('/tmp'); + const patchSpy = vi.fn(); + manager.onTaskProgressPatch(patchSpy); + Object.assign(manager as any, { + cliAvailable: true, + cliService: { + checkAvailability: vi.fn().mockResolvedValue(true), + getChangeStatus: vi.fn().mockResolvedValue({ artifacts: [] }), + }, + contentAccess: { + readArtifact: vi.fn().mockResolvedValue(''), + }, + cachedData: { + changes: [{ + name: 'same-change', + completedTasks: 3, + totalTasks: 3, + status: 'complete', + lastModified: '2026-01-01', + lifecycleStatus: 'ready-to-verify', + artifacts: [{ id: 'tasks', outputPath: 't.md', status: 'done' }], + }], + archivedChanges: [], + changeStatusCounts: { all: 1, planning: 0, readyToApply: 0, applying: 0, readyToVerify: 1, archived: 0, needsAttention: 0 }, + }, + }); + vi.spyOn(manager as any, 'isCurrentScope').mockReturnValue(true); + + await (manager as any).publishTaskProgressFromFile('same-change', undefined); + + expect(patchSpy).toHaveBeenCalledWith(expect.objectContaining({ + changeName: 'same-change', + completedTasks: 0, + totalTasks: 0, + tasksContent: '', + })); + }); + + it('debounces a single status reconcile when task counts cross a lifecycle boundary', async () => { + vi.useFakeTimers(); + const getChangeStatus = vi.fn().mockResolvedValue({ + artifacts: [{ id: 'tasks', outputPath: 'tasks.md', status: 'done' }], + }); + const manager = new DataManager('/tmp'); + const patchSpy = vi.fn(); + manager.onTaskProgressPatch(patchSpy); + Object.assign(manager as any, { + cliAvailable: true, + cliService: { + checkAvailability: vi.fn().mockResolvedValue(true), + getChangeStatus, + }, + contentAccess: { + readArtifact: vi.fn() + .mockResolvedValueOnce('- [x] A\n- [x] B\n- [x] C\n') + .mockResolvedValue('- [x] A\n- [x] B\n- [x] C\n'), + }, + cachedData: { + changes: [{ + name: 'same-change', + completedTasks: 0, + totalTasks: 3, + status: 'in-progress', + lastModified: '2026-01-01', + lifecycleStatus: 'ready-to-apply', + artifacts: [{ id: 'tasks', outputPath: 'tasks.md', status: 'done' }], + }], + archivedChanges: [], + changeStatusCounts: { all: 1, planning: 0, readyToApply: 1, applying: 0, readyToVerify: 0, archived: 0, needsAttention: 0 }, + }, + }); + vi.spyOn(manager as any, 'isCurrentScope').mockReturnValue(true); + + await (manager as any).publishTaskProgressFromFile('same-change', undefined); + expect(getChangeStatus).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(800); + expect(getChangeStatus).toHaveBeenCalledTimes(1); + expect(getChangeStatus).toHaveBeenCalledWith('same-change', undefined); + vi.useRealTimers(); + }); + it('accepts an optional cache service dependency', () => { const cacheService = { readDashboard: vi.fn(), diff --git a/test/shared/changeTaskProgressScope.test.ts b/test/shared/changeTaskProgressScope.test.ts new file mode 100644 index 0000000..3fc5c8b --- /dev/null +++ b/test/shared/changeTaskProgressScope.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { changeTaskProgressPatchAppliesToPanel } from '../../src/shared/changeTaskProgressScope'; +import type { ChangeTaskProgressPatch } from '../../src/shared/changeTaskProgressPatch'; + +function patch(overrides: Partial): ChangeTaskProgressPatch { + return { + type: 'changeTaskProgressPatch', + changeName: 'demo-change', + completedTasks: 1, + totalTasks: 3, + revisedAt: 1, + ...overrides, + }; +} + +describe('changeTaskProgressPatchAppliesToPanel', () => { + it('matches project-bound detail panel when patch uses local scope id at same root', () => { + const root = '/projects/acme/openspec'; + const applies = changeTaskProgressPatchAppliesToPanel( + patch({ + scopeId: `local:${root}`, + changeRootPath: root, + }), + { + changeName: 'demo-change', + scopeId: 'project:acme:/projects/acme/openspec:local:', + planningRoot: root, + }, + ); + expect(applies).toBe(true); + }); + + it('rejects patches for the same change name at a different root', () => { + const applies = changeTaskProgressPatchAppliesToPanel( + patch({ changeRootPath: '/workspace/a' }), + { + changeName: 'demo-change', + planningRoot: '/workspace/b', + }, + ); + expect(applies).toBe(false); + }); + + it('matches store scope panels via changeRootPath', () => { + const storeRoot = '/stores/team-plans'; + const applies = changeTaskProgressPatchAppliesToPanel( + patch({ scopeId: 'store:team-plans', changeRootPath: storeRoot }), + { + changeName: 'demo-change', + scopeId: 'store:team-plans', + planningRoot: storeRoot, + }, + ); + expect(applies).toBe(true); + }); +}); diff --git a/test/shared/taskProgressLifecycleBoundary.test.ts b/test/shared/taskProgressLifecycleBoundary.test.ts new file mode 100644 index 0000000..976b697 --- /dev/null +++ b/test/shared/taskProgressLifecycleBoundary.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { taskProgressCrossesLifecycleBoundary } from '../../src/shared/taskProgressLifecycleBoundary'; + +describe('taskProgressCrossesLifecycleBoundary', () => { + it('detects all-done transitions', () => { + expect(taskProgressCrossesLifecycleBoundary( + { completedTasks: 2, totalTasks: 3 }, + { completedTasks: 3, totalTasks: 3 }, + )).toBe(true); + }); + + it('detects empty file transitions', () => { + expect(taskProgressCrossesLifecycleBoundary( + { completedTasks: 3, totalTasks: 3 }, + { completedTasks: 0, totalTasks: 0 }, + )).toBe(true); + }); + + it('does not flag ordinary in-progress checkbox toggles', () => { + expect(taskProgressCrossesLifecycleBoundary( + { completedTasks: 1, totalTasks: 3 }, + { completedTasks: 2, totalTasks: 3 }, + )).toBe(false); + }); +}); diff --git a/test/webview/utils/applyTaskProgressPatch.test.ts b/test/webview/utils/applyTaskProgressPatch.test.ts index 18d84a9..faa5be4 100644 --- a/test/webview/utils/applyTaskProgressPatch.test.ts +++ b/test/webview/utils/applyTaskProgressPatch.test.ts @@ -10,6 +10,8 @@ describe('applyTaskProgressPatch', () => { totalTasks: 2, lastModified: '2026-01-01', status: 'in-progress', + lifecycleStatus: 'applying', + artifacts: [{ id: 'tasks', outputPath: 'tasks.md', status: 'done' }], } as ChangeInfo]; const next = applyTaskProgressPatchToChanges(changes, { type: 'changeTaskProgressPatch', @@ -20,5 +22,27 @@ describe('applyTaskProgressPatch', () => { }); expect(next[0].completedTasks).toBe(2); expect(next[0].status).toBe('complete'); + expect(next[0].lifecycleStatus).toBe('ready-to-verify'); + }); + + it('applies host lifecycle fields from the patch when provided', () => { + const changes = [{ + name: 'demo', + completedTasks: 0, + totalTasks: 3, + lastModified: '2026-01-01', + status: 'in-progress', + lifecycleStatus: 'ready-to-apply', + artifacts: [{ id: 'tasks', outputPath: 'tasks.md', status: 'done' }], + } as ChangeInfo]; + const next = applyTaskProgressPatchToChanges(changes, { + type: 'changeTaskProgressPatch', + changeName: 'demo', + completedTasks: 3, + totalTasks: 3, + lifecycleStatus: 'ready-to-verify', + revisedAt: 1, + }); + expect(next[0].lifecycleStatus).toBe('ready-to-verify'); }); }); From 2f847102e3a7d3fed0d975af0f1b36466b67752d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 06:41:08 +0000 Subject: [PATCH 08/19] fix: loosen task progress patch row type for project sidebar Co-authored-by: RandyZhang --- src/webview/utils/applyTaskProgressPatch.ts | 28 ++++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/webview/utils/applyTaskProgressPatch.ts b/src/webview/utils/applyTaskProgressPatch.ts index d62f987..2add86e 100644 --- a/src/webview/utils/applyTaskProgressPatch.ts +++ b/src/webview/utils/applyTaskProgressPatch.ts @@ -1,18 +1,22 @@ import type { ChangeInfo } from '../types/messages'; import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; -import { enrichChangeWithLifecycle } from '../../shared/changeLifecycle'; +import { + enrichChangeWithLifecycle, + type ActiveChangeLifecycleStatus, + type ChangeAttention, + type LifecycleArtifactInput, +} from '../../shared/changeLifecycle'; -type ChangeTaskProgressRow = Pick< - ChangeInfo, - | 'name' - | 'completedTasks' - | 'totalTasks' - | 'status' - | 'lastModified' - | 'artifacts' - | 'lifecycleStatus' - | 'attention' ->; +export type ChangeTaskProgressRow = { + name: string; + completedTasks: number; + totalTasks: number; + status: ChangeInfo['status']; + lastModified: string; + lifecycleStatus?: ActiveChangeLifecycleStatus; + attention?: ChangeAttention; + artifacts?: LifecycleArtifactInput[]; +}; export function applyTaskProgressPatchToChange( change: T, From e2df29298243b3435701f77d7c924d6cf8f19e55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 07:17:33 +0000 Subject: [PATCH 09/19] Release 0.2.5-agent-panel.7: fix ESLint no-useless-assignment in dataManager Co-authored-by: RandyZhang --- CHANGELOG.md | 6 ++++++ package.json | 2 +- src/extension/services/dataManager.ts | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab0767..86b0cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.5-agent-panel.7] - 2026-10-09 + +### Fixed + +- CI: resolve ESLint `no-useless-assignment` in tasks.md reconcile helpers (`dataManager.ts`). + ## [0.2.5-agent-panel.6] - 2026-10-09 ### Fixed diff --git a/package.json b/package.json index 1132184..3946f5d 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.5-agent-panel.6", + "version": "0.2.5-agent-panel.7", "publisher": "randysss", "license": "MIT", "engines": { diff --git a/src/extension/services/dataManager.ts b/src/extension/services/dataManager.ts index b93991f..cfaff0b 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -1398,7 +1398,7 @@ export class DataManager { if (!this.cliAvailable) return; const services = this.getScopedServices(scope); const existing = this.cachedData?.changes.find((c) => c.name === changeName); - let content = ''; + let content: string; try { content = await services.contentAccess.readArtifact(changeName, 'tasks'); } catch { @@ -1457,7 +1457,7 @@ export class DataManager { scope?: OpenSpecScope, ): Promise { const services = this.getScopedServices(scope); - let content = ''; + let content: string; let missingFile = false; try { content = await services.contentAccess.readArtifact(changeName, 'tasks'); From 4ae6bdb16150dfcfe83bfb3c5e1aa1e4b1c58799 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 02:02:18 +0000 Subject: [PATCH 10/19] fix: reconcile sidebar changes, dashboard clicks, and relative times - Rebuild project sidebar change list from dashboard refresh payloads - Wire Project Dashboard recent updates and priority change names to detail - Open bound Changes Explorer from the All Changes tile - Show localized relative timestamps on Project Dashboard (0.2.4-pre.1) Co-authored-by: RandyZhang --- package.json | 2 +- .../providers/dashboardViewProvider.ts | 21 ++--- src/webview/components/Dashboard.tsx | 32 +++++++- src/webview/components/ProjectDashboard.tsx | 31 +++++++- .../providers/dashboardViewProvider.test.ts | 65 ++++++++++++++++ test/webview/components/dashboard.test.tsx | 40 ++++++++++ .../components/projectDashboard.test.tsx | 76 +++++++++++++++++++ 7 files changed, 250 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 3946f5d..7a3ce9c 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.5-agent-panel.7", + "version": "0.2.4-pre.1", "publisher": "randysss", "license": "MIT", "engines": { diff --git a/src/extension/providers/dashboardViewProvider.ts b/src/extension/providers/dashboardViewProvider.ts index 7aba034..861b178 100644 --- a/src/extension/providers/dashboardViewProvider.ts +++ b/src/extension/providers/dashboardViewProvider.ts @@ -163,24 +163,25 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { } private mergeDashboardTaskProgressIntoProjectSidebar(data: DashboardData): void { - if (!data?.changes?.length) { - return; - } if (!this.cachedProjectSidebarData) { void this.reloadProjectSidebarData(); return; } - const byName = new Map(data.changes.map((change) => [change.name, change])); - const changes = this.cachedProjectSidebarData.changes.map((change) => { - const fresh = byName.get(change.name); - if (!fresh) return change; + const incoming = data?.changes ?? []; + const cachedByName = new Map( + this.cachedProjectSidebarData.changes.map((change) => [change.name, change]), + ); + const changes = incoming.map((fresh) => { + const cached = cachedByName.get(fresh.name); + const base = cached ?? fresh; return enrichChangeWithLifecycle({ - ...change, + ...base, completedTasks: fresh.completedTasks, totalTasks: fresh.totalTasks, status: fresh.status, - lifecycleStatus: fresh.lifecycleStatus, - attention: fresh.attention, + lifecycleStatus: fresh.lifecycleStatus ?? base.lifecycleStatus, + attention: fresh.attention ?? base.attention, + lastModified: fresh.lastModified ?? base.lastModified, }); }); this.cachedProjectSidebarData = { diff --git a/src/webview/components/Dashboard.tsx b/src/webview/components/Dashboard.tsx index 81f3c6b..829dd5a 100644 --- a/src/webview/components/Dashboard.tsx +++ b/src/webview/components/Dashboard.tsx @@ -62,6 +62,21 @@ export function selectProjectFirstTab( tab: ProjectFirstTab, ): void { setTab(tab); + if (tab === 'changes' && typeof document !== 'undefined') { + requestAnimationFrame(() => { + document.getElementById('project-first-changes-panel')?.scrollIntoView({ block: 'nearest' }); + }); + } +} + +export function activateProjectChangesList( + setTab: (tab: ProjectFirstTab) => void, + postMessage: DashboardPostMessage, + project: ProjectContext, + binding: OpenSpecRootBinding, +): void { + selectProjectFirstTab(setTab, 'changes'); + postMessage(sendMessage.openChangesExplorer(project, binding)); } export function sendProjectSidebarSpecDetail( @@ -751,7 +766,12 @@ export const Dashboard: React.FC = () => { project={projectSidebar?.project} binding={projectSidebar?.binding} onOpenChanges={projectSidebar - ? () => selectProjectFirstTab(setProjectFirstTab, 'changes') + ? () => activateProjectChangesList( + setProjectFirstTab, + postMessage, + projectSidebar.project, + projectSidebar.binding, + ) : undefined} onOpenSpecs={projectSidebar ? () => selectProjectFirstTab(setProjectFirstTab, 'specs') @@ -844,13 +864,17 @@ export const Dashboard: React.FC = () => {
{group.label}
-
handleOpenChange(change.name)} > {change.name} -
+
+ ))} diff --git a/test/extension/providers/dashboardViewProvider.test.ts b/test/extension/providers/dashboardViewProvider.test.ts index 11db9be..ab18205 100644 --- a/test/extension/providers/dashboardViewProvider.test.ts +++ b/test/extension/providers/dashboardViewProvider.test.ts @@ -3414,6 +3414,71 @@ describe('DashboardViewProvider', () => { })); }); + it('project sidebar refresh reconciles added and removed changes from dashboard refresh data', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture(); + const kept = makeProjectChange('kept-change'); + const removed = makeProjectChange('removed-change'); + const gateway = { + loadChanges: vi.fn() + .mockResolvedValueOnce({ + project: fixture.project, + binding: fixture.binding, + changes: [kept, removed], + }), + }; + const dataManager = makeDataManager(); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + + const refreshCallback = (dataManager.onRefresh as any).mock.calls[0]?.[0] as ((data: any) => void) | undefined; + refreshCallback?.({ + changes: [ + { + ...kept, + completedTasks: 2, + totalTasks: 4, + }, + makeProjectChange('added-change'), + ], + specs: [], + archivedChanges: [], + changeStatusCounts: { + all: 2, + planning: 2, + readyToApply: 0, + applying: 0, + readyToVerify: 0, + archived: 0, + needsAttention: 0, + }, + lastRefresh: 4, + }); + await vi.runAllTimersAsync(); + + expect(gateway.loadChanges).toHaveBeenCalledTimes(1); + const mergedSidebar = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(mergedSidebar).toEqual(expect.objectContaining({ + data: expect.objectContaining({ + changes: [ + expect.objectContaining({ name: 'kept-change', completedTasks: 2, totalTasks: 4 }), + expect.objectContaining({ name: 'added-change' }), + ], + }), + })); + expect( + (mergedSidebar as { data: ProjectSidebarData }).data.changes.some((change) => change.name === 'removed-change'), + ).toBe(false); + }); + it('project dashboard refresh merges task progress without reloading the Project loader', async () => { vi.useFakeTimers(); const fixture = makeProjectFixture(); diff --git a/test/webview/components/dashboard.test.tsx b/test/webview/components/dashboard.test.tsx index d6dcaa7..c6458a2 100644 --- a/test/webview/components/dashboard.test.tsx +++ b/test/webview/components/dashboard.test.tsx @@ -13,6 +13,7 @@ import { getWorksetsTabAvailable, returnToCurrentProject, selectProjectFirstTab, + activateProjectChangesList, sendProjectSidebarSpecDetail, toWorksetCreateResultState, toWorksetPickedMembersState, @@ -717,6 +718,45 @@ describe('project page contract', () => { expect(postMessage).not.toHaveBeenCalled(); }); + it('opens the bound Changes list when the All Changes tile is activated', () => { + const setTab = vi.fn(); + const postMessage = vi.fn(); + + activateProjectChangesList(setTab, postMessage, projectContext, projectBinding); + + expect(setTab).toHaveBeenCalledWith('changes'); + expect(postMessage).toHaveBeenCalledWith( + sendMessage.openChangesExplorer(projectContext, projectBinding), + ); + }); + + it('renders priority change names as open-detail controls', () => { + const verify = hostChange('verify-change', { + completedTasks: 1, + totalTasks: 1, + lifecycleStatus: 'ready-to-verify', + workflowSnapshot: { + changeName: 'verify-change', + schema: 'custom', + bindingKey: 'project-root', + artifacts: [{ + id: 'proposal', + status: 'done', + requires: [], + missingDeps: [], + outputPath: 'proposal.md', + existingOutputPaths: ['proposal.md'], + }], + }, + }); + const html = renderProjectSidebar({ + ...projectSidebarData, + changes: [verify], + }); + + expect(html).toMatch(/]*data-priority-change="verify-change"/); + }); + it('builds a dedicated Project Dashboard request instead of a list Explorer request', () => { const openProjectDashboard = ( sendMessage as typeof sendMessage & { openProjectDashboard?: () => unknown } diff --git a/test/webview/components/projectDashboard.test.tsx b/test/webview/components/projectDashboard.test.tsx index 4502470..6fe6aab 100644 --- a/test/webview/components/projectDashboard.test.tsx +++ b/test/webview/components/projectDashboard.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; +import { setLocale } from '../../../src/i18n'; import { AppProvider, type AppState } from '../../../src/webview/context/AppContext'; import { deriveProjectDashboardSummary, @@ -120,6 +121,81 @@ describe('ProjectDashboard summary', () => { }); describe('ProjectDashboard surface', () => { + it('formats recent update timestamps as relative labels instead of raw ISO strings', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-23T12:00:00.000Z')); + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toMatch(/