From 12963f08b75e50f6153ce7a6f4662f70d3e1a874 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 16:11:44 +0200 Subject: [PATCH] Keep selected Guardex lanes visible during SCM commit flow The Active Agents tree already grouped sessions and exposed lock-backed file decorations, but operators still lost the active lane once focus moved to the commit input or away from Source Control. This keeps the selected session identity in the SCM placeholder, mirrors the same state into a status-bar shortcut, and updates the test/docs surfaces to match the shipped behavior. Constraint: Keep the change inside the existing VS Code Active Agents companion without new dependencies Rejected: Add a separate panel for selected-session state | heavier UI than needed for a visibility fix Confidence: high Scope-risk: narrow Directive: Keep the SCM placeholder and status-bar summary sourced from the same session metadata so they do not drift Tested: node --test test/vscode-active-agents-session-state.test.js Not-tested: Manual VS Code install and live editor verification --- README.md | 2 +- .../notes.md | 26 +++ .../vscode/guardex-active-agents/README.md | 1 + .../vscode/guardex-active-agents/extension.js | 169 +++++++++++++++++- ...vscode-active-agents-session-state.test.js | 31 +++- 5 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md diff --git a/README.md b/README.md index b2c2d7a..6d8ecd7 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ To install the real companion into local VS Code from a GitGuardex-wired repo: node scripts/install-vscode-active-agents-extension.js ``` -It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` when those states are present, reads `.omx/state/active-sessions/*.json`, derives session state from git conflict markers, dirty worktree status, PID liveness, and recent file mtimes, and surfaces working/dead counts in the repo/header affordances. Reload the VS Code window after install. +It adds an `Active Agents` view to the Source Control container, groups each live repo into `ACTIVE AGENTS` and `CHANGES` sections, splits `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` when those states are present, mirrors the selected session or active-agent count in the VS Code status bar, reads `.omx/state/active-sessions/*.json`, derives session state from git conflict markers, dirty worktree status, PID liveness, and recent file mtimes, and surfaces working/dead counts in the repo/header affordances. Reload the VS Code window after install. --- diff --git a/openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md b/openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md new file mode 100644 index 0000000..605f4e2 --- /dev/null +++ b/openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md @@ -0,0 +1,26 @@ +# agent-codex-scm-active-agent-signals-2026-04-22-15-54 (minimal / T1) + +Branch: `agent/codex/scm-active-agent-signals-2026-04-22-15-54` + +Make the existing VS Code `Active Agents` Source Control surface harder to miss during commit flow. Keep the grouped tree in the SCM container, but add stronger current-agent cues in the commit input, a status-bar shortcut, and file decorations for Guardex lock ownership on changed files. + +Scope: +- Extend the Active Agents extension manifest with a focus command and status-bar-friendly wording. +- Update `templates/vscode/guardex-active-agents/extension.js` to show selected-session branch metadata in the custom SCM commit input and status bar. +- Add file decorations for real file URIs so Guardex lock ownership is visible directly on changed files. +- Extend the active-agents regression suite for the new SCM input, status bar, and lock-decoration behavior. +- Update the extension README so the SCM affordances match the shipped experience. + +Verification: +- `node --test test/vscode-active-agents-session-state.test.js` + +## Handoff + +- Handoff: change=`agent-codex-scm-active-agent-signals-2026-04-22-15-54`; branch=`agent/codex/scm-active-agent-signals-2026-04-22-15-54`; scope=`templates/vscode/guardex-active-agents/*, test/vscode-active-agents-session-state.test.js, openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/*`; action=`add SCM-visible agent cues, verify with targeted extension tests, then finish this sandbox via PR merge + cleanup`. +- Copy prompt: Continue `agent-codex-scm-active-agent-signals-2026-04-22-15-54` on branch `agent/codex/scm-active-agent-signals-2026-04-22-15-54`. Work inside the existing sandbox, review `openspec/changes/agent-codex-scm-active-agent-signals-2026-04-22-15-54/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/scm-active-agent-signals-2026-04-22-15-54 --base main --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/scm-active-agent-signals-2026-04-22-15-54 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/templates/vscode/guardex-active-agents/README.md b/templates/vscode/guardex-active-agents/README.md index 9d74245..6cfcf84 100644 --- a/templates/vscode/guardex-active-agents/README.md +++ b/templates/vscode/guardex-active-agents/README.md @@ -20,6 +20,7 @@ What it does: - Adds an `Active Agents` view to the Source Control container. - Renders one repo node per live Guardex workspace with grouped `ACTIVE AGENTS` and `CHANGES` sections. - Splits live sessions inside `ACTIVE AGENTS` into `BLOCKED`, `WORKING NOW`, `IDLE`, `STALLED`, and `DEAD` groups so stuck, active, and inactive lanes stand out immediately. +- Mirrors the same live state in the VS Code status bar so the selected session or active-agent count stays visible outside the tree. - Shows one row per live Guardex sandbox session inside those activity groups. - Shows repo-root git changes in a sibling `CHANGES` section when the guarded repo itself is dirty. - Derives session state from dirty worktree status, git conflict markers, PID liveness, and recent file mtimes, surfaces working/dead counts in the repo/header summary, and shows changed-file counts for active edits. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 4718b6c..947e008 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -93,10 +93,83 @@ function sessionIdleDecoration(session, now = Date.now()) { return undefined; } +function formatCountLabel(count, singular, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function sessionIdentityLabel(session) { + const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; + const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; + const label = typeof session?.label === 'string' ? session.label.trim() : ''; + + if (agentName && taskName) { + return `${agentName} · ${taskName}`; + } + if (agentName && label) { + return `${agentName} · ${label}`; + } + + return agentName || taskName || label || 'session'; +} + +function sessionCommitPlaceholder(session) { + if (!session?.branch) { + return 'Pick an Active Agents session to commit its worktree.'; + } + + return `Commit ${sessionIdentityLabel(session)} on ${session.branch} · ${formatCountLabel(session.lockCount || 0, 'lock')} (Ctrl+Enter)`; +} + +function agentNameFromBranch(branch) { + const segments = String(branch || '') + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments[0] === 'agent' && segments[1]) { + return segments[1]; + } + return segments[0] || 'lock'; +} + +function agentBadgeFromBranch(branch) { + const normalized = agentNameFromBranch(branch).toUpperCase().replace(/[^A-Z0-9]/g, ''); + return normalized.slice(0, 2) || 'LK'; +} + +function buildActiveAgentsStatusSummary(summary) { + const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); + if (activeCount > 0) { + return `$(git-branch) ${formatCountLabel(activeCount, 'active agent')}`; + } + return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`; +} + +function buildActiveAgentsStatusTooltip(selectedSession, summary) { + if (selectedSession?.branch) { + return [ + selectedSession.branch, + sessionIdentityLabel(selectedSession), + formatCountLabel(selectedSession.lockCount || 0, 'lock'), + selectedSession.worktreePath, + 'Click to open Source Control.', + ].filter(Boolean).join('\n'); + } + + const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); + return [ + formatCountLabel(activeCount, 'active agent'), + formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), + summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', + 'Click to open Source Control.', + ].filter(Boolean).join('\n'); +} + class SessionDecorationProvider { constructor(nowProvider = () => Date.now()) { this.nowProvider = nowProvider; this.sessionsByUri = new Map(); + this.lockEntriesByFileUri = new Map(); + this.selectedBranch = ''; this.onDidChangeFileDecorationsEmitter = new vscode.EventEmitter(); this.onDidChangeFileDecorations = this.onDidChangeFileDecorationsEmitter.event; } @@ -107,13 +180,54 @@ class SessionDecorationProvider { ); } + updateLockEntries(repoEntries) { + const nextEntriesByUri = new Map(); + for (const entry of repoEntries || []) { + for (const [relativePath, lockEntry] of entry.lockEntries || []) { + nextEntriesByUri.set( + vscode.Uri.file(path.join(entry.repoRoot, relativePath)).toString(), + { branch: lockEntry.branch }, + ); + } + } + this.lockEntriesByFileUri = nextEntriesByUri; + } + + setSelectedBranch(branch) { + this.selectedBranch = typeof branch === 'string' ? branch.trim() : ''; + } + refresh() { this.onDidChangeFileDecorationsEmitter.fire(); } provideFileDecoration(uri) { if (!uri || uri.scheme !== SESSION_DECORATION_SCHEME) { - return undefined; + if (!uri || uri.scheme !== 'file') { + return undefined; + } + + const lockEntry = this.lockEntriesByFileUri.get(uri.toString()); + if (!lockEntry?.branch) { + return undefined; + } + + const ownsSelectedSession = Boolean(this.selectedBranch) && lockEntry.branch === this.selectedBranch; + return { + badge: agentBadgeFromBranch(lockEntry.branch), + tooltip: ownsSelectedSession + ? `Locked by selected session ${lockEntry.branch}` + : this.selectedBranch + ? `Locked by ${lockEntry.branch} (selected session: ${this.selectedBranch})` + : `Locked by ${lockEntry.branch}`, + color: new vscode.ThemeColor( + ownsSelectedSession + ? 'gitDecoration.modifiedResourceForeground' + : this.selectedBranch + ? 'list.errorForeground' + : 'list.warningForeground', + ), + }; } return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider()); @@ -836,6 +950,11 @@ class ActiveAgentsProvider { this.treeView = null; this.lockRegistryByRepoRoot = new Map(); this.selectedSession = null; + this.viewSummary = { + sessionCount: 0, + workingCount: 0, + deadCount: 0, + }; } getTreeItem(element) { @@ -856,6 +975,7 @@ class ActiveAgentsProvider { const currentKey = sessionSelectionKey(this.selectedSession); const nextKey = sessionSelectionKey(nextSession); this.selectedSession = nextSession; + this.decorationProvider?.setSelectedBranch(nextSession?.branch || ''); if (currentKey !== nextKey) { this.onDidChangeSelectedSessionEmitter.fire(this.selectedSession); } @@ -865,6 +985,10 @@ class ActiveAgentsProvider { return this.selectedSession ? { ...this.selectedSession } : null; } + getViewSummary() { + return { ...this.viewSummary }; + } + syncSelectedSession(repoEntries) { if (!this.selectedSession) { return; @@ -882,6 +1006,11 @@ class ActiveAgentsProvider { } const activeCount = Math.max(0, sessionCount - deadCount); + this.viewSummary = { + sessionCount, + workingCount, + deadCount, + }; const badgeTooltipParts = []; if (activeCount > 0) { badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); @@ -918,6 +1047,7 @@ class ActiveAgentsProvider { this.updateViewState(sessionCount, workingCount, deadCount); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); + this.decorationProvider?.updateLockEntries(repoEntries); return repoEntries; } @@ -996,6 +1126,7 @@ class ActiveAgentsProvider { changes: readRepoChanges(repoRoot).map((change) => ( decorateChange(change, lockRegistry, currentBranch) )), + lockEntries: Array.from(lockRegistry.entriesByPath.entries()), }; }); } @@ -1080,6 +1211,9 @@ function activate(context) { 'gitguardex.activeAgents.commitInput', 'Active Agents Commit', ); + const activeAgentsStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10); + activeAgentsStatusItem.name = 'GitGuardex Active Agents'; + activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus'; provider.attachTreeView(treeView); const scheduleRefresh = () => refreshController.scheduleRefresh(); const refresh = () => void refreshController.refreshNow(); @@ -1089,11 +1223,24 @@ function activate(context) { const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; sourceControl.inputBox.visible = true; - sourceControl.inputBox.placeholder = session?.label - ? `Commit ${session.label} (Ctrl+Enter)` - : 'Pick an Active Agents session to commit its worktree.'; + sourceControl.inputBox.placeholder = sessionCommitPlaceholder(session); + }; + const updateStatusBar = () => { + const selectedSession = provider.getSelectedSession(); + const summary = provider.getViewSummary(); + if ((summary.sessionCount || 0) <= 0) { + activeAgentsStatusItem.hide(); + return; + } + + activeAgentsStatusItem.text = selectedSession?.branch + ? `$(git-branch) ${sessionIdentityLabel(selectedSession)} · ${formatCountLabel(selectedSession.lockCount || 0, 'lock')}` + : buildActiveAgentsStatusSummary(summary); + activeAgentsStatusItem.tooltip = buildActiveAgentsStatusTooltip(selectedSession, summary); + activeAgentsStatusItem.show(); }; updateCommitInput(null); + updateStatusBar(); const commitSelectedSession = async () => { const selectedSession = provider.getSelectedSession(); if (!selectedSession?.worktreePath) { @@ -1140,15 +1287,27 @@ function activate(context) { scheduleRefresh(); }; - provider.onDidChangeSelectedSession(updateCommitInput); + provider.onDidChangeSelectedSession((session) => { + updateCommitInput(session); + updateStatusBar(); + decorationProvider.refresh(); + }); + provider.onDidChangeTreeData(() => { + updateCommitInput(provider.getSelectedSession()); + updateStatusBar(); + }); context.subscriptions.push( treeView, sourceControl, + activeAgentsStatusItem, refreshController, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), + vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => { + await vscode.commands.executeCommand('workbench.view.scm'); + }), vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 7f14b3b..d4258e1 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -123,6 +123,7 @@ function createMockVscode(tempRoot) { providers: [], decorationProviders: [], treeViews: [], + statusBarItems: [], commands: new Map(), executedCommands: [], sourceControls: [], @@ -245,6 +246,10 @@ function createMockVscode(tempRoot) { None: 0, Expanded: 1, }, + StatusBarAlignment: { + Left: 1, + Right: 2, + }, commands: { executeCommand: async (command, ...args) => { registrations.executedCommands.push({ command, args }); @@ -364,6 +369,26 @@ function createMockVscode(tempRoot) { registrations.providers.push({ viewId, provider: options.treeDataProvider }); return treeView; }, + createStatusBarItem: (alignment, priority) => { + const statusBarItem = { + alignment, + priority, + text: '', + tooltip: '', + command: undefined, + name: undefined, + visible: false, + show() { + this.visible = true; + }, + hide() { + this.visible = false; + }, + dispose() {}, + }; + registrations.statusBarItems.push(statusBarItem); + return statusBarItem; + }, registerFileDecorationProvider: (provider) => { registrations.decorationProviders.push(provider); return disposable(); @@ -731,8 +756,12 @@ test('active-agents extension registers tree and decoration providers', async () assert.equal(registrations.treeViews.length, 1); assert.equal(registrations.sourceControls.length, 1); + assert.equal(registrations.statusBarItems.length, 1); assert.equal(registrations.treeViews[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.sourceControls[0].label, 'Active Agents Commit'); + assert.equal(registrations.statusBarItems[0].name, 'GitGuardex Active Agents'); + assert.equal(registrations.statusBarItems[0].command, 'gitguardex.activeAgents.focus'); + assert.equal(registrations.statusBarItems[0].visible, false); assert.equal( registrations.sourceControls[0].inputBox.placeholder, 'Pick an Active Agents session to commit its worktree.', @@ -1587,7 +1616,7 @@ test('active-agents extension commits the selected session worktree from the SCM assert.equal( registrations.sourceControls[0].inputBox.placeholder, - `Commit ${sessionItem.session.label} (Ctrl+Enter)`, + `Commit ${sessionItem.session.agentName} · ${sessionItem.session.taskName} on ${sessionItem.session.branch} · 0 locks (Ctrl+Enter)`, ); registrations.sourceControls[0].inputBox.value = 'Ship the selected sandbox';