From 855327b7b7d5963094a54c6b6493c1e0e58e4223 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 18:22:36 +0200 Subject: [PATCH] Make Active Agents easier to scan by grouping sessions under worktrees The Active Agents view already exposed session-level state, but users still had to mentally map flat agent rows back to worktree ownership. This change adds an explicit worktree layer for both ACTIVE AGENTS and CHANGES while keeping the existing watcher-driven refresh path and session-scoped actions intact. Constraint: Preserve the current debounced watcher and session action flow Rejected: Create a separate SCM provider per worktree | larger surface than needed for this tree-view change Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep worktree rows as grouping surfaces unless the session-scoped commit and inspect flow is redesigned together Tested: node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js Tested: openspec validate agent-codex-group-active-agents-by-worktree-2026-04-22-18-12 --type change --strict Tested: openspec validate --specs (returned no items found, exit 0) Not-tested: Manual VS Code render/install check --- .../proposal.md | 17 +++ .../vscode-active-agents-extension/spec.md | 22 +++ .../tasks.md | 35 +++++ .../vscode/guardex-active-agents/extension.js | 125 +++++++++++++++--- .../vscode/guardex-active-agents/package.json | 2 +- ...vscode-active-agents-session-state.test.js | 81 ++++++++---- vscode/guardex-active-agents/extension.js | 125 +++++++++++++++--- vscode/guardex-active-agents/package.json | 2 +- 8 files changed, 338 insertions(+), 71 deletions(-) create mode 100644 openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/proposal.md create mode 100644 openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/tasks.md diff --git a/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/proposal.md b/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/proposal.md new file mode 100644 index 0000000..fc830ec --- /dev/null +++ b/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/proposal.md @@ -0,0 +1,17 @@ +## Why + +- The Active Agents companion already groups sessions by activity, but the tree still flattens sessions directly under those state buckets. +- The requested VS Code shape is worktree-first: users should be able to scan a worktree row and then expand to the agents running inside it. +- The current `CHANGES` grouping already knows ownership through `worktreePath` and `changedPaths`, so the missing behavior is tree presentation, not new runtime telemetry. + +## What Changes + +- Add a worktree row above agent rows inside `ACTIVE AGENTS`. +- Group `CHANGES` by worktree first, then by owning agent session, while leaving unmatched files in `Repo root`. +- Keep the existing debounced watcher model, session actions, lock decorations, and selected-session SCM commit flow unchanged. +- Mirror the tree-shape change in both shipped extension sources and focused regression tests. + +## Impact + +- Affected surfaces: `vscode/guardex-active-agents/extension.js`, `templates/vscode/guardex-active-agents/extension.js`, focused extension tests, and this OpenSpec change. +- Risk is limited to tree rendering and test expectations. No session-state schema or watcher lifecycle contract changes are required. diff --git a/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..46547d5 --- /dev/null +++ b/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Worktree-first Active Agents rows + +The VS Code `gitguardex.activeAgents` view MUST group agent session rows under their owning worktree rows before rendering session-owned file details. + +#### Scenario: ACTIVE AGENTS shows worktree rows inside activity groups + +- **GIVEN** the companion reads one or more live sessions for the same repo +- **WHEN** it renders an activity bucket such as `WORKING NOW` or `THINKING` +- **THEN** each child row under that bucket is a worktree row derived from `worktreePath` +- **AND** expanding the worktree row reveals the agent/session rows for that worktree +- **AND** expanding a session row reveals that session's touched-file rows. + +#### Scenario: CHANGES shows worktree rows before session-owned files + +- **GIVEN** repo changes belong to managed agent worktrees +- **WHEN** the companion renders `CHANGES` +- **THEN** it groups those changes under worktree rows first +- **AND** expanding a worktree row reveals the owning session row +- **AND** expanding that session row reveals the localized changed-file rows +- **AND** files not owned by any active worktree remain under `Repo root`. diff --git a/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/tasks.md b/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/tasks.md new file mode 100644 index 0000000..4cdeb0d --- /dev/null +++ b/openspec/changes/agent-codex-group-active-agents-by-worktree-2026-04-22-18-12/tasks.md @@ -0,0 +1,35 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-group-active-agents-by-worktree-2026-04-22-18-12`; branch=`agent/codex/group-active-agents-by-worktree-2026-04-22-18-12`; scope=`worktree-first Active Agents tree grouping in live/template extension copies plus focused regression coverage`; action=`add worktree rows above agent rows, verify focused tests/specs, then finish via PR merge cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-group-active-agents-by-worktree-2026-04-22-18-12`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-extension/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add a worktree tree item and regroup `ACTIVE AGENTS` by `worktreePath` before agent/session rows. +- [x] 2.2 Regroup `CHANGES` by worktree first, then by owning session, while keeping unmatched files in `Repo root`. +- [x] 2.3 Mirror the tree-shape change in `templates/vscode/guardex-active-agents/extension.js`. +- [x] 2.4 Add/update focused regression coverage for the new tree shape. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-group-active-agents-by-worktree-2026-04-22-18-12 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run `gx branch finish --branch agent/codex/group-active-agents-by-worktree-2026-04-22-18-12 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index e455245..3bf5573 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -421,11 +421,49 @@ class SectionItem extends vscode.TreeItem { } } +class WorktreeItem extends vscode.TreeItem { + constructor(worktreePath, sessions, items = [], options = {}) { + const normalizedWorktreePath = typeof worktreePath === 'string' ? worktreePath.trim() : ''; + const sessionList = Array.isArray(sessions) ? sessions : []; + const changedCount = Number.isInteger(options.changedCount) + ? options.changedCount + : sessionList.reduce((total, session) => total + (session.changeCount || 0), 0); + const descriptionParts = [formatCountLabel(sessionList.length, 'agent')]; + if (changedCount > 0) { + descriptionParts.push(`${changedCount} changed`); + } + super( + path.basename(normalizedWorktreePath || '') || 'worktree', + items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + ); + this.worktreePath = normalizedWorktreePath; + this.sessions = sessionList; + this.items = items; + this.description = options.description || descriptionParts.join(' · '); + this.tooltip = [ + normalizedWorktreePath, + ...sessionList.map((session) => session.branch).filter(Boolean), + ].filter(Boolean).join('\n'); + this.iconPath = new vscode.ThemeIcon('folder'); + this.contextValue = 'gitguardex.worktree'; + if (sessionList[0]?.worktreePath) { + this.command = { + command: 'gitguardex.activeAgents.openWorktree', + title: 'Open Agent Worktree', + arguments: [sessionList[0]], + }; + } + } +} + class SessionItem extends vscode.TreeItem { - constructor(session, items = []) { + constructor(session, items = [], options = {}) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : session.label; super( - session.label, + label, items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.session = session; @@ -544,6 +582,10 @@ function sessionDisplayLabel(session) { return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session'; } +function sessionTreeLabel(session) { + return session?.branch || sessionDisplayLabel(session); +} + function sessionWorktreePath(session) { return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : ''; } @@ -1151,6 +1193,36 @@ function countChangedPaths(repoRoot, sessions, changes) { return changedKeys.size; } +function groupSessionsByWorktree(sessions) { + const sessionsByWorktree = new Map(); + + for (const session of sessions || []) { + const worktreePath = sessionWorktreePath(session); + const key = worktreePath || session?.branch || `session-${sessionsByWorktree.size + 1}`; + if (!sessionsByWorktree.has(key)) { + sessionsByWorktree.set(key, { + worktreePath, + sessions: [], + }); + } + sessionsByWorktree.get(key).sessions.push(session); + } + + return [...sessionsByWorktree.values()] + .map((entry) => ({ + ...entry, + sessions: entry.sessions.sort((left, right) => ( + sessionTreeLabel(left).localeCompare(sessionTreeLabel(right)) + )), + })) + .sort((left, right) => { + const leftLabel = path.basename(left.worktreePath || '') || ''; + const rightLabel = path.basename(right.worktreePath || '') || ''; + return leftLabel.localeCompare(rightLabel) + || (left.worktreePath || '').localeCompare(right.worktreePath || ''); + }); +} + function buildGroupedChangeTreeNodes(sessions, changes) { const changesBySession = new Map(); const sessionByChangedPath = new Map(); @@ -1183,15 +1255,22 @@ function buildGroupedChangeTreeNodes(sessions, changes) { changesBySession.get(session.branch).push(localizedChange); } - const items = sessions - .map((session) => { - const sessionChanges = changesBySession.get(session.branch) || []; - if (sessionChanges.length === 0) { - return null; - } - return new SessionItem(session, buildChangeTreeNodes(sessionChanges)); - }) - .filter(Boolean); + const items = groupSessionsByWorktree( + sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), + ).map(({ worktreePath, sessions: worktreeSessions }) => { + const sessionItems = worktreeSessions.map((session) => ( + new SessionItem( + session, + buildChangeTreeNodes(changesBySession.get(session.branch) || []), + { label: sessionTreeLabel(session) }, + ) + )); + const changedCount = worktreeSessions.reduce( + (total, session) => total + ((changesBySession.get(session.branch) || []).length), + 0, + ); + return new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }); + }); if (repoRootChanges.length > 0) { items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { @@ -1319,14 +1398,20 @@ function commitWorktree(worktreePath, message) { function buildActiveAgentGroupNodes(sessions) { const groups = []; for (const group of SESSION_ACTIVITY_GROUPS) { - const groupSessions = sessions - .filter((session) => session.activityKind === group.kind) - .map((session) => new SessionItem( - session, - buildChangeTreeNodes(session.touchedChanges || []), - )); - if (groupSessions.length > 0) { - groups.push(new SectionItem(group.label, groupSessions)); + const groupSessions = sessions.filter((session) => session.activityKind === group.kind); + const worktreeItems = groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ( + new WorktreeItem( + worktreePath, + worktreeSessions, + worktreeSessions.map((session) => new SessionItem( + session, + buildChangeTreeNodes(session.touchedChanges || []), + { label: sessionTreeLabel(session) }, + )), + ) + )); + if (worktreeItems.length > 0) { + groups.push(new SectionItem(group.label, worktreeItems)); } } @@ -1488,7 +1573,7 @@ class ActiveAgentsProvider { return sectionItems; } - if (element instanceof SectionItem || element instanceof FolderItem || element instanceof SessionItem) { + if (element instanceof SectionItem || element instanceof FolderItem || element instanceof WorktreeItem || element instanceof SessionItem) { return element.items; } diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 6795d0b..8e1224e 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.6", + "version": "0.0.7", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 90a763a..6a8c1aa 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -186,6 +186,18 @@ function writeWorktreeLock(worktreePath, overrides = {}) { return lockPath; } +async function getOnlyChild(provider, item) { + const children = await provider.getChildren(item); + assert.equal(children.length, 1, `Expected exactly one child for ${item?.label || 'item'}`); + return children[0]; +} + +async function getOnlyWorktreeAndSession(provider, sectionItem) { + const worktreeItem = await getOnlyChild(provider, sectionItem); + const sessionItem = await getOnlyChild(provider, worktreeItem); + return { worktreeItem, sessionItem }; +} + function loadExtensionWithMockVscode(mockVscode, mockSessionSchema = null) { const Module = require('node:module'); const originalLoad = Module._load; @@ -1370,8 +1382,9 @@ test('active-agents extension groups live sessions under a repo node', async () const [idleSection] = await provider.getChildren(agentsSection); assert.equal(idleSection.label, 'THINKING'); - const [sessionItem] = await provider.getChildren(idleSection); - assert.equal(sessionItem.label, 'live-task'); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); + assert.equal(worktreeItem.label, 'live-task'); + assert.equal(sessionItem.label, 'agent/codex/live-task'); assert.match(sessionItem.description, /^idle · \d+[smhd]/); assert.equal(sessionItem.iconPath.id, 'comment-discussion'); assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); @@ -1615,8 +1628,9 @@ test('active-agents extension shows grouped repo changes beside active agents', const [workingSection] = await provider.getChildren(agentsSection); assert.equal(workingSection.label, 'WORKING NOW'); - const [sessionItem] = await provider.getChildren(workingSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); + assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(sessionItem.label, sessionItem.session.branch); assert.match(sessionItem.description, /^working · 2 files · /); assert.match(sessionItem.tooltip, /Changed 2 files: src\/nested\.js, tracked\.txt/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); @@ -1629,11 +1643,13 @@ test('active-agents extension shows grouped repo changes beside active agents', tooltip: '1 active agent · 1 working now', }); - const [sessionGroup, repoRootGroup] = await provider.getChildren(changesSection); - assert.equal(sessionGroup.label, `${path.basename(worktreePath)}`); - assert.match(sessionGroup.description, /^working · 2 files · /); + const [worktreeGroup, repoRootGroup] = await provider.getChildren(changesSection); + assert.equal(worktreeGroup.label, `${path.basename(worktreePath)}`); + assert.equal(worktreeGroup.description, '1 agent · 2 changed'); assert.equal(repoRootGroup.label, 'Repo root'); + const [sessionGroup] = await provider.getChildren(worktreeGroup); + assert.equal(sessionGroup.label, sessionItem.label); const [folderItem, trackedItem] = await provider.getChildren(sessionGroup); assert.equal(folderItem.label, 'src'); assert.equal(trackedItem.label, 'tracked.txt'); @@ -1697,9 +1713,10 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa const [agentsSection] = await provider.getChildren(repoItem); assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), ['ACTIVE AGENTS']); const [workingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(workingSection); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); + assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(sessionItem.label, 'agent/codex/lock-visible-task'); assert.match(sessionItem.description, /^working · 1 file · /); assert.match(sessionItem.tooltip, /Telemetry updated 2026-04-22T09:01:00.000Z/); @@ -1775,8 +1792,9 @@ test('active-agents extension decorates sessions and repo changes from the lock const [agentsSection, changesSection] = await provider.getChildren(repoItem); assert.equal(repoItem.description, '1 active · 1 working · 2 changed'); const [workingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(workingSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); + assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(sessionItem.label, branch); assert.match(sessionItem.tooltip, /Locks 1/); assert.match(sessionItem.tooltip, /Conflicts 1/); @@ -1870,8 +1888,9 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); const [idleSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(idleSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); + assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(sessionItem.label, branch); assert.equal(lockReadCount, 1); await provider.getChildren(); @@ -1897,8 +1916,9 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [updatedRepoItem] = await provider.getChildren(); const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); const [updatedIdleSection] = await provider.getChildren(updatedAgentsSection); - const [updatedSessionItem] = await provider.getChildren(updatedIdleSection); - assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)}`); + const { worktreeItem: updatedWorktreeItem, sessionItem: updatedSessionItem } = await getOnlyWorktreeAndSession(provider, updatedIdleSection); + assert.equal(updatedWorktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(updatedSessionItem.label, branch); await provider.getChildren(); assert.equal(lockReadCount, 2); @@ -2020,11 +2040,11 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s assert.equal(stalledSection.label, 'STALLED'); assert.equal(deadSection.label, 'DEAD'); - const [blockedItem] = await provider.getChildren(blockedSection); - const [workingItem] = await provider.getChildren(workingSection); - const [idleItem] = await provider.getChildren(idleSection); - const [stalledItem] = await provider.getChildren(stalledSection); - const [deadItem] = await provider.getChildren(deadSection); + const { sessionItem: blockedItem } = await getOnlyWorktreeAndSession(provider, blockedSection); + const { sessionItem: workingItem } = await getOnlyWorktreeAndSession(provider, workingSection); + const { sessionItem: idleItem } = await getOnlyWorktreeAndSession(provider, idleSection); + const { sessionItem: stalledItem } = await getOnlyWorktreeAndSession(provider, stalledSection); + const { sessionItem: deadItem } = await getOnlyWorktreeAndSession(provider, deadSection); assert.match(blockedItem.description, /^blocked · \d+[smhd]/); assert.equal(blockedItem.iconPath.id, 'warning'); assert.match(workingItem.description, /^working · 1 file · /); @@ -2164,7 +2184,7 @@ test('active-agents extension commits the selected session worktree from the SCM const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); const [workingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(workingSection); + const { sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); registrations.treeViews[0].fireSelection([sessionItem]); assert.equal( @@ -2270,8 +2290,9 @@ test('active-agents extension decorates sessions and repo changes from the lock const [repoItem] = await provider.getChildren(); const [agentsSection, changesSection] = await provider.getChildren(repoItem); const [idleSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(idleSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); + assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(sessionItem.label, branch); assert.match(sessionItem.tooltip, /Locks 1/); const [repoRootGroup] = await provider.getChildren(changesSection); @@ -2346,8 +2367,9 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); const [thinkingSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(thinkingSection); - assert.equal(sessionItem.label, `${path.basename(worktreePath)}`); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, thinkingSection); + assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(sessionItem.label, branch); assert.equal(lockReadCount, 1); await provider.getChildren(); @@ -2373,8 +2395,9 @@ test('active-agents extension re-reads lock state on watcher events instead of e const [updatedRepoItem] = await provider.getChildren(); const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); const [updatedThinkingSection] = await provider.getChildren(updatedAgentsSection); - const [updatedSessionItem] = await provider.getChildren(updatedThinkingSection); - assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)}`); + const { worktreeItem: updatedWorktreeItem, sessionItem: updatedSessionItem } = await getOnlyWorktreeAndSession(provider, updatedThinkingSection); + assert.equal(updatedWorktreeItem.label, `${path.basename(worktreePath)}`); + assert.equal(updatedSessionItem.label, branch); await provider.getChildren(); assert.equal(lockReadCount, 2); @@ -2422,7 +2445,7 @@ test('active-agents extension launches finish and sync commands in session termi const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); const [idleSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(idleSection); + const { sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); await registrations.commands.get('gitguardex.activeAgents.finishSession')(sessionItem.session); await registrations.commands.get('gitguardex.activeAgents.syncSession')(sessionItem.session); @@ -2522,7 +2545,7 @@ test('active-agents extension opens and refreshes the inspect panel from shared const [repoItem] = await provider.getChildren(); const [agentsSection] = await provider.getChildren(repoItem); const [groupSection] = await provider.getChildren(agentsSection); - const [sessionItem] = await provider.getChildren(groupSection); + const { sessionItem } = await getOnlyWorktreeAndSession(provider, groupSection); await registrations.commands.get('gitguardex.activeAgents.inspect')(sessionItem.session); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index e455245..3bf5573 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -421,11 +421,49 @@ class SectionItem extends vscode.TreeItem { } } +class WorktreeItem extends vscode.TreeItem { + constructor(worktreePath, sessions, items = [], options = {}) { + const normalizedWorktreePath = typeof worktreePath === 'string' ? worktreePath.trim() : ''; + const sessionList = Array.isArray(sessions) ? sessions : []; + const changedCount = Number.isInteger(options.changedCount) + ? options.changedCount + : sessionList.reduce((total, session) => total + (session.changeCount || 0), 0); + const descriptionParts = [formatCountLabel(sessionList.length, 'agent')]; + if (changedCount > 0) { + descriptionParts.push(`${changedCount} changed`); + } + super( + path.basename(normalizedWorktreePath || '') || 'worktree', + items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + ); + this.worktreePath = normalizedWorktreePath; + this.sessions = sessionList; + this.items = items; + this.description = options.description || descriptionParts.join(' · '); + this.tooltip = [ + normalizedWorktreePath, + ...sessionList.map((session) => session.branch).filter(Boolean), + ].filter(Boolean).join('\n'); + this.iconPath = new vscode.ThemeIcon('folder'); + this.contextValue = 'gitguardex.worktree'; + if (sessionList[0]?.worktreePath) { + this.command = { + command: 'gitguardex.activeAgents.openWorktree', + title: 'Open Agent Worktree', + arguments: [sessionList[0]], + }; + } + } +} + class SessionItem extends vscode.TreeItem { - constructor(session, items = []) { + constructor(session, items = [], options = {}) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : session.label; super( - session.label, + label, items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, ); this.session = session; @@ -544,6 +582,10 @@ function sessionDisplayLabel(session) { return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session'; } +function sessionTreeLabel(session) { + return session?.branch || sessionDisplayLabel(session); +} + function sessionWorktreePath(session) { return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : ''; } @@ -1151,6 +1193,36 @@ function countChangedPaths(repoRoot, sessions, changes) { return changedKeys.size; } +function groupSessionsByWorktree(sessions) { + const sessionsByWorktree = new Map(); + + for (const session of sessions || []) { + const worktreePath = sessionWorktreePath(session); + const key = worktreePath || session?.branch || `session-${sessionsByWorktree.size + 1}`; + if (!sessionsByWorktree.has(key)) { + sessionsByWorktree.set(key, { + worktreePath, + sessions: [], + }); + } + sessionsByWorktree.get(key).sessions.push(session); + } + + return [...sessionsByWorktree.values()] + .map((entry) => ({ + ...entry, + sessions: entry.sessions.sort((left, right) => ( + sessionTreeLabel(left).localeCompare(sessionTreeLabel(right)) + )), + })) + .sort((left, right) => { + const leftLabel = path.basename(left.worktreePath || '') || ''; + const rightLabel = path.basename(right.worktreePath || '') || ''; + return leftLabel.localeCompare(rightLabel) + || (left.worktreePath || '').localeCompare(right.worktreePath || ''); + }); +} + function buildGroupedChangeTreeNodes(sessions, changes) { const changesBySession = new Map(); const sessionByChangedPath = new Map(); @@ -1183,15 +1255,22 @@ function buildGroupedChangeTreeNodes(sessions, changes) { changesBySession.get(session.branch).push(localizedChange); } - const items = sessions - .map((session) => { - const sessionChanges = changesBySession.get(session.branch) || []; - if (sessionChanges.length === 0) { - return null; - } - return new SessionItem(session, buildChangeTreeNodes(sessionChanges)); - }) - .filter(Boolean); + const items = groupSessionsByWorktree( + sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), + ).map(({ worktreePath, sessions: worktreeSessions }) => { + const sessionItems = worktreeSessions.map((session) => ( + new SessionItem( + session, + buildChangeTreeNodes(changesBySession.get(session.branch) || []), + { label: sessionTreeLabel(session) }, + ) + )); + const changedCount = worktreeSessions.reduce( + (total, session) => total + ((changesBySession.get(session.branch) || []).length), + 0, + ); + return new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }); + }); if (repoRootChanges.length > 0) { items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { @@ -1319,14 +1398,20 @@ function commitWorktree(worktreePath, message) { function buildActiveAgentGroupNodes(sessions) { const groups = []; for (const group of SESSION_ACTIVITY_GROUPS) { - const groupSessions = sessions - .filter((session) => session.activityKind === group.kind) - .map((session) => new SessionItem( - session, - buildChangeTreeNodes(session.touchedChanges || []), - )); - if (groupSessions.length > 0) { - groups.push(new SectionItem(group.label, groupSessions)); + const groupSessions = sessions.filter((session) => session.activityKind === group.kind); + const worktreeItems = groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ( + new WorktreeItem( + worktreePath, + worktreeSessions, + worktreeSessions.map((session) => new SessionItem( + session, + buildChangeTreeNodes(session.touchedChanges || []), + { label: sessionTreeLabel(session) }, + )), + ) + )); + if (worktreeItems.length > 0) { + groups.push(new SectionItem(group.label, worktreeItems)); } } @@ -1488,7 +1573,7 @@ class ActiveAgentsProvider { return sectionItems; } - if (element instanceof SectionItem || element instanceof FolderItem || element instanceof SessionItem) { + if (element instanceof SectionItem || element instanceof FolderItem || element instanceof WorktreeItem || element instanceof SessionItem) { return element.items; } diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 6795d0b..8e1224e 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.", "publisher": "recodeee", - "version": "0.0.6", + "version": "0.0.7", "license": "MIT", "icon": "icon.png", "engines": {