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": {