diff --git a/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/proposal.md b/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/proposal.md new file mode 100644 index 0000000..9c7ff1f --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/proposal.md @@ -0,0 +1,15 @@ +## Why + +- The Active Agents SCM contribution can render session rows, but it cannot render the header count badge shown in the intended VS Code screenshot because it only registers a tree-data provider. +- The view also depends on prior user view-state persistence, so the section may stay hidden instead of appearing by default in Source Control. + +## What Changes + +- Create the SCM view with `createTreeView(...)` so the extension can set a live badge and empty-state message. +- Mark the contributed SCM view as visible by default. +- Add regression coverage for both the empty-state message and the live-session badge count. + +## Impact + +- Affected surfaces: `templates/vscode/guardex-active-agents/package.json`, `templates/vscode/guardex-active-agents/extension.js`, and `test/vscode-active-agents-session-state.test.js`. +- Risk is narrow because the change stays inside the VS Code companion and does not alter Guardex session-state generation. diff --git a/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/specs/vscode-active-agents-scm/spec.md b/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/specs/vscode-active-agents-scm/spec.md new file mode 100644 index 0000000..9e1bb72 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/specs/vscode-active-agents-scm/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Active Agents SCM view exposes header state +The Guardex Active Agents VS Code companion SHALL create the `gitguardex.activeAgents` SCM view through a tree-view handle so the view can expose header state in addition to item rows. + +#### Scenario: Live sessions set a header badge +- **WHEN** one or more live Guardex sessions are available in the current workspace +- **THEN** the SCM view shows the session rows +- **AND** the view header badge reflects the live session count. + +#### Scenario: Empty state sets a view message +- **WHEN** no live Guardex sessions are available in the current workspace +- **THEN** the SCM view remains available in Source Control +- **AND** the view exposes an empty-state message that tells the operator to start a sandbox session. + +### Requirement: Active Agents SCM view is visible by default +The `gitguardex.activeAgents` SCM contribution SHALL default to visible so operators do not need to discover it manually in the SCM views menu on first install. + +#### Scenario: First load shows the section +- **WHEN** the extension is installed in a workspace with Source Control open +- **THEN** the Active Agents section is available in the SCM container without requiring a manual enable step. diff --git a/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/tasks.md b/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/tasks.md new file mode 100644 index 0000000..34fa58a --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31/tasks.md @@ -0,0 +1,10 @@ +## Definition of Done + +- [x] 1.1 Capture the SCM badge and default-visibility acceptance criteria in the proposal/spec. +- [x] 2.1 Create the Active Agents SCM view with badge/message support. +- [x] 2.2 Default the SCM view contribution to visible. +- [x] 2.3 Add regression coverage for empty and live SCM view states. +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-vscode-active-agents-scm-badge-visibilit-2026-04-21-18-31 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. +- [ ] 4.1 Run the Guardex finish flow with PR merge + cleanup, or record a `BLOCKED:` note. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 7b340f5..1028cd3 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -51,12 +51,34 @@ class ActiveAgentsProvider { constructor() { this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + this.treeView = null; } getTreeItem(element) { return element; } + attachTreeView(treeView) { + this.treeView = treeView; + this.updateViewState(0); + } + + updateViewState(sessionCount) { + if (!this.treeView) { + return; + } + + this.treeView.badge = sessionCount > 0 + ? { + value: sessionCount, + tooltip: `${sessionCount} active agent${sessionCount === 1 ? '' : 's'}`, + } + : undefined; + this.treeView.message = sessionCount > 0 + ? undefined + : 'Start a sandbox session to populate this view.'; + } + refresh() { this.onDidChangeTreeDataEmitter.fire(); } @@ -67,13 +89,15 @@ class ActiveAgentsProvider { } const sessionsByRepo = await this.loadSessionsByRepo(); + const sessionCount = [...sessionsByRepo.values()].reduce((total, sessions) => total + sessions.length, 0); + this.updateViewState(sessionCount); const repos = [...sessionsByRepo.entries()] .map(([repoRoot, sessions]) => ({ repoRoot, sessions })) .filter((entry) => entry.sessions.length > 0) .sort((left, right) => left.repoRoot.localeCompare(right.repoRoot)); if (repos.length === 0) { - return [new InfoItem('No active Guardex agents', 'Start a sandbox session to populate this view.')]; + return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; } if (repos.length === 1) { @@ -115,12 +139,17 @@ class ActiveAgentsProvider { function activate(context) { const provider = new ActiveAgentsProvider(); + const treeView = vscode.window.createTreeView('gitguardex.activeAgents', { + treeDataProvider: provider, + showCollapseAll: true, + }); + provider.attachTreeView(treeView); const refresh = () => provider.refresh(); const watcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); const interval = setInterval(refresh, 5_000); context.subscriptions.push( - vscode.window.registerTreeDataProvider('gitguardex.activeAgents', provider), + treeView, vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 354efa2..3c3b0d1 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -32,7 +32,8 @@ "scm": [ { "id": "gitguardex.activeAgents", - "name": "Active Agents" + "name": "Active Agents", + "visibility": "visible" } ] }, diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 4d89e5f..7497b51 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -46,6 +46,7 @@ function loadExtensionWithMockVscode(mockVscode) { function createMockVscode(tempRoot) { const registrations = { providers: [], + treeViews: [], }; class TreeItem { @@ -95,6 +96,18 @@ function createMockVscode(tempRoot) { file: (fsPath) => ({ fsPath }), }, window: { + createTreeView: (viewId, options) => { + const treeView = { + viewId, + options, + badge: undefined, + message: undefined, + dispose() {}, + }; + registrations.treeViews.push(treeView); + registrations.providers.push({ viewId, provider: options.treeDataProvider }); + return treeView; + }, registerTreeDataProvider: (viewId, provider) => { registrations.providers.push({ viewId, provider }); return disposable(); @@ -228,6 +241,8 @@ test('active-agents extension registers a provider with getTreeItem', async () = extension.activate(context); + assert.equal(registrations.treeViews.length, 1); + assert.equal(registrations.treeViews[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.providers.length, 1); assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); @@ -237,6 +252,47 @@ test('active-agents extension registers a provider with getTreeItem', async () = const [rootItem] = await provider.getChildren(); assert.equal(rootItem.label, 'No active Guardex agents'); assert.equal(provider.getTreeItem(rootItem), rootItem); + assert.equal(registrations.treeViews[0].badge, undefined); + assert.equal(registrations.treeViews[0].message, 'Start a sandbox session to populate this view.'); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension updates the SCM badge for live sessions', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-live-view-')); + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task'); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/live-task', + taskName: 'live-task', + agentName: 'codex', + worktreePath: path.join(tempRoot, '.omx', 'agent-worktrees', 'live-task'), + pid: process.pid, + cliName: 'codex', + }), null, 2)}\n`, + 'utf8', + ); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + const provider = registrations.providers[0].provider; + const [sessionItem] = await provider.getChildren(); + assert.equal(sessionItem.label, 'live-task'); + assert.deepEqual(registrations.treeViews[0].badge, { + value: 1, + tooltip: '1 active agent', + }); + assert.equal(registrations.treeViews[0].message, undefined); for (const subscription of context.subscriptions) { subscription.dispose?.();