feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
This commit is contained in:
@@ -156,7 +156,26 @@ const mountProjects = vi.fn(() => ({
|
||||
},
|
||||
refresh: vi.fn(),
|
||||
}))
|
||||
vi.mock('../public/projects.js', () => ({ mountProjects }))
|
||||
// W5: TabApp imports these worktree helpers from projects.js for launchFanout /
|
||||
// keepFanoutWinner. Mocked so we drive them without real fetch and assert routing.
|
||||
const createWorktreeReqMock = vi.fn(
|
||||
async (_repoPath: string, branch: string) => ({ ok: true, path: `/wt/${branch}` }) as {
|
||||
ok: boolean
|
||||
path?: string
|
||||
status?: number
|
||||
error?: string
|
||||
},
|
||||
)
|
||||
const removeWorktreeReqMock = vi.fn(
|
||||
async () => ({ ok: true }) as { ok: boolean; status?: number; error?: string },
|
||||
)
|
||||
const validateBranchNameClientMock = vi.fn((_b: string): string | null => null)
|
||||
vi.mock('../public/projects.js', () => ({
|
||||
mountProjects,
|
||||
createWorktreeReq: (...a: unknown[]) => createWorktreeReqMock(...(a as [string, string])),
|
||||
removeWorktreeReq: (...a: unknown[]) => removeWorktreeReqMock(...(a as [])),
|
||||
validateBranchNameClient: (...a: unknown[]) => validateBranchNameClientMock(...(a as [string])),
|
||||
}))
|
||||
|
||||
// settings.js is light but imports nothing heavy; let it load for real.
|
||||
|
||||
@@ -200,6 +219,16 @@ beforeEach(() => {
|
||||
quickReply.onSend = undefined
|
||||
quickReply.onQueue = undefined
|
||||
enqueueFollowupMock.mockClear()
|
||||
// W5 fan-out mocks: reset to permissive defaults each test.
|
||||
createWorktreeReqMock.mockReset()
|
||||
createWorktreeReqMock.mockImplementation(async (_repoPath: string, branch: string) => ({
|
||||
ok: true,
|
||||
path: `/wt/${branch}`,
|
||||
}))
|
||||
removeWorktreeReqMock.mockReset()
|
||||
removeWorktreeReqMock.mockResolvedValue({ ok: true })
|
||||
validateBranchNameClientMock.mockReset()
|
||||
validateBranchNameClientMock.mockReturnValue(null)
|
||||
voice.onTranscript = undefined
|
||||
voice.onInterim = undefined
|
||||
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
|
||||
@@ -208,6 +237,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks() // restore window.confirm etc. spied via vi.spyOn (W5 tests)
|
||||
})
|
||||
|
||||
describe('TabApp — v0.5 launcher chooser', () => {
|
||||
@@ -1718,3 +1748,169 @@ describe('TabApp — W2 inject queue', () => {
|
||||
expect(enqueueFollowupMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── W5: fan-out board (launchFanout + keepFanoutWinner) ──────────────────────
|
||||
describe('TabApp — W5 fan-out board', () => {
|
||||
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
/** Fan out N lanes and stamp server ids on the resulting sessions. */
|
||||
async function launched(
|
||||
app: InstanceType<typeof TabApp>,
|
||||
lanes: number,
|
||||
branchBase = 'feat',
|
||||
): Promise<void> {
|
||||
await app.launchFanout('/repo', 'repo', { prompt: 'add feature', lanes, branchBase, mode: 'default' })
|
||||
FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`))
|
||||
}
|
||||
|
||||
const keepBtnOf = (i: number): HTMLButtonElement =>
|
||||
FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-keep')!
|
||||
|
||||
it('launchFanout creates N worktrees SEQUENTIALLY, one tab per lane, on a grid-4 board', async () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
|
||||
await app.launchFanout('/repo', 'repo', {
|
||||
prompt: 'add feature',
|
||||
lanes: 3,
|
||||
branchBase: 'feat',
|
||||
mode: 'default',
|
||||
})
|
||||
|
||||
expect(app.snapshot()).toHaveLength(3)
|
||||
expect(FakeTerminalSession.instances).toHaveLength(3)
|
||||
// Sequential, in lane order.
|
||||
expect(createWorktreeReqMock).toHaveBeenCalledTimes(3)
|
||||
expect(createWorktreeReqMock.mock.calls.map((c) => c[1])).toEqual([
|
||||
'feat-lane-1',
|
||||
'feat-lane-2',
|
||||
'feat-lane-3',
|
||||
])
|
||||
// Same shell-quoted launch command injected into EVERY lane.
|
||||
for (const inst of FakeTerminalSession.instances) {
|
||||
expect(inst.initialInput).toBe("claude 'add feature'\r")
|
||||
}
|
||||
// Each lane spawns in its own worktree cwd.
|
||||
const cwds = FakeTerminalSession.instances.map(
|
||||
(i) => (i.cbs as Record<string, unknown>)['cwd'],
|
||||
)
|
||||
expect(cwds).toEqual(['/wt/feat-lane-1', '/wt/feat-lane-2', '/wt/feat-lane-3'])
|
||||
expect(app.getGridLayout()).toBe('grid-4')
|
||||
})
|
||||
|
||||
it('launchFanout uses grid-6 for more than four lanes', async () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await launched(app, 5)
|
||||
expect(app.snapshot()).toHaveLength(5)
|
||||
expect(app.getGridLayout()).toBe('grid-6')
|
||||
})
|
||||
|
||||
it('launchFanout refuses an invalid branch base (no worktrees, banner shown)', async () => {
|
||||
validateBranchNameClientMock.mockReturnValue('bad branch')
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: '-bad', mode: 'default' })
|
||||
expect(createWorktreeReqMock).not.toHaveBeenCalled()
|
||||
expect(app.snapshot()).toHaveLength(0)
|
||||
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
|
||||
})
|
||||
|
||||
it('launchFanout tolerates a partial failure — records it in the banner, never throws', async () => {
|
||||
createWorktreeReqMock.mockImplementation(async (_repo: string, branch: string) =>
|
||||
branch.endsWith('-2')
|
||||
? { ok: false, status: 500, error: 'boom' }
|
||||
: { ok: true, path: `/wt/${branch}` },
|
||||
)
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await expect(
|
||||
app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' }),
|
||||
).resolves.toBeUndefined()
|
||||
expect(app.snapshot()).toHaveLength(2) // lanes 1 and 3 created
|
||||
const banner = document.getElementById('fanout-banner')!
|
||||
expect(banner.style.display).toBe('block')
|
||||
expect(banner.textContent).toContain('Started 2 of 3')
|
||||
})
|
||||
|
||||
it('every created lane cell shows a 🏆 Keep button (fan-out lanes only)', async () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await launched(app, 3)
|
||||
for (let i = 0; i < 3; i++) expect(keepBtnOf(i).style.display).not.toBe('none')
|
||||
})
|
||||
|
||||
it('a plain (non-fan-out) tab has no visible Keep button', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const cell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')!
|
||||
expect((cell.querySelector('.cell-keep') as HTMLElement).style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('keepFanoutWinner keeps the winner, closes losers, removes their worktrees, layout → single', async () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await launched(app, 3)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
keepBtnOf(0).click() // keep sess-0 (feat-lane-1)
|
||||
await flush()
|
||||
|
||||
expect(app.snapshot()).toHaveLength(1) // only the winner remains
|
||||
expect(app.activeSessionId()).toBe('sess-0')
|
||||
// One DELETE per loser, force=false, with the loser's worktree path.
|
||||
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
|
||||
const paths = removeWorktreeReqMock.mock.calls.map((c) => (c as unknown[])[1]).sort()
|
||||
expect(paths).toEqual(['/wt/feat-lane-2', '/wt/feat-lane-3'])
|
||||
expect(removeWorktreeReqMock.mock.calls.every((c) => (c as unknown[])[2] === false)).toBe(true)
|
||||
expect(app.getGridLayout()).toBe('single')
|
||||
})
|
||||
|
||||
it('keepFanoutWinner force-removes a dirty (409) loser without a second confirm', async () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await launched(app, 2)
|
||||
removeWorktreeReqMock
|
||||
.mockResolvedValueOnce({ ok: false, status: 409, error: 'dirty' })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
keepBtnOf(0).click() // keep sess-0; one loser (sess-1)
|
||||
await flush()
|
||||
|
||||
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
|
||||
expect((removeWorktreeReqMock.mock.calls[0] as unknown[])[2]).toBe(false) // first try
|
||||
expect((removeWorktreeReqMock.mock.calls[1] as unknown[])[2]).toBe(true) // forced retry
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1) // only the batch confirm
|
||||
})
|
||||
|
||||
it('keepFanoutWinner cancelled → no tabs closed, no worktree removed', async () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await launched(app, 3)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
keepBtnOf(0).click()
|
||||
await flush()
|
||||
|
||||
expect(app.snapshot()).toHaveLength(3) // nothing closed
|
||||
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keepFanoutWinner refuses when the winner has not attached (null id) — deletes nothing', async () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
// Fan out but do NOT stamp ids (sessions still null).
|
||||
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' })
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
keepBtnOf(0).click() // entry.session.id is null → winnerSessionId ''
|
||||
await flush()
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled() // refused before the confirm
|
||||
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
|
||||
expect(app.snapshot()).toHaveLength(3)
|
||||
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user