diff --git a/codev/projects/bugfix-1170-vscode-agents-tree-group-heade/status.yaml b/codev/projects/bugfix-1170-vscode-agents-tree-group-heade/status.yaml new file mode 100644 index 000000000..3bbdb484e --- /dev/null +++ b/codev/projects/bugfix-1170-vscode-agents-tree-group-heade/status.yaml @@ -0,0 +1,17 @@ +id: bugfix-1170 +title: vscode-agents-tree-group-heade +protocol: bugfix +phase: pr +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: approved + requested_at: '2026-07-13T03:59:15.458Z' + approved_at: '2026-07-13T06:03:29.092Z' +iteration: 1 +build_complete: false +history: [] +started_at: '2026-07-13T03:46:12.160Z' +updated_at: '2026-07-13T06:03:29.092Z' +pr_ready_for_human: false diff --git a/codev/state/bugfix-1170_thread.md b/codev/state/bugfix-1170_thread.md new file mode 100644 index 000000000..3dde30cb7 --- /dev/null +++ b/codev/state/bugfix-1170_thread.md @@ -0,0 +1,43 @@ +# bugfix-1170 — vscode Agents tree group-header contextValue collision + +## Investigate + +**Bug**: Right-clicking an Agents-tree group header (architect/phase/area axis) surfaces 7 +builder-scoped context-menu entries that no-op on a group header. + +**Root cause (confirmed)**: `packages/vscode/src/views/area-group-tree-item.ts:34` sets +`this.contextValue = `${kind}-group``. `BuilderGroupTreeItem` calls `super(groupName, 'builder', ...)` +(builder-tree-item.ts:54), so kind='builder' → contextValue `builder-group`. The 7 offending menu +entries in package.json (lines 537–587) gate on `/^(builder|blocked-builder|awaiting-builder)-/`, +which matches `builder-group`. The 3 protocol-scoped entries (viewSpec/Plan/ReviewFile) already +require a protocol suffix, so they're unaffected. + +**Fix**: line 34 → `this.contextValue = `group-${kind}``. Yields `group-builder` / `group-backlog`, +neither matches the offending regexes. No package.json change. `id` (line 33) stays `${kind}-group` +(unrelated — drives expansion persistence). + +**Tests**: grep found NO existing assertion on the literal `builder-group` string, so nothing to +update. The regression home is `packages/vscode/src/__tests__/menu-when-clauses.test.ts` — add a +block asserting the 7 builder-scoped regexes reject `group-builder` / `group-backlog`. + +Scope: 1-line src change + new regression test. Comfortably within BUGFIX. + +## Fix + +- `area-group-tree-item.ts:34`: `${kind}-group` → `group-${kind}`. Build + tests green + (`porch check`: build 6.2s, tests 21s; 566 tests pass). +- Regression test `group-header-context-value.test.ts` derives contextValue from the real + `AreaGroupTreeItem` and feeds it to the actual package.json menu regexes. Verified it fails + without the fix (8 failures) and passes with it. +- Note: unit tests need `@cluesmith/codev-core` + `@cluesmith/codev-types` built first, else + suites fail at import resolution (pre-existing, unrelated). `porch check` runs the build. + +## PR + +- PR #1172 opened, `Fixes #1170`. +- CMAP (all three APPROVE, HIGH confidence, zero issues): gemini=APPROVE, codex=APPROVE + (couldn't re-run vitest in its sandbox, approved on inspection), claude=APPROVE. +- Consult snag: `consult --type pr` auto-detect failed from the worktree ("Multiple projects + found" — it scanned the full `codev/projects/`). Adding `--issue 1170` disambiguated. Worth + a look for other bugfix builders. +- Requested `pr` gate via `porch done`. Awaiting human approval to merge. diff --git a/packages/vscode/src/__tests__/group-header-context-value.test.ts b/packages/vscode/src/__tests__/group-header-context-value.test.ts new file mode 100644 index 000000000..eeac6dd83 --- /dev/null +++ b/packages/vscode/src/__tests__/group-header-context-value.test.ts @@ -0,0 +1,108 @@ +/** + * Group-header rows must NOT surface builder-scoped context-menu entries (#1170). + * + * Agents-tree group headers (architect / phase / area axis, plus the Backlog + * view) are `AreaGroupTreeItem`s. Their `contextValue` is what VSCode matches + * the `view/item/context` menu `when` regexes against. The seven builder-scoped + * commands gate on the state-family prefix `/^(builder|blocked-builder| + * awaiting-builder)-/`. + * + * The bug: the header contextValue was built as `${kind}-group`, so a builder + * group header got `builder-group` — which matches `^builder-` and surfaced all + * seven entries. Each silently no-ops on a header (the handlers narrow via + * `instanceof BuilderTreeItem`), so the menu was pure noise. The fix flips the + * template to `group-${kind}` (`group-builder`), moving the family token off the + * front so none of the regexes match. + * + * This test derives the contextValue from the REAL `AreaGroupTreeItem` and feeds + * it to the actual package.json regexes. Reverting the source to `${kind}-group` + * makes the header value `builder-group` again, matches the regexes, and fails + * these assertions — which a hardcoded string wouldn't catch. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +vi.mock('vscode', () => { + class TreeItem { + id?: string; + contextValue?: string; + constructor(public label: string, public collapsibleState?: number) {} + } + return { + TreeItem, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + }; +}); + +// Import AFTER the mock is registered. +const vscode = await import('vscode'); +const { AreaGroupTreeItem } = await import('../views/area-group-tree-item.js'); + +const PKG = JSON.parse( + readFileSync(resolve(__dirname, '../../package.json'), 'utf8'), +); +const viewItemMenuEntries: Array<{ command: string; when: string }> = + PKG.contributes.menus['view/item/context']; + +/** + * The seven builder-scoped commands whose `when` gates on the state-family + * prefix regex. Two (run/stopWorktreeDev) append `&& codev.hasDevCommand`, so + * the regex isn't at the tail of the clause — extract it tolerantly. + */ +const BUILDER_SCOPED = [ + 'codev.openBuilderById', + 'codev.viewDiff', + 'codev.openWorktreeWindow', + 'codev.openWorktreeFolder', + 'codev.runWorktreeSetup', + 'codev.runWorktreeDev', + 'codev.stopWorktreeDev', +] as const; + +function viewItemRegex(command: string): RegExp { + const entry = viewItemMenuEntries.find( + e => e.command === command && e.when.includes('view == codev.agents')); + if (!entry) { + throw new Error(`No agents view/item/context entry for ${command}`); + } + const m = entry.when.match(/viewItem =~ \/(.+?)\/(?:\s|$)/); + if (!m) { + throw new Error(`No viewItem regex in when clause: ${entry.when}`); + } + return new RegExp(m[1]); +} + +/** contextValue of a group header for the given kind, from the real class. */ +function headerContextValue(kind: 'builder' | 'backlog'): string { + const header = new AreaGroupTreeItem( + 'area/tower', kind, 2, vscode.TreeItemCollapsibleState.Expanded); + return header.contextValue!; +} + +describe('group-header rows reject builder-scoped menu entries (#1170)', () => { + it('AreaGroupTreeItem puts the group token first (group-)', () => { + expect(headerContextValue('builder')).toBe('group-builder'); + expect(headerContextValue('backlog')).toBe('group-backlog'); + }); + + for (const command of BUILDER_SCOPED) { + const regex = viewItemRegex(command); + + it(`${command} is hidden for a builder group header`, () => { + expect(regex.test(headerContextValue('builder')), + `${command} menu for builder group header`).toBe(false); + }); + + it(`${command} is hidden for a backlog group header`, () => { + expect(regex.test(headerContextValue('backlog')), + `${command} menu for backlog group header`).toBe(false); + }); + + it(`${command} still shows for a builder row (guards against over-tightening)`, () => { + expect(regex.test('builder-bugfix'), + `${command} for builder-bugfix`).toBe(true); + }); + } +}); diff --git a/packages/vscode/src/views/area-group-tree-item.ts b/packages/vscode/src/views/area-group-tree-item.ts index bc643fda6..c6c656761 100644 --- a/packages/vscode/src/views/area-group-tree-item.ts +++ b/packages/vscode/src/views/area-group-tree-item.ts @@ -31,6 +31,6 @@ export class AreaGroupTreeItem extends vscode.TreeItem { ) { super(`${uppercaseAreaName(groupName)} (${count})`, collapsibleState); this.id = `${kind}-group:${groupName}`; - this.contextValue = `${kind}-group`; + this.contextValue = `group-${kind}`; } }