// @vitest-environment jsdom /** * test/tabs.test.ts — frontend TabApp unit tests. * * Mocks TerminalSession + the launcher + projects panel so we test TabApp's * tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant, * that addEntry builds a real (non-null) session before pushing the entry * (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { ApprovalPreview } from '../src/types.js' // ── Mock TerminalSession ────────────────────────────────────────────────────── let constructed = 0 class FakeTerminalSession { el: HTMLDivElement id: string | null status = 'connecting' claudeStatus = 'unknown' cwd: string | null = null pendingApproval = false pendingTool: string | undefined = undefined /** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */ pendingGate: 'tool' | 'plan' | null = null /** W1: bounded command/diff preview of the held tool (or null). */ pendingPreview: ApprovalPreview | null = null /** VC: nonce that counts false→true pendingApproval flips (stale-gate guard). */ pendingEpoch = 0 /** B2: latest statusLine telemetry (single source of truth). */ telemetry: unknown = null /** W2: pending inject-queue depth (tests set it before driving onQueue). */ queueLength = 0 /** Captured so tests can assert initialInput ends with \r (Finding 1). */ initialInput: string | undefined = undefined connect = vi.fn() dispose = vi.fn() show = vi.fn() hide = vi.fn() applyTheme = vi.fn() refit = vi.fn() send = vi.fn() approve = vi.fn() reject = vi.fn() findNext = vi.fn() findPrevious = vi.fn() clearSearch = vi.fn() // Captured callbacks so tests can drive status/title/activity events. cbs: { onClaudeStatus?: (s: string, d?: string) => void onStatus?: (s: string) => void onActivity?: () => void onTitle?: (t: string) => void onTelemetry?: (t: unknown) => void onQueue?: (n: number) => void } static instances: FakeTerminalSession[] = [] constructor(opts: { sessionId: string | null initialInput?: string onClaudeStatus?: (s: string, d?: string) => void onStatus?: (s: string) => void onActivity?: () => void onTitle?: (t: string) => void onTelemetry?: (t: unknown) => void onQueue?: (n: number) => void [key: string]: unknown }) { constructed += 1 this.id = opts.sessionId this.initialInput = opts.initialInput this.el = document.createElement('div') this.cbs = opts FakeTerminalSession.instances.push(this) } } vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession })) // ── Mock the v0.7 panels (push / quick-reply / timeline / voice / gauge) ────── const renderTelemetryGauge = vi.fn() vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge })) // ── Mock the v3 cell-monitor (real one pulls in xterm; we assert wiring only) ── const monitorHandles: Array<{ dispose: ReturnType }> = [] const mountCellMonitor = vi.fn((_host: HTMLElement, _id: string) => { const handle = { dispose: vi.fn() } monitorHandles.push(handle) return handle }) vi.mock('../public/cell-monitor.js', () => ({ mountCellMonitor })) const mountPushToggle = vi.fn() vi.mock('../public/push.js', () => ({ mountPushToggle })) const quickReply: { onSend?: (d: string) => void onQueue?: (text: string, appendEnter: boolean) => void } = {} const mountQuickReply = vi.fn( ( _c: HTMLElement, opts: { onSend: (d: string) => void; onQueue?: (text: string, appendEnter: boolean) => void }, ) => { quickReply.onSend = opts.onSend quickReply.onQueue = opts.onQueue return { dispose: vi.fn() } }, ) vi.mock('../public/quick-reply.js', () => ({ mountQuickReply })) // ── Mock the W2 queue helper (assert enqueue routing without real fetch) ────── const enqueueFollowupMock = vi.fn(async () => ({ ok: true, length: 1 })) vi.mock('../public/queue.js', () => ({ enqueueFollowup: (...args: unknown[]) => enqueueFollowupMock(...(args as [])), })) const timelineDispose = vi.fn() const mountTimeline = vi.fn(() => ({ dispose: timelineDispose })) vi.mock('../public/timeline.js', () => ({ mountTimeline })) const voice: { onTranscript?: (t: string, confidence?: number) => void onInterim?: (t: string) => void } = {} const voiceStart = vi.fn() const voiceStop = vi.fn() const createVoiceInput = vi.fn( ( onTranscript: (t: string, confidence?: number) => void, opts?: { onInterim?: (t: string) => void }, ) => { voice.onTranscript = onTranscript voice.onInterim = opts?.onInterim return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false } }, ) const isSpeechSupported = vi.fn(() => true) vi.mock('../public/voice.js', () => ({ createVoiceInput, isSpeechSupported })) // ── Mock the launcher (records visibility) ──────────────────────────────────── const launcherVisible = { value: false } const mountLauncher = vi.fn(() => ({ setVisible: (v: boolean) => { launcherVisible.value = v }, refresh: vi.fn(), })) vi.mock('../public/launcher.js', () => ({ mountLauncher })) // ── Mock the projects panel (records visibility) ────────────────────────────── const projectsVisible = { value: false } const mountProjects = vi.fn(() => ({ setVisible: (v: boolean) => { projectsVisible.value = v }, refresh: vi.fn(), })) // 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. const { TabApp } = await import('../public/tabs.js') function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } { const paneHost = document.createElement('div') const tabBar = document.createElement('div') // #keybar must exist so the quick-reply row mounts directly above it (A3). const keybar = document.createElement('div') keybar.id = 'keybar' document.body.append(paneHost, tabBar, keybar) return { paneHost, tabBar } } /** Stub fetch('/config/ui') → { allowAutoMode }. */ function stubUiConfigFetch(allowAutoMode: boolean): void { vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, json: async () => ({ allowAutoMode }) })), ) } beforeEach(() => { constructed = 0 FakeTerminalSession.instances = [] launcherVisible.value = false projectsVisible.value = false document.body.replaceChildren() localStorage.clear() renderTelemetryGauge.mockClear() mountCellMonitor.mockClear() monitorHandles.length = 0 mountPushToggle.mockClear() mountQuickReply.mockClear() mountTimeline.mockClear() timelineDispose.mockClear() createVoiceInput.mockClear() voiceStart.mockClear() voiceStop.mockClear() 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). vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('no fetch in test') })) }) afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() // restore window.confirm etc. spied via vi.spyOn (W5 tests) }) describe('TabApp — v0.5 launcher chooser', () => { it('starts with NO auto-created tab and shows the launcher', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) // No TerminalSession constructed on boot (no auto tab). expect(constructed).toBe(0) expect(launcherVisible.value).toBe(true) }) it('newTab() creates a tab and hides the launcher', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(constructed).toBe(1) expect(launcherVisible.value).toBe(false) }) it('closing the last tab returns to the launcher (no auto-blank tab)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(launcherVisible.value).toBe(false) app.closeTab(0) // Back to the chooser, and no new tab was auto-created. expect(launcherVisible.value).toBe(true) expect(constructed).toBe(1) }) }) describe('TabApp — addEntry has no null-session hole (Phase 1#5)', () => { it('the constructed session is real and used immediately (connect called)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() // The entry's session must be the constructed FakeTerminalSession with connect() called. const snap = app.snapshot() expect(snap).toHaveLength(1) // sendToActive routes to a real session (would throw if session were null). expect(() => app.sendToActive('x')).not.toThrow() }) it('openSession joins by id and focuses it', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openSession('11111111-1111-4111-8111-111111111111') expect(constructed).toBe(1) expect(app.activeSessionId()).toBe('11111111-1111-4111-8111-111111111111') }) it('openSession re-focuses an already-open session instead of duplicating', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const id = '22222222-2222-4222-8222-222222222222' app.openSession(id) app.openSession(id) expect(constructed).toBe(1) // not duplicated }) }) describe('TabApp — multi-tab lifecycle', () => { it('renders a tab bar with one .tab per tab plus the add button', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() expect(tabBar.querySelectorAll('.tab')).toHaveLength(2) expect(tabBar.querySelector('.tab-add')).not.toBeNull() }) it('activate() switches the active tab (show/hide on sessions)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() app.activate(0) const snap = app.snapshot() expect(snap.find((t) => t.active)?.idx).toBe(0) }) it('closeTab on a middle tab keeps the others and re-activates a neighbor', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() app.newTab() app.closeTab(1) expect(app.snapshot()).toHaveLength(2) }) it('applySettings re-themes every tab without throwing', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(() => app.applySettings({ theme: 'dark', fontSize: 15 })).not.toThrow() }) it('findInActive / clearActiveSearch route to the active session', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(() => { app.findInActive('q', 'next') app.findInActive('q', 'prev') app.clearActiveSearch() app.refitActive() }).not.toThrow() }) it('newTabForResume opens a tab seeded with a resume command', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333') expect(constructed).toBe(1) expect(app.snapshot()).toHaveLength(1) }) it('newTabForResume initialInput ends with \\r so the shell auto-executes it (Finding 1)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333') const session = FakeTerminalSession.instances[0]! expect(session.initialInput).toBe('claude --resume 33333333-3333-4333-8333-333333333333\r') }) it('openProject default initialInput is "claude\\r" so the shell auto-executes it (A3/Finding 1)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/path/to/myrepo', 'myrepo') const session = FakeTerminalSession.instances[0]! expect(session.initialInput).toBe('claude\r') }) it('snapshot reflects per-tab connection + claude status fields', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const snap = app.snapshot() expect(snap[0]).toMatchObject({ idx: 0, conn: 'connecting', claude: 'unknown' }) }) }) describe('TabApp — DOM interactions on the tab bar', () => { function pointerdown(elm: Element): void { elm.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) } it('pointerdown on a tab activates it', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const tabs = tabBar.querySelectorAll('.tab') pointerdown(tabs[0]!) expect(app.snapshot().find((t) => t.active)?.idx).toBe(0) }) it('clicking the × close button closes that tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const close = tabBar.querySelector('.tab-close') as HTMLButtonElement close.dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(app.snapshot()).toHaveLength(1) }) it('clicking the + add button opens a new tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const add = tabBar.querySelector('.tab-add') as HTMLButtonElement add.click() expect(app.snapshot()).toHaveLength(1) }) it('middle-click (auxclick button 1) closes a tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const tab = tabBar.querySelector('.tab') as HTMLElement tab.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 })) expect(app.snapshot()).toHaveLength(1) }) it('double-click the label enters rename mode, Enter commits a custom title', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const label = tabBar.querySelector('.tab-label') as HTMLElement label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement expect(input).not.toBeNull() input.value = 'My Tab' input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) expect(app.snapshot()[0]!.title).toBe('My Tab') }) it('activate() out of range is ignored', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(() => { app.activate(99) app.activate(-1) app.focusTab(0) }).not.toThrow() }) it('sendToActive / clearActiveSearch with no active tab do not throw', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) // No tab open (launcher visible). expect(() => { app.sendToActive('x') app.clearActiveSearch() app.refitActive() }).not.toThrow() expect(app.activeSessionId()).toBeNull() }) it('Escape in rename mode cancels without committing', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const label = tabBar.querySelector('.tab-label') as HTMLElement label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement input.value = 'discard me' input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) // Title falls back to the auto/default, not the typed value. expect(app.snapshot()[0]!.title).not.toBe('discard me') }) it('onTitle/onStatus/onActivity callbacks refresh the tab in place', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const fake = FakeTerminalSession.instances[0]! // Drive the captured callbacks like the real TerminalSession would. expect(() => { fake.cbs.onTitle?.('myproj') fake.cbs.onStatus?.('connected') fake.cbs.onActivity?.() }).not.toThrow() }) it('onClaudeStatus "waiting" on a background tab notifies (H2/H4)', () => { const NotificationMock = vi.fn() ;(NotificationMock as unknown as { permission: string }).permission = 'granted' vi.stubGlobal('Notification', NotificationMock) const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() // tab 0 (active) app.newTab() // tab 1 (active now) // tab 0 is in the background; fire its onClaudeStatus 'waiting'. const bg = FakeTerminalSession.instances[0]! bg.claudeStatus = 'waiting' bg.cbs.onClaudeStatus?.('waiting') expect(NotificationMock).toHaveBeenCalled() vi.unstubAllGlobals() }) it('drag-and-drop reorders tabs', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const tabs = tabBar.querySelectorAll('.tab') // jsdom has no DataTransfer; the handlers use optional chaining + this.dragIndex, // so plain drag events (no dataTransfer) still drive the reorder path. tabs[0]!.dispatchEvent(new Event('dragstart', { bubbles: true })) tabs[1]!.dispatchEvent(new Event('dragover', { bubbles: true })) tabs[1]!.dispatchEvent(new Event('drop', { bubbles: true })) tabs[0]!.dispatchEvent(new Event('dragend', { bubbles: true })) expect(app.snapshot()).toHaveLength(2) }) }) describe('TabApp — P6 openProject + home segmented control', () => { it('openProject creates a tab with the repo name as its displayed title', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/path/to/myrepo', 'myrepo') expect(app.snapshot()).toHaveLength(1) expect(app.snapshot()[0]!.title).toBe('myrepo') }) it('a second openProject with the same name gets a #2 suffix', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/path/to/myrepo', 'myrepo') app.openProject('/path/to/myrepo', 'myrepo') const snap = app.snapshot() expect(snap).toHaveLength(2) expect(snap[0]!.title).toBe('myrepo') expect(snap[1]!.title).toBe('myrepo #2') }) it('countOpenWithTitlePrefix increments correctly for three tabs with the same base name', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p', 'proj') app.openProject('/p', 'proj') app.openProject('/p', 'proj') const snap = app.snapshot() expect(snap[0]!.title).toBe('proj') expect(snap[1]!.title).toBe('proj #2') expect(snap[2]!.title).toBe('proj #3') }) it('openProject hides the segmented control and both panels (tab open)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p/repo', 'repo') const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg).not.toBeNull() expect(seg.style.display).toBe('none') expect(launcherVisible.value).toBe(false) expect(projectsVisible.value).toBe(false) }) it('home segmented control shows both panels depending on selected view', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) // Initially: sessions view → launcher visible, projects hidden. expect(launcherVisible.value).toBe(true) expect(projectsVisible.value).toBe(false) // Click the "Projects" button (second .home-seg-btn). const projBtn = paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement projBtn.click() expect(launcherVisible.value).toBe(false) expect(projectsVisible.value).toBe(true) // Click the "Sessions" button (first .home-seg-btn). const sessBtn = paneHost.querySelectorAll('.home-seg-btn')[0] as HTMLButtonElement sessBtn.click() expect(launcherVisible.value).toBe(true) expect(projectsVisible.value).toBe(false) }) it('closing the last tab shows the segmented control and restores home view', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg.style.display).toBe('none') // hidden while tab open app.closeTab(0) expect(seg.style.display).not.toBe('none') // back to home expect(launcherVisible.value).toBe(true) // sessions view by default }) it('openProject with an empty repoPath still opens a tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) expect(() => app.openProject('', 'bare-repo')).not.toThrow() expect(app.snapshot()).toHaveLength(1) expect(app.snapshot()[0]!.title).toBe('bare-repo') }) }) describe('TabApp — Home (⌂) button: reach the chooser from an open session', () => { it('overlays the home chooser over an open tab, then toggles back', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const seg = paneHost.querySelector('.home-seg') as HTMLElement const homeBtn = tabBar.querySelector('.tab-home') as HTMLButtonElement expect(homeBtn).not.toBeNull() expect(seg.style.display).toBe('none') // terminal owns the screen homeBtn.click() // overlay the home chooser expect(seg.style.display).toBe('flex') expect(launcherVisible.value).toBe(true) // default sessions view expect(homeBtn.classList.contains('active')).toBe(true) homeBtn.click() // back to the terminal expect(seg.style.display).toBe('none') expect(launcherVisible.value).toBe(false) expect(homeBtn.classList.contains('active')).toBe(false) }) it('can switch to Projects from the overlay and open another project', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p/one', 'one') ;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click() // overlay home ;(paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement).click() // Projects expect(projectsVisible.value).toBe(true) app.openProject('/p/two', 'two') // opening dismisses the overlay expect(app.snapshot()).toHaveLength(2) const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg.style.display).toBe('none') expect(projectsVisible.value).toBe(false) expect((tabBar.querySelector('.tab-home') as HTMLButtonElement).classList.contains('active')).toBe(false) }) it('⌂ is a no-op when no tab is open (home already shown)', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg.style.display).toBe('flex') ;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click() expect(seg.style.display).toBe('flex') // still shown, no crash }) }) describe('TabApp — v0.7 panels mount once (A1 push / A3 quick-reply)', () => { it('mounts the 🔔 push toggle exactly once on construction (A1)', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) expect(mountPushToggle).toHaveBeenCalledTimes(1) }) it('mounts the quick-reply row directly above #keybar (A3)', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) expect(mountQuickReply).toHaveBeenCalledTimes(1) const qr = document.getElementById('quickreply') expect(qr).not.toBeNull() expect(qr?.nextElementSibling).toBe(document.getElementById('keybar')) }) it('quick-reply onSend routes bytes to the active session (A3-FR3)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() quickReply.onSend?.('yes\r') expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('yes\r') }) it('the push bell survives a tab-bar rebuild (re-parented, not re-mounted)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() // rebuild app.newTab() // rebuild again expect(mountPushToggle).toHaveBeenCalledTimes(1) expect(tabBar.querySelector('.push-host')).not.toBeNull() }) }) describe('TabApp — B2 telemetry gauge + A5 stuck badge (SP10/M4)', () => { it('renders the telemetry gauge from the session.telemetry single source of truth', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const session = FakeTerminalSession.instances[0]! const tele = { at: Date.now(), costUsd: 0.5, contextUsedPct: 40 } session.telemetry = tele renderTelemetryGauge.mockClear() session.cbs.onTelemetry?.(tele) // server pushed new telemetry expect(renderTelemetryGauge).toHaveBeenCalled() expect(renderTelemetryGauge.mock.calls.at(-1)![1]).toBe(tele) }) it('a stuck claudeStatus shows ⚠ + the claude-stuck class on a background tab (AC-A5.5)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() // tab 0 app.newTab() // tab 1 active → tab 0 background const bg = FakeTerminalSession.instances[0]! bg.claudeStatus = 'stuck' bg.cbs.onClaudeStatus?.('stuck') const tab0 = tabBar.querySelectorAll('.tab')[0]! expect(tab0.classList.contains('claude-stuck')).toBe(true) expect(tab0.querySelector('.tab-claude')?.textContent).toBe('⚠') }) }) describe('TabApp — B4 plan-gate approval bar', () => { function openWithGate(gate: 'tool' | 'plan', tool?: string): FakeTerminalSession { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const session = FakeTerminalSession.instances.at(-1)! session.pendingApproval = true session.pendingGate = gate session.pendingTool = tool session.cbs.onClaudeStatus?.('waiting') return session } it('a tool gate renders exactly two buttons (no regression, AC-B4.3)', () => { openWithGate('tool', 'Bash') const bar = document.getElementById('approvalbar')! expect(bar.style.display).toBe('flex') expect(bar.querySelectorAll('button')).toHaveLength(2) }) it('a plan gate maps three buttons to acceptEdits / default / reject (AC-B4.2)', () => { const session = openWithGate('plan') const bar = document.getElementById('approvalbar')! const click = (i: number): void => (bar.querySelectorAll('button')[i] as HTMLButtonElement).click() expect(bar.querySelectorAll('button')).toHaveLength(3) click(0) // Approve + Auto expect(session.approve).toHaveBeenLastCalledWith('acceptEdits') click(1) // Approve + Review expect(session.approve).toHaveBeenLastCalledWith('default') click(2) // Keep Planning expect(session.reject).toHaveBeenCalled() }) }) describe('TabApp — W1 approval preview on the bar', () => { /** Open one tab, hold a tool-gate approval carrying `preview`, render the bar. */ function openWithPreview(preview: ApprovalPreview | null, tool = 'Bash'): FakeTerminalSession { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const session = FakeTerminalSession.instances.at(-1)! session.pendingApproval = true session.pendingGate = 'tool' session.pendingTool = tool session.pendingPreview = preview session.cbs.onClaudeStatus?.('waiting') // triggers updateApprovalBar return session } it('renders a command preview as a .approval-cmd whose textContent is the command', () => { openWithPreview({ kind: 'command', text: 'rm -rf /tmp/x' }) const bar = document.getElementById('approvalbar')! const cmd = bar.querySelector('.approval-cmd') expect(cmd).not.toBeNull() expect(cmd!.textContent).toBe('rm -rf /tmp/x') // Preview sits before the buttons; buttons still present (two for a tool gate). expect(bar.querySelectorAll('button')).toHaveLength(2) }) it('renders a diff preview via renderDiffFile (.df-file present, path shown)', () => { const preview: ApprovalPreview = { kind: 'diff', file: { oldPath: '/p/f.ts', newPath: '/p/f.ts', status: 'modified', added: 1, removed: 1, binary: false, hunks: [{ header: '', lines: [{ kind: 'removed', text: 'a' }, { kind: 'added', text: 'b' }] }], }, } openWithPreview(preview) const bar = document.getElementById('approvalbar')! expect(bar.querySelector('.approval-preview .df-file')).not.toBeNull() expect(bar.querySelector('.df-path')!.textContent).toBe('/p/f.ts') }) it('shows the "… truncated" note when preview.truncated is set', () => { openWithPreview({ kind: 'command', text: 'x', truncated: true }) const bar = document.getElementById('approvalbar')! expect(bar.querySelector('.approval-truncated')).not.toBeNull() }) it('no preview → today\'s name-only bar (regression: label + 2 buttons, no preview)', () => { openWithPreview(null, 'Bash') const bar = document.getElementById('approvalbar')! expect(bar.style.display).toBe('flex') expect(bar.querySelector('.approval-preview')).toBeNull() expect(bar.querySelectorAll('button')).toHaveLength(2) expect(bar.querySelector('.approval-label')!.textContent).toBe('Claude wants to use Bash') }) it('renders attacker-influenced content via textContent only (no HTML injection)', () => { openWithPreview({ kind: 'command', text: '' }) const bar = document.getElementById('approvalbar')! const cmd = bar.querySelector('.approval-cmd')! expect(cmd.textContent).toBe('') // literal text expect(cmd.querySelector('img')).toBeNull() // NOT parsed into an element expect(bar.querySelector('img')).toBeNull() }) }) describe('TabApp — B4 permission-mode relay', () => { it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p', 'repo', 'claude\r', 'plan') expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe( 'claude --permission-mode plan\r', ) }) it('openProject with default mode keeps the plain claude command (no regression)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p', 'repo', 'claude\r', 'default') expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe('claude\r') }) it('availablePermissionModes hides "auto" until the server allows it (SEC-M5/AC-B4.4)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) // default fetch stub → allowAutoMode false expect(app.availablePermissionModes()).toEqual(['default', 'acceptEdits', 'plan']) }) it('availablePermissionModes includes "auto" once /config/ui allows it', async () => { stubUiConfigFetch(true) const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) await vi.waitFor(() => expect(app.availablePermissionModes()).toContain('auto')) }) it('loadDefaultMode downgrades stored "auto" to "default" when the server forbids it', () => { localStorage.setItem('web-terminal:permission-mode', 'auto') const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) // allowAutoMode stays false expect(app.loadDefaultMode()).toBe('default') }) it('saveDefaultMode round-trips a valid mode; invalid stored value falls back', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.saveDefaultMode('plan') expect(app.loadDefaultMode()).toBe('plan') localStorage.setItem('web-terminal:permission-mode', 'garbage') expect(app.loadDefaultMode()).toBe('default') }) }) describe('TabApp — A4 timeline panel', () => { const SID = '44444444-4444-4444-8444-444444444444' it('toggling 📜 mounts the timeline for the active session, then disposes on close', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openSession(SID) const tlBtn = tabBar.querySelector('.tab-timeline') as HTMLButtonElement expect(tlBtn).not.toBeNull() tlBtn.click() // open expect(mountTimeline).toHaveBeenCalledTimes(1) expect(document.getElementById('timeline-panel')!.style.display).toBe('block') tlBtn.click() // close expect(timelineDispose).toHaveBeenCalled() expect(document.getElementById('timeline-panel')!.style.display).toBe('none') }) it('closing a tab with its timeline open disposes the polling handle', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openSession(SID) ;(tabBar.querySelector('.tab-timeline') as HTMLButtonElement).click() // open expect(mountTimeline).toHaveBeenCalled() app.closeTab(0) expect(timelineDispose).toHaveBeenCalled() }) }) describe('TabApp — A2 push-to-talk voice', () => { it('handleVoiceTrigger("start") creates a recognizer and shows the interim overlay', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.handleVoiceTrigger('start') expect(createVoiceInput).toHaveBeenCalledTimes(1) expect(voiceStart).toHaveBeenCalled() expect(document.getElementById('voice-interim')!.style.display).toBe('block') }) it('a final transcript is sent to the active session as raw input (A2-FR1)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.handleVoiceTrigger('start') voice.onTranscript?.('list the files') expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('list the files') }) it('interim text updates the overlay; "stop" hides it (A2-FR2)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.handleVoiceTrigger('start') voice.onInterim?.('hello wor') const overlay = document.getElementById('voice-interim')! expect(overlay.textContent).toBe('hello wor') app.handleVoiceTrigger('stop') expect(voiceStop).toHaveBeenCalled() expect(overlay.style.display).toBe('none') }) it('voice is a no-op when SpeechRecognition is unsupported (AC-A2.3)', () => { createVoiceInput.mockReturnValueOnce(null as never) const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) expect(() => app.handleVoiceTrigger('start')).not.toThrow() expect(voiceStart).not.toHaveBeenCalled() }) }) describe('TabApp — VC voice command mapping (context-gated confirm/reject)', () => { /** Open one tab, hold a tool-gate approval, start PTT, and return its session. */ function pendingToolSession(app: InstanceType): FakeTerminalSession { app.newTab() const session = FakeTerminalSession.instances[0]! session.status = 'connected' session.pendingApproval = true session.pendingGate = 'tool' app.handleVoiceTrigger('start') return session } afterEach(() => { vi.useRealTimers() }) it('a held tool-gate "confirm" arms the approve window, then commits once after it elapses (send NOT called)', () => { vi.useFakeTimers() const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('confirm', 0.9) expect(session.approve).not.toHaveBeenCalled() // armed, not yet committed vi.advanceTimersByTime(1500) expect(session.approve).toHaveBeenCalledTimes(1) expect(session.send).not.toHaveBeenCalled() }) it('a held tool-gate "拒绝" rejects immediately (fail-safe, no confirm window)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('拒绝') expect(session.reject).toHaveBeenCalledTimes(1) expect(session.send).not.toHaveBeenCalled() }) it('an unmatched phrase ("deploy to prod") falls through to dictation even while pending', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('deploy to prod') expect(session.send).toHaveBeenCalledWith('deploy to prod') expect(session.approve).not.toHaveBeenCalled() expect(session.reject).not.toHaveBeenCalled() }) it('pending=false: "yes" is ordinary dictation (decision 1 — context gate)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const session = FakeTerminalSession.instances[0]! session.status = 'connected' session.pendingApproval = false app.handleVoiceTrigger('start') voice.onTranscript?.('yes') expect(session.send).toHaveBeenCalledWith('yes') expect(session.approve).not.toHaveBeenCalled() }) it('plan gate: "confirm" falls through to dictation (decision A — tool-gate only)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const session = FakeTerminalSession.instances[0]! session.status = 'connected' session.pendingApproval = true session.pendingGate = 'plan' app.handleVoiceTrigger('start') voice.onTranscript?.('confirm') expect(session.send).toHaveBeenCalledWith('confirm') expect(session.approve).not.toHaveBeenCalled() }) it('stale gate: an epoch flip after PTT-start downgrades an approve to dictation (safeguard 5)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) // captures pendingEpoch=0 at PTT-start session.pendingEpoch = 1 // a new pending flip landed mid-utterance voice.onTranscript?.('confirm', 0.9) expect(session.send).toHaveBeenCalledWith('confirm') expect(session.approve).not.toHaveBeenCalled() }) it('tab switch: activating a different tab after PTT-start downgrades an approve to dictation (safeguard 5)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session0 = pendingToolSession(app) // PTT started while tab 0 is active app.newTab() // tab 1 becomes active mid-utterance const session1 = FakeTerminalSession.instances[1]! session1.status = 'connected' session1.pendingApproval = true session1.pendingGate = 'tool' voice.onTranscript?.('confirm', 0.9) expect(session1.send).toHaveBeenCalledWith('confirm') expect(session1.approve).not.toHaveBeenCalled() expect(session0.approve).not.toHaveBeenCalled() }) it('WS not connected: approve is never sent and never throws; flashes "未连接" (decision C)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const session = FakeTerminalSession.instances[0]! session.status = 'connecting' // not connected session.pendingApproval = true session.pendingGate = 'tool' app.handleVoiceTrigger('start') expect(() => voice.onTranscript?.('确认', 0.9)).not.toThrow() expect(session.approve).not.toHaveBeenCalled() const overlay = document.getElementById('voice-confirm') expect(overlay?.textContent).toBe('未连接') expect(overlay?.style.display).toBe('block') }) it('WS not connected: reject is never sent and never throws (decision C)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const session = FakeTerminalSession.instances[0]! session.status = 'reconnecting' // not connected session.pendingApproval = true session.pendingGate = 'tool' app.handleVoiceTrigger('start') expect(() => voice.onTranscript?.('拒绝')).not.toThrow() expect(session.reject).not.toHaveBeenCalled() }) it('a confidence below the threshold downgrades an approve phrase to dictation (decision D)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('confirm', 0.4) expect(session.send).toHaveBeenCalledWith('confirm') expect(session.approve).not.toHaveBeenCalled() }) // ── confirm-window cancellation + commit-time re-validation (review fixes) ── it('a "拒绝" spoken during the confirm window cancels the pending approve (reject wins)', () => { vi.useFakeTimers() const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('confirm', 0.9) // arm the window voice.onTranscript?.('拒绝') // change of mind mid-window expect(session.reject).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(1500) expect(session.approve).not.toHaveBeenCalled() // stale timer must NOT commit }) it('tapping the confirm overlay cancels the pending approve before it commits', () => { vi.useFakeTimers() const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('confirm', 0.9) document.getElementById('voice-confirm')!.click() // tap-to-cancel (§4) vi.advanceTimersByTime(1500) expect(session.approve).not.toHaveBeenCalled() expect(document.getElementById('voice-confirm')!.style.display).toBe('none') }) it('a new dictation utterance during the window supersedes (cancels) the pending approve', () => { vi.useFakeTimers() const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('confirm', 0.9) voice.onTranscript?.('deploy to prod') // user moved on vi.advanceTimersByTime(1500) expect(session.approve).not.toHaveBeenCalled() expect(session.send).toHaveBeenCalledWith('deploy to prod') }) it('an epoch flip DURING the confirm window drops the commit (TOCTOU re-validation)', () => { vi.useFakeTimers() const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) // epoch 0 at PTT-start AND at dispatch voice.onTranscript?.('confirm', 0.9) // arms against epoch 0 session.pendingEpoch = 1 // gate A resolved + gate B raised during the window vi.advanceTimersByTime(1500) expect(session.approve).not.toHaveBeenCalled() // must not approve the new gate }) it('a WS drop DURING the confirm window drops the commit (no local/server desync)', () => { vi.useFakeTimers() const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('confirm', 0.9) session.status = 'reconnecting' // socket dropped mid-window vi.advanceTimersByTime(1500) expect(session.approve).not.toHaveBeenCalled() }) it('re-confirming within the window re-arms and commits exactly once', () => { vi.useFakeTimers() const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const session = pendingToolSession(app) voice.onTranscript?.('confirm', 0.9) vi.advanceTimersByTime(500) voice.onTranscript?.('confirm', 0.9) // supersede first, re-arm vi.advanceTimersByTime(1500) expect(session.approve).toHaveBeenCalledTimes(1) }) }) describe('TabApp — split-grid watch board (v1)', () => { /** Open `n` tabs and return the TabApp + its paneHost/tabBar. */ function withTabs(n: number): { app: InstanceType paneHost: HTMLElement tabBar: HTMLElement } { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) for (let i = 0; i < n; i++) app.newTab() return { app, paneHost, tabBar } } const realCells = (host: HTMLElement): HTMLElement[] => [...host.querySelectorAll('.term-cell:not(.slot-empty)')] const visibleReal = (host: HTMLElement): HTMLElement[] => realCells(host).filter((c) => c.style.display !== 'none') it('defaults to single layout — one visible cell, no grid class, no placeholders', () => { const { paneHost } = withTabs(3) expect(paneHost.classList.contains('lay-single')).toBe(true) expect(visibleReal(paneHost)).toHaveLength(1) expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) }) it('setGridLayout("grid-4") applies the class, persists, and getGridLayout reflects it', () => { const { app, paneHost } = withTabs(2) app.setGridLayout('grid-4') expect(app.getGridLayout()).toBe('grid-4') expect(paneHost.classList.contains('lay-grid-4')).toBe(true) expect(localStorage.getItem('web-terminal:grid-layout')).toBe('grid-4') }) it('grid-4 with 2 tabs shows 2 live cells + 2 empty placeholders', () => { const { app, paneHost } = withTabs(2) app.setGridLayout('grid-4') expect(visibleReal(paneHost)).toHaveLength(2) expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(2) }) it('grid-4 with 5 tabs shows exactly the first 4, none as placeholders', () => { const { app, paneHost } = withTabs(5) app.focusTab(0) app.setGridLayout('grid-4') expect(visibleReal(paneHost)).toHaveLength(4) expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) }) it('exactly one visible cell carries the focus ring', () => { const { app, paneHost } = withTabs(3) app.setGridLayout('grid-4') expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1) }) it('clicking a quadrant (onFocus) moves the focus ring to it', () => { const { app, paneHost } = withTabs(3) app.focusTab(0) app.setGridLayout('grid-4') // Simulate a pointerdown inside tab 2's pane. const inst2 = FakeTerminalSession.instances[2]! ;(inst2.cbs as { onFocus?: () => void }).onFocus?.() const focused = paneHost.querySelector('.term-cell.focused .cell-name') expect(focused?.textContent).toBe('Term 3') }) it('a non-focused pending tool-gate quadrant shows inline ✓/✗ that resolve it', () => { const { app, paneHost } = withTabs(2) app.focusTab(1) // focus tab 1; tab 0 is the background quadrant app.setGridLayout('grid-4') const bg = FakeTerminalSession.instances[0]! bg.pendingApproval = true bg.pendingGate = 'tool' bg.pendingTool = 'Bash' bg.claudeStatus = 'waiting' bg.cbs.onClaudeStatus?.('waiting') const cell0 = realCells(paneHost)[0]! const approve = cell0.querySelector('.cell-approve-yes') const reject = cell0.querySelector('.cell-approve-no') expect(approve).not.toBeNull() approve!.click() expect(bg.approve).toHaveBeenCalledTimes(1) reject!.click() expect(bg.reject).toHaveBeenCalledTimes(1) }) it('the FOCUSED pending pane gets no inline footer (it uses the global bar)', () => { const { app, paneHost } = withTabs(2) app.setGridLayout('grid-4') // activeIndex = 1 (last opened) const focused = FakeTerminalSession.instances[1]! focused.pendingApproval = true focused.pendingGate = 'tool' focused.claudeStatus = 'waiting' focused.cbs.onClaudeStatus?.('waiting') const cell1 = realCells(paneHost)[1]! expect(cell1.querySelector('.cell-approve')).toBeNull() }) it('suppresses the OS notification for an on-board (visible) pending quadrant', () => { const NotificationMock = vi.fn() ;(NotificationMock as unknown as { permission: string }).permission = 'granted' vi.stubGlobal('Notification', NotificationMock) const { app } = withTabs(2) app.focusTab(1) app.setGridLayout('grid-4') const bg = FakeTerminalSession.instances[0]! // visible, non-focused bg.claudeStatus = 'waiting' bg.cbs.onClaudeStatus?.('waiting') expect(NotificationMock).not.toHaveBeenCalled() vi.unstubAllGlobals() }) it('still notifies for an OFF-board tab that needs approval', () => { const NotificationMock = vi.fn() ;(NotificationMock as unknown as { permission: string }).permission = 'granted' vi.stubGlobal('Notification', NotificationMock) const { app } = withTabs(5) app.focusTab(0) // keep order; tab 4 stays off-board app.setGridLayout('grid-4') const off = FakeTerminalSession.instances[4]! // idx 4 — off the 2×2 board off.claudeStatus = 'waiting' off.cbs.onClaudeStatus?.('waiting') expect(NotificationMock).toHaveBeenCalled() vi.unstubAllGlobals() }) it('a placeholder "+ New session" opens another tab', () => { const { app, paneHost } = withTabs(2) app.setGridLayout('grid-4') expect(app.snapshot()).toHaveLength(2) const slot = paneHost.querySelector('.slot-empty .slot-new')! slot.click() expect(app.snapshot()).toHaveLength(3) }) it('closing the focused quadrant removes its cell wrapper', () => { const { app, paneHost } = withTabs(3) app.setGridLayout('grid-4') expect(realCells(paneHost)).toHaveLength(3) app.closeTab(app.snapshot().findIndex((t) => t.active)) expect(realCells(paneHost)).toHaveLength(2) }) it('narrowing back to single restores one visible cell and drops placeholders', () => { const { app, paneHost } = withTabs(2) app.setGridLayout('grid-4') expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(2) app.setGridLayout('single') expect(paneHost.classList.contains('lay-single')).toBe(true) expect(visibleReal(paneHost)).toHaveLength(1) expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) }) // ── Board-aware focus (regression: activate() must never focus a hidden pane) ── it('opening a new tab while the grid board is full keeps the focused pane visible', () => { const { app, paneHost } = withTabs(4) app.focusTab(0) app.setGridLayout('grid-4') // board full (4/4) app.newTab() // 5th tab — must be pulled onto the board, not left hidden const focused = paneHost.querySelector('.term-cell.focused') expect(focused).not.toBeNull() expect(focused!.style.display).not.toBe('none') expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1) }) it('clicking an off-board tab in the tab bar pulls it onto the board and focuses it', () => { const { app, tabBar } = withTabs(5) app.focusTab(0) app.setGridLayout('grid-4') // tab 4 is off the 2×2 board const offSession = FakeTerminalSession.instances[4]! // identity survives reordering tabBar.querySelectorAll('.tab')[4]!.dispatchEvent( new Event('pointerdown', { bubbles: true }), ) const cell = offSession.el.closest('.term-cell')! expect(cell.classList.contains('focused')).toBe(true) expect(cell.style.display).not.toBe('none') }) it('shrinking the layout moves an off-board active pane back onto the board', () => { const { app } = withTabs(4) app.focusTab(3) // active index 3 (the 4th session) const active = FakeTerminalSession.instances[3]! app.setGridLayout('split-2') // cap 2 → index 3 is now off-board expect(app.getGridLayout()).toBe('split-2') const cell = active.el.closest('.term-cell')! expect(cell.classList.contains('focused')).toBe(true) expect(cell.style.display).not.toBe('none') }) // ── refitVisible / show(focus) / home overlay ──────────────────────────────── it('refitVisible refits only the on-board panes', () => { const { app } = withTabs(5) app.focusTab(0) app.setGridLayout('grid-4') // tabs 0-3 visible, tab 4 off-board FakeTerminalSession.instances.forEach((i) => i.refit.mockClear()) app.refitVisible() for (let i = 0; i < 4; i++) expect(FakeTerminalSession.instances[i]!.refit).toHaveBeenCalled() expect(FakeTerminalSession.instances[4]!.refit).not.toHaveBeenCalled() }) it('only the focused quadrant is shown with keyboard focus (focus: false for the rest)', () => { const { app } = withTabs(3) app.setGridLayout('grid-4') // active = tab 2 (last opened) expect(FakeTerminalSession.instances[2]!.show.mock.lastCall).toEqual([{ focus: true }]) expect(FakeTerminalSession.instances[0]!.show.mock.lastCall).toEqual([{ focus: false }]) expect(FakeTerminalSession.instances[1]!.show.mock.lastCall).toEqual([{ focus: false }]) }) it('the ⌂ home overlay hides every grid pane, then restores the board on toggle back', () => { const { app, paneHost, tabBar } = withTabs(3) app.setGridLayout('grid-4') expect(visibleReal(paneHost).length).toBeGreaterThan(0) const home = tabBar.querySelector('.tab-home')! home.click() // overlay home expect(visibleReal(paneHost)).toHaveLength(0) expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) home.click() // back to the terminals expect(visibleReal(paneHost)).toHaveLength(3) expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1) }) it('does NOT suppress the notification for a pending pane hidden behind the home overlay', () => { const NotificationMock = vi.fn() ;(NotificationMock as unknown as { permission: string }).permission = 'granted' vi.stubGlobal('Notification', NotificationMock) const { app, tabBar } = withTabs(2) app.setGridLayout('grid-4') tabBar.querySelector('.tab-home')!.click() // home overlay up const bg = FakeTerminalSession.instances[0]! // on-board by layout, but behind home bg.claudeStatus = 'waiting' bg.cbs.onClaudeStatus?.('waiting') expect(NotificationMock).toHaveBeenCalled() vi.unstubAllGlobals() }) // ── v2: higher-capacity layouts, focus cycling, maximize, drag-to-quadrant ──── it('grid-6 shows up to six live panes', () => { const { app, paneHost } = withTabs(6) app.focusTab(0) app.setGridLayout('grid-6') expect(visibleReal(paneHost)).toHaveLength(6) expect(paneHost.classList.contains('lay-grid-6')).toBe(true) expect(paneHost.classList.contains('grid')).toBe(true) }) it('row-3 shows exactly three panes', () => { const { app, paneHost } = withTabs(5) app.focusTab(0) app.setGridLayout('row-3') expect(visibleReal(paneHost)).toHaveLength(3) }) it('cycleFocus moves the focus ring around the visible panes and wraps', () => { const { app } = withTabs(3) app.focusTab(0) app.setGridLayout('grid-4') // 3 visible, active 0 app.cycleFocus(1) expect(app.snapshot().findIndex((t) => t.active)).toBe(1) app.cycleFocus(1) expect(app.snapshot().findIndex((t) => t.active)).toBe(2) app.cycleFocus(1) // wrap expect(app.snapshot().findIndex((t) => t.active)).toBe(0) app.cycleFocus(-1) // reverse wrap expect(app.snapshot().findIndex((t) => t.active)).toBe(2) }) it('cycleFocus is a no-op in single mode', () => { const { app } = withTabs(3) // single mode, active 2 app.cycleFocus(1) expect(app.snapshot().findIndex((t) => t.active)).toBe(2) }) it('toggleMaximize marks the focused cell and toggling off clears it', () => { const { app, paneHost } = withTabs(3) app.setGridLayout('grid-4') app.toggleMaximize() expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1) expect(paneHost.querySelector('.term-cell.maximized')!.classList.contains('focused')).toBe(true) app.toggleMaximize() expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0) }) it('maximize follows the focused pane as you cycle', () => { const { app, paneHost } = withTabs(3) app.focusTab(0) app.setGridLayout('grid-4') app.toggleMaximize() const first = FakeTerminalSession.instances[0]!.el.closest('.term-cell') expect(first!.classList.contains('maximized')).toBe(true) app.cycleFocus(1) // now tab 1 is focused expect(first!.classList.contains('maximized')).toBe(false) expect(FakeTerminalSession.instances[1]!.el.closest('.term-cell')!.classList.contains('maximized')).toBe(true) }) it('changing layout exits maximize', () => { const { app, paneHost } = withTabs(3) app.setGridLayout('grid-4') app.toggleMaximize() expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1) app.setGridLayout('split-2') expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0) }) it('dragging an off-board tab onto a quadrant assigns it there and focuses it', () => { const { app, paneHost, tabBar } = withTabs(5) app.focusTab(0) app.setGridLayout('grid-4') // tab 4 off-board const dragged = FakeTerminalSession.instances[4]! const targetCell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')! // Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0. tabBar.querySelectorAll('.tab')[4]!.dispatchEvent( new Event('dragstart', { bubbles: true }), ) targetCell.dispatchEvent(new Event('dragover', { bubbles: true })) targetCell.dispatchEvent(new Event('drop', { bubbles: true })) const cell = dragged.el.closest('.term-cell')! expect(cell.style.display).not.toBe('none') // now on-board expect(cell.classList.contains('focused')).toBe(true) // and focused }) it('closing a tab while a quadrant is maximized clears the maximized state', () => { const { app, paneHost } = withTabs(3) app.focusTab(0) app.setGridLayout('grid-4') app.toggleMaximize() expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1) app.closeTab(1) // close a different (non-focused) tab expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0) }) it('drag-to-quadrant is a no-op in single mode (no reorder, no drag-target)', () => { const { app, paneHost, tabBar } = withTabs(3) // default single layout const before = app.snapshot().map((t) => t.title) tabBar.querySelectorAll('.tab')[1]!.dispatchEvent( new Event('dragstart', { bubbles: true }), ) const cell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')! cell.dispatchEvent(new Event('dragover', { bubbles: true })) cell.dispatchEvent(new Event('drop', { bubbles: true })) expect(cell.classList.contains('drag-target')).toBe(false) expect(app.snapshot().map((t) => t.title)).toEqual(before) // order unchanged }) it('the ⛶ button focuses its quadrant and maximizes it in one click', () => { const { app } = withTabs(3) app.focusTab(0) app.setGridLayout('grid-4') const cell2 = FakeTerminalSession.instances[2]!.el.closest('.term-cell')! const maxBtn = cell2.querySelector('.cell-max')! expect(maxBtn.textContent).toBe('⛶') maxBtn.click() expect(cell2.classList.contains('focused')).toBe(true) // focused the clicked quadrant expect(cell2.classList.contains('maximized')).toBe(true) // and maximized it expect(maxBtn.textContent).toBe('⤡') // glyph flips to restore }) // ── v3: read-only monitor quadrants ────────────────────────────────────────── /** Open n tabs, give each a server id (as if attached), and return the app. */ function withAttachedTabs(n: number): { app: InstanceType paneHost: HTMLElement tabBar: HTMLElement } { const hosts = withTabs(n) FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`)) return hosts } const monBtnOf = (paneHost: HTMLElement, i: number): HTMLButtonElement => FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-monitor-btn')! it('the 👁 button switches a quadrant to a read-only monitor preview', () => { const { app, paneHost } = withAttachedTabs(3) app.focusTab(0) app.setGridLayout('grid-4') const cell1 = FakeTerminalSession.instances[1]!.el.closest('.term-cell')! monBtnOf(paneHost, 1).click() expect(mountCellMonitor).toHaveBeenCalled() expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1') // polls that session expect(cell1.querySelector('.cell-monitor')).not.toBeNull() expect(monBtnOf(paneHost, 1).classList.contains('active')).toBe(true) }) it('toggling monitor off disposes the preview and restores the live pane', () => { const { app, paneHost } = withAttachedTabs(3) app.focusTab(0) app.setGridLayout('grid-4') monBtnOf(paneHost, 1).click() // on expect(monitorHandles).toHaveLength(1) monBtnOf(paneHost, 1).click() // off expect(monitorHandles[0]!.dispose).toHaveBeenCalled() const cell1 = FakeTerminalSession.instances[1]!.el.closest('.term-cell')! expect(cell1.querySelector('.cell-monitor')).toBeNull() expect(monBtnOf(paneHost, 1).classList.contains('active')).toBe(false) }) it('closing a monitored quadrant disposes its monitor', () => { const { app, paneHost } = withAttachedTabs(3) app.focusTab(0) app.setGridLayout('grid-4') monBtnOf(paneHost, 1).click() app.closeTab(1) expect(monitorHandles[0]!.dispose).toHaveBeenCalled() }) it('leaving grid mode stops any monitor', () => { const { app, paneHost } = withAttachedTabs(3) app.focusTab(0) app.setGridLayout('grid-4') monBtnOf(paneHost, 1).click() expect(monitorHandles).toHaveLength(1) app.setGridLayout('single') expect(monitorHandles[0]!.dispose).toHaveBeenCalled() }) // ── v3: resizable splitters ────────────────────────────────────────────────── it('grid-4 renders one column + one row splitter and sets an fr template', () => { const { app, paneHost } = withTabs(4) app.focusTab(0) app.setGridLayout('grid-4') expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(1) expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1) expect(paneHost.style.gridTemplateColumns).toBe('1fr 1fr') expect(paneHost.style.gridTemplateRows).toBe('1fr 1fr') }) it('grid-6 renders two column + one row splitters', () => { const { app, paneHost } = withTabs(6) app.focusTab(0) app.setGridLayout('grid-6') expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(2) expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1) }) it('single mode has no splitters and clears the inline grid template', () => { const { app, paneHost } = withTabs(3) app.setGridLayout('grid-4') app.setGridLayout('single') expect(paneHost.querySelectorAll('.grid-gutter')).toHaveLength(0) expect(paneHost.style.gridTemplateColumns).toBe('') }) it('a persisted custom split is applied to the grid template on load', () => { localStorage.setItem( 'web-terminal:grid-splits', JSON.stringify({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }), ) const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() app.setGridLayout('grid-4') expect(paneHost.style.gridTemplateColumns).toBe('1.5fr 0.5fr') }) it('dragging a column splitter re-templates the grid and persists on release', () => { const { app, paneHost } = withTabs(4) app.focusTab(0) app.setGridLayout('grid-4') // jsdom has no layout — give #term a measurable width for the drag math. vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({ width: 800, height: 600, left: 0, top: 0, right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({}), } as DOMRect) const gutter = paneHost.querySelector('.grid-gutter-col')! gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true })) gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true })) // +0.2fr gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr') const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!) expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2) }) it('a re-render during a gutter drag does not destroy the dragged handle', () => { const { app, paneHost } = withTabs(4) app.focusTab(0) app.setGridLayout('grid-4') vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({ width: 800, height: 600, left: 0, top: 0, right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({}), } as DOMRect) const gutter = paneHost.querySelector('.grid-gutter-col')! gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true })) app.toggleMaximize() // triggers applyLayout → renderGutters (must skip while dragging) expect(paneHost.contains(gutter)).toBe(true) // same handle survives gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true })) gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr') }) it('a monitor toggled before attach engages once the session id arrives', () => { const { app } = withTabs(3) // fresh sessions — ids are null until attached app.focusTab(0) app.setGridLayout('grid-4') const inst = FakeTerminalSession.instances[1]! inst.el.closest('.term-cell')!.querySelector('.cell-monitor-btn')!.click() expect(mountCellMonitor).not.toHaveBeenCalled() // id still null → not engaged inst.id = 'sess-1' // simulate 'attached' ;(inst.cbs as { onSessionId?: (id: string) => void }).onSessionId?.('sess-1') expect(mountCellMonitor).toHaveBeenCalled() expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1') }) }) // ── W2: inject-queue badge + enqueue affordance ────────────────────────────── describe('TabApp — W2 inject queue', () => { it('a queue frame (onQueue) updates the active tab’s ⧗N badge in place', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const inst = FakeTerminalSession.instances[0]! // No queued items → empty badge. expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('') // Server broadcasts depth 3 → badge shows ⧗3. inst.queueLength = 3 inst.cbs.onQueue?.(3) expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('⧗3') // Drains back to 0 → badge clears. inst.queueLength = 0 inst.cbs.onQueue?.(0) expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('') }) it('enqueueToActive routes to enqueueFollowup with the active session id', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openSession('11111111-1111-4111-8111-111111111111') app.enqueueToActive('run tests', true) expect(enqueueFollowupMock).toHaveBeenCalledWith( '11111111-1111-4111-8111-111111111111', 'run tests', true, ) }) it('the quick-reply Queue affordance enqueues for the active session', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openSession('22222222-2222-4222-8222-222222222222') // The onQueue handler wired into mountQuickReply routes through enqueueToActive. quickReply.onQueue?.('open a PR', true) expect(enqueueFollowupMock).toHaveBeenCalledWith( '22222222-2222-4222-8222-222222222222', 'open a PR', true, ) }) it('enqueueToActive is a no-op when no session has attached (id null)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() // fresh session → id null until server assigns app.enqueueToActive('x', false) expect(enqueueFollowupMock).not.toHaveBeenCalled() }) }) // ── W5: fan-out board (launchFanout + keepFanoutWinner) ────────────────────── describe('TabApp — W5 fan-out board', () => { const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) /** Fan out N lanes and stamp server ids on the resulting sessions. */ async function launched( app: InstanceType, lanes: number, branchBase = 'feat', ): Promise { 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)['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') }) })