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:
@@ -44,6 +44,7 @@ vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip }))
|
||||
const {
|
||||
validateBranchNameClient,
|
||||
renderNewWorktreeForm,
|
||||
renderFanoutForm,
|
||||
renderProjectDetail,
|
||||
makeWorktreeRow,
|
||||
confirmAndRemoveWorktree,
|
||||
@@ -53,7 +54,7 @@ const {
|
||||
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
function makeHooks() {
|
||||
return { onOpenProject: vi.fn(), onEnterSession: vi.fn() }
|
||||
return { onOpenProject: vi.fn(), onEnterSession: vi.fn(), onFanout: vi.fn() }
|
||||
}
|
||||
|
||||
function makeCbs() {
|
||||
@@ -286,6 +287,7 @@ describe('renderNewWorktreeForm', () => {
|
||||
// Flush async microtasks
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(hooks.onOpenProject).toHaveBeenCalledWith(
|
||||
'/home/user/my-repo-worktrees/feat',
|
||||
@@ -309,6 +311,7 @@ describe('renderNewWorktreeForm', () => {
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r')
|
||||
})
|
||||
@@ -327,6 +330,7 @@ describe('renderNewWorktreeForm', () => {
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
||||
expect(errorEl.style.display).not.toBe('none')
|
||||
@@ -345,6 +349,7 @@ describe('renderNewWorktreeForm', () => {
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
||||
expect(errorEl.textContent).toBeTruthy()
|
||||
@@ -365,6 +370,7 @@ describe('renderNewWorktreeForm', () => {
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
||||
// No <script> element in DOM — textContent was used
|
||||
@@ -492,6 +498,112 @@ describe('renderProjectDetail — B3 New Worktree Form', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* ── renderFanoutForm (W5 fan-out board) ──────────────────────────────────── */
|
||||
|
||||
describe('renderFanoutForm', () => {
|
||||
it('renders a prompt textarea, lane stepper, branch input, mode select, submit', () => {
|
||||
const form = renderFanoutForm(makeDetail(), makeHooks())
|
||||
expect(form.querySelector('textarea.proj-fanout-prompt')).not.toBeNull()
|
||||
const lanes = form.querySelector('input.proj-fanout-lanes') as HTMLInputElement
|
||||
expect(lanes).not.toBeNull()
|
||||
expect(lanes.min).toBe('2')
|
||||
expect(lanes.max).toBe('6') // default maxFanoutLanes
|
||||
expect(form.querySelector('input.proj-fanout-branch')).not.toBeNull()
|
||||
expect(form.querySelector('select.proj-fanout-mode')).not.toBeNull()
|
||||
expect(form.querySelector('button.proj-fanout-submit')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('honors a server-controlled max lane count', () => {
|
||||
const form = renderFanoutForm(makeDetail(), makeHooks(), 3)
|
||||
expect((form.querySelector('input.proj-fanout-lanes') as HTMLInputElement).max).toBe('3')
|
||||
})
|
||||
|
||||
it('disables submit until a prompt is entered', () => {
|
||||
const form = renderFanoutForm(makeDetail(), makeHooks())
|
||||
const submit = form.querySelector('button.proj-fanout-submit') as HTMLButtonElement
|
||||
expect(submit.disabled).toBe(true)
|
||||
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
|
||||
prompt.value = 'add dark mode'
|
||||
prompt.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(submit.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('auto-fills the branch base from the prompt slug until the user edits it', () => {
|
||||
const form = renderFanoutForm(makeDetail(), makeHooks())
|
||||
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
|
||||
const branch = form.querySelector('input.proj-fanout-branch') as HTMLInputElement
|
||||
prompt.value = 'Add Dark Mode!'
|
||||
prompt.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(branch.value).toBe('add-dark-mode')
|
||||
branch.value = 'custom-base'
|
||||
branch.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
prompt.value = 'Add Dark Mode changed'
|
||||
prompt.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(branch.value).toBe('custom-base') // not clobbered after manual edit
|
||||
})
|
||||
|
||||
it('submitting calls onFanout with the repo path, name, and options', () => {
|
||||
const hooks = makeHooks()
|
||||
const detail = makeDetail({ path: '/home/user/my-repo', name: 'my-repo' })
|
||||
const form = renderFanoutForm(detail, hooks)
|
||||
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
|
||||
const lanes = form.querySelector('input.proj-fanout-lanes') as HTMLInputElement
|
||||
const mode = form.querySelector('select.proj-fanout-mode') as HTMLSelectElement
|
||||
prompt.value = 'add dark mode'
|
||||
prompt.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
lanes.value = '4'
|
||||
mode.value = 'plan'
|
||||
;(form.querySelector('button.proj-fanout-submit') as HTMLButtonElement).click()
|
||||
expect(hooks.onFanout).toHaveBeenCalledWith('/home/user/my-repo', 'my-repo', {
|
||||
prompt: 'add dark mode',
|
||||
lanes: 4,
|
||||
branchBase: 'add-dark-mode',
|
||||
mode: 'plan',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an inline error (textContent) and does not call onFanout with no prompt', () => {
|
||||
const hooks = makeHooks()
|
||||
const form = renderFanoutForm(makeDetail(), hooks)
|
||||
const submit = form.querySelector('button.proj-fanout-submit') as HTMLButtonElement
|
||||
submit.disabled = false // exercise the submit-handler guard directly
|
||||
submit.click()
|
||||
const err = form.querySelector('.proj-fanout-error') as HTMLElement
|
||||
expect(err.textContent).not.toBe('')
|
||||
expect(err.style.display).not.toBe('none')
|
||||
expect(hooks.onFanout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid branch base via inline error, not onFanout', () => {
|
||||
const hooks = makeHooks()
|
||||
const form = renderFanoutForm(makeDetail(), hooks)
|
||||
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
|
||||
const branch = form.querySelector('input.proj-fanout-branch') as HTMLInputElement
|
||||
prompt.value = 'ok prompt'
|
||||
prompt.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
branch.value = '-bad' // leading dash → invalid git branch
|
||||
branch.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
;(form.querySelector('button.proj-fanout-submit') as HTMLButtonElement).click()
|
||||
expect(hooks.onFanout).not.toHaveBeenCalled()
|
||||
expect((form.querySelector('.proj-fanout-error') as HTMLElement).textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('renders the Fan out section inside a git project detail', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
expect(root.querySelector('.proj-fanout-form')).not.toBeNull()
|
||||
expect(root.textContent).toContain('Fan out')
|
||||
})
|
||||
|
||||
it('omits the Fan out form for a non-git directory', () => {
|
||||
const root = renderProjectDetail(
|
||||
makeDetail({ isGit: false, branch: undefined }),
|
||||
makeHooks(),
|
||||
makeCbs(),
|
||||
)
|
||||
expect(root.querySelector('.proj-fanout-form')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── renderProjectDetail — A4: Activity section ───────────────────────────── */
|
||||
|
||||
describe('renderProjectDetail — A4 Activity section', () => {
|
||||
|
||||
Reference in New Issue
Block a user