diff --git a/docs/superpowers/plans/2026-09-11-project-scope-by-tab.md b/docs/superpowers/plans/2026-09-11-project-scope-by-tab.md new file mode 100644 index 000000000..03a7ce309 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-project-scope-by-tab.md @@ -0,0 +1,59 @@ +# Project scope by tab in Switch Agents and Close Old Agents + +Status: implemented, tests green, PR open for review. +PR: [agent-code#909](https://github.com/Juliusolsson05/agent-code/pull/909). Merge requires explicit approval. + +Bug: [agent-code#908](https://github.com/Juliusolsson05/agent-code/issues/908). +Branch: `fix/project-scope-by-tab`. Worktree: `.worktrees/project-scope-by-tab`. +Base: `origin/main` at `24072148` (2026-09-11). This plan is the first commit. + +## Outcome + +The **Selected projects** scope in the Switch Agents (bulk provider switch) and +Close Old Agents modals lists projects the way Dispatch does: one entry per +project tab, labelled by the tab letter and title (`A · agent-code`, +`E · agent-code`), in tab order. An agent whose working directory is a worktree +stays under its tab. The working directory remains the secondary line on each +agent row, and each project entry lists the distinct directories it contains so +the worktrees are visible without becoming projects. + +## Why the bug exists + +Both modals predate Grid Dispatch's project bindings. Close Old Agents (#303, +June) and the bulk switch (June 24) were written when the tab title was the only +project label and was not unique, so they keyed projects by `meta.cwd` and +labelled them by the directory name. Grid Dispatch (#687, #695, late August) +gave every tab a stable letter plus title and made the tab the project +everywhere else. The cwd grouping then became a second, contradicting notion of +project, and worktree agents split into pseudo-projects exactly when the bulk +switch is needed most. + +## Design + +- One shared derivation, `src/renderer/src/features/workspace/lib/projectScope.ts`: + - `buildProjectScopeRows(rows, matchingRows)` groups rows that carry + `tabId`/`tabIndex`/`tabTitle`/`cwd` by tab, in tab order, with `total`, + `matching`, the Dispatch label (`tabIndexLabel(index) · title`) and the + distinct directory basenames inside the tab. + - `filterProjectScopeRows(rows, query)` matches label, title and directory + names, so typing a worktree name still finds the project that holds it. + - `rowsInSelectedProjects(rows, selectedTabIds)` is the one membership rule. +- Both modals replace their private `ProjectRow`/cwd grouping with the shared + rows; selection state becomes a set of tab IDs; the agent row's secondary + line uses the same letter vocabulary (`E · agent-code · /path`) instead of + `tab 5`. +- Ordering follows the tab order rather than the old count-first sort, because + the point is that the list reads like the Dispatch index. + +## Tests + +- `projectScope.test.ts`: two agents in the same tab in different worktrees + produce one entry; same-titled tabs get distinct letters and keep tab order + regardless of counts; `matching` counts only the second argument; the filter + finds a project by its worktree name; membership filters by tab. +- The existing modal tests (`CloseOldAgentsModal.rows`, `BulkProviderSwitchModal.policy`) + keep passing; `tsc` on both projects; `npm run test:contract`. + +## Out of scope + +Any change to what the switch or the close does once agents are selected. diff --git a/src/renderer/src/features/workspace/lib/projectScope.test.ts b/src/renderer/src/features/workspace/lib/projectScope.test.ts new file mode 100644 index 000000000..c897ab76f --- /dev/null +++ b/src/renderer/src/features/workspace/lib/projectScope.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { + buildProjectScopeRows, + filterProjectScopeRows, + rowsInSelectedProjects, +} from '@renderer/features/workspace/lib/projectScope' + +// The workspace that surfaced #908: three tabs titled agent-code, agents spread +// over the main checkout and worktrees, plus an unrelated project. +const rows = [ + { sessionId: 'audit', tabId: 'tab-a', tabIndex: 0, tabTitle: 'agent-code', cwd: '/dev/agent-code' }, + { sessionId: 'grok', tabId: 'tab-a', tabIndex: 0, tabTitle: 'agent-code', cwd: '/dev/agent-code/.worktrees/grok-package-wiring' }, + { sessionId: 'terminal-headless', tabId: 'tab-a', tabIndex: 0, tabTitle: 'agent-code', cwd: '/dev/agent-code/.worktrees/opencode-terminal-headless' }, + { sessionId: 'startup', tabId: 'tab-b', tabIndex: 1, tabTitle: 'startup', cwd: '/dev/startup' }, + { sessionId: 'export-review', tabId: 'tab-c', tabIndex: 2, tabTitle: 'agent-code', cwd: '/dev/agent-code/.worktrees/opencode-export-integrity' }, +] + +describe('project scope rows', () => { + it('keeps worktree agents under their tab and names tabs the way Dispatch does', () => { + const projects = buildProjectScopeRows(rows, rows.filter(row => row.sessionId !== 'grok')) + + // The failure this pins: one entry per working directory, so "agent-code" + // became four pseudo-projects and no scope meant "everyone in this tab". + expect(projects.map(project => project.label)).toEqual(['A · agent-code', 'B · startup', 'C · agent-code']) + expect(projects[0]).toMatchObject({ + tabId: 'tab-a', + total: 3, + matching: 2, + directories: ['agent-code', 'grok-package-wiring', 'opencode-terminal-headless'], + }) + expect(projects[2]).toMatchObject({ tabId: 'tab-c', total: 1, matching: 1, directories: ['opencode-export-integrity'] }) + }) + + it('orders by tab, not by count', () => { + // Tab C holds the most agents here, so the old count-first sort would put + // it before A; the Dispatch order must win regardless of population. + const crowdedLaterTab = [ + ...rows, + { sessionId: 'c-two', tabId: 'tab-c', tabIndex: 2, tabTitle: 'agent-code', cwd: '/dev/agent-code' }, + { sessionId: 'c-three', tabId: 'tab-c', tabIndex: 2, tabTitle: 'agent-code', cwd: '/dev/agent-code' }, + { sessionId: 'c-four', tabId: 'tab-c', tabIndex: 2, tabTitle: 'agent-code', cwd: '/dev/agent-code' }, + ] + const projects = buildProjectScopeRows([...crowdedLaterTab].reverse(), []) + expect(projects.map(project => [project.tabIndex, project.total])).toEqual([[0, 3], [1, 1], [2, 4]]) + expect(projects.every(project => project.matching === 0)).toBe(true) + }) + + it('finds a project by its worktree name and filters membership by tab', () => { + const projects = buildProjectScopeRows(rows, rows) + expect(filterProjectScopeRows(projects, 'grok').map(project => project.tabId)).toEqual(['tab-a']) + expect(filterProjectScopeRows(projects, 'AGENT-CODE').map(project => project.tabId)).toEqual(['tab-a', 'tab-c']) + expect(filterProjectScopeRows(projects, 'c ·').map(project => project.tabId)).toEqual(['tab-c']) + expect(rowsInSelectedProjects(rows, new Set(['tab-a'])).map(row => row.sessionId)) + .toEqual(['audit', 'grok', 'terminal-headless']) + }) +}) diff --git a/src/renderer/src/features/workspace/lib/projectScope.ts b/src/renderer/src/features/workspace/lib/projectScope.ts new file mode 100644 index 000000000..cb530be41 --- /dev/null +++ b/src/renderer/src/features/workspace/lib/projectScope.ts @@ -0,0 +1,110 @@ +import { tabIndexLabel } from '@renderer/workspace/tile-tree/paneLabelFormat' +// Imported from its owner rather than through sessionDisplay.ts, which pulls in +// the renderer provider registry; this module is pure data shaping. +import { cwdBasename } from '@renderer/workspace/sessionDisplayTitle' + +/** + * A project as the Dispatch index names it: the project TAB, labelled by its + * letter and title. Shared by the Switch Agents and Close Old Agents scope + * pickers (#908). + * + * WHY the tab and not the working directory: Agent Code's project is the tab. + * Worktree agents run in a different directory but belong to the same project, + * and Dispatch row filters, pinned chips and pane labels all already say + * `A · agent-code`. Both modals used to key projects by `meta.cwd` because they + * predate Grid Dispatch's project bindings, when the title was the only label + * and was not unique; that fallback split every worktree into a pseudo-project + * exactly when a bulk switch needs "everyone in this project" in one click. + */ +export type ProjectScopeRow = { + tabId: string + tabIndex: number + /** `A · agent-code`, the same vocabulary the Dispatch index uses. */ + label: string + title: string + /** + * Distinct working-directory basenames of the agents in this tab, in row + * order. Shown as the secondary line so worktrees stay visible as + * directories inside the project rather than as projects of their own. + */ + directories: string[] + /** Every agent the modal knows about in this tab. */ + total: number + /** The subset that passes the modal's other criteria (age, provider). */ + matching: number +} + +export type ProjectScopeAgent = { + tabId: string + tabIndex: number + tabTitle: string + cwd: string +} + +export function projectScopeLabel(tabIndex: number, title: string): string { + return `${tabIndexLabel(tabIndex)} · ${title}` +} + +/** + * Group agent rows by tab, in tab order. + * + * WHY tab order rather than the old count-first sort: the whole point of the + * change is that this list reads like the Dispatch index, where A comes before + * B regardless of how many agents each holds. Two tabs with the same title + * stay distinct because the letter is part of the label. + */ +export function buildProjectScopeRows( + rows: readonly T[], + matchingRows: readonly T[], +): ProjectScopeRow[] { + const matchingByTab = new Map() + for (const row of matchingRows) { + matchingByTab.set(row.tabId, (matchingByTab.get(row.tabId) ?? 0) + 1) + } + const byTab = new Map() + for (const row of rows) { + const directory = cwdBasename(row.cwd) + const existing = byTab.get(row.tabId) + if (existing) { + existing.total += 1 + if (!existing.directories.includes(directory)) existing.directories.push(directory) + continue + } + byTab.set(row.tabId, { + tabId: row.tabId, + tabIndex: row.tabIndex, + label: projectScopeLabel(row.tabIndex, row.tabTitle), + title: row.tabTitle, + directories: [directory], + total: 1, + matching: 0, + }) + } + for (const project of byTab.values()) { + project.matching = matchingByTab.get(project.tabId) ?? 0 + } + return Array.from(byTab.values()).sort((a, b) => a.tabIndex - b.tabIndex) +} + +/** Case-insensitive match on the label (which embeds the title) and the + * directory names, so typing a worktree name still finds the project that + * contains it. */ +export function filterProjectScopeRows( + rows: readonly ProjectScopeRow[], + query: string, +): ProjectScopeRow[] { + const needle = query.trim().toLowerCase() + if (!needle) return [...rows] + return rows.filter(project => + project.label.toLowerCase().includes(needle) || + project.directories.some(directory => directory.toLowerCase().includes(needle)), + ) +} + +/** The one membership rule both modals apply when scope is "Selected projects". */ +export function rowsInSelectedProjects( + rows: readonly T[], + selectedTabIds: ReadonlySet, +): T[] { + return rows.filter(row => selectedTabIds.has(row.tabId)) +} diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.scope.renderer.test.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.scope.renderer.test.tsx new file mode 100644 index 000000000..7f62db1ed --- /dev/null +++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.scope.renderer.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { emptyRuntime } from '@renderer/session-runtime/state' +import type { UsageSnapshot } from '@shared/types/usage' +import type { Workspace } from '@renderer/workspace/workspaceStore' + +import { BulkProviderSwitchModal } from './BulkProviderSwitchModal' + +// The selected-scope path is where #908 lived: the checkbox list was built +// from working directories, so a worktree agent had its own checkbox and the +// tab's checkbox did not exist. This drives the real modal from the project +// checkbox to the preview; see the policy test for why the usage hook is +// mocked instead of the IPC beneath it. +const usage = vi.hoisted(() => ({ snapshot: null as UsageSnapshot | null })) + +vi.mock('@renderer/features/usage/hooks/useUsageHeaderSnapshot', () => ({ + useUsageHeaderSnapshot: () => ({ stale: false, snapshot: usage.snapshot }), +})) + +function healthySnapshot(): UsageSnapshot { + const provider = (name: 'codex' | 'claude') => ({ + provider: name, + status: 'ok' as const, + sourceLabel: 'keychain', + plan: 'pro', + spend: null, + extraUsage: null, + credits: null, + rows: [], + }) + return { + fetchedAt: new Date().toISOString(), + cache: { hit: false, ttlMs: 30_000 }, + providers: [provider('codex'), provider('claude')], + } +} + +/** One agent-code tab whose agents span the main checkout and a worktree (one + * of them detached to Dispatch), plus an unrelated startup tab. */ +function workspaceFixture(): Workspace { + return { + state: { + activeTabId: 'tab-agent-code', + dispatchMode: null, + gridRelatedSelections: {}, + tabs: [ + { id: 'tab-agent-code', title: 'agent-code', focusedSessionId: 'audit', root: { type: 'leaf', sessionId: 'audit' } }, + { id: 'tab-startup', title: 'startup', focusedSessionId: 'pitch', root: { type: 'leaf', sessionId: 'pitch' } }, + ], + sessions: { + audit: { cwd: '/dev/agent-code', kind: 'codex' }, + grok: { cwd: '/dev/agent-code/.worktrees/grok-package-wiring', kind: 'codex' }, + pitch: { cwd: '/dev/startup', kind: 'codex' }, + }, + detachedSessions: { + grok: { sessionId: 'grok', surface: 'dispatch', projectTabId: 'tab-agent-code', projectTabTitle: 'agent-code', projectTabIndex: 0, detachedAt: 1 }, + }, + buried: [], + pinnedSessionIds: [], + lastProviderSwitchBatch: null, + }, + runtimes: { audit: emptyRuntime(), grok: emptyRuntime(), pitch: emptyRuntime() }, + focusSessionInTab: vi.fn(), + closeSession: vi.fn(), + switchAgentsToProvider: vi.fn().mockResolvedValue(undefined), + returnLastProviderSwitchBatch: vi.fn(), + } as unknown as Workspace +} + +afterEach(() => { + usage.snapshot = null +}) + +describe('BulkProviderSwitchModal project scope', () => { + it('lists tabs the way Dispatch does and selects a worktree agent through its tab', () => { + usage.snapshot = healthySnapshot() + render( {}} />) + + expect(screen.getByText('Will switch · 3 agents')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Selected projects' })) + expect(screen.getByText(/No Codex agents match the current scope/i)).toBeInTheDocument() + + // Two project entries, named like the Dispatch index. The old behaviour + // produced three, one of them "grok-package-wiring". + expect(screen.getAllByText(/^[A-Z] · /).map(node => node.textContent)).toEqual(['A · agent-code', 'B · startup']) + expect(screen.getByText('agent-code · grok-package-wiring')).toBeInTheDocument() + + const agentCodeProject = screen.getByText('A · agent-code').closest('label') + if (!agentCodeProject) throw new Error('project entry is not a label') + fireEvent.click(within(agentCodeProject).getByRole('checkbox')) + + // Ticking the tab brings both of its agents, including the detached + // worktree one, into the preview; the startup agent stays out. + expect(screen.getByText('Will switch · 2 agents')).toBeInTheDocument() + expect(screen.getByText('1 selected')).toBeInTheDocument() + expect(screen.getByText('A · agent-code · /dev/agent-code/.worktrees/grok-package-wiring')).toBeInTheDocument() + expect(screen.queryByText('B · startup · /dev/startup')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx index 47d68d58b..4a2bd2393 100644 --- a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx +++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx @@ -10,6 +10,13 @@ import { } from '@renderer/components/ui/dialog' import { relativeTime } from '@renderer/lib/relativeTime' import { cwdBasename, pluralAgents, providerGlyph } from '@renderer/features/workspace/lib/sessionDisplay' +import { + buildProjectScopeRows, + filterProjectScopeRows, + rowsInSelectedProjects, +} from '@renderer/features/workspace/lib/projectScope' +import type { ProjectScopeRow } from '@renderer/features/workspace/lib/projectScope' +import { tabIndexLabel } from '@renderer/workspace/tile-tree/paneLabelFormat' import { resolveTabSessions } from '@renderer/workspace/queries' import type { SessionId, Tab } from '@renderer/workspace/types' import type { Workspace } from '@renderer/workspace/workspaceStore' @@ -57,12 +64,6 @@ type AgentRow = { isLive: boolean } -type ProjectRow = { - cwd: string - cwdBase: string - total: number -} - type SwitchDirection = { key: string source: AgentProviderKind @@ -324,43 +325,37 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { } }) + // Project (tab) first so the preview reads like the Dispatch index, then + // the directory inside it so a tab's worktrees sit together. rows.sort((a, b) => { - if (a.cwdBase !== b.cwdBase) return a.cwdBase.localeCompare(b.cwdBase) - return a.tabIndex - b.tabIndex + if (a.tabIndex !== b.tabIndex) return a.tabIndex - b.tabIndex + return a.cwdBase.localeCompare(b.cwdBase) }) return rows }, [open, workspace.runtimes, workspace.state, source, nowTick]) const matchingRows = useMemo(() => { if (scopeMode === 'all') return agentRows - return agentRows.filter(row => selectedProjects.has(row.cwd)) + return rowsInSelectedProjects(agentRows, selectedProjects) }, [agentRows, scopeMode, selectedProjects]) - const projects = useMemo(() => { - const byProject = new Map() - for (const row of agentRows) { - const existing = byProject.get(row.cwd) - if (existing) existing.total += 1 - else byProject.set(row.cwd, { cwd: row.cwd, cwdBase: row.cwdBase, total: 1 }) - } - return Array.from(byProject.values()).sort((a, b) => { - if (a.total !== b.total) return b.total - a.total - return a.cwdBase.localeCompare(b.cwdBase) - }) - }, [agentRows]) - - const filteredProjects = useMemo(() => { - const query = projectFilter.trim().toLowerCase() - if (!query) return projects - return projects.filter( - project => - project.cwd.toLowerCase().includes(query) || - project.cwdBase.toLowerCase().includes(query), - ) - }, [projectFilter, projects]) + // Projects are TABS here, keyed by tab id (#908). Every row is eligible for a + // switch, so `matching` equals `total` and only `total` is shown. + const projects = useMemo( + () => buildProjectScopeRows(agentRows, agentRows), + [agentRows], + ) + + const filteredProjects = useMemo( + () => filterProjectScopeRows(projects, projectFilter), + [projectFilter, projects], + ) const midTurnCount = matchingRows.filter(row => row.isLive).length - const selectedCount = selectedProjects.size + // Count only tabs that still exist: a selected tab closed while the modal is + // open keeps its id in the set (membership is unaffected, its rows are gone) + // and would otherwise inflate "N selected". + const selectedCount = projects.filter(project => selectedProjects.has(project.tabId)).length // Biggest conversation in the batch, not the sum: arrival compaction runs // per agent, so the question is whether ANY single pane will land oversized. @@ -437,13 +432,13 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // changing the set disarms it — otherwise a user who confirms three agents // and then ticks a fourth project spends quota on agents they never saw named // in the confirmation. - const toggleProject = useCallback((cwd: string) => { + const toggleProject = useCallback((tabId: string) => { setSourceConfirmArmed(false) setConfirmedSessionIds(null) setSelectedProjects(prev => { const next = new Set(prev) - if (next.has(cwd)) next.delete(cwd) - else next.add(cwd) + if (next.has(tabId)) next.delete(tabId) + else next.add(tabId) return next }) }, []) @@ -451,7 +446,7 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { const selectAllProjects = useCallback(() => { setSourceConfirmArmed(false) setConfirmedSessionIds(null) - setSelectedProjects(new Set(projects.map(project => project.cwd))) + setSelectedProjects(new Set(projects.map(project => project.tabId))) }, [projects]) const clearProjects = useCallback(() => { @@ -865,11 +860,11 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { ) : ( filteredProjects.map(project => { - const selected = selectedProjects.has(project.cwd) + const selected = selectedProjects.has(project.tabId) const disabled = scopeMode === 'all' return (