From 04ddbf95eaeddc110903c35ebeac639400ac84af Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 13:11:41 +0200 Subject: [PATCH] Make Active Agents scan by task first Dense sandbox views were still organized around raw worktree and path trees, which made the operator scan path too slow when several sessions were live. This change promotes an overview plus working/idle task-card sections while preserving the older raw trees behind advanced disclosure. Constraint: Live and template VS Code companion sources must remain byte-aligned. Rejected: Keep ACTIVE AGENTS and CHANGES as primary sections | the new acceptance criteria requires task-first triage before raw path browsing. Confidence: high Scope-risk: moderate Directive: Do not remove Advanced details unless replacement coverage preserves raw worktree/path inspection. Tested: node --test test/vscode-active-agents-session-state.test.js Tested: npm test Tested: node --check vscode/guardex-active-agents/extension.js Tested: node --check templates/vscode/guardex-active-agents/extension.js Tested: openspec validate agent-codex-active-agents-task-first-layout-2026-04-23-12-29 --type change --strict Tested: openspec validate --specs --- .../proposal.md | 16 + .../spec.md | 29 + .../tasks.md | 33 + .../vscode/guardex-active-agents/extension.js | 779 +++++++++++++++--- ...vscode-active-agents-session-state.test.js | 259 +++--- vscode/guardex-active-agents/extension.js | 779 +++++++++++++++--- 6 files changed, 1544 insertions(+), 351 deletions(-) create mode 100644 openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/proposal.md create mode 100644 openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/specs/vscode-active-agents-task-first-layout/spec.md create mode 100644 openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/tasks.md diff --git a/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/proposal.md b/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/proposal.md new file mode 100644 index 0000000..1b49bc6 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/proposal.md @@ -0,0 +1,16 @@ +## Why + +The VS Code Active Agents panel already exposes working-state groups, lock state, inspect actions, and session-owned changes, but the main tree is still optimized for filesystem grouping instead of operator triage. When several lanes are active at once, the user still has to drill through worktree grouping and raw change trees before they can answer the high-value questions: who is active now, what task each lane owns, what changed recently, what is risky, and which repo drift is unassigned. + +## What Changes + +- Replace the primary repo view with a task-first operator layout: `Overview`, `Working now`, `Idle / thinking`, `Unassigned changes`, and `Advanced details`. +- Render active sessions as compact task rows that prioritize task title, agent, state, changed-file count, lock count, freshness, recent-change summary, and inline risk markers. +- Move raw worktree/path trees behind a collapsed `Advanced details` section instead of using them as the default scan path. +- Surface repo-level overview counts for working lanes, idle lanes, unassigned changes, locked files, and conflicts. +- Update the focused Active Agents tests and extension manifests for the new layout contract. + +## Impact + +- Affected surfaces: `vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, `test/vscode-active-agents-session-state.test.js`, and this change workspace. +- No session-file schema or launcher contract changes; the panel still reads the existing Active Agents, lock-registry, and inspect data. diff --git a/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/specs/vscode-active-agents-task-first-layout/spec.md b/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/specs/vscode-active-agents-task-first-layout/spec.md new file mode 100644 index 0000000..b463e87 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/specs/vscode-active-agents-task-first-layout/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Active Agents defaults to an operator-first task layout +The VS Code Active Agents companion SHALL prioritize active task ownership and risk scanning over raw worktree and folder completeness. + +#### Scenario: Repo view shows task-first operator sections +- **WHEN** a repo has one or more visible Guardex sessions +- **THEN** the repo view contains an `Overview` section +- **AND** it contains a `Working now` section above `Idle / thinking` +- **AND** it contains an `Unassigned changes` section when repo drift is not clearly owned by a live session +- **AND** it keeps raw worktree/path trees under a collapsed `Advanced details` section rather than as the default primary view. + +### Requirement: Session rows summarize work and risk inline +The VS Code Active Agents companion SHALL render each session row as a compact task-first summary instead of leading with branch or folder grouping. + +#### Scenario: Working row answers operator triage questions +- **WHEN** a session appears in `Working now` or `Idle / thinking` +- **THEN** its primary label is the task title or best available task summary +- **AND** its row description includes the agent name, session state, changed-file count when present, lock count when present, and a human-readable freshness label +- **AND** expanding the row reveals recent-change summary, top changed files, and branch/worktree details +- **AND** conflicts, stale state, lock ownership, or refresh deltas appear inline or in the expanded summary without requiring the raw path tree first. + +### Requirement: Repo overview summarizes actionable counts +The VS Code Active Agents companion SHALL expose repo-level counts for high-value operator decisions. + +#### Scenario: Overview row reports working, idle, unassigned, locked, and conflict counts +- **WHEN** the provider refreshes a repo entry +- **THEN** the overview surface reports working-agent count, idle-agent count, unassigned-change count, locked-file count, and conflict count +- **AND** the repo row and badge tooltip reuse those counts instead of only reporting active/dead totals. diff --git a/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/tasks.md b/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/tasks.md new file mode 100644 index 0000000..2b22224 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-task-first-layout-2026-04-23-12-29/tasks.md @@ -0,0 +1,33 @@ +## 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: 2026-04-23 12:29Z codex owns `vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, `test/vscode-active-agents-session-state.test.js`, and this change workspace to ship a task-first Active Agents panel layout for dense multi-agent repo triage. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-active-agents-task-first-layout-2026-04-23-12-29`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-task-first-layout/spec.md`. + +## 2. Implementation + +- [x] 2.1 Replace the default repo tree with task-first operator sections for overview, working now, idle / thinking, unassigned changes, and advanced details. +- [x] 2.2 Render session rows as compact task cards with task title, agent, state, file/lock counts, freshness, recent-change detail, and inline risk summaries. +- [x] 2.3 Keep raw path/tree detail behind collapsed advanced disclosure and update the focused tests plus mirrored template sources. +- [x] 2.4 Bump the live/template Active Agents manifest versions for the new shipped layout. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-active-agents-task-first-layout-2026-04-23-12-29 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup + +- [ ] 4.1 Run `gx branch finish --branch "agent/codex/active-agents-task-first-layout-2026-04-23-12-29" --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 7c962c6..255344c 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -22,6 +22,8 @@ const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.o const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; const SESSION_SCAN_LIMIT = 200; const REFRESH_DEBOUNCE_MS = 250; +const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000; +const SESSION_TOP_FILE_COUNT = 3; const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agents', 'package.json'); const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js'); const RELOAD_WINDOW_ACTION = 'Reload Window'; @@ -224,9 +226,10 @@ function agentBadgeFromBranch(branch) { } function buildActiveAgentsStatusSummary(summary) { - const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); - if (activeCount > 0) { - return `$(git-branch) ${formatCountLabel(activeCount, 'active agent')}`; + const workingCount = summary?.workingCount || 0; + const idleCount = summary?.idleCount || 0; + if (workingCount > 0 || idleCount > 0) { + return `$(git-branch) ${workingCount} working · ${idleCount} idle`; } return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`; } @@ -246,11 +249,375 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) { return [ formatCountLabel(activeCount, 'active agent'), formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), + formatCountLabel(summary?.idleCount || 0, 'idle session'), + formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), + formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', 'Click to open Source Control.', ].filter(Boolean).join('\n'); } +function compactRelativePath(relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (!normalized) { + return ''; + } + + const segments = normalized.split('/').filter(Boolean); + if (segments.length <= 2) { + return normalized; + } + + return `${segments[0]}/.../${segments[segments.length - 1]}`; +} + +function summarizeCompactPaths(paths, maxCount = SESSION_TOP_FILE_COUNT) { + const compactPaths = uniqueStringList((paths || []) + .map(normalizeRelativePath) + .filter(Boolean) + .map((relativePath) => compactRelativePath(relativePath))) + .slice(0, maxCount); + if (compactPaths.length === 0) { + return ''; + } + return compactPaths.join(', '); +} + +function isProtectedBranchName(branch) { + return branch === 'main' || branch === 'dev'; +} + +function countWorkingSessions(sessions) { + return sessions.filter((session) => ( + session.activityKind === 'working' || session.activityKind === 'blocked' + )).length; +} + +function countIdleSessions(sessions) { + return sessions.filter((session) => ( + session.activityKind === 'idle' || session.activityKind === 'stalled' + )).length; +} + +function sessionLastActiveAt(session) { + return [ + session?.lastHeartbeatAt, + session?.lastFileActivityAt, + session?.telemetryUpdatedAt, + session?.startedAt, + ].find((value) => typeof value === 'string' && value.trim().length > 0) || ''; +} + +function sessionLastActiveLabel(session) { + const lastActiveAt = sessionLastActiveAt(session); + if (!lastActiveAt) { + return ''; + } + return formatElapsedFrom(lastActiveAt); +} + +function sessionLastActiveAgeMs(session, now = Date.now()) { + const lastActiveAt = sessionLastActiveAt(session); + const timestamp = Date.parse(lastActiveAt); + if (!Number.isFinite(timestamp)) { + return null; + } + return Math.max(0, now - timestamp); +} + +function sessionFreshnessLabel(session, now = Date.now()) { + const ageMs = sessionLastActiveAgeMs(session, now); + if (session.activityKind === 'blocked') { + return 'Needs attention'; + } + if (session.activityKind === 'stalled') { + return 'Possibly stale'; + } + if (session.activityKind === 'dead') { + return 'Stopped'; + } + if (ageMs === null) { + return ''; + } + if (ageMs <= IDLE_WARNING_MS) { + return 'Fresh'; + } + if (ageMs <= RECENTLY_ACTIVE_WINDOW_MS) { + return 'Recently active'; + } + if (session.activityKind === 'idle') { + return 'Idle'; + } + return 'Recently active'; +} + +function sessionStatusLabel(session) { + switch (session.activityKind) { + case 'blocked': + return 'Blocked'; + case 'working': + return 'Working'; + case 'idle': + return 'Idle'; + case 'stalled': + return 'Stale'; + case 'dead': + return 'Dead'; + default: + return 'Thinking'; + } +} + +function buildSessionTopFiles(session) { + return uniqueStringList((session?.worktreeChangedPaths || []) + .map(normalizeRelativePath) + .filter(Boolean)) + .slice(0, SESSION_TOP_FILE_COUNT); +} + +function buildSessionRecentChangeSummary(session) { + if (session?.latestTaskPreview && session.latestTaskPreview !== session.taskName) { + return session.latestTaskPreview; + } + const topFiles = summarizeCompactPaths(session?.worktreeChangedPaths || []); + if (topFiles) { + return `Changed ${topFiles}`; + } + if (session?.activitySummary) { + return session.activitySummary; + } + return 'No recent change summary.'; +} + +function sessionRiskBadges(session) { + return uniqueStringList([ + session?.activityKind === 'blocked' ? 'Blocked' : '', + session?.activityKind === 'stalled' ? 'Stale' : '', + session?.conflictCount > 0 ? 'Conflict' : '', + session?.lockCount > 0 ? 'Locked' : '', + ].filter(Boolean)); +} + +function changeRiskBadges(change) { + return uniqueStringList([ + change?.protectedBranch ? 'Protected branch' : '', + change?.hasForeignLock ? 'Conflict' : '', + !change?.hasForeignLock && change?.lockOwnerBranch ? 'Locked' : '', + change?.deltaLabel || '', + ].filter(Boolean)); +} + +function buildSessionCardDescription(session) { + const descriptionParts = [ + session.agentName || 'agent', + sessionStatusLabel(session), + session.deltaLabel || '', + session.changeCount > 0 ? formatCountLabel(session.changeCount, 'changed file') : '', + session.lockCount > 0 ? formatCountLabel(session.lockCount, 'lock') : '', + session.freshnessLabel || '', + session.lastActiveLabel ? `${session.lastActiveLabel} ago` : '', + ].filter(Boolean); + return descriptionParts.join(' · '); +} + +function buildRawSessionDescription(session) { + const descriptionParts = [session.activityLabel || 'thinking']; + if (session.activityCountLabel) { + descriptionParts.push(session.activityCountLabel); + } + descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); + if (session.lockCount > 0) { + descriptionParts.push(formatCountLabel(session.lockCount, 'lock')); + } + return descriptionParts.join(' · '); +} + +function buildSessionTooltip(session, description) { + const riskSummary = uniqueStringList([ + ...(session?.riskBadges || []), + session?.deltaLabel || '', + ].filter(Boolean)).join(', '); + const topFiles = session?.topChangedFilesLabel || summarizeCompactPaths(session?.worktreeChangedPaths || []); + return [ + session.branch, + `${session.agentName} · ${session.taskName}`, + `Status ${description}`, + session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', + topFiles ? `Top files ${topFiles}` : '', + riskSummary ? `Signals ${riskSummary}` : '', + session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', + session.lastActiveAt ? `Last active ${session.lastActiveAt}` : '', + session.sourceKind === 'worktree-lock' + ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` + : `Started ${session.startedAt}`, + session.worktreePath, + ].filter(Boolean).join('\n'); +} + +function buildUnassignedChangeDescription(change) { + return [ + change.statusLabel, + ...changeRiskBadges(change), + ].filter(Boolean).join(' · '); +} + +function buildOverviewDescription(summary) { + return [ + formatCountLabel(summary?.workingCount || 0, 'working agent'), + formatCountLabel(summary?.idleCount || 0, 'idle agent'), + formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), + formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), + formatCountLabel(summary?.conflictCount || 0, 'conflict'), + ].join(' · '); +} + +function buildRepoDescription(summary) { + return buildOverviewDescription(summary); +} + +function buildRepoTooltip(repoRoot, summary) { + return [ + repoRoot, + buildOverviewDescription(summary), + ].join('\n'); +} + +function sessionSnapshotKey(session) { + return `${session?.repoRoot || ''}::${session?.branch || ''}`; +} + +function changeSnapshotKey(repoRoot, change) { + return `${repoRoot || ''}::${normalizeRelativePath(change?.relativePath)}`; +} + +function buildSessionSnapshot(session) { + return { + activityKind: session.activityKind, + changeCount: session.changeCount || 0, + conflictCount: session.conflictCount || 0, + lockCount: session.lockCount || 0, + changedPaths: [...(session.changedPaths || [])], + }; +} + +function buildChangeSnapshot(change) { + return { + statusLabel: change.statusLabel, + hasForeignLock: Boolean(change.hasForeignLock), + lockOwnerBranch: change.lockOwnerBranch || '', + }; +} + +function deriveSessionDelta(previousSnapshot, currentSession) { + if (!previousSnapshot) { + return ''; + } + if (currentSession.conflictCount > previousSnapshot.conflictCount) { + return 'Conflict'; + } + if (currentSession.activityKind !== previousSnapshot.activityKind) { + return sessionStatusLabel(currentSession); + } + if ( + currentSession.changeCount !== previousSnapshot.changeCount + || !stringListsEqual(currentSession.changedPaths || [], previousSnapshot.changedPaths || []) + ) { + return 'New'; + } + if (currentSession.lockCount !== previousSnapshot.lockCount) { + return 'Updated'; + } + return ''; +} + +function deriveChangeDelta(previousSnapshot, currentChange) { + if (!previousSnapshot) { + return ''; + } + if (currentChange.hasForeignLock && !previousSnapshot.hasForeignLock) { + return 'Conflict'; + } + if ( + currentChange.statusLabel !== previousSnapshot.statusLabel + || currentChange.lockOwnerBranch !== previousSnapshot.lockOwnerBranch + ) { + return 'Updated'; + } + return ''; +} + +function workingSessionSortKey(session) { + if (session.activityKind === 'blocked') { + return 0; + } + if (session.conflictCount > 0) { + return 1; + } + if (session.deltaLabel === 'Conflict') { + return 2; + } + if (session.deltaLabel === 'New') { + return 3; + } + return 4; +} + +function idleSessionSortKey(session) { + if (session.activityKind === 'stalled') { + return 0; + } + if (session.activityKind === 'idle') { + return 1; + } + if (session.activityKind === 'dead') { + return 2; + } + return 3; +} + +function sortSessionsForWorkingNow(sessions) { + return [...sessions].sort((left, right) => { + const keyDelta = workingSessionSortKey(left) - workingSessionSortKey(right); + if (keyDelta !== 0) { + return keyDelta; + } + const timeDelta = sessionLastActiveAgeMs(left) - sessionLastActiveAgeMs(right); + if (Number.isFinite(timeDelta) && timeDelta !== 0) { + return timeDelta; + } + const changeDelta = (right.changeCount || 0) - (left.changeCount || 0); + if (changeDelta !== 0) { + return changeDelta; + } + return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); + }); +} + +function sortSessionsForIdleThinking(sessions) { + return [...sessions].sort((left, right) => { + const keyDelta = idleSessionSortKey(left) - idleSessionSortKey(right); + if (keyDelta !== 0) { + return keyDelta; + } + const timeDelta = sessionLastActiveAgeMs(right) - sessionLastActiveAgeMs(left); + if (Number.isFinite(timeDelta) && timeDelta !== 0) { + return timeDelta; + } + return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); + }); +} + +function sortUnassignedChanges(changes) { + return [...changes].sort((left, right) => { + const leftBadges = changeRiskBadges(left).length; + const rightBadges = changeRiskBadges(right).length; + if (leftBadges !== rightBadges) { + return rightBadges - leftBadges; + } + return normalizeRelativePath(left.relativePath).localeCompare(normalizeRelativePath(right.relativePath)); + }); +} + function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') @@ -454,37 +821,30 @@ class InfoItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); this.description = description; this.iconPath = new vscode.ThemeIcon('info'); + this.tooltip = [label, description].filter(Boolean).join('\n'); + } +} + +class DetailItem extends vscode.TreeItem { + constructor(label, description = '', options = {}) { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = description; + this.tooltip = options.tooltip || [label, description].filter(Boolean).join('\n'); + this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; } } class RepoItem extends vscode.TreeItem { - constructor(repoRoot, sessions, changes) { + constructor(repoRoot, sessions, changes, options = {}) { super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; - const descriptionParts = []; - const activeCount = countActiveSessions(sessions); - const deadCount = countSessionsByActivityKind(sessions, 'dead'); - const workingCount = countWorkingSessions(sessions); - if (activeCount > 0) { - descriptionParts.push(`${activeCount} active`); - } - if (deadCount > 0) { - descriptionParts.push(`${deadCount} dead`); - } - if (workingCount > 0) { - descriptionParts.push(`${workingCount} working`); - } - const changedCount = countChangedPaths(repoRoot, sessions, changes); - if (changedCount > 0) { - descriptionParts.push(`${changedCount} changed`); - } - this.description = descriptionParts.join(' · '); - this.tooltip = [ - repoRoot, - this.description, - ].join('\n'); + this.unassignedChanges = options.unassignedChanges || []; + this.lockEntries = options.lockEntries || []; + this.overview = options.overview || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries); + this.description = buildRepoDescription(this.overview); + this.tooltip = buildRepoTooltip(repoRoot, this.overview); this.iconPath = new vscode.ThemeIcon('repo'); this.contextValue = 'gitguardex.repo'; } @@ -492,10 +852,15 @@ class RepoItem extends vscode.TreeItem { class SectionItem extends vscode.TreeItem { constructor(label, items, options = {}) { - super(label, vscode.TreeItemCollapsibleState.Expanded); + const collapsibleState = items.length > 0 + ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded) + : vscode.TreeItemCollapsibleState.None; + super(label, collapsibleState); this.items = items; this.description = options.description || (items.length > 0 ? String(items.length) : ''); + this.tooltip = options.tooltip || [label, this.description].filter(Boolean).join('\n'); + this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; this.contextValue = 'gitguardex.section'; } } @@ -537,50 +902,28 @@ class WorktreeItem extends vscode.TreeItem { class SessionItem extends vscode.TreeItem { constructor(session, items = [], options = {}) { - const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; + const variant = options.variant === 'raw' ? 'raw' : 'card'; const label = typeof options.label === 'string' && options.label.trim() ? options.label.trim() - : session.label; + : (variant === 'raw' ? session.label : sessionDisplayLabel(session)); + const collapsibleState = items.length > 0 + ? (options.collapsedState ?? ( + variant === 'raw' + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed + )) + : vscode.TreeItemCollapsibleState.None; super( label, - items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + collapsibleState, ); this.session = session; this.items = items; this.resourceUri = sessionDecorationUri(session.branch); - const descriptionParts = [session.activityLabel || 'thinking']; - if (session.activityCountLabel) { - descriptionParts.push(session.activityCountLabel); - } - descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); - if (lockCount > 0) { - descriptionParts.push(`${lockCount} $(lock)`); - } - this.description = descriptionParts.join(' · '); - const tooltipLines = [ - session.branch, - `${session.agentName} · ${session.taskName}`, - session.latestTaskPreview && session.latestTaskPreview !== session.taskName - ? `Live task ${session.latestTaskPreview}` - : '', - `Status ${this.description}`, - session.changeCount > 0 - ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` - : session.activitySummary, - `Locks ${lockCount}`, - session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', - Number.isInteger(session.pid) && session.pid > 0 - ? session.pidAlive === false - ? `PID ${session.pid} not alive` - : `PID ${session.pid} alive` - : '', - session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', - session.sourceKind === 'worktree-lock' - ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` - : `Started ${session.startedAt}`, - session.worktreePath, - ]; - this.tooltip = tooltipLines.filter(Boolean).join('\n'); + this.description = variant === 'raw' + ? buildRawSessionDescription(session) + : buildSessionCardDescription(session); + this.tooltip = buildSessionTooltip(session, this.description); this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { @@ -603,20 +946,26 @@ class FolderItem extends vscode.TreeItem { } class ChangeItem extends vscode.TreeItem { - constructor(change) { - super(path.basename(change.relativePath), vscode.TreeItemCollapsibleState.None); + constructor(change, options = {}) { + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : path.basename(change.relativePath); + super(label, vscode.TreeItemCollapsibleState.None); this.change = change; - this.description = change.statusLabel; + this.description = typeof options.description === 'string' + ? options.description + : change.statusLabel; this.tooltip = [ change.relativePath, + `Summary ${this.description}`, `Status ${change.statusText}`, change.originalPath ? `Renamed from ${change.originalPath}` : '', change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '', change.absolutePath, ].filter(Boolean).join('\n'); this.resourceUri = vscode.Uri.file(change.absolutePath); - if (change.hasForeignLock) { - this.iconPath = new vscode.ThemeIcon('warning'); + if (options.iconId || change.hasForeignLock) { + this.iconPath = new vscode.ThemeIcon(options.iconId || 'warning'); } this.contextValue = 'gitguardex.change'; this.command = { @@ -1031,22 +1380,33 @@ async function maybeAutoUpdateActiveAgentsExtension(context) { function decorateSession(session, lockRegistry) { const touchedChanges = buildSessionTouchedChanges(session, lockRegistry); - return { + const decorated = { ...session, lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, touchedChanges, conflictCount: touchedChanges.filter((change) => change.hasForeignLock).length, }; + decorated.lastActiveAt = sessionLastActiveAt(decorated); + decorated.lastActiveLabel = sessionLastActiveLabel(decorated); + decorated.freshnessLabel = sessionFreshnessLabel(decorated); + decorated.topChangedFiles = buildSessionTopFiles(decorated); + decorated.topChangedFilesLabel = summarizeCompactPaths(decorated.topChangedFiles); + decorated.recentChangeSummary = buildSessionRecentChangeSummary(decorated); + decorated.riskBadges = sessionRiskBadges(decorated); + return decorated; } function decorateChange(change, lockRegistry, owningBranch) { const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath)); const lockOwnerBranch = lockEntry?.branch || ''; - return { + const decorated = { ...change, lockOwnerBranch, hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch), + protectedBranch: isProtectedBranchName(owningBranch), }; + decorated.riskBadges = changeRiskBadges(decorated); + return decorated; } function buildSessionTouchedChanges(session, lockRegistry) { @@ -1054,7 +1414,6 @@ function buildSessionTouchedChanges(session, lockRegistry) { ? session.worktreeChangedPaths : []; return [...new Set(changedPaths.map(normalizeRelativePath).filter(Boolean))] - .sort((left, right) => left.localeCompare(right)) .map((relativePath) => { const lockEntry = lockRegistry.entriesByPath.get(relativePath); const lockOwnerBranch = lockEntry?.branch || ''; @@ -1243,10 +1602,6 @@ function buildChangeTreeNodes(changes) { return materialize(root); } -function countWorkingSessions(sessions) { - return sessions.filter((session) => session.activityKind === 'working').length; -} - function countChangedPaths(repoRoot, sessions, changes) { const changedKeys = new Set(); @@ -1272,6 +1627,20 @@ function countChangedPaths(repoRoot, sessions, changes) { return changedKeys.size; } +function buildRepoOverview(sessions, unassignedChanges, lockEntries) { + return { + sessionCount: sessions.length, + workingCount: countWorkingSessions(sessions), + idleCount: countIdleSessions(sessions), + unassignedChangeCount: (unassignedChanges || []).length, + lockedFileCount: Array.isArray(lockEntries) ? lockEntries.length : 0, + conflictCount: sessions.reduce( + (total, session) => total + (session.conflictCount || 0), + 0, + ) + (unassignedChanges || []).filter((change) => change.hasForeignLock).length, + }; +} + function groupSessionsByWorktree(sessions) { const sessionsByWorktree = new Map(); @@ -1302,7 +1671,7 @@ function groupSessionsByWorktree(sessions) { }); } -function buildGroupedChangeTreeNodes(sessions, changes) { +function partitionChangesByOwnership(sessions, changes) { const changesBySession = new Map(); const sessionByChangedPath = new Map(); const repoRootChanges = []; @@ -1334,6 +1703,15 @@ function buildGroupedChangeTreeNodes(sessions, changes) { changesBySession.get(session.branch).push(localizedChange); } + return { + changesBySession, + repoRootChanges, + }; +} + +function buildGroupedChangeTreeNodes(sessions, changes) { + const { changesBySession, repoRootChanges } = partitionChangesByOwnership(sessions, changes); + const items = groupSessionsByWorktree( sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), ).map(({ worktreePath, sessions: worktreeSessions }) => { @@ -1341,7 +1719,10 @@ function buildGroupedChangeTreeNodes(sessions, changes) { new SessionItem( session, buildChangeTreeNodes(changesBySession.get(session.branch) || []), - { label: sessionTreeLabel(session) }, + { + label: sessionTreeLabel(session), + variant: 'raw', + }, ) )); const changedCount = worktreeSessions.reduce( @@ -1474,7 +1855,59 @@ function commitWorktree(worktreePath, message) { runGitCommand(worktreePath, ['commit', '-m', message]); } -function buildActiveAgentGroupNodes(sessions) { +function buildSessionDetailItems(session) { + const items = [ + new DetailItem('Recent change', session.recentChangeSummary || 'No recent change summary.', { + iconId: 'history', + }), + new DetailItem('Top files', session.topChangedFilesLabel || 'No tracked file edits.', { + iconId: 'list-flat', + }), + new DetailItem('Branch', session.branch, { + iconId: 'git-branch', + }), + new DetailItem('Worktree', session.worktreePath, { + iconId: 'folder', + tooltip: session.worktreePath, + }), + ]; + const badgeSummary = uniqueStringList([ + ...(session.riskBadges || []), + session.deltaLabel || '', + ].filter(Boolean)).join(', '); + if (badgeSummary) { + items.splice(2, 0, new DetailItem('Signals', badgeSummary, { + iconId: 'warning', + })); + } + return items; +} + +function buildWorkingNowNodes(sessions) { + return sortSessionsForWorkingNow( + sessions.filter((session) => ( + session.activityKind === 'working' || session.activityKind === 'blocked' + )), + ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); +} + +function buildIdleThinkingNodes(sessions) { + return sortSessionsForIdleThinking( + sessions.filter((session) => !( + session.activityKind === 'working' || session.activityKind === 'blocked' + )), + ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); +} + +function buildUnassignedChangeNodes(changes) { + return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { + label: compactRelativePath(change.relativePath), + description: buildUnassignedChangeDescription(change), + iconId: changeRiskBadges(change).length > 0 ? 'warning' : undefined, + })); +} + +function buildRawActiveAgentGroupNodes(sessions) { const groups = []; for (const group of SESSION_ACTIVITY_GROUPS) { const groupSessions = sessions.filter((session) => session.activityKind === group.kind); @@ -1485,7 +1918,10 @@ function buildActiveAgentGroupNodes(sessions) { worktreeSessions.map((session) => new SessionItem( session, buildChangeTreeNodes(session.touchedChanges || []), - { label: sessionTreeLabel(session) }, + { + label: sessionTreeLabel(session), + variant: 'raw', + }, )), ) )); @@ -1510,9 +1946,13 @@ class ActiveAgentsProvider { this.viewSummary = { sessionCount: 0, workingCount: 0, + idleCount: 0, + unassignedChangeCount: 0, + lockedFileCount: 0, deadCount: 0, conflictCount: 0, }; + this.previousSnapshot = null; } getTreeItem(element) { @@ -1521,7 +1961,15 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; - this.updateViewState(0, 0, 0); + this.updateViewState({ + sessionCount: 0, + workingCount: 0, + idleCount: 0, + unassignedChangeCount: 0, + lockedFileCount: 0, + deadCount: 0, + conflictCount: 0, + }); treeView.onDidChangeSelection?.((event) => { const sessionItem = event.selection.find((item) => item instanceof SessionItem); this.setSelectedSession(sessionItem?.session || null); @@ -1558,60 +2006,100 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount, deadCount, conflictCount = 0) { + updateViewState(summary) { if (!this.treeView) { return; } - const activeCount = Math.max(0, sessionCount - deadCount); - this.viewSummary = { - sessionCount, - workingCount, - deadCount, - conflictCount, - }; + const sessionCount = summary?.sessionCount || 0; + const conflictCount = summary?.conflictCount || 0; + this.viewSummary = { ...summary }; void vscode.commands.executeCommand('setContext', 'guardex.hasAgents', sessionCount > 0); void vscode.commands.executeCommand('setContext', 'guardex.hasConflicts', conflictCount > 0); - const badgeTooltipParts = []; - if (activeCount > 0) { - badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); - } - if (deadCount > 0) { - badgeTooltipParts.push(`${deadCount} dead`); - } - if (workingCount > 0) { - badgeTooltipParts.push(`${workingCount} working now`); - } - if (conflictCount > 0) { - badgeTooltipParts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`); - } this.treeView.badge = sessionCount > 0 ? { value: sessionCount, - tooltip: badgeTooltipParts.join(' · '), + tooltip: buildOverviewDescription(summary), } : undefined; this.treeView.message = undefined; } + annotateRepoEntries(repoEntries) { + const hasPreviousSnapshot = Boolean(this.previousSnapshot); + const nextSnapshot = { + sessions: new Map(), + changes: new Map(), + }; + + const annotatedEntries = repoEntries.map((entry) => { + const sessions = entry.sessions.map((session) => { + const snapshotKey = sessionSnapshotKey(session); + nextSnapshot.sessions.set(snapshotKey, buildSessionSnapshot(session)); + const deltaLabel = hasPreviousSnapshot + ? deriveSessionDelta(this.previousSnapshot.sessions.get(snapshotKey), session) + : ''; + return { + ...session, + deltaLabel, + riskBadges: uniqueStringList([ + ...(session.riskBadges || []), + deltaLabel, + ].filter(Boolean)), + }; + }); + + const changes = entry.changes.map((change) => { + const snapshotKey = changeSnapshotKey(entry.repoRoot, change); + nextSnapshot.changes.set(snapshotKey, buildChangeSnapshot(change)); + const deltaLabel = hasPreviousSnapshot + ? deriveChangeDelta(this.previousSnapshot.changes.get(snapshotKey), change) + : ''; + return { + ...change, + deltaLabel, + riskBadges: changeRiskBadges({ + ...change, + deltaLabel, + }), + }; + }); + + const { repoRootChanges } = partitionChangesByOwnership(sessions, changes); + const unassignedChanges = sortUnassignedChanges(repoRootChanges); + return { + ...entry, + sessions, + changes, + unassignedChanges, + overview: buildRepoOverview(sessions, unassignedChanges, entry.lockEntries), + }; + }); + + this.previousSnapshot = nextSnapshot; + return annotatedEntries; + } + async syncRepoEntries() { - const repoEntries = await this.loadRepoEntries(); - const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0); - const workingCount = repoEntries.reduce( - (total, entry) => total + countWorkingSessions(entry.sessions), - 0, - ); - const deadCount = repoEntries.reduce( - (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), - 0, - ); - const conflictCount = repoEntries.reduce( - (total, entry) => total + countEntryConflicts(entry), - 0, - ); + const repoEntries = this.annotateRepoEntries(await this.loadRepoEntries()); + const summary = { + sessionCount: repoEntries.reduce((total, entry) => total + entry.sessions.length, 0), + workingCount: repoEntries.reduce((total, entry) => total + entry.overview.workingCount, 0), + idleCount: repoEntries.reduce((total, entry) => total + entry.overview.idleCount, 0), + unassignedChangeCount: repoEntries.reduce( + (total, entry) => total + entry.overview.unassignedChangeCount, + 0, + ), + lockedFileCount: repoEntries.reduce((total, entry) => total + entry.overview.lockedFileCount, 0), + deadCount: repoEntries.reduce( + (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), + 0, + ), + conflictCount: repoEntries.reduce((total, entry) => total + entry.overview.conflictCount, 0), + }; - this.updateViewState(sessionCount, workingCount, deadCount, conflictCount); + this.updateViewState(summary); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); this.decorationProvider?.updateLockEntries(repoEntries); return repoEntries; @@ -1640,13 +2128,56 @@ class ActiveAgentsProvider { async getChildren(element) { if (element instanceof RepoItem) { const sectionItems = [ - new SectionItem('ACTIVE AGENTS', buildActiveAgentGroupNodes(element.sessions), { - description: String(element.sessions.length), + new SectionItem('Overview', [ + new DetailItem('Summary', buildOverviewDescription(element.overview), { + iconId: 'graph', + tooltip: buildRepoTooltip(element.repoRoot, element.overview), + }), + ], { + description: '1', }), ]; - if (element.changes.length > 0) { - sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), { + + const workingNowItems = buildWorkingNowNodes(element.sessions); + if (workingNowItems.length > 0) { + sectionItems.push(new SectionItem('Working now', workingNowItems, { + description: String(workingNowItems.length), + })); + } + + const idleThinkingItems = buildIdleThinkingNodes(element.sessions); + if (idleThinkingItems.length > 0) { + sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { + description: String(idleThinkingItems.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + })); + } + + if (element.unassignedChanges.length > 0) { + sectionItems.push(new SectionItem('Unassigned changes', buildUnassignedChangeNodes(element.unassignedChanges), { + description: String(element.unassignedChanges.length), + })); + } + + const advancedItems = []; + const rawActiveAgents = buildRawActiveAgentGroupNodes(element.sessions); + if (rawActiveAgents.length > 0) { + advancedItems.push(new SectionItem('Active agent tree', rawActiveAgents, { + description: String(element.sessions.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + })); + } + const rawChangeTree = buildGroupedChangeTreeNodes(element.sessions, element.changes); + if (rawChangeTree.length > 0) { + advancedItems.push(new SectionItem('Raw path tree', rawChangeTree, { description: String(element.changes.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + })); + } + if (advancedItems.length > 0) { + sectionItems.push(new SectionItem('Advanced details', advancedItems, { + description: String(advancedItems.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, })); } return sectionItems; @@ -1663,7 +2194,11 @@ class ActiveAgentsProvider { return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; } - return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes)); + return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes, { + overview: entry.overview, + unassignedChanges: entry.unassignedChanges, + lockEntries: entry.lockEntries, + })); } async loadRepoEntries() { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 6e5fda6..1a43482 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -193,11 +193,36 @@ async function getOnlyChild(provider, item) { } async function getOnlyWorktreeAndSession(provider, sectionItem) { - const worktreeItem = await getOnlyChild(provider, sectionItem); - const sessionItem = await getOnlyChild(provider, worktreeItem); + const firstItem = await getOnlyChild(provider, sectionItem); + if (firstItem?.session) { + return { worktreeItem: null, sessionItem: firstItem }; + } + const worktreeItem = firstItem; + const sessionItem = await getOnlyChild(provider, firstItem); return { worktreeItem, sessionItem }; } +async function getSectionByLabel(provider, parentItem, label) { + const children = await provider.getChildren(parentItem); + const match = children.find((item) => item.label === label); + assert.ok(match, `Expected section ${label}`); + return match; +} + +async function getChildByLabel(provider, parentItem, label) { + const children = await provider.getChildren(parentItem); + const match = children.find((item) => item.label === label); + assert.ok(match, `Expected child ${label}`); + return match; +} + +async function getSessionByBranch(provider, sectionItem, branch) { + const children = await provider.getChildren(sectionItem); + const match = children.find((item) => item.session?.branch === branch); + assert.ok(match, `Expected session ${branch}`); + return match; +} + function loadExtensionWithMockVscode(mockVscode, mockSessionSchema = null) { const Module = require('node:module'); const originalLoad = Module._load; @@ -1536,19 +1561,26 @@ test('active-agents extension groups live sessions under a repo node', async () const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); assert.equal(repoItem.label, path.basename(tempRoot)); - assert.equal(repoItem.description, '1 active'); + assert.equal(repoItem.description, '0 working agents · 1 idle agent · 0 unassigned changes · 0 locked files · 0 conflicts'); - const [agentsSection] = await provider.getChildren(repoItem); - assert.equal(agentsSection.label, 'ACTIVE AGENTS'); - assert.equal(agentsSection.description, '1'); + assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ + 'Overview', + 'Idle / thinking', + 'Advanced details', + ]); + const overviewSection = await getSectionByLabel(provider, repoItem, 'Overview'); + const [summaryItem] = await provider.getChildren(overviewSection); + assert.equal(summaryItem.label, 'Summary'); + assert.equal(summaryItem.description, repoItem.description); - const [idleSection] = await provider.getChildren(agentsSection); - assert.equal(idleSection.label, 'THINKING'); + const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); + assert.equal(idleSection.description, '1'); 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(worktreeItem, null); + assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.session.branch, 'agent/codex/live-task'); + assert.match(sessionItem.description, /^codex · Idle/); assert.equal(sessionItem.iconPath.id, 'comment-discussion'); assert.equal(sessionItem.resourceUri.scheme, 'gitguardex-agent'); assert.equal( @@ -1557,7 +1589,7 @@ test('active-agents extension groups live sessions under a repo node', async () ); assert.deepEqual(registrations.treeViews[0].badge, { value: 1, - tooltip: '1 active agent', + tooltip: repoItem.description, }); assert.equal(registrations.treeViews[0].message, undefined); assert.equal( @@ -1783,49 +1815,57 @@ test('active-agents extension shows grouped repo changes beside active agents', const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 active · 1 working · 3 changed'); - const [agentsSection, changesSection] = await provider.getChildren(repoItem); - assert.equal(agentsSection.label, 'ACTIVE AGENTS'); - assert.equal(changesSection.label, 'CHANGES'); + assert.equal(repoItem.description, '1 working agent · 0 idle agents · 1 unassigned change · 0 locked files · 0 conflicts'); + assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ + 'Overview', + 'Working now', + 'Unassigned changes', + 'Advanced details', + ]); - const [workingSection] = await provider.getChildren(agentsSection); - assert.equal(workingSection.label, 'WORKING NOW'); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); + const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); + const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details'); 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(worktreeItem, null); + assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.session.branch, 'agent/codex/live-task'); + assert.match(sessionItem.description, /^codex · Working · 2 changed files/); + assert.match(sessionItem.tooltip, /Recent Changed src\/nested\.js, tracked\.txt/); assert.equal(sessionItem.iconPath.id, 'loading~spin'); - const [activeFolderItem, activeTrackedItem] = await provider.getChildren(sessionItem); - assert.equal(activeFolderItem.label, 'src'); - assert.equal(activeTrackedItem.label, 'tracked.txt'); - assert.match(activeTrackedItem.tooltip, /^tracked\.txt\nStatus Touched\n/); + const sessionDetails = await provider.getChildren(sessionItem); + assert.equal(sessionDetails.find((item) => item.label === 'Top files')?.description, 'src/nested.js, tracked.txt'); assert.deepEqual(registrations.treeViews[0].badge, { value: 1, - tooltip: '1 active agent · 1 working now', + tooltip: repoItem.description, }); - const [worktreeGroup, repoRootGroup] = await provider.getChildren(changesSection); + const [unassignedChangeItem] = await provider.getChildren(unassignedSection); + assert.equal(unassignedChangeItem.label, 'root-file.txt'); + assert.equal(unassignedChangeItem.description, 'M · Protected branch'); + + const rawPathTree = await getSectionByLabel(provider, advancedSection, 'Raw path tree'); + const [worktreeGroup, repoRootGroup] = await provider.getChildren(rawPathTree); 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); + assert.equal(sessionGroup.label, sessionItem.session.branch); const [folderItem, trackedItem] = await provider.getChildren(sessionGroup); assert.equal(folderItem.label, 'src'); assert.equal(trackedItem.label, 'tracked.txt'); - assert.match(trackedItem.tooltip, /^tracked\.txt\nStatus Modified\n/); + assert.match(trackedItem.tooltip, /^tracked\.txt\nSummary M\nStatus Modified\n/); const [nestedItem] = await provider.getChildren(folderItem); assert.equal(nestedItem.label, 'nested.js'); - assert.match(nestedItem.tooltip, /^src\/nested\.js\nStatus Modified\n/); + assert.match(nestedItem.tooltip, /^src\/nested\.js\nSummary M\nStatus Modified\n/); const [rootItem] = await provider.getChildren(repoRootGroup); assert.equal(rootItem.label, 'root-file.txt'); assert.equal(rootItem.description, 'M'); - assert.match(rootItem.tooltip, /^root-file\.txt\nStatus Modified\n/); + assert.match(rootItem.tooltip, /^root-file\.txt\nSummary M\nStatus Modified\n/); for (const subscription of context.subscriptions) { subscription.dispose?.(); @@ -1871,16 +1911,18 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 active · 1 working · 1 changed'); + assert.equal(repoItem.description, '1 working agent · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - const [agentsSection] = await provider.getChildren(repoItem); - assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), ['ACTIVE AGENTS']); - const [workingSection] = await provider.getChildren(agentsSection); + assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ + 'Overview', + 'Working now', + 'Advanced details', + ]); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); - assert.equal(sessionItem.label, 'agent/codex/lock-visible-task'); - assert.match(sessionItem.description, /^working · 1 file · /); + assert.equal(worktreeItem, null); + assert.equal(sessionItem.session.branch, 'agent/codex/lock-visible-task'); + assert.match(sessionItem.description, /^codex · Working · 1 changed file/); assert.match(sessionItem.tooltip, /Telemetry updated 2026-04-22T09:01:00.000Z/); for (const subscription of context.subscriptions) { @@ -1916,15 +1958,13 @@ test('active-agents extension surfaces plain managed worktrees from workspace fa const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 active · 1 working · 1 changed'); + assert.equal(repoItem.description, '1 working agent · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); - const [agentsSection] = await provider.getChildren(repoItem); - const [workingSection] = await provider.getChildren(agentsSection); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); - assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(worktreeItem.label, path.basename(worktreePath)); - assert.equal(sessionItem.label, 'agent/codex/plain-visible-task'); - assert.match(sessionItem.description, /^working · 1 file · /); + assert.equal(worktreeItem, null); + assert.equal(sessionItem.session.branch, 'agent/codex/plain-visible-task'); + assert.match(sessionItem.description, /^codex · Working · 1 changed file/); assert.match(sessionItem.tooltip, /Started /); for (const subscription of context.subscriptions) { @@ -1996,28 +2036,33 @@ test('active-agents extension decorates sessions and repo changes from the lock const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - const [agentsSection, changesSection] = await provider.getChildren(repoItem); - assert.equal(repoItem.description, '1 active · 1 working · 2 changed'); - const [workingSection] = await provider.getChildren(agentsSection); + assert.equal(repoItem.description, '1 working agent · 0 idle agents · 1 unassigned change · 3 locked files · 2 conflicts'); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); + const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); + const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details'); 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.equal(worktreeItem, null); + assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.session.branch, branch); + assert.match(sessionItem.tooltip, /1 lock/); assert.match(sessionItem.tooltip, /Conflicts 1/); - const [sessionChangeItem] = await provider.getChildren(sessionItem); + const activeAgentTree = await getSectionByLabel(provider, advancedSection, 'Active agent tree'); + const rawWorkingSection = await getSectionByLabel(provider, activeAgentTree, 'WORKING NOW'); + const worktreeGroup = await getChildByLabel(provider, rawWorkingSection, path.basename(worktreePath)); + const [sessionGroup] = await provider.getChildren(worktreeGroup); + const [sessionChangeItem] = await provider.getChildren(sessionGroup); assert.equal(sessionChangeItem.label, 'tracked.txt'); assert.equal(sessionChangeItem.iconPath.id, 'warning'); assert.match(sessionChangeItem.tooltip, /Locked by agent\/codex\/other-task/); - const [repoRootGroup] = await provider.getChildren(changesSection); - const [changeItem] = await provider.getChildren(repoRootGroup); + const [changeItem] = await provider.getChildren(unassignedSection); assert.equal(changeItem.label, 'root-file.txt'); assert.equal(changeItem.iconPath.id, 'warning'); assert.match(changeItem.tooltip, /Locked by agent\/codex\/other-task/); assert.deepEqual(registrations.treeViews[0].badge, { value: 1, - tooltip: '1 active agent · 1 working now · 2 conflicts', + tooltip: repoItem.description, }); assert.equal( registrations.executedCommands.some((entry) => ( @@ -2093,11 +2138,11 @@ test('active-agents extension re-reads lock state on watcher events instead of e assert.ok(lockWatcher, 'expected lock watcher registration'); const [repoItem] = await provider.getChildren(); - const [agentsSection] = await provider.getChildren(repoItem); - const [idleSection] = await provider.getChildren(agentsSection); + const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); - assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); - assert.equal(sessionItem.label, branch); + assert.equal(worktreeItem, null); + assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.session.branch, branch); assert.equal(lockReadCount, 1); await provider.getChildren(); @@ -2121,11 +2166,11 @@ test('active-agents extension re-reads lock state on watcher events instead of e assert.equal(lockReadCount, 2); const [updatedRepoItem] = await provider.getChildren(); - const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); - const [updatedIdleSection] = await provider.getChildren(updatedAgentsSection); + const updatedIdleSection = await getSectionByLabel(provider, updatedRepoItem, 'Idle / thinking'); const { worktreeItem: updatedWorktreeItem, sessionItem: updatedSessionItem } = await getOnlyWorktreeAndSession(provider, updatedIdleSection); - assert.equal(updatedWorktreeItem.label, `${path.basename(worktreePath)}`); - assert.equal(updatedSessionItem.label, branch); + assert.equal(updatedWorktreeItem, null); + assert.equal(updatedSessionItem.label, 'live-task'); + assert.equal(updatedSessionItem.session.branch, branch); await provider.getChildren(); assert.equal(lockReadCount, 2); @@ -2237,34 +2282,37 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '4 active · 1 dead · 1 working · 1 changed'); - - const [agentsSection] = await provider.getChildren(repoItem); - const [blockedSection, workingSection, idleSection, stalledSection, deadSection] = await provider.getChildren(agentsSection); - assert.equal(blockedSection.label, 'BLOCKED'); - assert.equal(workingSection.label, 'WORKING NOW'); - assert.equal(idleSection.label, 'THINKING'); - assert.equal(stalledSection.label, 'STALLED'); - assert.equal(deadSection.label, 'DEAD'); - - 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(repoItem.description, '2 working agents · 2 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + + assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ + 'Overview', + 'Working now', + 'Idle / thinking', + 'Advanced details', + ]); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); + const idleThinkingSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); + assert.equal(workingSection.description, '2'); + assert.equal(idleThinkingSection.description, '3'); + + const blockedItem = await getSessionByBranch(provider, workingSection, 'agent/codex/blocked-task'); + const workingItem = await getSessionByBranch(provider, workingSection, 'agent/codex/working-task'); + const idleItem = await getSessionByBranch(provider, idleThinkingSection, 'agent/codex/idle-task'); + const stalledItem = await getSessionByBranch(provider, idleThinkingSection, 'agent/codex/stalled-task'); + const deadItem = await getSessionByBranch(provider, idleThinkingSection, 'agent/codex/dead-task'); + assert.match(blockedItem.description, /^codex · Blocked/); assert.equal(blockedItem.iconPath.id, 'warning'); - assert.match(workingItem.description, /^working · 1 file · /); + assert.match(workingItem.description, /^codex · Working · 1 changed file/); assert.equal(workingItem.iconPath.id, 'loading~spin'); - assert.match(idleItem.description, /^idle · \d+[smhd]/); + assert.match(idleItem.description, /^codex · Idle/); assert.equal(idleItem.iconPath.id, 'comment-discussion'); - assert.match(stalledItem.description, /^stalled · \d+[smhd]/); + assert.match(stalledItem.description, /^codex · Stale/); assert.equal(stalledItem.iconPath.id, 'clock'); - assert.match(deadItem.description, /^dead · \d+[smhd]/); + assert.match(deadItem.description, /^codex · Dead/); assert.equal(deadItem.iconPath.id, 'error'); assert.deepEqual(registrations.treeViews[0].badge, { value: 5, - tooltip: '4 active agents · 1 dead · 1 working now', + tooltip: repoItem.description, }); for (const subscription of context.subscriptions) { @@ -2389,8 +2437,7 @@ test('active-agents extension commits the selected session worktree from the SCM const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - const [agentsSection] = await provider.getChildren(repoItem); - const [workingSection] = await provider.getChildren(agentsSection); + const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); const { sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); registrations.treeViews[0].fireSelection([sessionItem]); @@ -2495,15 +2542,15 @@ test('active-agents extension decorates sessions and repo changes from the lock const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - const [agentsSection, changesSection] = await provider.getChildren(repoItem); - const [idleSection] = await provider.getChildren(agentsSection); + const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); + const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); 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/); + assert.equal(worktreeItem, null); + assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.session.branch, branch); + assert.match(sessionItem.tooltip, /1 lock/); - const [repoRootGroup] = await provider.getChildren(changesSection); - const [changeItem] = await provider.getChildren(repoRootGroup); + const [changeItem] = await provider.getChildren(unassignedSection); assert.equal(changeItem.label, 'root-file.txt'); assert.equal(changeItem.iconPath.id, 'warning'); assert.match(changeItem.tooltip, /Locked by agent\/codex\/other-task/); @@ -2572,11 +2619,11 @@ test('active-agents extension re-reads lock state on watcher events instead of e assert.ok(lockWatcher, 'expected lock watcher registration'); const [repoItem] = await provider.getChildren(); - const [agentsSection] = await provider.getChildren(repoItem); - const [thinkingSection] = await provider.getChildren(agentsSection); + const thinkingSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, thinkingSection); - assert.equal(worktreeItem.label, `${path.basename(worktreePath)}`); - assert.equal(sessionItem.label, branch); + assert.equal(worktreeItem, null); + assert.equal(sessionItem.label, 'live-task'); + assert.equal(sessionItem.session.branch, branch); assert.equal(lockReadCount, 1); await provider.getChildren(); @@ -2600,11 +2647,11 @@ test('active-agents extension re-reads lock state on watcher events instead of e assert.equal(lockReadCount, 2); const [updatedRepoItem] = await provider.getChildren(); - const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); - const [updatedThinkingSection] = await provider.getChildren(updatedAgentsSection); + const updatedThinkingSection = await getSectionByLabel(provider, updatedRepoItem, 'Idle / thinking'); const { worktreeItem: updatedWorktreeItem, sessionItem: updatedSessionItem } = await getOnlyWorktreeAndSession(provider, updatedThinkingSection); - assert.equal(updatedWorktreeItem.label, `${path.basename(worktreePath)}`); - assert.equal(updatedSessionItem.label, branch); + assert.equal(updatedWorktreeItem, null); + assert.equal(updatedSessionItem.label, 'live-task'); + assert.equal(updatedSessionItem.session.branch, branch); await provider.getChildren(); assert.equal(lockReadCount, 2); @@ -2650,8 +2697,7 @@ test('active-agents extension launches finish and sync commands in session termi const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - const [agentsSection] = await provider.getChildren(repoItem); - const [idleSection] = await provider.getChildren(agentsSection); + const idleSection = await getSectionByLabel(provider, repoItem, 'Idle / thinking'); const { sessionItem } = await getOnlyWorktreeAndSession(provider, idleSection); await registrations.commands.get('gitguardex.activeAgents.finishSession')(sessionItem.session); @@ -2750,8 +2796,7 @@ test('active-agents extension opens and refreshes the inspect panel from shared const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - const [agentsSection] = await provider.getChildren(repoItem); - const [groupSection] = await provider.getChildren(agentsSection); + const groupSection = await getSectionByLabel(provider, repoItem, 'Working now'); 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 7c962c6..255344c 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -22,6 +22,8 @@ const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.o const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; const SESSION_SCAN_LIMIT = 200; const REFRESH_DEBOUNCE_MS = 250; +const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000; +const SESSION_TOP_FILE_COUNT = 3; const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agents', 'package.json'); const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js'); const RELOAD_WINDOW_ACTION = 'Reload Window'; @@ -224,9 +226,10 @@ function agentBadgeFromBranch(branch) { } function buildActiveAgentsStatusSummary(summary) { - const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); - if (activeCount > 0) { - return `$(git-branch) ${formatCountLabel(activeCount, 'active agent')}`; + const workingCount = summary?.workingCount || 0; + const idleCount = summary?.idleCount || 0; + if (workingCount > 0 || idleCount > 0) { + return `$(git-branch) ${workingCount} working · ${idleCount} idle`; } return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`; } @@ -246,11 +249,375 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) { return [ formatCountLabel(activeCount, 'active agent'), formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), + formatCountLabel(summary?.idleCount || 0, 'idle session'), + formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), + formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', 'Click to open Source Control.', ].filter(Boolean).join('\n'); } +function compactRelativePath(relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (!normalized) { + return ''; + } + + const segments = normalized.split('/').filter(Boolean); + if (segments.length <= 2) { + return normalized; + } + + return `${segments[0]}/.../${segments[segments.length - 1]}`; +} + +function summarizeCompactPaths(paths, maxCount = SESSION_TOP_FILE_COUNT) { + const compactPaths = uniqueStringList((paths || []) + .map(normalizeRelativePath) + .filter(Boolean) + .map((relativePath) => compactRelativePath(relativePath))) + .slice(0, maxCount); + if (compactPaths.length === 0) { + return ''; + } + return compactPaths.join(', '); +} + +function isProtectedBranchName(branch) { + return branch === 'main' || branch === 'dev'; +} + +function countWorkingSessions(sessions) { + return sessions.filter((session) => ( + session.activityKind === 'working' || session.activityKind === 'blocked' + )).length; +} + +function countIdleSessions(sessions) { + return sessions.filter((session) => ( + session.activityKind === 'idle' || session.activityKind === 'stalled' + )).length; +} + +function sessionLastActiveAt(session) { + return [ + session?.lastHeartbeatAt, + session?.lastFileActivityAt, + session?.telemetryUpdatedAt, + session?.startedAt, + ].find((value) => typeof value === 'string' && value.trim().length > 0) || ''; +} + +function sessionLastActiveLabel(session) { + const lastActiveAt = sessionLastActiveAt(session); + if (!lastActiveAt) { + return ''; + } + return formatElapsedFrom(lastActiveAt); +} + +function sessionLastActiveAgeMs(session, now = Date.now()) { + const lastActiveAt = sessionLastActiveAt(session); + const timestamp = Date.parse(lastActiveAt); + if (!Number.isFinite(timestamp)) { + return null; + } + return Math.max(0, now - timestamp); +} + +function sessionFreshnessLabel(session, now = Date.now()) { + const ageMs = sessionLastActiveAgeMs(session, now); + if (session.activityKind === 'blocked') { + return 'Needs attention'; + } + if (session.activityKind === 'stalled') { + return 'Possibly stale'; + } + if (session.activityKind === 'dead') { + return 'Stopped'; + } + if (ageMs === null) { + return ''; + } + if (ageMs <= IDLE_WARNING_MS) { + return 'Fresh'; + } + if (ageMs <= RECENTLY_ACTIVE_WINDOW_MS) { + return 'Recently active'; + } + if (session.activityKind === 'idle') { + return 'Idle'; + } + return 'Recently active'; +} + +function sessionStatusLabel(session) { + switch (session.activityKind) { + case 'blocked': + return 'Blocked'; + case 'working': + return 'Working'; + case 'idle': + return 'Idle'; + case 'stalled': + return 'Stale'; + case 'dead': + return 'Dead'; + default: + return 'Thinking'; + } +} + +function buildSessionTopFiles(session) { + return uniqueStringList((session?.worktreeChangedPaths || []) + .map(normalizeRelativePath) + .filter(Boolean)) + .slice(0, SESSION_TOP_FILE_COUNT); +} + +function buildSessionRecentChangeSummary(session) { + if (session?.latestTaskPreview && session.latestTaskPreview !== session.taskName) { + return session.latestTaskPreview; + } + const topFiles = summarizeCompactPaths(session?.worktreeChangedPaths || []); + if (topFiles) { + return `Changed ${topFiles}`; + } + if (session?.activitySummary) { + return session.activitySummary; + } + return 'No recent change summary.'; +} + +function sessionRiskBadges(session) { + return uniqueStringList([ + session?.activityKind === 'blocked' ? 'Blocked' : '', + session?.activityKind === 'stalled' ? 'Stale' : '', + session?.conflictCount > 0 ? 'Conflict' : '', + session?.lockCount > 0 ? 'Locked' : '', + ].filter(Boolean)); +} + +function changeRiskBadges(change) { + return uniqueStringList([ + change?.protectedBranch ? 'Protected branch' : '', + change?.hasForeignLock ? 'Conflict' : '', + !change?.hasForeignLock && change?.lockOwnerBranch ? 'Locked' : '', + change?.deltaLabel || '', + ].filter(Boolean)); +} + +function buildSessionCardDescription(session) { + const descriptionParts = [ + session.agentName || 'agent', + sessionStatusLabel(session), + session.deltaLabel || '', + session.changeCount > 0 ? formatCountLabel(session.changeCount, 'changed file') : '', + session.lockCount > 0 ? formatCountLabel(session.lockCount, 'lock') : '', + session.freshnessLabel || '', + session.lastActiveLabel ? `${session.lastActiveLabel} ago` : '', + ].filter(Boolean); + return descriptionParts.join(' · '); +} + +function buildRawSessionDescription(session) { + const descriptionParts = [session.activityLabel || 'thinking']; + if (session.activityCountLabel) { + descriptionParts.push(session.activityCountLabel); + } + descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); + if (session.lockCount > 0) { + descriptionParts.push(formatCountLabel(session.lockCount, 'lock')); + } + return descriptionParts.join(' · '); +} + +function buildSessionTooltip(session, description) { + const riskSummary = uniqueStringList([ + ...(session?.riskBadges || []), + session?.deltaLabel || '', + ].filter(Boolean)).join(', '); + const topFiles = session?.topChangedFilesLabel || summarizeCompactPaths(session?.worktreeChangedPaths || []); + return [ + session.branch, + `${session.agentName} · ${session.taskName}`, + `Status ${description}`, + session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '', + topFiles ? `Top files ${topFiles}` : '', + riskSummary ? `Signals ${riskSummary}` : '', + session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', + session.lastActiveAt ? `Last active ${session.lastActiveAt}` : '', + session.sourceKind === 'worktree-lock' + ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` + : `Started ${session.startedAt}`, + session.worktreePath, + ].filter(Boolean).join('\n'); +} + +function buildUnassignedChangeDescription(change) { + return [ + change.statusLabel, + ...changeRiskBadges(change), + ].filter(Boolean).join(' · '); +} + +function buildOverviewDescription(summary) { + return [ + formatCountLabel(summary?.workingCount || 0, 'working agent'), + formatCountLabel(summary?.idleCount || 0, 'idle agent'), + formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), + formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), + formatCountLabel(summary?.conflictCount || 0, 'conflict'), + ].join(' · '); +} + +function buildRepoDescription(summary) { + return buildOverviewDescription(summary); +} + +function buildRepoTooltip(repoRoot, summary) { + return [ + repoRoot, + buildOverviewDescription(summary), + ].join('\n'); +} + +function sessionSnapshotKey(session) { + return `${session?.repoRoot || ''}::${session?.branch || ''}`; +} + +function changeSnapshotKey(repoRoot, change) { + return `${repoRoot || ''}::${normalizeRelativePath(change?.relativePath)}`; +} + +function buildSessionSnapshot(session) { + return { + activityKind: session.activityKind, + changeCount: session.changeCount || 0, + conflictCount: session.conflictCount || 0, + lockCount: session.lockCount || 0, + changedPaths: [...(session.changedPaths || [])], + }; +} + +function buildChangeSnapshot(change) { + return { + statusLabel: change.statusLabel, + hasForeignLock: Boolean(change.hasForeignLock), + lockOwnerBranch: change.lockOwnerBranch || '', + }; +} + +function deriveSessionDelta(previousSnapshot, currentSession) { + if (!previousSnapshot) { + return ''; + } + if (currentSession.conflictCount > previousSnapshot.conflictCount) { + return 'Conflict'; + } + if (currentSession.activityKind !== previousSnapshot.activityKind) { + return sessionStatusLabel(currentSession); + } + if ( + currentSession.changeCount !== previousSnapshot.changeCount + || !stringListsEqual(currentSession.changedPaths || [], previousSnapshot.changedPaths || []) + ) { + return 'New'; + } + if (currentSession.lockCount !== previousSnapshot.lockCount) { + return 'Updated'; + } + return ''; +} + +function deriveChangeDelta(previousSnapshot, currentChange) { + if (!previousSnapshot) { + return ''; + } + if (currentChange.hasForeignLock && !previousSnapshot.hasForeignLock) { + return 'Conflict'; + } + if ( + currentChange.statusLabel !== previousSnapshot.statusLabel + || currentChange.lockOwnerBranch !== previousSnapshot.lockOwnerBranch + ) { + return 'Updated'; + } + return ''; +} + +function workingSessionSortKey(session) { + if (session.activityKind === 'blocked') { + return 0; + } + if (session.conflictCount > 0) { + return 1; + } + if (session.deltaLabel === 'Conflict') { + return 2; + } + if (session.deltaLabel === 'New') { + return 3; + } + return 4; +} + +function idleSessionSortKey(session) { + if (session.activityKind === 'stalled') { + return 0; + } + if (session.activityKind === 'idle') { + return 1; + } + if (session.activityKind === 'dead') { + return 2; + } + return 3; +} + +function sortSessionsForWorkingNow(sessions) { + return [...sessions].sort((left, right) => { + const keyDelta = workingSessionSortKey(left) - workingSessionSortKey(right); + if (keyDelta !== 0) { + return keyDelta; + } + const timeDelta = sessionLastActiveAgeMs(left) - sessionLastActiveAgeMs(right); + if (Number.isFinite(timeDelta) && timeDelta !== 0) { + return timeDelta; + } + const changeDelta = (right.changeCount || 0) - (left.changeCount || 0); + if (changeDelta !== 0) { + return changeDelta; + } + return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); + }); +} + +function sortSessionsForIdleThinking(sessions) { + return [...sessions].sort((left, right) => { + const keyDelta = idleSessionSortKey(left) - idleSessionSortKey(right); + if (keyDelta !== 0) { + return keyDelta; + } + const timeDelta = sessionLastActiveAgeMs(right) - sessionLastActiveAgeMs(left); + if (Number.isFinite(timeDelta) && timeDelta !== 0) { + return timeDelta; + } + return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right)); + }); +} + +function sortUnassignedChanges(changes) { + return [...changes].sort((left, right) => { + const leftBadges = changeRiskBadges(left).length; + const rightBadges = changeRiskBadges(right).length; + if (leftBadges !== rightBadges) { + return rightBadges - leftBadges; + } + return normalizeRelativePath(left.relativePath).localeCompare(normalizeRelativePath(right.relativePath)); + }); +} + function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') @@ -454,37 +821,30 @@ class InfoItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); this.description = description; this.iconPath = new vscode.ThemeIcon('info'); + this.tooltip = [label, description].filter(Boolean).join('\n'); + } +} + +class DetailItem extends vscode.TreeItem { + constructor(label, description = '', options = {}) { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = description; + this.tooltip = options.tooltip || [label, description].filter(Boolean).join('\n'); + this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; } } class RepoItem extends vscode.TreeItem { - constructor(repoRoot, sessions, changes) { + constructor(repoRoot, sessions, changes, options = {}) { super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded); this.repoRoot = repoRoot; this.sessions = sessions; this.changes = changes; - const descriptionParts = []; - const activeCount = countActiveSessions(sessions); - const deadCount = countSessionsByActivityKind(sessions, 'dead'); - const workingCount = countWorkingSessions(sessions); - if (activeCount > 0) { - descriptionParts.push(`${activeCount} active`); - } - if (deadCount > 0) { - descriptionParts.push(`${deadCount} dead`); - } - if (workingCount > 0) { - descriptionParts.push(`${workingCount} working`); - } - const changedCount = countChangedPaths(repoRoot, sessions, changes); - if (changedCount > 0) { - descriptionParts.push(`${changedCount} changed`); - } - this.description = descriptionParts.join(' · '); - this.tooltip = [ - repoRoot, - this.description, - ].join('\n'); + this.unassignedChanges = options.unassignedChanges || []; + this.lockEntries = options.lockEntries || []; + this.overview = options.overview || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries); + this.description = buildRepoDescription(this.overview); + this.tooltip = buildRepoTooltip(repoRoot, this.overview); this.iconPath = new vscode.ThemeIcon('repo'); this.contextValue = 'gitguardex.repo'; } @@ -492,10 +852,15 @@ class RepoItem extends vscode.TreeItem { class SectionItem extends vscode.TreeItem { constructor(label, items, options = {}) { - super(label, vscode.TreeItemCollapsibleState.Expanded); + const collapsibleState = items.length > 0 + ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded) + : vscode.TreeItemCollapsibleState.None; + super(label, collapsibleState); this.items = items; this.description = options.description || (items.length > 0 ? String(items.length) : ''); + this.tooltip = options.tooltip || [label, this.description].filter(Boolean).join('\n'); + this.iconPath = options.iconId ? new vscode.ThemeIcon(options.iconId) : undefined; this.contextValue = 'gitguardex.section'; } } @@ -537,50 +902,28 @@ class WorktreeItem extends vscode.TreeItem { class SessionItem extends vscode.TreeItem { constructor(session, items = [], options = {}) { - const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; + const variant = options.variant === 'raw' ? 'raw' : 'card'; const label = typeof options.label === 'string' && options.label.trim() ? options.label.trim() - : session.label; + : (variant === 'raw' ? session.label : sessionDisplayLabel(session)); + const collapsibleState = items.length > 0 + ? (options.collapsedState ?? ( + variant === 'raw' + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed + )) + : vscode.TreeItemCollapsibleState.None; super( label, - items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + collapsibleState, ); this.session = session; this.items = items; this.resourceUri = sessionDecorationUri(session.branch); - const descriptionParts = [session.activityLabel || 'thinking']; - if (session.activityCountLabel) { - descriptionParts.push(session.activityCountLabel); - } - descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt)); - if (lockCount > 0) { - descriptionParts.push(`${lockCount} $(lock)`); - } - this.description = descriptionParts.join(' · '); - const tooltipLines = [ - session.branch, - `${session.agentName} · ${session.taskName}`, - session.latestTaskPreview && session.latestTaskPreview !== session.taskName - ? `Live task ${session.latestTaskPreview}` - : '', - `Status ${this.description}`, - session.changeCount > 0 - ? `Changed ${session.activityCountLabel}: ${session.activitySummary}` - : session.activitySummary, - `Locks ${lockCount}`, - session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '', - Number.isInteger(session.pid) && session.pid > 0 - ? session.pidAlive === false - ? `PID ${session.pid} not alive` - : `PID ${session.pid} alive` - : '', - session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '', - session.sourceKind === 'worktree-lock' - ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}` - : `Started ${session.startedAt}`, - session.worktreePath, - ]; - this.tooltip = tooltipLines.filter(Boolean).join('\n'); + this.description = variant === 'raw' + ? buildRawSessionDescription(session) + : buildSessionCardDescription(session); + this.tooltip = buildSessionTooltip(session, this.description); this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind)); this.contextValue = 'gitguardex.session'; this.command = { @@ -603,20 +946,26 @@ class FolderItem extends vscode.TreeItem { } class ChangeItem extends vscode.TreeItem { - constructor(change) { - super(path.basename(change.relativePath), vscode.TreeItemCollapsibleState.None); + constructor(change, options = {}) { + const label = typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : path.basename(change.relativePath); + super(label, vscode.TreeItemCollapsibleState.None); this.change = change; - this.description = change.statusLabel; + this.description = typeof options.description === 'string' + ? options.description + : change.statusLabel; this.tooltip = [ change.relativePath, + `Summary ${this.description}`, `Status ${change.statusText}`, change.originalPath ? `Renamed from ${change.originalPath}` : '', change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '', change.absolutePath, ].filter(Boolean).join('\n'); this.resourceUri = vscode.Uri.file(change.absolutePath); - if (change.hasForeignLock) { - this.iconPath = new vscode.ThemeIcon('warning'); + if (options.iconId || change.hasForeignLock) { + this.iconPath = new vscode.ThemeIcon(options.iconId || 'warning'); } this.contextValue = 'gitguardex.change'; this.command = { @@ -1031,22 +1380,33 @@ async function maybeAutoUpdateActiveAgentsExtension(context) { function decorateSession(session, lockRegistry) { const touchedChanges = buildSessionTouchedChanges(session, lockRegistry); - return { + const decorated = { ...session, lockCount: lockRegistry.countsByBranch.get(session.branch) || 0, touchedChanges, conflictCount: touchedChanges.filter((change) => change.hasForeignLock).length, }; + decorated.lastActiveAt = sessionLastActiveAt(decorated); + decorated.lastActiveLabel = sessionLastActiveLabel(decorated); + decorated.freshnessLabel = sessionFreshnessLabel(decorated); + decorated.topChangedFiles = buildSessionTopFiles(decorated); + decorated.topChangedFilesLabel = summarizeCompactPaths(decorated.topChangedFiles); + decorated.recentChangeSummary = buildSessionRecentChangeSummary(decorated); + decorated.riskBadges = sessionRiskBadges(decorated); + return decorated; } function decorateChange(change, lockRegistry, owningBranch) { const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath)); const lockOwnerBranch = lockEntry?.branch || ''; - return { + const decorated = { ...change, lockOwnerBranch, hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch), + protectedBranch: isProtectedBranchName(owningBranch), }; + decorated.riskBadges = changeRiskBadges(decorated); + return decorated; } function buildSessionTouchedChanges(session, lockRegistry) { @@ -1054,7 +1414,6 @@ function buildSessionTouchedChanges(session, lockRegistry) { ? session.worktreeChangedPaths : []; return [...new Set(changedPaths.map(normalizeRelativePath).filter(Boolean))] - .sort((left, right) => left.localeCompare(right)) .map((relativePath) => { const lockEntry = lockRegistry.entriesByPath.get(relativePath); const lockOwnerBranch = lockEntry?.branch || ''; @@ -1243,10 +1602,6 @@ function buildChangeTreeNodes(changes) { return materialize(root); } -function countWorkingSessions(sessions) { - return sessions.filter((session) => session.activityKind === 'working').length; -} - function countChangedPaths(repoRoot, sessions, changes) { const changedKeys = new Set(); @@ -1272,6 +1627,20 @@ function countChangedPaths(repoRoot, sessions, changes) { return changedKeys.size; } +function buildRepoOverview(sessions, unassignedChanges, lockEntries) { + return { + sessionCount: sessions.length, + workingCount: countWorkingSessions(sessions), + idleCount: countIdleSessions(sessions), + unassignedChangeCount: (unassignedChanges || []).length, + lockedFileCount: Array.isArray(lockEntries) ? lockEntries.length : 0, + conflictCount: sessions.reduce( + (total, session) => total + (session.conflictCount || 0), + 0, + ) + (unassignedChanges || []).filter((change) => change.hasForeignLock).length, + }; +} + function groupSessionsByWorktree(sessions) { const sessionsByWorktree = new Map(); @@ -1302,7 +1671,7 @@ function groupSessionsByWorktree(sessions) { }); } -function buildGroupedChangeTreeNodes(sessions, changes) { +function partitionChangesByOwnership(sessions, changes) { const changesBySession = new Map(); const sessionByChangedPath = new Map(); const repoRootChanges = []; @@ -1334,6 +1703,15 @@ function buildGroupedChangeTreeNodes(sessions, changes) { changesBySession.get(session.branch).push(localizedChange); } + return { + changesBySession, + repoRootChanges, + }; +} + +function buildGroupedChangeTreeNodes(sessions, changes) { + const { changesBySession, repoRootChanges } = partitionChangesByOwnership(sessions, changes); + const items = groupSessionsByWorktree( sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), ).map(({ worktreePath, sessions: worktreeSessions }) => { @@ -1341,7 +1719,10 @@ function buildGroupedChangeTreeNodes(sessions, changes) { new SessionItem( session, buildChangeTreeNodes(changesBySession.get(session.branch) || []), - { label: sessionTreeLabel(session) }, + { + label: sessionTreeLabel(session), + variant: 'raw', + }, ) )); const changedCount = worktreeSessions.reduce( @@ -1474,7 +1855,59 @@ function commitWorktree(worktreePath, message) { runGitCommand(worktreePath, ['commit', '-m', message]); } -function buildActiveAgentGroupNodes(sessions) { +function buildSessionDetailItems(session) { + const items = [ + new DetailItem('Recent change', session.recentChangeSummary || 'No recent change summary.', { + iconId: 'history', + }), + new DetailItem('Top files', session.topChangedFilesLabel || 'No tracked file edits.', { + iconId: 'list-flat', + }), + new DetailItem('Branch', session.branch, { + iconId: 'git-branch', + }), + new DetailItem('Worktree', session.worktreePath, { + iconId: 'folder', + tooltip: session.worktreePath, + }), + ]; + const badgeSummary = uniqueStringList([ + ...(session.riskBadges || []), + session.deltaLabel || '', + ].filter(Boolean)).join(', '); + if (badgeSummary) { + items.splice(2, 0, new DetailItem('Signals', badgeSummary, { + iconId: 'warning', + })); + } + return items; +} + +function buildWorkingNowNodes(sessions) { + return sortSessionsForWorkingNow( + sessions.filter((session) => ( + session.activityKind === 'working' || session.activityKind === 'blocked' + )), + ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); +} + +function buildIdleThinkingNodes(sessions) { + return sortSessionsForIdleThinking( + sessions.filter((session) => !( + session.activityKind === 'working' || session.activityKind === 'blocked' + )), + ).map((session) => new SessionItem(session, buildSessionDetailItems(session))); +} + +function buildUnassignedChangeNodes(changes) { + return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { + label: compactRelativePath(change.relativePath), + description: buildUnassignedChangeDescription(change), + iconId: changeRiskBadges(change).length > 0 ? 'warning' : undefined, + })); +} + +function buildRawActiveAgentGroupNodes(sessions) { const groups = []; for (const group of SESSION_ACTIVITY_GROUPS) { const groupSessions = sessions.filter((session) => session.activityKind === group.kind); @@ -1485,7 +1918,10 @@ function buildActiveAgentGroupNodes(sessions) { worktreeSessions.map((session) => new SessionItem( session, buildChangeTreeNodes(session.touchedChanges || []), - { label: sessionTreeLabel(session) }, + { + label: sessionTreeLabel(session), + variant: 'raw', + }, )), ) )); @@ -1510,9 +1946,13 @@ class ActiveAgentsProvider { this.viewSummary = { sessionCount: 0, workingCount: 0, + idleCount: 0, + unassignedChangeCount: 0, + lockedFileCount: 0, deadCount: 0, conflictCount: 0, }; + this.previousSnapshot = null; } getTreeItem(element) { @@ -1521,7 +1961,15 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; - this.updateViewState(0, 0, 0); + this.updateViewState({ + sessionCount: 0, + workingCount: 0, + idleCount: 0, + unassignedChangeCount: 0, + lockedFileCount: 0, + deadCount: 0, + conflictCount: 0, + }); treeView.onDidChangeSelection?.((event) => { const sessionItem = event.selection.find((item) => item instanceof SessionItem); this.setSelectedSession(sessionItem?.session || null); @@ -1558,60 +2006,100 @@ class ActiveAgentsProvider { this.setSelectedSession(nextSession || null); } - updateViewState(sessionCount, workingCount, deadCount, conflictCount = 0) { + updateViewState(summary) { if (!this.treeView) { return; } - const activeCount = Math.max(0, sessionCount - deadCount); - this.viewSummary = { - sessionCount, - workingCount, - deadCount, - conflictCount, - }; + const sessionCount = summary?.sessionCount || 0; + const conflictCount = summary?.conflictCount || 0; + this.viewSummary = { ...summary }; void vscode.commands.executeCommand('setContext', 'guardex.hasAgents', sessionCount > 0); void vscode.commands.executeCommand('setContext', 'guardex.hasConflicts', conflictCount > 0); - const badgeTooltipParts = []; - if (activeCount > 0) { - badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`); - } - if (deadCount > 0) { - badgeTooltipParts.push(`${deadCount} dead`); - } - if (workingCount > 0) { - badgeTooltipParts.push(`${workingCount} working now`); - } - if (conflictCount > 0) { - badgeTooltipParts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`); - } this.treeView.badge = sessionCount > 0 ? { value: sessionCount, - tooltip: badgeTooltipParts.join(' · '), + tooltip: buildOverviewDescription(summary), } : undefined; this.treeView.message = undefined; } + annotateRepoEntries(repoEntries) { + const hasPreviousSnapshot = Boolean(this.previousSnapshot); + const nextSnapshot = { + sessions: new Map(), + changes: new Map(), + }; + + const annotatedEntries = repoEntries.map((entry) => { + const sessions = entry.sessions.map((session) => { + const snapshotKey = sessionSnapshotKey(session); + nextSnapshot.sessions.set(snapshotKey, buildSessionSnapshot(session)); + const deltaLabel = hasPreviousSnapshot + ? deriveSessionDelta(this.previousSnapshot.sessions.get(snapshotKey), session) + : ''; + return { + ...session, + deltaLabel, + riskBadges: uniqueStringList([ + ...(session.riskBadges || []), + deltaLabel, + ].filter(Boolean)), + }; + }); + + const changes = entry.changes.map((change) => { + const snapshotKey = changeSnapshotKey(entry.repoRoot, change); + nextSnapshot.changes.set(snapshotKey, buildChangeSnapshot(change)); + const deltaLabel = hasPreviousSnapshot + ? deriveChangeDelta(this.previousSnapshot.changes.get(snapshotKey), change) + : ''; + return { + ...change, + deltaLabel, + riskBadges: changeRiskBadges({ + ...change, + deltaLabel, + }), + }; + }); + + const { repoRootChanges } = partitionChangesByOwnership(sessions, changes); + const unassignedChanges = sortUnassignedChanges(repoRootChanges); + return { + ...entry, + sessions, + changes, + unassignedChanges, + overview: buildRepoOverview(sessions, unassignedChanges, entry.lockEntries), + }; + }); + + this.previousSnapshot = nextSnapshot; + return annotatedEntries; + } + async syncRepoEntries() { - const repoEntries = await this.loadRepoEntries(); - const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0); - const workingCount = repoEntries.reduce( - (total, entry) => total + countWorkingSessions(entry.sessions), - 0, - ); - const deadCount = repoEntries.reduce( - (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), - 0, - ); - const conflictCount = repoEntries.reduce( - (total, entry) => total + countEntryConflicts(entry), - 0, - ); + const repoEntries = this.annotateRepoEntries(await this.loadRepoEntries()); + const summary = { + sessionCount: repoEntries.reduce((total, entry) => total + entry.sessions.length, 0), + workingCount: repoEntries.reduce((total, entry) => total + entry.overview.workingCount, 0), + idleCount: repoEntries.reduce((total, entry) => total + entry.overview.idleCount, 0), + unassignedChangeCount: repoEntries.reduce( + (total, entry) => total + entry.overview.unassignedChangeCount, + 0, + ), + lockedFileCount: repoEntries.reduce((total, entry) => total + entry.overview.lockedFileCount, 0), + deadCount: repoEntries.reduce( + (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'), + 0, + ), + conflictCount: repoEntries.reduce((total, entry) => total + entry.overview.conflictCount, 0), + }; - this.updateViewState(sessionCount, workingCount, deadCount, conflictCount); + this.updateViewState(summary); this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions)); this.decorationProvider?.updateLockEntries(repoEntries); return repoEntries; @@ -1640,13 +2128,56 @@ class ActiveAgentsProvider { async getChildren(element) { if (element instanceof RepoItem) { const sectionItems = [ - new SectionItem('ACTIVE AGENTS', buildActiveAgentGroupNodes(element.sessions), { - description: String(element.sessions.length), + new SectionItem('Overview', [ + new DetailItem('Summary', buildOverviewDescription(element.overview), { + iconId: 'graph', + tooltip: buildRepoTooltip(element.repoRoot, element.overview), + }), + ], { + description: '1', }), ]; - if (element.changes.length > 0) { - sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), { + + const workingNowItems = buildWorkingNowNodes(element.sessions); + if (workingNowItems.length > 0) { + sectionItems.push(new SectionItem('Working now', workingNowItems, { + description: String(workingNowItems.length), + })); + } + + const idleThinkingItems = buildIdleThinkingNodes(element.sessions); + if (idleThinkingItems.length > 0) { + sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { + description: String(idleThinkingItems.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + })); + } + + if (element.unassignedChanges.length > 0) { + sectionItems.push(new SectionItem('Unassigned changes', buildUnassignedChangeNodes(element.unassignedChanges), { + description: String(element.unassignedChanges.length), + })); + } + + const advancedItems = []; + const rawActiveAgents = buildRawActiveAgentGroupNodes(element.sessions); + if (rawActiveAgents.length > 0) { + advancedItems.push(new SectionItem('Active agent tree', rawActiveAgents, { + description: String(element.sessions.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + })); + } + const rawChangeTree = buildGroupedChangeTreeNodes(element.sessions, element.changes); + if (rawChangeTree.length > 0) { + advancedItems.push(new SectionItem('Raw path tree', rawChangeTree, { description: String(element.changes.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + })); + } + if (advancedItems.length > 0) { + sectionItems.push(new SectionItem('Advanced details', advancedItems, { + description: String(advancedItems.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, })); } return sectionItems; @@ -1663,7 +2194,11 @@ class ActiveAgentsProvider { return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')]; } - return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes)); + return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes, { + overview: entry.overview, + unassignedChanges: entry.unassignedChanges, + lockEntries: entry.lockEntries, + })); } async loadRepoEntries() {