Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/superpowers/plans/2026-09-11-project-scope-by-tab.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions src/renderer/src/features/workspace/lib/projectScope.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
110 changes: 110 additions & 0 deletions src/renderer/src/features/workspace/lib/projectScope.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ProjectScopeAgent>(
rows: readonly T[],
matchingRows: readonly T[],
): ProjectScopeRow[] {
const matchingByTab = new Map<string, number>()
for (const row of matchingRows) {
matchingByTab.set(row.tabId, (matchingByTab.get(row.tabId) ?? 0) + 1)
}
const byTab = new Map<string, ProjectScopeRow>()
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<T extends { tabId: string }>(
rows: readonly T[],
selectedTabIds: ReadonlySet<string>,
): T[] {
return rows.filter(row => selectedTabIds.has(row.tabId))
}
Original file line number Diff line number Diff line change
@@ -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(<BulkProviderSwitchModal open workspace={workspaceFixture()} onClose={() => {}} />)

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()
})
})
Loading