From b9a4e378e69bf71ffbe0134bfc8871466ebe8e32 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 16:21:09 +0200 Subject: [PATCH] Expose nested active-agent subprojects in VS Code The Active Agents view could show the parent recodee workspace while missing plain managed worktrees under nested repos such as gitguardex because discovery only started from active-session files, AGENT.lock files, or the workspace root fallback. This adds managed-worktree .git discovery, keeps workspace roots in the scan, and labels nested repo rows as workspace -> subproject so the top-level tree makes the actual work location visible. Constraint: Plain managed worktrees may have no active-session JSON and no AGENT.lock telemetry Rejected: Only add gitguardex as a hardcoded subproject | would not generalize to other nested repos under the same workspace Confidence: high Scope-risk: narrow Directive: Keep vscode/guardex-active-agents and templates/vscode/guardex-active-agents in lockstep for extension behavior changes Tested: node --check vscode/guardex-active-agents/extension.js && node --check templates/vscode/guardex-active-agents/extension.js Tested: node --test test/vscode-active-agents-session-state.test.js Tested: npm test Tested: openspec validate agent-codex-show-subproject-active-agents-2026-04-23-16-04 --type change --strict Tested: openspec validate --specs Not-tested: Manual VS Code screenshot verification after reinstall --- .../proposal.md | 17 ++++ .../vscode-active-agents-extension/spec.md | 36 +++++++++ .../tasks.md | 38 +++++++++ .../vscode/guardex-active-agents/extension.js | 79 +++++++++++++++++-- ...vscode-active-agents-session-state.test.js | 72 ++++++++++++++++- vscode/guardex-active-agents/extension.js | 79 +++++++++++++++++-- 6 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/proposal.md create mode 100644 openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/tasks.md diff --git a/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/proposal.md b/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/proposal.md new file mode 100644 index 0000000..7ec4b85 --- /dev/null +++ b/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/proposal.md @@ -0,0 +1,17 @@ +# Show nested Active Agents subprojects + +## Why + +The Active Agents view can show the parent workspace, such as `recodee`, while hiding active managed worktrees that belong to a nested repo such as `recodee/gitguardex` when that nested repo has plain managed worktrees but no active-session files or `AGENT.lock` files. Operators need the top-level view to show the full workspace path so it is clear that work is happening under `recodee -> gitguardex`. + +## What Changes + +- Discover nested repo roots from managed worktree `.git` files under `.omx/agent-worktrees` and `.omc/agent-worktrees`. +- Keep workspace roots in the scan even when other session files are found. +- Label nested repo roots relative to their workspace folder, for example `recodee -> gitguardex`. +- Watch managed worktree `.git` files so new plain worktrees refresh the Active Agents view. +- Keep live/template VS Code extension copies and focused tests in sync. + +## Impact + +This is limited to the VS Code Active Agents companion tree. It does not change Guardex branch creation, locking, or finish behavior. diff --git a/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..00bf91f --- /dev/null +++ b/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Nested subproject Active Agents discovery + +The VS Code `gitguardex.activeAgents` view MUST discover nested repository roots under the open workspace when those nested repositories have managed agent worktrees under `.omx/agent-worktrees` or `.omc/agent-worktrees`, even when those worktrees only expose a worktree `.git` file and do not expose active-session JSON or `AGENT.lock` telemetry. + +#### Scenario: Top-level view includes a nested repo with plain managed worktrees + +- **GIVEN** a workspace folder such as `recodee` +- **AND** a nested repository such as `recodee/gitguardex` +- **AND** the nested repository has a managed worktree under `.omx/agent-worktrees` +- **WHEN** the Active Agents view scans the workspace +- **THEN** it includes the nested repository in the top-level repo list +- **AND** it reads the nested repository sessions from that nested repository root. + +### Requirement: Workspace-relative nested repo labels + +The VS Code `gitguardex.activeAgents` view MUST label nested repository roots relative to the open workspace folder so operators can see the workspace-to-subproject path at the top level. + +#### Scenario: Nested repo label shows workspace and subproject + +- **GIVEN** the open workspace folder is `recodee` +- **AND** the discovered active repo root is `recodee/gitguardex` +- **WHEN** the Active Agents view renders the repo row +- **THEN** the repo row label is `recodee -> gitguardex`. + +### Requirement: Managed worktree discovery refresh + +The VS Code `gitguardex.activeAgents` view MUST refresh when managed worktree `.git` files are created, changed, or deleted under `.omx/agent-worktrees` or `.omc/agent-worktrees`. + +#### Scenario: New plain managed worktree appears without active-session telemetry + +- **GIVEN** a nested repository has no active-session JSON and no `AGENT.lock` telemetry +- **WHEN** a managed worktree `.git` file appears under `.omx/agent-worktrees` +- **THEN** the Active Agents view schedules a refresh +- **AND** the nested repository becomes visible if it has readable managed worktree sessions. diff --git a/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/tasks.md b/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/tasks.md new file mode 100644 index 0000000..438de1b --- /dev/null +++ b/openspec/changes/agent-codex-show-subproject-active-agents-2026-04-23-16-04/tasks.md @@ -0,0 +1,38 @@ +## 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-show-subproject-active-agents-2026-04-23-16-04`; branch=`agent/codex/show-subproject-active-agents-2026-04-23-16-04`; scope=`VS Code Active Agents nested subproject discovery, labels, watchers, template parity, and focused regression`; action=`show nested gitguardex-style managed worktrees at top level as workspace -> subproject, verify, then finish via PR merge cleanup`. + +## 1. Specification + +- [x] 1.1 Define nested subproject discovery and labeling requirements. +- [x] 1.2 Keep completion and cleanup evidence requirements explicit. + +## 2. Implementation + +- [x] 2.1 Discover plain managed-worktree repos from `.omx/.omc/agent-worktrees/*/.git`. +- [x] 2.2 Keep workspace roots in the candidate scan while filtering nested managed-worktree copies. +- [x] 2.3 Render nested repo labels as `workspace -> subproject`. +- [x] 2.4 Add a managed-worktree `.git` watcher for refresh. +- [x] 2.5 Mirror extension changes in `templates/vscode/guardex-active-agents/extension.js`. +- [x] 2.6 Bump live/template Active Agents manifests for extension install refresh. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-show-subproject-active-agents-2026-04-23-16-04 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. +- [x] 3.4 Run `npm test`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run `gx branch finish --branch agent/codex/show-subproject-active-agents-2026-04-23-16-04 --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 17b391e..5c573cc 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -17,6 +17,7 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock'; +const MANAGED_WORKTREE_GIT_FILES_GLOB = '**/{.omx,.omc}/agent-worktrees/*/.git'; const MANAGED_WORKTREE_RELATIVE_ROOTS = [ path.join('.omx', 'agent-worktrees'), path.join('.omc', 'agent-worktrees'), @@ -24,6 +25,7 @@ const MANAGED_WORKTREE_RELATIVE_ROOTS = [ const AGENT_LOG_FILES_GLOB = '**/.omx/logs/*.log'; const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; +const MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB = '**/node_modules/**'; const SESSION_SCAN_LIMIT = 200; const REFRESH_DEBOUNCE_MS = 250; const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000; @@ -735,6 +737,30 @@ function buildRepoTooltip(repoRoot, summary) { ].join('\n'); } +function repoRootDisplayLabel(repoRoot) { + const normalizedRepoRoot = path.resolve(repoRoot); + const matchingWorkspaceRoots = (vscode.workspace.workspaceFolders || []) + .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) + .filter((workspaceRoot) => workspaceRoot && isPathWithin(workspaceRoot, normalizedRepoRoot)) + .sort((left, right) => right.length - left.length); + + const workspaceRoot = matchingWorkspaceRoots[0]; + if (!workspaceRoot) { + return path.basename(normalizedRepoRoot); + } + + const workspaceLabel = path.basename(workspaceRoot); + const relativePath = normalizeRelativePath(path.relative(workspaceRoot, normalizedRepoRoot)); + if (!relativePath) { + return workspaceLabel; + } + + return [ + workspaceLabel, + ...relativePath.split('/').filter(Boolean), + ].join(' -> '); +} + function sessionSnapshotKey(session) { return `${session?.repoRoot || ''}::${session?.branch || ''}`; } @@ -1094,7 +1120,10 @@ class DetailItem extends vscode.TreeItem { class RepoItem extends vscode.TreeItem { constructor(repoRoot, sessions, changes, options = {}) { - super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : repoRootDisplayLabel(repoRoot); + super(label, vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; @@ -1684,6 +1713,10 @@ function repoRootFromWorktreeLockFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } +function repoRootFromManagedWorktreeGitFile(filePath) { + return path.resolve(path.dirname(filePath), '..', '..', '..'); +} + function repoRootFromLockFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..'); } @@ -1947,7 +1980,7 @@ function localizeChangeForSession(session, change) { } async function findRepoSessionEntries() { - const [sessionFiles, worktreeLockFiles] = await Promise.all([ + const [sessionFiles, worktreeLockFiles, managedWorktreeGitFiles] = await Promise.all([ vscode.workspace.findFiles( ACTIVE_SESSION_FILES_GLOB, SESSION_SCAN_EXCLUDE_GLOB, @@ -1958,22 +1991,49 @@ async function findRepoSessionEntries() { WORKTREE_LOCK_SCAN_EXCLUDE_GLOB, SESSION_SCAN_LIMIT, ), + vscode.workspace.findFiles( + MANAGED_WORKTREE_GIT_FILES_GLOB, + MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ), ]); const repoRoots = new Set(); + const addRepoRootCandidate = (repoRoot) => { + if (typeof repoRoot !== 'string' || !repoRoot.trim()) { + return; + } + + const normalizedRepoRoot = path.resolve(repoRoot); + const isInsideWorkspaceManagedWorktree = (vscode.workspace.workspaceFolders || []) + .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) + .filter(Boolean) + .some((workspaceRoot) => MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => ( + isPathWithin(path.join(workspaceRoot, relativeRoot), normalizedRepoRoot) + ))); + if (!isInsideWorkspaceManagedWorktree) { + repoRoots.add(normalizedRepoRoot); + } + }; + for (const uri of sessionFiles) { - repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + addRepoRootCandidate(repoRootFromSessionFile(uri.fsPath)); } for (const uri of worktreeLockFiles) { if (path.basename(uri.fsPath) !== 'AGENT.lock') { continue; } - repoRoots.add(repoRootFromWorktreeLockFile(uri.fsPath)); + addRepoRootCandidate(repoRootFromWorktreeLockFile(uri.fsPath)); } - - if (repoRoots.size === 0) { - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - repoRoots.add(workspaceFolder.uri.fsPath); + for (const uri of managedWorktreeGitFiles) { + if (path.basename(uri.fsPath) !== '.git') { + continue; + } + addRepoRootCandidate(repoRootFromManagedWorktreeGitFile(uri.fsPath)); + } + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + if (workspaceFolder?.uri?.fsPath) { + addRepoRootCandidate(workspaceFolder.uri.fsPath); } } @@ -2951,6 +3011,7 @@ function activate(context) { const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB); + const managedWorktreeGitWatcher = vscode.workspace.createFileSystemWatcher(MANAGED_WORKTREE_GIT_FILES_GLOB); const logWatcher = vscode.workspace.createFileSystemWatcher(AGENT_LOG_FILES_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; @@ -3076,6 +3137,7 @@ function activate(context) { activeSessionsWatcher, lockWatcher, worktreeLockWatcher, + managedWorktreeGitWatcher, logWatcher, { dispose: () => clearInterval(interval) }, ); @@ -3084,6 +3146,7 @@ function activate(context) { ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), + ...bindRefreshWatcher(managedWorktreeGitWatcher, scheduleRefresh), ...bindRefreshWatcher(logWatcher, scheduleRefresh), ); void ensureManagedRepoScanIgnores(); diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index dda64c6..c529594 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1387,13 +1387,14 @@ test('active-agents extension registers tree and decoration providers', async () assert.equal(registrations.providers.length, 1); assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.decorationProviders.length, 1); - assert.equal(registrations.fileWatchers.length, 4); + assert.equal(registrations.fileWatchers.length, 5); assert.deepEqual( registrations.fileWatchers.map((watcher) => watcher.pattern), [ '**/.omx/state/active-sessions/*.json', '**/.omx/state/agent-file-locks.json', '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock', + '**/{.omx,.omc}/agent-worktrees/*/.git', '**/.omx/logs/*.log', ], ); @@ -1669,6 +1670,69 @@ test('active-agents extension groups live sessions under a repo node', async () } }); +test('active-agents extension discovers nested managed-worktree subprojects under workspace roots', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-subprojects-')); + const nestedRepoRoot = path.join(tempRoot, 'gitguardex'); + initGitRepo(nestedRepoRoot); + fs.writeFileSync(path.join(nestedRepoRoot, 'tracked.txt'), 'base\n', 'utf8'); + runGit(nestedRepoRoot, ['add', 'tracked.txt']); + runGit(nestedRepoRoot, ['commit', '-m', 'baseline']); + + const worktreePath = path.join( + nestedRepoRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__nested-visible-task', + ); + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + runGit(nestedRepoRoot, [ + 'worktree', + 'add', + '-b', + 'agent/codex/nested-visible-task', + worktreePath, + ]); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); + + const managedWorktreeGitFile = path.join(worktreePath, '.git'); + assert.equal(fs.statSync(managedWorktreeGitFile).isFile(), true); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async (pattern) => { + if (pattern === '**/{.omx,.omc}/agent-worktrees/*/.git') { + return [{ fsPath: managedWorktreeGitFile }]; + } + return []; + }; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + assert.equal(repoItem.label, `${path.basename(tempRoot)} -> gitguardex`); + assert.equal(repoItem.repoRoot, nestedRepoRoot); + assert.equal(repoItem.description, '1 working agent · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); + assert.equal(worktreeItem, null); + assert.equal(sessionItem.session.repoRoot, nestedRepoRoot); + assert.equal(sessionItem.session.worktreePath, worktreePath); + assert.equal(sessionItem.session.branch, 'agent/codex/nested-visible-task'); + assert.match(sessionItem.description, /^Working: codex · via OpenAI · 1 changed file/); + assert.deepEqual(registrations.treeViews[0].badge, { + value: 1, + tooltip: repoItem.description, + }); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension shows provider and snapshot identity badges', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-provider-badges-')); const codexWorktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-provider-codex-')); @@ -2686,6 +2750,7 @@ test('active-agents extension watches active sessions, lock files, logs, and ses '**/.omx/state/active-sessions/*.json', '**/.omx/state/agent-file-locks.json', '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock', + '**/{.omx,.omc}/agent-worktrees/*/.git', '**/.omx/logs/*.log', path.join(worktreePath, '.git', 'index'), ], @@ -2697,7 +2762,7 @@ test('active-agents extension watches active sessions, lock files, logs, and ses await new Promise((resolve) => setTimeout(resolve, 350)); await flushAsyncWork(); - assert.equal(registrations.fileWatchers[4].disposed, true); + assert.equal(registrations.fileWatchers[5].disposed, true); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -2719,7 +2784,8 @@ test('active-agents extension debounces refresh events with a trailing 250ms tim registrations.fileWatchers[0].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'active-sessions', 'a.json') }); registrations.fileWatchers[1].fireChange({ fsPath: path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json') }); registrations.fileWatchers[2].fireChange({ fsPath: path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__a', 'AGENT.lock') }); - registrations.fileWatchers[3].fireChange({ fsPath: path.join(tempRoot, '.omx', 'logs', 'agent-agent__codex__a.log') }); + registrations.fileWatchers[3].fireChange({ fsPath: path.join(tempRoot, '.omx', 'agent-worktrees', 'agent__codex__a', '.git') }); + registrations.fileWatchers[4].fireChange({ fsPath: path.join(tempRoot, '.omx', 'logs', 'agent-agent__codex__a.log') }); assert.equal(provider.onDidChangeTreeDataEmitter.fireCount, 0); await new Promise((resolve) => setTimeout(resolve, 300)); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 17b391e..5c573cc 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -17,6 +17,7 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock'; +const MANAGED_WORKTREE_GIT_FILES_GLOB = '**/{.omx,.omc}/agent-worktrees/*/.git'; const MANAGED_WORKTREE_RELATIVE_ROOTS = [ path.join('.omx', 'agent-worktrees'), path.join('.omc', 'agent-worktrees'), @@ -24,6 +25,7 @@ const MANAGED_WORKTREE_RELATIVE_ROOTS = [ const AGENT_LOG_FILES_GLOB = '**/.omx/logs/*.log'; const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; +const MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB = '**/node_modules/**'; const SESSION_SCAN_LIMIT = 200; const REFRESH_DEBOUNCE_MS = 250; const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000; @@ -735,6 +737,30 @@ function buildRepoTooltip(repoRoot, summary) { ].join('\n'); } +function repoRootDisplayLabel(repoRoot) { + const normalizedRepoRoot = path.resolve(repoRoot); + const matchingWorkspaceRoots = (vscode.workspace.workspaceFolders || []) + .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) + .filter((workspaceRoot) => workspaceRoot && isPathWithin(workspaceRoot, normalizedRepoRoot)) + .sort((left, right) => right.length - left.length); + + const workspaceRoot = matchingWorkspaceRoots[0]; + if (!workspaceRoot) { + return path.basename(normalizedRepoRoot); + } + + const workspaceLabel = path.basename(workspaceRoot); + const relativePath = normalizeRelativePath(path.relative(workspaceRoot, normalizedRepoRoot)); + if (!relativePath) { + return workspaceLabel; + } + + return [ + workspaceLabel, + ...relativePath.split('/').filter(Boolean), + ].join(' -> '); +} + function sessionSnapshotKey(session) { return `${session?.repoRoot || ''}::${session?.branch || ''}`; } @@ -1094,7 +1120,10 @@ class DetailItem extends vscode.TreeItem { class RepoItem extends vscode.TreeItem { constructor(repoRoot, sessions, changes, options = {}) { - super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : repoRootDisplayLabel(repoRoot); + super(label, vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; @@ -1684,6 +1713,10 @@ function repoRootFromWorktreeLockFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } +function repoRootFromManagedWorktreeGitFile(filePath) { + return path.resolve(path.dirname(filePath), '..', '..', '..'); +} + function repoRootFromLockFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..'); } @@ -1947,7 +1980,7 @@ function localizeChangeForSession(session, change) { } async function findRepoSessionEntries() { - const [sessionFiles, worktreeLockFiles] = await Promise.all([ + const [sessionFiles, worktreeLockFiles, managedWorktreeGitFiles] = await Promise.all([ vscode.workspace.findFiles( ACTIVE_SESSION_FILES_GLOB, SESSION_SCAN_EXCLUDE_GLOB, @@ -1958,22 +1991,49 @@ async function findRepoSessionEntries() { WORKTREE_LOCK_SCAN_EXCLUDE_GLOB, SESSION_SCAN_LIMIT, ), + vscode.workspace.findFiles( + MANAGED_WORKTREE_GIT_FILES_GLOB, + MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB, + SESSION_SCAN_LIMIT, + ), ]); const repoRoots = new Set(); + const addRepoRootCandidate = (repoRoot) => { + if (typeof repoRoot !== 'string' || !repoRoot.trim()) { + return; + } + + const normalizedRepoRoot = path.resolve(repoRoot); + const isInsideWorkspaceManagedWorktree = (vscode.workspace.workspaceFolders || []) + .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : '')) + .filter(Boolean) + .some((workspaceRoot) => MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => ( + isPathWithin(path.join(workspaceRoot, relativeRoot), normalizedRepoRoot) + ))); + if (!isInsideWorkspaceManagedWorktree) { + repoRoots.add(normalizedRepoRoot); + } + }; + for (const uri of sessionFiles) { - repoRoots.add(repoRootFromSessionFile(uri.fsPath)); + addRepoRootCandidate(repoRootFromSessionFile(uri.fsPath)); } for (const uri of worktreeLockFiles) { if (path.basename(uri.fsPath) !== 'AGENT.lock') { continue; } - repoRoots.add(repoRootFromWorktreeLockFile(uri.fsPath)); + addRepoRootCandidate(repoRootFromWorktreeLockFile(uri.fsPath)); } - - if (repoRoots.size === 0) { - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - repoRoots.add(workspaceFolder.uri.fsPath); + for (const uri of managedWorktreeGitFiles) { + if (path.basename(uri.fsPath) !== '.git') { + continue; + } + addRepoRootCandidate(repoRootFromManagedWorktreeGitFile(uri.fsPath)); + } + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + if (workspaceFolder?.uri?.fsPath) { + addRepoRootCandidate(workspaceFolder.uri.fsPath); } } @@ -2951,6 +3011,7 @@ function activate(context) { const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB); + const managedWorktreeGitWatcher = vscode.workspace.createFileSystemWatcher(MANAGED_WORKTREE_GIT_FILES_GLOB); const logWatcher = vscode.workspace.createFileSystemWatcher(AGENT_LOG_FILES_GLOB); const updateCommitInput = (session) => { sourceControl.inputBox.enabled = true; @@ -3076,6 +3137,7 @@ function activate(context) { activeSessionsWatcher, lockWatcher, worktreeLockWatcher, + managedWorktreeGitWatcher, logWatcher, { dispose: () => clearInterval(interval) }, ); @@ -3084,6 +3146,7 @@ function activate(context) { ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh), ...bindRefreshWatcher(lockWatcher, refreshLockRegistry), ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), + ...bindRefreshWatcher(managedWorktreeGitWatcher, scheduleRefresh), ...bindRefreshWatcher(logWatcher, scheduleRefresh), ); void ensureManagedRepoScanIgnores();