feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)
When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.
Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.
- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
applyLayout() owns show/hide + grid class + cell order + placeholders;
board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
delegate to it; renderCell/renderInlineApprove; refitVisible; notification
suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).
Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
test/grid-layout.test.ts
Normal file
184
test/grid-layout.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* test/grid-layout.test.ts — split-grid layout logic + toolbar toggle.
|
||||
*
|
||||
* Covers the pure layout math (capacity, visible set, isVisible), the desktop
|
||||
* matchMedia gate, localStorage persistence (incl. the narrow-screen force-single
|
||||
* rule), and the segmented toggle control (pressed state, click → setLayout,
|
||||
* hide-when-too-narrow, and the auto-fallback-to-single on window narrow).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
type GridLayout,
|
||||
GRID_LAYOUTS,
|
||||
GRID_MIN_WIDTH,
|
||||
layoutCapacity,
|
||||
visibleIndices,
|
||||
isVisibleIndex,
|
||||
gridAllowed,
|
||||
loadGridLayout,
|
||||
saveGridLayout,
|
||||
mountGridToggle,
|
||||
} from '../public/grid-layout.js'
|
||||
|
||||
const KEY = 'web-terminal:grid-layout'
|
||||
|
||||
/** Install a matchMedia stub whose `(min-width: N)` matches iff width >= N.
|
||||
* Returns a fire() to simulate a viewport resize crossing the threshold. */
|
||||
function stubMatchMedia(width: number): { setWidth: (w: number) => void } {
|
||||
let current = width
|
||||
const lists: Array<{ q: string; list: { matches: boolean }; cbs: Array<() => void> }> = []
|
||||
const parse = (q: string): number => Number(/min-width:\s*(\d+)px/.exec(q)?.[1] ?? '0')
|
||||
window.matchMedia = ((q: string) => {
|
||||
const cbs: Array<() => void> = []
|
||||
const list = {
|
||||
get matches() {
|
||||
return current >= parse(q)
|
||||
},
|
||||
media: q,
|
||||
addEventListener: (_t: string, cb: () => void) => cbs.push(cb),
|
||||
removeEventListener: (_t: string, cb: () => void) => {
|
||||
const i = cbs.indexOf(cb)
|
||||
if (i >= 0) cbs.splice(i, 1)
|
||||
},
|
||||
addListener: (cb: () => void) => cbs.push(cb),
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
onchange: null,
|
||||
}
|
||||
lists.push({ q, list, cbs })
|
||||
return list
|
||||
}) as unknown as typeof window.matchMedia
|
||||
return {
|
||||
setWidth: (w: number) => {
|
||||
current = w
|
||||
for (const { cbs } of lists) for (const cb of cbs) cb()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('grid-layout: pure logic', () => {
|
||||
it('layoutCapacity maps each layout to its pane count', () => {
|
||||
expect(layoutCapacity('single')).toBe(1)
|
||||
expect(layoutCapacity('split-2')).toBe(2)
|
||||
expect(layoutCapacity('grid-4')).toBe(4)
|
||||
})
|
||||
|
||||
it('GRID_LAYOUTS is ordered single → split-2 → grid-4', () => {
|
||||
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'grid-4'])
|
||||
})
|
||||
|
||||
it('visibleIndices returns the first min(tabCount, capacity) indices', () => {
|
||||
expect(visibleIndices(0, 'grid-4')).toEqual([])
|
||||
expect(visibleIndices(3, 'grid-4')).toEqual([0, 1, 2])
|
||||
expect(visibleIndices(9, 'grid-4')).toEqual([0, 1, 2, 3])
|
||||
expect(visibleIndices(9, 'split-2')).toEqual([0, 1])
|
||||
expect(visibleIndices(9, 'single')).toEqual([0])
|
||||
})
|
||||
|
||||
it('isVisibleIndex: single mode shows only the active pane', () => {
|
||||
expect(isVisibleIndex(2, 2, 5, 'single')).toBe(true)
|
||||
expect(isVisibleIndex(0, 2, 5, 'single')).toBe(false)
|
||||
})
|
||||
|
||||
it('isVisibleIndex: grid shows the first-N regardless of which is active', () => {
|
||||
expect(isVisibleIndex(0, 3, 9, 'grid-4')).toBe(true)
|
||||
expect(isVisibleIndex(3, 3, 9, 'grid-4')).toBe(true)
|
||||
expect(isVisibleIndex(4, 3, 9, 'grid-4')).toBe(false) // off-board
|
||||
expect(isVisibleIndex(2, 0, 9, 'split-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid-layout: gate + persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// @ts-expect-error — reset the stub between tests
|
||||
delete window.matchMedia
|
||||
})
|
||||
|
||||
it('gridAllowed is true at/above GRID_MIN_WIDTH and false below', () => {
|
||||
stubMatchMedia(GRID_MIN_WIDTH)
|
||||
expect(gridAllowed()).toBe(true)
|
||||
stubMatchMedia(GRID_MIN_WIDTH - 1)
|
||||
expect(gridAllowed()).toBe(false)
|
||||
})
|
||||
|
||||
it('saveGridLayout + loadGridLayout round-trips on a wide screen', () => {
|
||||
stubMatchMedia(1440)
|
||||
saveGridLayout('grid-4')
|
||||
expect(localStorage.getItem(KEY)).toBe('grid-4')
|
||||
expect(loadGridLayout()).toBe('grid-4')
|
||||
})
|
||||
|
||||
it('loadGridLayout forces single on a narrow screen (no grid on a phone)', () => {
|
||||
stubMatchMedia(1440)
|
||||
saveGridLayout('grid-4')
|
||||
stubMatchMedia(800) // window shrank / opened on a small device
|
||||
expect(loadGridLayout()).toBe('single')
|
||||
})
|
||||
|
||||
it('loadGridLayout ignores a garbage stored value', () => {
|
||||
stubMatchMedia(1440)
|
||||
localStorage.setItem(KEY, 'not-a-layout')
|
||||
expect(loadGridLayout()).toBe('single')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid-layout: mountGridToggle', () => {
|
||||
let toolbar: HTMLElement
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
toolbar = document.createElement('div')
|
||||
document.body.appendChild(toolbar)
|
||||
})
|
||||
afterEach(() => {
|
||||
toolbar.remove()
|
||||
// @ts-expect-error — reset the stub
|
||||
delete window.matchMedia
|
||||
})
|
||||
|
||||
it('renders one button per layout with the current one pressed', () => {
|
||||
stubMatchMedia(1440)
|
||||
let layout: GridLayout = 'split-2'
|
||||
mountGridToggle(toolbar, { getLayout: () => layout, setLayout: (l) => (layout = l) })
|
||||
const btns = toolbar.querySelectorAll<HTMLButtonElement>('.grid-toggle-btn')
|
||||
expect(btns).toHaveLength(GRID_LAYOUTS.length)
|
||||
const pressed = [...btns].filter((b) => b.getAttribute('aria-pressed') === 'true')
|
||||
expect(pressed).toHaveLength(1)
|
||||
expect(pressed[0]!.dataset.layout).toBe('split-2')
|
||||
})
|
||||
|
||||
it('clicking a button calls setLayout with that layout', () => {
|
||||
stubMatchMedia(1440)
|
||||
const setLayout = vi.fn()
|
||||
mountGridToggle(toolbar, { getLayout: () => 'single', setLayout })
|
||||
const grid = toolbar.querySelector<HTMLButtonElement>('.grid-toggle-btn[data-layout="grid-4"]')!
|
||||
grid.click()
|
||||
expect(setLayout).toHaveBeenCalledWith('grid-4')
|
||||
})
|
||||
|
||||
it('is hidden below the desktop threshold', () => {
|
||||
stubMatchMedia(800)
|
||||
mountGridToggle(toolbar, { getLayout: () => 'single', setLayout: vi.fn() })
|
||||
const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
|
||||
expect(seg.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('forces single + hides itself when the window narrows past the threshold', () => {
|
||||
const mm = stubMatchMedia(1440)
|
||||
let layout: GridLayout = 'grid-4'
|
||||
const setLayout = vi.fn((l: GridLayout) => {
|
||||
layout = l
|
||||
})
|
||||
mountGridToggle(toolbar, { getLayout: () => layout, setLayout })
|
||||
const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
|
||||
expect(seg.style.display).toBe('inline-flex')
|
||||
mm.setWidth(700) // simulate a resize below the threshold
|
||||
expect(setLayout).toHaveBeenCalledWith('single')
|
||||
expect(seg.style.display).toBe('none')
|
||||
})
|
||||
})
|
||||
@@ -1032,3 +1032,240 @@ describe('TabApp — VC voice command mapping (context-gated confirm/reject)', (
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user