Files
web-terminal/test/tabs.test.ts
Yaojia Wang 007e598802 feat(grid): v3b/c — resizable splitters + saved layout presets
Resizable splitters: draggable gutters between grid tracks adjust per-layout
column/row fractions (fr units), applied as an inline grid-template and persisted
(web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between
adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested
helper in grid-layout.ts; the drag translates pixel motion into an fr delta.

Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current
layout + its split under a name, re-apply it in one click, or delete it
(web-terminal:grid-presets). Desktop-only, like the layout toggle.

- grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate,
  load/saveGridSplits, splitForLayout (validates stored shape).
- tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline
  template; gridArrangement()/applyGridPreset() hooks.
- main.ts: mount the presets dropdown. style.css: gutters + presets menu.
- tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets
  persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:09:49 +02:00

1530 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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<typeof vi.fn> }> = []
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<typeof TabApp>): 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<typeof TabApp>
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<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.cell-approve-yes')
const reject = cell0.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLElement>('.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<HTMLElement>('.tab')[4]!.dispatchEvent(
new Event('pointerdown', { bubbles: true }),
)
const cell = offSession.el.closest<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLElement>('.term-cell')!
// Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0.
tabBar.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.tab')[1]!.dispatchEvent(
new Event('dragstart', { bubbles: true }),
)
const cell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.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<HTMLElement>('.term-cell')!
const maxBtn = cell2.querySelector<HTMLButtonElement>('.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<typeof TabApp>
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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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)
})
})