// @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' // ── 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 /** VC: nonce that counts false→true pendingApproval flips (stale-gate guard). */ pendingEpoch = 0 /** B2: latest statusLine telemetry (single source of truth). */ telemetry: unknown = null /** 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 } 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 [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 } = {} const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => { quickReply.onSend = opts.onSend return { dispose: vi.fn() } }) vi.mock('../public/quick-reply.js', () => ({ mountQuickReply })) 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(), })) vi.mock('../public/projects.js', () => ({ mountProjects })) // 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 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() }) 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 — 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() }) })