diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c85107..6de4618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.4] - 2026-10-10 + +### Added + +- Project sidebar **vertical navigation** (`data-project-nav-list`): **Project Dashboard** (open in editor), **Specs** (with count badge when > 0), **Workset Projects**; tooltips and i18n (`navProjectDashboard`, `navWorksetProjects`). +- **Changes (N)** section title action (**Open in editor**, `data-open-changes-in-editor`) to open the bound Changes explorer from the sidebar. +- **Changes explorer** manual **refresh** control; explorer panel titles localized (`explorer.changesPanelTitle`, specs panel). +- Relative **timestamps** on Project Dashboard (calendar-relative dates, clock-skew clamp); zh-CN calendar wording. +- VSIX packaging check: `scripts/verify-vsix-webview.js` validates webview markers, `./index.ttf` in CSS, and font file inside the package. + +### Changed + +- Removed the **All Changes** tile from the project sidebar header; navigation and open-in-editor entry points replace it. +- **Project Dashboard** recent updates and **priority change** cards: names and CTAs navigate to change detail; priority row layout tuned for narrow sidebar (260px). +- **Priority card CTA** at 260px: drop `max-w-[55%]` / `truncate`; use `flex-wrap`, `min-w-[7rem]` on the name, and `shrink-0` on the button so full CTA labels wrap instead of clipping. +- Rebuild project sidebar change list from dashboard refresh payloads; wire dashboard click targets (recent updates, priority names) to change detail. +- zh-CN: **项目仪表盘** for project dashboard title; explorer and sidebar copy polish. + +### Fixed + +- **Auto-refresh** when changes are added/removed or updated externally: sidebar list reconciles from refresh payloads; publish explorer snapshots on sidebar merge/reload so open **Changes** panels update without stale data. +- **Codicon** icons in the webview: Vite `base: './'`, relative `url(./index.ttf)` in CSS, CSP `font-src` and font preload in webview HTML; fixes square glyph boxes in sidebar and dashboard. +- **ChangeCard** **APPLYING** lifecycle badge contrast in light theme (uses button foreground/background tokens). +- Typecheck: cast explorer snapshot changes at `publishChangesExplorerSnapshots` to stay within CI baseline. + +## [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 + +- 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 + +- 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 + +- 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 + +- 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 + +- **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 + +- `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..67885da --- /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** visible while the new Agent run opens in another tab. + +## Investigation (2026-10-09 QA) + +- 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. **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 + +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 921ee51..03c60e3 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.4", "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/scripts/check-typecheck-baseline.sh b/scripts/check-typecheck-baseline.sh index 61739be..7be16a1 100755 --- a/scripts/check-typecheck-baseline.sh +++ b/scripts/check-typecheck-baseline.sh @@ -1,14 +1,68 @@ #!/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 -# Must match local: npx tsc --noEmit -p tsconfig.typecheck.json -npx tsc --noEmit -p tsconfig.typecheck.json >"$LOG" 2>&1 || true -COUNT="$(rg -c 'error TS' "$LOG" || true)" -COUNT="${COUNT:-0}" +if ! 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 + +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 --pretty true + fi +} + +# 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' "$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 + 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 [ "$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" diff --git a/scripts/verify-vsix-webview.js b/scripts/verify-vsix-webview.js index cffa253..320cc2e 100644 --- a/scripts/verify-vsix-webview.js +++ b/scripts/verify-vsix-webview.js @@ -33,6 +33,7 @@ if (!fs.existsSync(inputPath)) { } let contents; +let packagedCss; let label = path.basename(inputPath); if (inputPath.endsWith('.js')) { contents = fs.readFileSync(inputPath, 'utf8'); @@ -45,6 +46,15 @@ if (inputPath.endsWith('.js')) { fail('extension/dist/webview/index.js missing in VSIX'); } contents = fs.readFileSync(webviewJs, 'utf8'); + const cssPath = path.join(tmpDir, 'extension', 'dist', 'webview', 'index.css'); + const fontPath = path.join(tmpDir, 'extension', 'dist', 'webview', 'index.ttf'); + if (!fs.existsSync(cssPath)) { + fail('extension/dist/webview/index.css missing in VSIX'); + } + if (!fs.existsSync(fontPath)) { + fail('extension/dist/webview/index.ttf missing in VSIX'); + } + packagedCss = fs.readFileSync(cssPath, 'utf8'); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -70,6 +80,15 @@ try { fail(`gate pattern "${GATE_PATTERN}" matched 0 times in webview/index.js`); } + if (packagedCss) { + if (/url\(\/index\.ttf/.test(packagedCss) || /url\(['"]?\//.test(packagedCss)) { + fail('webview CSS must not use root-absolute font urls'); + } + if (!/url\(\.\/index\.ttf\?/.test(packagedCss)) { + fail('webview CSS must reference ./index.ttf for codicon'); + } + } + console.log(`verify-vsix-webview: PASS (${label})`); } catch (error) { fail(error instanceof Error ? error.message : String(error)); diff --git a/src/extension/extension.ts b/src/extension/extension.ts index b97fe20..304b37f 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; @@ -136,13 +138,21 @@ 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 = 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/changeDetailPanelManager.ts b/src/extension/providers/changeDetailPanelManager.ts index f1c3fcc..991bc8c 100644 --- a/src/extension/providers/changeDetailPanelManager.ts +++ b/src/extension/providers/changeDetailPanelManager.ts @@ -20,6 +20,8 @@ import { isWorkflowSnapshotBoundTo, 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'; @@ -428,6 +430,26 @@ 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; + 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) { + 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..f7bb2a7 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, @@ -84,7 +86,6 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { private projectRequestGeneration = 0; /** Newest in-flight (or settled) Project Sidebar reload; lets a superseded caller follow the chain. */ private latestProjectReload?: Promise; - private skipNextProjectRefreshCallback = false; /** Ephemeral, process-local explicit Planning Store selector for the current Project. */ private explicitProjectStoreId?: string; /** Single-flight lock: at most one Workset creation may run at a time. */ @@ -112,17 +113,88 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { return; } if (this.isProjectFirst()) { - if (this.skipNextProjectRefreshCallback) { - 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 (!this.cachedProjectSidebarData) { + void this.reloadProjectSidebarData(); + return; + } + 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({ + ...base, + completedTasks: fresh.completedTasks, + totalTasks: fresh.totalTasks, + status: fresh.status, + lifecycleStatus: fresh.lifecycleStatus ?? base.lifecycleStatus, + attention: fresh.attention ?? base.attention, + lastModified: fresh.lastModified ?? base.lastModified, + }); + }); + this.cachedProjectSidebarData = { + ...this.cachedProjectSidebarData, + changes, + archivedChanges: data.archivedChanges ?? this.cachedProjectSidebarData.archivedChanges, + lastRefresh: data.lastRefresh ?? this.cachedProjectSidebarData.lastRefresh, + }; + this.publishProjectSnapshot( + this.cachedProjectSidebarData, + this._view?.webview, + 'sidebar', + { source: 'memory', stale: false }, + true, + ); + this.publishChangesExplorerSnapshots(this.cachedProjectSidebarData); + } + /** * Called when the view is first opened or becomes visible */ @@ -368,6 +440,24 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { post(targetWebview, targetSurface); post(this._view?.webview, 'sidebar'); if (publishDashboard) post(this.dashboardPanel?.webview, 'dashboard'); + this.publishChangesExplorerSnapshots(data); + } + + private publishChangesExplorerSnapshots(data: ProjectSidebarData): void { + const key = this.explorerPanelKey('changesExplorer', data.binding); + const panel = this.explorerPanels.get(key); + if (!panel) return; + const explorerData: ProjectChangesExplorerData = { + project: data.project, + binding: data.binding, + changes: data.changes as ProjectChangesExplorerData['changes'], + archivedChanges: data.archivedChanges ?? [], + }; + panel.webview.postMessage({ + type: 'setContext', + view: 'changesExplorer', + data: explorerData, + }); } private postProjectLoadFailure( @@ -848,12 +938,19 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { */ private postPendingExplorerContext(webview: vscode.Webview): boolean { const pending = this.pendingExplorerContexts.get(webview); - if (!pending) return false; + if (!pending || pending.sent) return false; webview.postMessage(pending.message); pending.sent = true; return true; } + private isExplorerPanelWebview(webview: vscode.Webview): boolean { + for (const panel of this.explorerPanels.values()) { + if (panel.webview === webview) return true; + } + return false; + } + private async handleMessage( message: any, webview: vscode.Webview, @@ -920,7 +1017,16 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { return; } if (explorerContextConsumed) return; - const surface = webview === this.dashboardPanel?.webview ? 'dashboard' : 'sidebar'; + if (this.isExplorerPanelWebview(webview)) { + const pending = this.pendingExplorerContexts.get(webview); + if (pending?.sent) { + webview.postMessage(pending.message); + } else if (this.cachedProjectSidebarData) { + this.publishChangesExplorerSnapshots(this.cachedProjectSidebarData); + } + return; + } + const surface = this.resolveProjectSurface(webview); await this.postCachedProjectSidebarData(webview, surface); await this.reloadProjectSidebarData(webview, surface); return; @@ -930,15 +1036,12 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { this.markAgentUnavailable(webview, getPrimaryWorkspacePath()); return; } - this.skipNextProjectRefreshCallback = true; try { await this.dataManager.refresh(); } catch (error) { logger.error('Project Sidebar refresh failed', error as Error); - } finally { - this.skipNextProjectRefreshCallback = false; } - const surface = webview === this.dashboardPanel?.webview ? 'dashboard' : 'sidebar'; + const surface = this.resolveProjectSurface(webview); await this.reloadProjectSidebarData(webview, surface); return; } @@ -1084,6 +1187,11 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { } } + private resolveProjectSurface(webview: vscode.Webview): ProjectSurface { + if (webview === this.dashboardPanel?.webview) return 'dashboard'; + return 'sidebar'; + } + private explorerPanelKey( pageKind: 'changesExplorer' | 'specsExplorer', binding: OpenSpecRootBinding @@ -1586,12 +1694,22 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { if (existing) { existing.reveal(vscode.ViewColumn.One); if (this.explorerPanels.get(key) === existing) { - existing.webview.postMessage(contextMessage); + if ( + pageKind === 'changesExplorer' + && this.cachedProjectSidebarData + && this.sameBinding(this.cachedProjectSidebarData.binding, binding) + ) { + this.publishChangesExplorerSnapshots(this.cachedProjectSidebarData); + } else { + existing.webview.postMessage(contextMessage); + } } return; } - const title = pageKind === 'changesExplorer' ? 'OpenSpec Changes' : 'OpenSpec Specs'; + const title = pageKind === 'changesExplorer' + ? t('explorer.changesPanelTitle') + : t('explorer.specsPanelTitle'); const viewType = pageKind === 'changesExplorer' ? 'openspecChangesExplorer' : 'openspecSpecsExplorer'; 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..b7d9e75 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', @@ -1462,10 +1491,9 @@ export async function handleAgentUnavailableMessage( } export function getWorkflowLaunchConfigMessage() { - const config = getWorkflowLaunchConfig(); return { type: 'workflowLaunchConfig' as const, - config: toWorkflowLaunchConfigView(config), + config: getWorkflowLaunchConfigViewForUi(), isCursorHost: isCursorHost(), }; } @@ -1560,6 +1588,9 @@ export function getWebviewContent( const styleUri = webview.asWebviewUri( vscode.Uri.file(path.join(extensionPath, 'dist', 'webview', 'index.css')) ); + const fontUri = webview.asWebviewUri( + vscode.Uri.file(path.join(extensionPath, 'dist', 'webview', 'index.ttf')), + ); const lang = vscode.env.language || 'en'; const rootAttributes = buildWebviewRootAttributes(bootstrap); const inlineBootstrap = buildInlineBootstrapScript(bootstrap); @@ -1569,9 +1600,10 @@ export function getWebviewContent( - + OpenSpec Dashboard +
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 new file mode 100644 index 0000000..32b7920 --- /dev/null +++ b/src/extension/services/agentLaunchPrefillNotify.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; +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'); + 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..4b3d0f2 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'; @@ -164,14 +165,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 +210,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/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/changeTaskProgressFromFile.ts b/src/extension/services/changeTaskProgressFromFile.ts new file mode 100644 index 0000000..7b598e3 --- /dev/null +++ b/src/extension/services/changeTaskProgressFromFile.ts @@ -0,0 +1,56 @@ +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); + 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 dabce39..c24b238 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -5,7 +5,7 @@ import YAML from 'yaml'; import { logger } from '../utils/logger'; import { OpenSpecCliService } from './openspecCli'; import { FileManagerService } from './fileManager'; -import { FileWatcherService } from './fileWatcher'; +import { FileWatcherService, type FileChangeEvent } from './fileWatcher'; import { TaskExecutorService } from './taskExecutorService'; import { StateReader } from './stateReader'; import type { IOpenSpecContentAccess } from './contentAccess'; @@ -22,6 +22,14 @@ import { enrichChangeWithLifecycle, type ChangeStatusCounts, } from '../../shared/changeLifecycle'; +import { + applyTaskProgressToChange, + overlayChangeTaskProgressFromFiles, + taskProgressFromTasksMarkdown, +} from './changeTaskProgressFromFile'; +import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; +import { taskProgressCrossesLifecycleBoundary } from '../../shared/taskProgressLifecycleBoundary'; +import { reconcileChangeLifecycleFromCli } from './changeStatusFromCli'; export interface ScopeInfo { id: string; @@ -114,6 +122,41 @@ export interface DataManagerOptions { projectRoots?: { path: string; label: string }[]; } +export function tasksMdChangeNameFromRelative(relative: string): string | null { + const archiveTasksMatch = relative.match(/^openspec\/changes\/archive\/([^/]+)\/tasks\.md$/); + const draftTasksMatch = relative.match(/^openspec\/changes\/(?!archive\/)([^/]+)\/tasks\.md$/); + if (archiveTasksMatch) return `archive:${archiveTasksMatch[1]}`; + if (draftTasksMatch) return draftTasksMatch[1]; + return null; +} + +/** Fast path: in-place task counts only — never for create/delete or unknown changes. */ +export function shouldUseTasksOnlyRefreshFastPath( + events: FileChangeEvent[], + watchedProjectRoot: string, + cachedData: Pick | null | undefined, +): boolean { + if (events.length === 0) return false; + const onlyTasksMd = events.every((event) => { + const relative = path.relative(watchedProjectRoot, event.uri.fsPath).replace(/\\/g, '/'); + return /\/tasks\.md$/.test(relative); + }); + if (!onlyTasksMd) return false; + + const knownChanges = new Set([ + ...(cachedData?.changes.map((change) => change.name) ?? []), + ...(cachedData?.archivedChanges?.map((item) => item.name) ?? []), + ...(cachedData?.archivedChanges?.map((item) => `archive:${item.directoryName}`) ?? []), + ]); + + return events.every((event) => { + if (event.type !== 'change') return false; + const relative = path.relative(watchedProjectRoot, event.uri.fsPath).replace(/\\/g, '/'); + const changeName = tasksMdChangeNameFromRelative(relative); + return changeName != null && knownChanges.has(changeName); + }); +} + export class DataManager { private cliService: OpenSpecCliService; private stateReader: StateReader; @@ -128,7 +171,10 @@ 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 tasksStatusReconcileTimer: ReturnType | undefined; + private tasksStatusReconcilePending: { changeName: string; scope?: OpenSpecScope } | undefined; private cliAvailable = false; // Scope-aware additions @@ -523,58 +569,83 @@ 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) - ); + void this.handleFileWatcherEvents(events); + }); + + this.warmDashboardData(); + } + + private async handleFileWatcherEvents( + events: FileChangeEvent[], + ): 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); } + } - // 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); - } + 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); } } + } - // Notify artifact-level change subscribers (e.g. open change detail panels) - for (const [changeName, types] of artifactChanges) { + for (const [changeName, types] of artifactChanges) { + this.notifyArtifactChanged({ + changeName, + artifactTypes: [...types], + rootPath: this.canonicalRootPath(this.watchedProjectRoot), + }); + } + + if (shouldUseTasksOnlyRefreshFastPath(events, this.watchedProjectRoot, this.cachedData)) { + for (const changeName of tasksPatchedChanges) { this.notifyArtifactChanged({ changeName, - artifactTypes: [...types], + artifactTypes: ['tasks'], rootPath: this.canonicalRootPath(this.watchedProjectRoot), }); } + return; + } + if (events.length > 0) { logger.info(`File changes detected (${events.length} events), refreshing...`); - void (async () => { - await this.invalidateDashboardCache(this.resolveScopeForRoot(this.watchedProjectRoot)); - await this.refresh(); - })().catch((error) => { + try { + await this.invalidateDashboardCache(scope); + await this.refresh(scope); + } catch (error) { logger.warn('Failed to refresh after file changes', error as Error); - }); - }); - - this.warmDashboardData(); + } + } } /** @@ -833,7 +904,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 +1405,160 @@ export class DataManager { await fs.promises.writeFile(filePath, YAML.stringify(data), 'utf8'); } + private scheduleTasksStatusReconcile(changeName: string, scope?: OpenSpecScope): void { + this.tasksStatusReconcilePending = { changeName, scope }; + if (this.tasksStatusReconcileTimer) { + clearTimeout(this.tasksStatusReconcileTimer); + } + this.tasksStatusReconcileTimer = setTimeout(() => { + this.tasksStatusReconcileTimer = undefined; + const pending = this.tasksStatusReconcilePending; + this.tasksStatusReconcilePending = undefined; + if (!pending) return; + void this.reconcileChangeStatusAfterTasksBoundary(pending.changeName, pending.scope).catch((error) => { + logger.warn('Failed debounced tasks status reconcile', error as Error); + }); + }, 750); + } + + private async reconcileChangeStatusAfterTasksBoundary( + changeName: string, + scope?: OpenSpecScope, + ): Promise { + if (!this.cliAvailable) return; + const services = this.getScopedServices(scope); + const existing = this.cachedData?.changes.find((c) => c.name === changeName); + let content: string; + try { + content = await services.contentAccess.readArtifact(changeName, 'tasks'); + } catch { + 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 changes = [...this.cachedData.changes]; + changes[index] = reconciled; + this.cachedData = { + ...this.cachedData, + changes, + changeStatusCounts: buildChangeStatusCounts(changes, this.cachedData.archivedChanges), + lastRefresh: Date.now(), + }; + } + } + + 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, + 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: string; + 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(() => { + 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); + } + } + } + /** * Register refresh callback */ 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 4da8791..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, @@ -25,6 +22,8 @@ import { } from './agentPanelLauncher'; import { isCursorHost } from '../utils/isCursorHost'; import { notifyWorkflowCommandCopied } from './workflowClipboardNotify'; +import { notifyAgentPrefillLaunchHint } from './agentLaunchPrefillNotify'; +import { maybeShowFirstCursorAgentPanelLaunchHint } from './agentCursorSessionHint'; export type WorkflowAgentLaunchTarget = | 'clipboard' @@ -89,20 +88,13 @@ function workflowLaunchDedupeKey(request: WorkflowAgentLaunchRequest): string { return `${request.workspaceRoot}\u0000${request.changeName}\u0000${request.action}`; } -function readAgentAutoSubmitMode(): AgentAutoSubmitMode { - const raw = vscode.workspace.getConfiguration('openspec').get('agentAutoSubmit'); - if (raw === 'never' || raw === 'always' || raw === 'readOnly') { - return raw; - } - return 'readOnly'; -} function notifyLaunchResult( result: WorkflowAgentLaunchResult, command: string, ): void { if (result.outcome === 'deduped') { - void vscode.window.showInformationMessage(t('workflow.launchDeduped')); + void vscode.window.showInformationMessage(t('workflow.launchJustLaunched')); return; } if (result.target === 'clipboard' || result.outcome === 'copied') { @@ -113,8 +105,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 +147,7 @@ function buildDedupedLaunchResult(request: WorkflowAgentLaunchRequest): Workflow target: resolveLaunchTargetFromPayload(payload, view), layer: undefined, outcome: 'deduped', - message: t('workflow.launchDeduped'), + message: t('workflow.launchJustLaunched'), }; } @@ -206,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); @@ -287,6 +287,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/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/en.json b/src/i18n/locales/en.json index faa2c47..958be8b 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,21 +75,25 @@ "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", "workflow.receiptDelivered": "Delivered", + "workflow.receiptPrefilled": "Prefilled", "workflow.receiptCopied": "Copied", "workflow.receiptFallback": "Fallback", "workflow.receiptFailed": "Failed", @@ -170,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.", @@ -232,6 +252,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.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.", @@ -302,6 +326,8 @@ "projectSidebar.navigationLabel": "Project navigation", "projectSidebar.rootContextLabel": "OpenSpec root context", "projectSidebar.project": "Project", + "explorer.changesPanelTitle": "OpenSpec Changes", + "explorer.specsPanelTitle": "OpenSpec Specs", "projectSidebar.allChanges": "All Changes", "projectSidebar.specs": "Specs", "projectSidebar.cardChangesSupporting": "Browse local Changes", @@ -314,6 +340,8 @@ "projectSidebar.referencedStoreSpecs": "Referenced Store Specs: {storeId}", "projectSidebar.emptyStoreSpecs": "No specs in referenced Store {storeId}", "projectSidebar.worksets": "Browse Workset Projects", + "projectSidebar.navProjectDashboard": "Project Dashboard", + "projectSidebar.navWorksetProjects": "Workset Projects", "projectSidebar.dashboard": "Dashboard", "projectSidebar.worksetsUnavailable": "No trusted Workset membership available", "projectDashboard.title": "Project Dashboard", @@ -398,6 +426,7 @@ "action.showInSidebar": "Show in sidebar", "time.today": "Today", + "time.justNow": "Just now", "time.yesterday": "Yesterday", "time.daysAgo": "{days}d ago", "time.weeksAgo": "{weeks}w ago", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 4f1752c..acff8f1 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,10 +54,20 @@ "workflow.copiedCommand": "已复制命令:{command}", "workflow.noAdapterCopied": "当前没有可用执行者,已复制命令:{command}", "workflow.adapterFallback": "无法启动适配器,命令已复制。", - "workflow.bindingMismatch": "无法启动工作流:Change 根目录已变化。请刷新 Dashboard 后重试。", - "workflow.bindingValidationFailed": "无法校验此工作流对应的 Change 根目录。", + "workflow.bindingMismatch": "无法启动工作流:change 根目录已变化。请刷新 Dashboard 后重试。", + "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,23 +75,27 @@ "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": "等待中", "workflow.receiptDelivered": "已投递", + "workflow.receiptPrefilled": "已预填", "workflow.receiptCopied": "已复制", "workflow.receiptFallback": "已回退", "workflow.receiptFailed": "失败", @@ -113,25 +127,25 @@ "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", "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.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 的需求提案文档)", @@ -159,19 +173,25 @@ "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": "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}。", + "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} 复制到剪贴板。", @@ -179,18 +199,18 @@ "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", - "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": "可开始", @@ -198,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} 字符", @@ -234,35 +254,39 @@ "agentLaunch.copied": "已复制到剪贴板,请粘贴到 Agent:{command}", "agentLaunch.agentCliStarted": "正在以 headless Agent CLI 运行:{command}", "agentLaunch.failed": "无法在 Agent 中启动该工作流。", + "agentLaunch.prefillHint": "已填入 Agent 输入框,按 Enter 发送", + "agentLaunch.prefillHintOpenSettings": "自动发送设置", + "agentLaunch.prefillHintDontShowAgain": "不再提示", + "agentLaunch.cursorFirstPanelTabHint": "已在新的 Agent 标签中打开;若前台仍是旧对话,请切换到最新标签。", "dashboard.loading": "正在加载 OpenSpec 数据...", "dashboard.loadFailed": "加载数据失败,请尝试刷新。", "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": "创建 New Change", + "dashboard.createNew": "新建 change", "dashboard.specs": "Specs ({count})", "dashboard.emptySpecs": "此工作区尚未定义 specs。", "dashboard.emptySpecsInRoot": "{root} 中还没有定义 spec。", "dashboard.archived": "已归档", "dashboard.emptyArchived": "没有已归档 change", "dashboard.emptyArchivedInRoot": "{root} 中没有已归档 change", - "dashboard.emptyPlanning": "没有 Planning change", - "dashboard.emptyReadyToApply": "没有 Ready to Apply change", - "dashboard.emptyApplying": "没有 Applying change", - "dashboard.emptyReadyToVerify": "没有 Ready to Verify change", + "dashboard.emptyPlanning": "没有规划中的 change", + "dashboard.emptyReadyToApply": "没有待实施的 change", + "dashboard.emptyApplying": "没有实施中的 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", + "dashboard.needsAttention": "需要关注", "dashboard.priorityNeedsAttention": "需要关注", "dashboard.priorityReadyToVerify": "可验证", "dashboard.priorityRecommended": "推荐动作", @@ -270,12 +294,12 @@ "dashboard.priorityReasonNeedsAttention": "需要关注", "dashboard.priorityReasonReadyToVerify": "可以验证", "dashboard.priorityReasonRecommended": "推荐动作", - "dashboard.priorityActionAriaLabel": "{action}(Change:{name})", + "dashboard.priorityActionAriaLabel": "{action}(change:{name})", "dashboard.noRecommendedAction": "没有推荐动作", "dashboard.moreActions": "另有 {count} 个", "dashboard.blockedCount": "{count} 个已阻塞", "dashboard.skippedCount": "{count} 个已跳过", - "dashboard.needsAttentionFilterLabel": "Needs Attention 筛选", + "dashboard.needsAttentionFilterLabel": "需要关注筛选", "dashboard.moreFilters": "更多筛选", "dashboard.sortLabel": "排序", "dashboard.sortUpdatedDesc": "更新时间降序", @@ -295,42 +319,46 @@ "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", + "explorer.changesPanelTitle": "OpenSpec 全部 change", + "explorer.specsPanelTitle": "OpenSpec Spec", + "projectSidebar.allChanges": "全部 change", "projectSidebar.specs": "Specs", - "projectSidebar.cardChangesSupporting": "浏览本地 Changes", - "projectSidebar.cardSpecsSupporting": "浏览本地 Specs", + "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.navProjectDashboard": "项目仪表盘", + "projectSidebar.navWorksetProjects": "Workset 项目", "projectSidebar.dashboard": "Dashboard", "projectSidebar.worksetsUnavailable": "暂无可信的 Workset 成员关系", - "projectDashboard.title": "项目 Dashboard", + "projectDashboard.title": "项目仪表盘", "projectDashboard.summary": "项目摘要", - "projectDashboard.totalChanges": "Change 总数", - "projectDashboard.activeChanges": "Active Change", - "projectDashboard.readyToVerify": "待 Verify", + "projectDashboard.totalChanges": "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": "正在刷新,当前显示缓存的项目数据...", @@ -390,14 +418,15 @@ "header.refresh": "刷新", "header.loading": "加载中...", - "header.newChange": "+ New Change", + "header.newChange": "+ 新建 change", "action.openInEditor": "在编辑器中打开", "action.refresh": "刷新", - "action.archiveChange": "归档 Change", + "action.archiveChange": "归档 change", "action.showInSidebar": "在侧边栏中显示", "time.today": "今天", + "time.justNow": "刚刚", "time.yesterday": "昨天", "time.daysAgo": "{days}天前", "time.weeksAgo": "{weeks}周前", diff --git a/src/shared/changeTaskProgressPatch.ts b/src/shared/changeTaskProgressPatch.ts new file mode 100644 index 0000000..fe4f328 --- /dev/null +++ b/src/shared/changeTaskProgressPatch.ts @@ -0,0 +1,25 @@ +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; +} + +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/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/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/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/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/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/shared/workflowLaunchConfig.ts b/src/shared/workflowLaunchConfig.ts index 45738d2..5df1c1e 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). */ @@ -121,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/ActionBar.tsx b/src/webview/components/ActionBar.tsx index d86d84f..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', @@ -113,8 +114,15 @@ export const ActionBar: React.FC = ({ launching: pendingAction === recommendedAction.action, })} - {launchModeHint && pendingAction !== recommendedAction.action && ( - + {launchModeHint && ( + {launchModeHint} )} diff --git a/src/webview/components/ChangeCard.tsx b/src/webview/components/ChangeCard.tsx index 1051711..6fe9fd4 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', @@ -35,8 +36,8 @@ const LIFECYCLE_BADGE_STYLES: Record color: 'var(--vscode-button-foreground)', }, applying: { - background: 'var(--vscode-progressBar-background)', - color: 'var(--vscode-editor-foreground)', + background: 'var(--vscode-button-background)', + color: 'var(--vscode-button-foreground)', }, 'ready-to-verify': { background: 'var(--vscode-inputValidation-infoBackground)', @@ -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 52da4d4..5ad305e 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -58,6 +58,8 @@ import { shouldShowActiveTabLoadingForArtifactFetch, } 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'); @@ -349,11 +351,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); @@ -548,6 +556,30 @@ export const ChangeDetail: React.FC = ({ })) { setVerifyArchiveTasksLoading(false); } + } else if (isChangeTaskProgressPatch(msg)) { + const patch = msg; + if (!changeTaskProgressPatchAppliesToPanel(patch, { changeName, scopeId, planningRoot })) { + return; + } + setTasksProgressError(null); + setTasksProgressUnknown(false); + setCompletedTasks(patch.completedTasks); + setTotalTasks(patch.totalTasks); + setVerifyArchiveTasksLoading(false); + if (typeof patch.tasksContent === 'string') { + const onTasksSurface = activeTabRef.current === 'tasks' + || activeTabRef.current === 'verifyArchive'; + if (onTasksSurface) { + setContent(patch.tasksContent); + } + completeArtifactFetchAndStoreContent({ + scopeId, + artifactType: 'tasks', + content: patch.tasksContent, + coordinator: artifactFetchCoordinatorRef.current, + contentCache: contentCacheRef.current, + }); + } } else if (data.type === 'workflowActionReceipt' && data.changeName === changeName && workflowSnapshot @@ -700,7 +732,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; @@ -1025,6 +1057,7 @@ export const ChangeDetail: React.FC = ({ {activeTab === 'verifyArchive' ? (
= ({ data }) => { data-root-path={data.binding.rootPath} className="p-4" > -
-

- {t('projectSidebar.allChanges')} -

-
- {data.project.label} · {data.binding.rootPath} +
+
+

+ {t('projectSidebar.allChanges')} +

+
+ {data.project.label} · {data.binding.rootPath} +
+
void; } function resolveEmptyMessage( @@ -119,6 +121,7 @@ export const ChangesSection: React.FC = ({ allowPageClamp = true, layout = 'auto', compact = false, + onOpenInEditor, }) => { const [internalState, dispatch] = useReducer( changesViewReducer, @@ -196,12 +199,25 @@ export const ChangesSection: React.FC = ({ `} )}
-

- {t('dashboard.changes', { count: changeStatusCounts.all })} -

+
+

+ {t('dashboard.changes', { count: changeStatusCounts.all })} +

+ {onOpenInEditor && ( +
{onRequestNewChange && state.lifecycleStatus !== 'archived' && (
+ +
-
); })} @@ -907,6 +938,11 @@ export const Dashboard: React.FC = () => { workflowLaunchPendingKeys={workflowLaunchPending.pendingKeys} workflowActionReceipts={workflowReceipts} projectBinding={projectSidebar.binding} + onOpenInEditor={() => openProjectChangesInEditor( + postMessage, + projectSidebar.project, + projectSidebar.binding, + )} layout="narrow" /> diff --git a/src/webview/components/Header.tsx b/src/webview/components/Header.tsx index 846b876..5a1246d 100644 --- a/src/webview/components/Header.tsx +++ b/src/webview/components/Header.tsx @@ -25,16 +25,34 @@ export interface HeaderProps { onSetupStore?: () => void; project?: ProjectContext; binding?: OpenSpecRootBinding; - onOpenChanges?: () => void; onOpenSpecs?: () => void; onOpenWorksets?: () => void; onOpenDashboard?: () => void; worksetCount?: number; + /** Total specs in project + referenced stores; badge shown only when count is positive. */ + specsCount?: number; /** Host capability fact; `false` explains the disabled tab with the upgrade copy. */ worksetsCapabilityAvailable?: boolean; activeProjectTab?: 'changes' | 'specs' | 'worksets'; } +const navListBorderColor = 'var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border))'; + +function NavCountBadge({ count }: { count: number }) { + return ( + + ); +} + export const Header: React.FC = ({ onRefresh, onNewChange, @@ -48,11 +66,11 @@ export const Header: React.FC = ({ onSetupStore, project, binding, - onOpenChanges, onOpenSpecs, onOpenWorksets, onOpenDashboard, worksetCount, + specsCount = 0, worksetsCapabilityAvailable, activeProjectTab, }) => { @@ -71,8 +89,14 @@ export const Header: React.FC = ({ ? t('scope.featureGated.upgradeNotice') : t('projectSidebar.worksetsUnavailable'); const worksetsAccessibleName = `${t('projectSidebar.worksets')}${worksetCount !== undefined ? ` (${worksetCount})` : ''}`; - const dashboardAccessibleName = `${t('projectSidebar.dashboard')} · ${t('action.openInEditor')}`; + const dashboardNavLabel = t('projectSidebar.navProjectDashboard'); + const dashboardAccessibleName = `${dashboardNavLabel} · ${t('action.openInEditor')}`; + const specsNavLabel = t('projectSidebar.specs'); + const specsTooltip = t('projectSidebar.cardSpecsSupporting'); + const worksetsNavLabel = t('projectSidebar.navWorksetProjects'); const worksetsUnavailableId = 'project-worksets-unavailable'; + const navRowClass = + 'group flex h-[26px] w-full min-w-0 items-center gap-2 rounded px-1.5 text-left text-xs focus:outline-none focus-visible:outline focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-60'; // Disable root actions during any in-flight scope/store operation so the // selector reflects the pending state (preserved from the old ScopeBar logic). @@ -155,64 +179,52 @@ export const Header: React.FC = ({ aria-label={t('projectSidebar.navigationLabel')} data-project-navigation > -
+
-
diff --git a/src/webview/components/ProjectDashboard.tsx b/src/webview/components/ProjectDashboard.tsx index 4b2057d..050d12c 100644 --- a/src/webview/components/ProjectDashboard.tsx +++ b/src/webview/components/ProjectDashboard.tsx @@ -2,6 +2,9 @@ import React from 'react'; import { useAppState } from '../context/AppContext'; import { t, type LocaleKey } from '../../i18n'; import type { ChangeInfo, ProjectSidebarData } from '../types/messages'; +import { useVscode } from '../hooks/useVscode'; +import { sendMessage } from '../types/messages'; +import { formatRelativeDateLabel } from '../utils/dateLabels'; export type ProjectDashboardLifecycle = | 'planning' @@ -101,8 +104,21 @@ function Kpi({ label, value }: { label: string; value: string | number }) { export const ProjectDashboard: React.FC = () => { const { state } = useAppState(); + const { postMessage } = useVscode(); const data = state.projectSidebar; + const openChangeDetail = (changeName: string) => { + if (!data) return; + postMessage(sendMessage.openChangeDetailInEditor( + changeName, + undefined, + undefined, + undefined, + data.project, + data.binding, + )); + }; + if (!data && state.loading) { return
{t('projectDashboard.loading')}
; } @@ -202,8 +218,19 @@ export const ProjectDashboard: React.FC = () => {
    {summary.recentUpdates.map((update) => (
  • - {update.name} - + +
  • ))}
diff --git a/src/webview/components/VerifyArchivePanel.tsx b/src/webview/components/VerifyArchivePanel.tsx index c48ffb1..9485fa7 100644 --- a/src/webview/components/VerifyArchivePanel.tsx +++ b/src/webview/components/VerifyArchivePanel.tsx @@ -10,6 +10,7 @@ import { } from '../utils/workflowLaunchLabels'; export interface VerifyArchivePanelProps { + changeName: string; isArchived: boolean; canArchiveNow?: boolean; archiveNowDisabledReason?: string; @@ -56,6 +57,7 @@ const mutedTextStyle: React.CSSProperties = { }; export const VerifyArchivePanel: React.FC = ({ + 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/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..2add86e --- /dev/null +++ b/src/webview/utils/applyTaskProgressPatch.ts @@ -0,0 +1,58 @@ +import type { ChangeInfo } from '../types/messages'; +import type { ChangeTaskProgressPatch } from '../../shared/changeTaskProgressPatch'; +import { + enrichChangeWithLifecycle, + type ActiveChangeLifecycleStatus, + type ChangeAttention, + type LifecycleArtifactInput, +} from '../../shared/changeLifecycle'; + +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, + patch: ChangeTaskProgressPatch, +): T { + if (change.name !== patch.changeName) return change; + const status = patch.totalTasks === 0 + ? 'draft' + : patch.completedTasks === patch.totalTasks + ? 'complete' + : 'in-progress'; + 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( + changes: readonly T[], + patch: ChangeTaskProgressPatch, +): T[] { + return changes.map((change) => applyTaskProgressPatchToChange(change, patch)); +} 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/src/webview/utils/dateLabels.ts b/src/webview/utils/dateLabels.ts index 445f8b6..5d6ef60 100644 --- a/src/webview/utils/dateLabels.ts +++ b/src/webview/utils/dateLabels.ts @@ -1,13 +1,27 @@ -import { t } from '../../i18n'; +import { getLocale, t } from '../../i18n'; function parseDate(iso: string): Date | null { const date = new Date(iso); return Number.isFinite(date.getTime()) ? date : null; } +/** Whole calendar days from `from` (earlier) to `to` (later) in the local timezone. */ +export function localCalendarDayDifference(from: Date, to: Date): number { + const startOfDay = (value: Date) => new Date( + value.getFullYear(), + value.getMonth(), + value.getDate(), + ).getTime(); + const msPerDay = 24 * 60 * 60 * 1000; + return Math.round((startOfDay(to) - startOfDay(from)) / msPerDay); +} + export function formatDateLabel(iso: string): string { const date = parseDate(iso); if (!date) return ''; + if (getLocale() === 'zh-cn') { + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; + } return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', @@ -15,15 +29,19 @@ export function formatDateLabel(iso: string): string { }); } -export function formatRelativeDateLabel(iso: string): string { +export function formatRelativeDateLabel(iso: string, now: Date = new Date()): string { const date = parseDate(iso); if (!date) return ''; - const now = new Date(); const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (24 * 60 * 60 * 1000)); - if (diffDays === 0) return t('time.today' as any); - if (diffDays === 1) return t('time.yesterday' as any); - if (diffDays < 7) return t('time.daysAgo' as any, { days: diffDays }); - if (diffDays < 30) return t('time.weeksAgo' as any, { weeks: Math.floor(diffDays / 7) }); + if (diffMs < 0) { + return t('time.justNow'); + } + const calendarDaysAgo = localCalendarDayDifference(date, now); + if (calendarDaysAgo === 0) return t('time.today'); + if (calendarDaysAgo === 1) return t('time.yesterday'); + if (calendarDaysAgo < 7) return t('time.daysAgo', { days: calendarDaysAgo }); + if (calendarDaysAgo < 30) { + return t('time.weeksAgo', { weeks: Math.floor(calendarDaysAgo / 7) }); + } return formatDateLabel(iso); } diff --git a/src/webview/utils/workflowLaunchLabels.ts b/src/webview/utils/workflowLaunchLabels.ts index f79daf7..fd25e7f 100644 --- a/src/webview/utils/workflowLaunchLabels.ts +++ b/src/webview/utils/workflowLaunchLabels.ts @@ -6,19 +6,39 @@ import { resolveWorkflowCommandTargetForUi, type WorkflowAction, } from '../../shared/workflowCommand'; - -const VERIFY_ARCHIVE_EXAMPLE_CHANGE = 'my-change'; +import { shouldAutoSubmitWorkflowAction } from '../../shared/agentAutoSubmit'; + +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 +54,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,41 +116,53 @@ 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), }); } +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, runtime?: WorkflowCommandFormatRuntime, + changeName?: string, ): string { - const command = getExampleWorkflowCommand(action, config, runtime); + const command = getExampleWorkflowCommand(action, config, runtime, changeName); + const autoSubmit = willAutoSubmitWorkflowAction(action, config); if (!config || !shouldUseAgentWorkflowLabels(config)) { return action === 'verify' ? t('verifyArchive.verifyCopyHint', { command }) @@ -144,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( @@ -166,7 +214,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-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/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/providers/agentUnavailableHandler.test.ts b/test/extension/providers/agentUnavailableHandler.test.ts index c6c3475..0a022e9 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(), }, @@ -32,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', () => { @@ -104,6 +118,9 @@ describe('agent unavailable webview handler', () => { expect(html).toContain('data-openspec-view="agentUnavailable"'); expect(html).toContain('data-workspace-path="/tmp/ws-b"'); + expect(html).toContain('font-src vscode-webview:'); + expect(html).toContain('rel="preload"'); + expect(html).toContain('index.ttf'); }); it('postAgentUnavailableContext includes workflow launch config', () => { 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/providers/dashboardViewProvider.test.ts b/test/extension/providers/dashboardViewProvider.test.ts index cb8090b..3884439 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,157 @@ describe('DashboardViewProvider', () => { })); }); - it('project refreshes use the Project loader for watcher and manual refresh', async () => { + 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('still merges watcher refresh data while a manual Project Sidebar refresh is in flight', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture(); + const gateway = { + loadChanges: vi.fn() + .mockResolvedValue({ project: fixture.project, binding: fixture.binding, changes: [makeProjectChange('seed-change')] }), + }; + let resolveRefresh: ((value: unknown) => void) | undefined; + const refresh = vi.fn(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + const dataManager = makeDataManager({ refresh }); + 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; + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + const manualRefresh = handler?.({ type: 'refresh' }); + refreshCallback?.(makeDashboardData({ + changeName: 'watcher-added-change', + lastRefresh: 99, + })); + resolveRefresh?.(makeDashboardData({ changeName: 'watcher-added-change', lastRefresh: 99 })); + await manualRefresh; + await vi.runAllTimersAsync(); + + expect(postMessage.mock.calls.some(([message]) => ( + message.type === 'setContext' + && message.view === 'sidebar' + && message.data?.changes?.some((change: { name: string }) => change.name === 'watcher-added-change') + ))).toBe(true); + }); + + it('merges watcher refresh into an open Changes Explorer panel', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture(); + const change = makeProjectChange('explorer-change'); + const gateway = { + resolveBinding: vi.fn().mockResolvedValue(fixture.binding), + loadChanges: vi.fn() + .mockResolvedValueOnce({ project: fixture.project, binding: fixture.binding, changes: [change] }) + .mockResolvedValue({ project: fixture.project, binding: fixture.binding, changes: [change] }), + loadArchivedChanges: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + archivedChanges: [], + }), + }; + const dataManager = makeDataManager(); + const sidebarWebview = makeWebview(); + const explorerPanel = makeEditorPanel(); + const vscode = await import('vscode'); + vi.mocked(vscode.window.createWebviewPanel).mockReturnValueOnce(explorerPanel as any); + const provider = makeProjectProvider(dataManager, gateway, fixture); + + provider.resolveWebviewView(makeWebviewView(sidebarWebview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(sidebarWebview.onDidReceiveMessage).mock.calls[0]?.[0]; + await handler?.({ type: 'openChangesExplorer', project: fixture.project, binding: fixture.binding }); + await vi.runAllTimersAsync(); + vi.mocked(explorerPanel.webview.postMessage).mockClear(); + + const refreshCallback = (dataManager.onRefresh as any).mock.calls[0]?.[0] as ((data: any) => void) | undefined; + refreshCallback?.(makeDashboardData({ + changeName: 'watcher-added-change', + lastRefresh: 42, + })); + await vi.runAllTimersAsync(); + + expect(explorerPanel.webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'setContext', + view: 'changesExplorer', + data: expect.objectContaining({ + changes: expect.arrayContaining([ + expect.objectContaining({ name: 'watcher-added-change' }), + ]), + }), + })); + }); + + 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 +3585,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(); @@ -3802,10 +3979,12 @@ describe('DashboardViewProvider', () => { const contextMessages = explorerPanel.webview.postMessage.mock.calls .map(([message]) => message) - .filter((message) => message?.type === 'setContext'); - expect(contextMessages).toHaveLength(2); - expect(contextMessages[0]).toEqual(contextMessages[1]); - expect(contextMessages).not.toContainEqual(expect.objectContaining({ view: 'sidebar' })); + .filter((message) => message?.type === 'setContext' && message.view === 'changesExplorer'); + expect(contextMessages.length).toBeGreaterThanOrEqual(2); + expect(contextMessages.at(-1)).toEqual(contextMessages.at(-2)); + expect(explorerPanel.webview.postMessage.mock.calls).not.toContainEqual([ + expect.objectContaining({ type: 'setContext', view: 'sidebar' }), + ]); expect(gateway.loadChanges).toHaveBeenCalledTimes(loadChangesCallsBeforeReplay); }); diff --git a/test/extension/services/agentLaunchPrefillNotify.test.ts b/test/extension/services/agentLaunchPrefillNotify.test.ts new file mode 100644 index 0000000..4bfbd3b --- /dev/null +++ b/test/extension/services/agentLaunchPrefillNotify.test.ts @@ -0,0 +1,63 @@ +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('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: { + 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..92f7c4a 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( @@ -202,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']); @@ -227,6 +298,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/dataManager.test.ts b/test/extension/services/dataManager.test.ts index db6bfe1..8ad1130 100644 --- a/test/extension/services/dataManager.test.ts +++ b/test/extension/services/dataManager.test.ts @@ -1,6 +1,10 @@ import { realpathSync, rmSync } from 'fs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { DataManager } from '@extension/services/dataManager'; +import { + DataManager, + shouldUseTasksOnlyRefreshFastPath, +} from '@extension/services/dataManager'; +import type { FileChangeEvent } from '@extension/services/fileWatcher'; import { TaskExecutorService } from '@extension/services/taskExecutorService'; import type { ArchivedChangeInfo, ChangeInfo, SpecInfo } from '@extension/services/types'; @@ -432,6 +436,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); @@ -461,6 +467,11 @@ describe('DataManager dashboard data loading', () => { autoCompleteParents: vi.fn().mockResolvedValue(undefined), }, 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); @@ -469,15 +480,253 @@ 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([{ + type: 'change', uri: { fsPath: '/tmp/openspec/changes/same-change/tasks.md' }, + timestamp: Date.now(), }]); - expect(artifactChanged).toHaveBeenCalledWith({ + expect(artifactChanged).toHaveBeenCalledWith(expect.objectContaining({ changeName: 'same-change', artifactTypes: ['tasks'], - rootPath: realpathSync('/tmp'), + rootPath: '/tmp', + })); + expect((manager as any).publishTaskProgressFromFile).toHaveBeenCalledWith( + 'same-change', + undefined, + ); + }); + + it('refreshes when tasks.md is created for a change that is not cached yet', async () => { + const manager = new DataManager('/tmp'); + Object.assign(manager as any, { + contentAccess: { + autoCompleteParents: vi.fn().mockResolvedValue(undefined), + }, + cachedData: { + changes: [{ name: 'existing-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, 'publishTaskProgressFromFile').mockResolvedValue(undefined); + vi.spyOn(manager as any, 'invalidateDashboardCache').mockResolvedValue(undefined); + const refreshSpy = vi.spyOn(manager, 'refresh').mockResolvedValue({} as any); + + await (manager as any).handleFileWatcherEvents([{ + type: 'create', + uri: { fsPath: '/tmp/openspec/changes/brand-new-change/tasks.md' }, + timestamp: Date.now(), + }]); + + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('refreshes on consecutive watcher batches that add and remove changes', async () => { + const manager = new DataManager('/tmp'); + Object.assign(manager as any, { + contentAccess: { + autoCompleteParents: vi.fn().mockResolvedValue(undefined), + }, + cachedData: { + changes: [{ name: 'existing-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, 'publishTaskProgressFromFile').mockResolvedValue(undefined); + vi.spyOn(manager as any, 'invalidateDashboardCache').mockResolvedValue(undefined); + const refreshSpy = vi.spyOn(manager, 'refresh').mockResolvedValue({} as any); + + const createTasks = (name: string) => ([{ + type: 'create' as const, + uri: { fsPath: `/tmp/openspec/changes/${name}/tasks.md` }, + timestamp: Date.now(), + }]); + await (manager as any).handleFileWatcherEvents(createTasks('first-new')); + await (manager as any).handleFileWatcherEvents([{ + type: 'delete', + uri: { fsPath: '/tmp/openspec/changes/first-new/proposal.md' }, + timestamp: Date.now(), + }]); + await (manager as any).handleFileWatcherEvents(createTasks('second-new')); + + expect(refreshSpy).toHaveBeenCalledTimes(3); + }); + + describe('shouldUseTasksOnlyRefreshFastPath', () => { + const cached = { + changes: [{ name: 'existing-change' }], + archivedChanges: [{ directoryName: '2026-archived', name: 'archived', archiveDate: '2026-01-01' }], + }; + + function event( + type: FileChangeEvent['type'], + fsPath: string, + ): FileChangeEvent { + return { type, uri: { fsPath }, timestamp: Date.now() }; + } + + it('allows the fast path only for in-place tasks.md edits on known changes', () => { + expect(shouldUseTasksOnlyRefreshFastPath( + [event('change', '/tmp/openspec/changes/existing-change/tasks.md')], + '/tmp', + cached as any, + )).toBe(true); + }); + + it('requires a full refresh for tasks.md create, delete, and unknown changes', () => { + expect(shouldUseTasksOnlyRefreshFastPath( + [event('create', '/tmp/openspec/changes/new-change/tasks.md')], + '/tmp', + cached as any, + )).toBe(false); + expect(shouldUseTasksOnlyRefreshFastPath( + [event('delete', '/tmp/openspec/changes/existing-change/tasks.md')], + '/tmp', + cached as any, + )).toBe(false); + expect(shouldUseTasksOnlyRefreshFastPath( + [event('change', '/tmp/openspec/changes/missing-change/tasks.md')], + '/tmp', + cached as any, + )).toBe(false); + }); + }); + + 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([{ + type: 'change', + uri: { fsPath: '/tmp/openspec/changes/same-change/tasks.md' }, + timestamp: Date.now(), + }]); + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(execOpenSpec).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2500); + expect(refreshSpy).not.toHaveBeenCalled(); + expect(execOpenSpec).not.toHaveBeenCalled(); + 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', () => { 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/workflowAgentLaunch.test.ts b/test/extension/services/workflowAgentLaunch.test.ts index 245c88c..7348af0 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,37 @@ 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, + 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/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 5f81904..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('已完成'); @@ -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( @@ -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 new file mode 100644 index 0000000..f15d26f --- /dev/null +++ b/test/scripts/check-typecheck-baseline.test.ts @@ -0,0 +1,75 @@ +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 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, 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"); + }); + + 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}`).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/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/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/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/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/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/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'); 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/changesSection.test.tsx b/test/webview/components/changesSection.test.tsx index e924402..444908b 100644 --- a/test/webview/components/changesSection.test.tsx +++ b/test/webview/components/changesSection.test.tsx @@ -1,6 +1,7 @@ import React, { isValidElement, type ReactElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setLocale } from '../../../src/i18n'; import { ChangesSection } from '../../../src/webview/components/ChangesSection'; import { ChangeStatusFilter } from '../../../src/webview/components/ChangeStatusFilter'; import type { ChangeStatusCounts } from '../../../src/shared/changeLifecycle'; @@ -148,6 +149,32 @@ describe('ChangesSection root-scoped empty states', () => { }); }); +describe('Changes list title open-in-editor action', () => { + afterEach(() => setLocale('en')); + + it('renders the external-link control with English tooltip when a handler is provided', () => { + const onOpenInEditor = vi.fn(); + const html = renderSection({ onOpenInEditor }); + + expect(html).toContain('data-open-changes-in-editor'); + expect(html).toMatch(/data-open-changes-in-editor[^>]*title="Open in Editor"/); + expect(html).toMatch(/data-open-changes-in-editor[^>]*aria-label="Open in Editor"/); + }); + + it('localizes the open-in-editor tooltip in zh-cn', () => { + setLocale('zh-cn'); + const html = renderSection({ onOpenInEditor: vi.fn() }); + + expect(html).toMatch(/data-open-changes-in-editor[^>]*title="在编辑器中打开"/); + expect(html).toMatch(/data-open-changes-in-editor[^>]*aria-label="在编辑器中打开"/); + }); + + it('omits the control when no handler is provided', () => { + const html = renderSection(); + expect(html).not.toContain('data-open-changes-in-editor'); + }); +}); + describe('Project Sidebar compact active work', () => { it('does not render archived cards or explorer filters in compact mode', () => { const html = renderToStaticMarkup( diff --git a/test/webview/components/dashboard.test.tsx b/test/webview/components/dashboard.test.tsx index 6d76302..95df6da 100644 --- a/test/webview/components/dashboard.test.tsx +++ b/test/webview/components/dashboard.test.tsx @@ -13,6 +13,7 @@ import { getWorksetsTabAvailable, returnToCurrentProject, selectProjectFirstTab, + openProjectChangesInEditor, sendProjectSidebarSpecDetail, toWorksetCreateResultState, toWorksetPickedMembersState, @@ -315,6 +316,12 @@ describe('Dashboard recommended action rail', () => { expect(html).toMatch(/data-priority-row="verify-change"[^>]*data-priority-status="[^"]+"/); expect(html).toMatch(/data-priority-row="recommended-change"[^>]*data-priority-status="[^"]+"/); expect(html).toMatch(/data-change-name="attention-change"[^>]*class="[^"]*truncate/); + expect(html).toMatch(/data-priority-row="recommended-change"[^>]*flex-col/); + expect(html).toContain('flex-wrap'); + expect(html).toMatch(/data-priority-change="recommended-change"[^>]*min-w-\[7rem\]/); + expect(html).toMatch(/data-priority-cta="recommended-change"[^>]*shrink-0/); + expect(html).not.toMatch(/data-priority-cta="recommended-change"[^>]*truncate/); + expect(html).not.toMatch(/data-priority-cta="recommended-change"[^>]*max-w-\[55%\]/); expect(html).toContain('Review'); expect(html).toContain('Copy Verify'); expect(html).toContain('Copy Continue planning'); @@ -420,7 +427,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'); }); @@ -644,22 +651,21 @@ describe('project page contract', () => { }], }); - expect(html).toContain('data-project-action-grid'); - expect(html).toContain('data-project-action="changes"'); + expect(html).toContain('data-project-nav-list'); + expect(html).not.toContain('data-project-action-grid'); + expect(html).not.toContain('data-project-action="changes"'); expect(html).toContain('data-project-action="specs"'); expect(html).toContain('data-project-action="worksets"'); expect(html).toContain('data-project-action="dashboard"'); + expect(html).toContain('data-open-changes-in-editor'); + expect(html).toContain('Project Dashboard'); expect(html).not.toContain('role="tablist"'); - expect(html).toContain('aria-pressed="true"'); - expect(html.indexOf('data-project-action="changes"')).toBeLessThan( + expect(html.indexOf('data-project-action="dashboard"')).toBeLessThan( html.indexOf('data-project-action="specs"'), ); expect(html.indexOf('data-project-action="specs"')).toBeLessThan( html.indexOf('data-project-action="worksets"'), ); - expect(html.indexOf('data-project-action="worksets"')).toBeLessThan( - html.indexOf('data-project-action="dashboard"'), - ); expect(html).toContain('active-change'); expect(html).not.toContain('Project Specs'); expect(html).not.toContain('Referenced Store Specs: aihelp-workspace'); @@ -686,7 +692,7 @@ describe('project page contract', () => { expect(html).toContain('data-project-action="worksets"'); // The disabled attribute must be absent (Tailwind disabled: classes always exist). expect(html).not.toMatch(/]*disabled=""[^>]*data-project-action="worksets"/); - expect(html).toContain('Browse Workset Projects'); + expect(html).toContain('Workset Projects'); expect(html).not.toContain('No trusted Workset membership available'); }); @@ -700,10 +706,9 @@ describe('project page contract', () => { expect(html).toContain('data-project-action="worksets"'); expect(html).toMatch(/]*disabled=""[^>]*data-project-action="worksets"/); expect(html).toContain('Stores and worksets require OpenSpec 1.5.0 or newer.'); - // Changes/Specs stay fully usable next to the disabled Worksets tab. - expect(html).toContain('data-project-action="changes"'); + // Specs stay fully usable next to the disabled Worksets tab. + expect(html).not.toContain('data-project-action="changes"'); expect(html).toContain('data-project-action="specs"'); - expect(html).not.toMatch(/]*disabled=""[^>]*data-project-action="changes"/); expect(html).not.toMatch(/]*disabled=""[^>]*data-project-action="specs"/); }); @@ -717,6 +722,58 @@ describe('project page contract', () => { expect(postMessage).not.toHaveBeenCalled(); }); + it('opens the bound Changes explorer when the list title action is activated', () => { + const postMessage = vi.fn(); + + openProjectChangesInEditor(postMessage, projectContext, projectBinding); + + expect(postMessage).toHaveBeenCalledWith( + sendMessage.openChangesExplorer(projectContext, projectBinding), + ); + }); + + it('renders the open-in-editor control beside the Changes list title', () => { + const html = renderProjectSidebar(projectSidebarData); + + expect(html).toMatch(/data-open-changes-in-editor[^>]*title="Open in Editor"/); + expect(html).toMatch(/data-open-changes-in-editor[^>]*aria-label="Open in Editor"/); + }); + + it('localizes the Changes list open-in-editor control in zh-cn', () => { + setLocale('zh-cn'); + const html = renderProjectSidebar(projectSidebarData); + + expect(html).toMatch(/data-open-changes-in-editor[^>]*title="在编辑器中打开"/); + expect(html).toMatch(/data-open-changes-in-editor[^>]*aria-label="在编辑器中打开"/); + }); + + 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 } @@ -943,10 +1000,11 @@ describe('project page contract', () => { expect(html).toContain('Project A'); expect(html).toContain('active-change'); - expect(html).toContain('All Changes'); expect(html).toContain('Specs'); + expect(html).toContain('data-open-changes-in-editor'); expect(html).toContain('New Change'); expect(html).not.toContain('archive:old-change'); + expect(html).not.toContain('data-project-action="changes"'); expect(html).not.toContain('Root selector'); expect(html).not.toContain('Stores & Worksets'); }); @@ -973,12 +1031,12 @@ describe('project page contract', () => { }); }); - it('keeps All Changes and Specs available when active work is empty', () => { + it('keeps Specs launcher and Changes list title action when active work is empty', () => { const html = renderProjectSidebar({ ...projectSidebarData, changes: [] }); expect(html).toMatch(/No changes in this Root/i); - expect(html).toContain('All Changes'); expect(html).toContain('Specs'); + expect(html).toContain('data-open-changes-in-editor'); expect(html).toContain('New Change'); }); diff --git a/test/webview/components/header.test.tsx b/test/webview/components/header.test.tsx index 287bd6a..52bcbdc 100644 --- a/test/webview/components/header.test.tsx +++ b/test/webview/components/header.test.tsx @@ -25,20 +25,6 @@ function collectProjectActionButtons(node: React.ReactNode): ActionButton[] { : children; } -function collectElements(node: React.ReactNode, predicate: (element: React.ReactElement) => boolean): React.ReactElement[] { - if (Array.isArray(node)) return node.flatMap((item) => collectElements(item, predicate)); - if (!React.isValidElement(node)) return []; - - const props = node.props as { children?: React.ReactNode }; - return [ - ...(predicate(node) ? [node] : []), - ...React.Children.toArray(props.children).flatMap((item) => collectElements(item, predicate)), - ]; -} - -// Direct-call rendering returns `ReactNode | Promise` under React 19 -// types; Header renders synchronously, so a precise element return type keeps -// every collector call site free of tsc debt. function createProjectHeader(overrides: Partial = {}): React.ReactElement { return Header({ onRefresh: vi.fn(), @@ -51,42 +37,40 @@ function createProjectHeader(overrides: Partial = {}): React.ReactE describe('Project-first Header', () => { afterEach(() => setLocale('en')); - it('renders the four actions in a narrow, non-tablist launcher', () => { + it('renders the three nav rows in a vertical list (not tablist tiles)', () => { const html = renderToStaticMarkup(
); - expect(html).toContain('data-project-action-grid'); + expect(html).toContain('data-project-nav-list'); + expect(html).not.toContain('data-project-action-grid'); expect(html).not.toContain('role="tablist"'); + expect(html).not.toContain('data-project-action="changes"'); expect(html).toContain('aria-pressed="true"'); - expect(html.indexOf('data-project-action="changes"')).toBeLessThan( + expect(html.indexOf('data-project-action="dashboard"')).toBeLessThan( html.indexOf('data-project-action="specs"'), ); expect(html.indexOf('data-project-action="specs"')).toBeLessThan( html.indexOf('data-project-action="worksets"'), ); - expect(html.indexOf('data-project-action="worksets"')).toBeLessThan( - html.indexOf('data-project-action="dashboard"'), - ); }); - it('keeps the Worksets cell visible and disabled when navigation is unavailable', () => { + it('keeps the Worksets row visible and disabled when navigation is unavailable', () => { const html = renderToStaticMarkup(
@@ -96,42 +80,29 @@ describe('Project-first Header', () => { expect(html).toMatch(/No trusted Workset membership|Worksets unavailable/i); }); - it('renders four named, bounded card buttons with icons and local selection semantics', () => { + it('renders compact nav rows with short labels, badges, and trailing cues', () => { const header = createProjectHeader({ - onOpenChanges: vi.fn(), onOpenSpecs: vi.fn(), onOpenWorksets: vi.fn(), onOpenDashboard: vi.fn(), worksetCount: 2, + specsCount: 12, activeProjectTab: 'specs', }); const buttons = collectProjectActionButtons(header); - expect(buttons).toHaveLength(4); + expect(buttons).toHaveLength(3); expect(buttons.map((button) => button.props['aria-label'])).toEqual([ - 'All Changes', + 'Project Dashboard · Open in Editor', 'Specs', 'Browse Workset Projects (2)', - 'Dashboard · Open in Editor', ]); - expect(buttons.every((button) => button.props.type === 'button')).toBe(true); - expect(buttons.every((button) => button.props.className?.includes('min-w-0'))).toBe(true); - expect(buttons.every((button) => button.props.className?.includes('hover:'))).toBe(true); - expect(buttons.every((button) => button.props.className?.includes('focus-visible'))).toBe(true); - expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([false, true, false, undefined]); - expect(buttons.map((button) => ( - collectElements(button, (element) => ( - typeof (element.props as { className?: unknown }).className === 'string' - && (element.props as { className: string }).className.includes('codicon-') - )).length - ))).toEqual([1, 1, 1, 1]); - expect(buttons.every((button) => collectElements(button, (element) => ( - Boolean((element.props as { 'data-project-action-supporting'?: string })['data-project-action-supporting']) - )).length === 1)).toBe(true); - - const dashboard = buttons[3]; - expect(dashboard.props.title).toContain('Dashboard'); - expect(dashboard.props.title).toContain('Editor'); + expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([undefined, true, false]); + expect(renderToStaticMarkup(header)).toContain('Project Dashboard'); + expect(renderToStaticMarkup(header)).toContain('Workset Projects'); + expect(renderToStaticMarkup(header)).toContain('codicon-link-external'); + expect(renderToStaticMarkup(header)).toContain('codicon-chevron-right'); + expect(renderToStaticMarkup(header)).not.toContain('data-project-action-supporting'); }); it('includes an explicit Editor cue in the Dashboard accessible name', () => { @@ -147,10 +118,7 @@ describe('Project-first Header', () => { it('gives unavailable Worksets a complete reason and prevents navigation', () => { const onOpenWorksets = vi.fn(); - // No onOpenWorksets handler: upstream (navigation + capability) decided the - // surface is unavailable, so the tab must stay disabled and inert. const header = createProjectHeader({ - onOpenChanges: vi.fn(), onOpenSpecs: vi.fn(), onOpenDashboard: vi.fn(), worksetCount: 0, @@ -164,11 +132,6 @@ describe('Project-first Header', () => { expect(worksets?.props.disabled).toBe(true); expect(worksets?.props['aria-describedby']).toBeDefined(); expect(worksets?.props.title).toContain('No trusted Workset membership available'); - const reason = collectElements(header, (element) => ( - (element.props as { id?: string }).id === worksets?.props['aria-describedby'] - )); - expect(reason).toHaveLength(1); - expect(reason[0].props.children).toContain('No trusted Workset membership available'); expect(worksets?.props.onClick).toBeUndefined(); if (worksets && !worksets.props.disabled) worksets.props.onClick?.(); expect(onOpenWorksets).not.toHaveBeenCalled(); @@ -183,19 +146,10 @@ describe('Project-first Header', () => { ); expect(worksets?.props.disabled).toBe(true); - expect(worksets?.props['aria-describedby']).toBeDefined(); expect(worksets?.props.title).toContain('Stores and worksets require OpenSpec 1.5.0 or newer.'); - const reason = collectElements(header, (element) => ( - (element.props as { id?: string }).id === worksets?.props['aria-describedby'] - )); - expect(reason).toHaveLength(1); - expect((reason[0]?.props as { children?: string } | undefined)?.children) - .toContain('Stores and worksets require OpenSpec 1.5.0 or newer.'); }); it('keeps Worksets available for zero worksets when an open handler exists', () => { - // Zero worksets is the first-creation case, never an unavailable state: - // the count must not gate the tab, only the upstream-provided handler does. const onOpenWorksets = vi.fn(); const header = createProjectHeader({ onOpenWorksets, @@ -207,7 +161,6 @@ describe('Project-first Header', () => { ); expect(worksets?.props.disabled).toBe(false); - expect(worksets?.props['aria-describedby']).toBeUndefined(); expect(worksets?.props.title).toContain('Browse Workset Projects'); worksets?.props.onClick?.(); expect(onOpenWorksets).toHaveBeenCalledTimes(1); @@ -216,7 +169,6 @@ describe('Project-first Header', () => { it('opens Dashboard in its Editor route without changing the selected local view', () => { const onOpenDashboard = vi.fn(); const header = createProjectHeader({ - onOpenChanges: vi.fn(), onOpenSpecs: vi.fn(), onOpenWorksets: vi.fn(), onOpenDashboard, @@ -246,7 +198,6 @@ describe('Project-first Header', () => { rootPath: '/projects/long-project', rootSource: 'nearest', }} - onOpenChanges={vi.fn()} onOpenSpecs={vi.fn()} /> ); @@ -254,13 +205,11 @@ describe('Project-first Header', () => { expect(html).toContain('aria-label="Current Project"'); expect(html).toContain('data-project-identity'); expect(html).toContain('data-project-navigation'); - expect(html).toContain('Current Project'); expect(html.indexOf('data-project-identity')).toBeLessThan(html.indexOf('data-project-navigation')); expect(html).toContain('data-project-navigation="true"'); - expect(html).toContain('class="flex flex-col gap-1"'); }); - it('exposes a separate Worksets navigation action without moving Project identity', () => { + it('exposes Worksets with a short visible label and descriptive accessible name', () => { const html = renderToStaticMarkup(
{ /> ); - expect(html).toContain('Browse Workset Projects'); + expect(html).toContain('Workset Projects'); expect(html).toContain('data-project-action="worksets"'); expect(html).toContain('aria-label="Browse Workset Projects (1)"'); expect(html).toContain('title="Browse Workset Projects"'); - expect(html.indexOf('data-project-identity')).toBeLessThan(html.indexOf('Browse Workset Projects')); + expect(html.indexOf('data-project-identity')).toBeLessThan(html.indexOf('Workset Projects')); }); - it('translates card supporting text and navigation accessible names', () => { + it('translates nav labels and keeps long copy in tooltips', () => { setLocale('zh-cn'); const html = renderToStaticMarkup(
{ onNewChange={vi.fn()} loading={false} project={{ id: '/projects/current', label: 'Current Project', projectPath: '/projects/current' }} - onOpenChanges={vi.fn()} onOpenSpecs={vi.fn()} onOpenDashboard={vi.fn()} + onOpenWorksets={vi.fn()} + worksetCount={4} /> ); expect(html).toContain('aria-label="项目导航"'); - expect(html).toContain('浏览本地 Changes'); - expect(html).toContain('浏览本地 Specs'); - 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). - expect(html).toContain('浏览 Workset 项目'); + expect(html).toContain('项目仪表盘'); + expect(html).toContain('Workset 项目'); + expect(html).toContain('title="浏览本地 Spec"'); + expect(html).toContain('title="在编辑器中打开项目 Dashboard"'); expect(html).not.toContain('aria-label="Project navigation"'); - expect(html).not.toContain('Browse Workset Projects'); }); }); 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(/