feat(grid): v3a — read-only monitor quadrants (no shared-PTY shrink)

A quadrant can be flipped to "monitor" mode via a 👁 header toggle: instead of
the live interactive terminal, it renders the session's screen from periodic
GET /live-sessions/:id/preview snapshots into a read-only xterm. A monitored
quadrant never attaches a WS and never sends a resize, so — unlike a live
quadrant (latest-writer-wins) — it does not drive the shared PTY size. That lets
you watch a session in a small quadrant without shrinking it for another device
using it full-screen (the cross-device shrink the split-grid design flagged).

- public/cell-monitor.ts (new): mountCellMonitor(host, id) — polling read-only
  preview, scaled to fit, best-effort, disposes cleanly (no write after dispose).
- tabs.ts: per-entry monitor state; applyLayout keeps the live pane hidden and
  starts/stops the monitor; 👁 toggle in the cell header; teardown on close /
  leaving grid mode.
- style.css: monitor button + .cell-monitor container.
- tests: cell-monitor.test.ts (poll→write, dispose, late-resolve guard) + monitor
  wiring in tabs.test.ts. typecheck + build clean, 1587 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 19:58:47 +02:00
parent 5475b661ae
commit cd97114f87
6 changed files with 331 additions and 3 deletions

83
test/cell-monitor.test.ts Normal file
View File

@@ -0,0 +1,83 @@
// @vitest-environment jsdom
/**
* test/cell-monitor.test.ts — read-only monitor preview for a grid quadrant.
*
* Stubs xterm + preview-grid.fetchPreview so we can assert the poll→write loop and
* the dispose teardown without a real terminal or server.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const terms: FakeTerm[] = []
class FakeTerm {
cols = 80
rows = 24
open = vi.fn()
write = vi.fn((_data: string, cb?: () => void) => cb?.())
reset = vi.fn()
resize = vi.fn()
dispose = vi.fn()
constructor() {
terms.push(this)
}
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerm }))
const fetchPreview = vi.fn()
vi.mock('../public/preview-grid.js', () => ({
fetchPreview,
PREVIEW_CLEAR: '<CLEAR>',
PREVIEW_THEME: {},
}))
const { mountCellMonitor } = await import('../public/cell-monitor.js')
beforeEach(() => {
terms.length = 0
fetchPreview.mockReset()
})
afterEach(() => vi.restoreAllMocks())
describe('cell-monitor', () => {
it('opens a read-only term and writes the fetched preview into it', async () => {
fetchPreview.mockResolvedValue({ id: 's1', cols: 100, rows: 30, data: 'HELLO' })
const host = document.createElement('div')
const m = mountCellMonitor(host, 's1')
expect(terms).toHaveLength(1)
expect(terms[0]!.open).toHaveBeenCalledWith(host)
expect(fetchPreview).toHaveBeenCalledWith('s1')
await vi.waitFor(() => expect(terms[0]!.write).toHaveBeenCalled())
expect(terms[0]!.resize).toHaveBeenCalledWith(100, 30)
expect(terms[0]!.write.mock.calls[0]![0]).toBe('<CLEAR>HELLO')
m.dispose()
})
it('dispose() tears down the term', () => {
fetchPreview.mockResolvedValue({ id: 's1', cols: 80, rows: 24, data: 'X' })
const m = mountCellMonitor(document.createElement('div'), 's1')
m.dispose()
expect(terms[0]!.dispose).toHaveBeenCalled()
})
it('a failed preview fetch skips the write (best-effort)', async () => {
fetchPreview.mockResolvedValue(null)
const m = mountCellMonitor(document.createElement('div'), 's1')
await Promise.resolve()
await Promise.resolve()
expect(terms[0]!.write).not.toHaveBeenCalled()
m.dispose()
})
it('does not write after dispose (guards a late resolve)', async () => {
let resolve!: (v: unknown) => void
fetchPreview.mockReturnValue(new Promise((r) => (resolve = r)))
const m = mountCellMonitor(document.createElement('div'), 's1')
m.dispose() // dispose BEFORE the fetch resolves
resolve({ id: 's1', cols: 80, rows: 24, data: 'LATE' })
await Promise.resolve()
await Promise.resolve()
expect(terms[0]!.write).not.toHaveBeenCalled()
})
})

View File

@@ -74,6 +74,15 @@ vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalS
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 }))
@@ -157,6 +166,8 @@ beforeEach(() => {
document.body.replaceChildren()
localStorage.clear()
renderTelemetryGauge.mockClear()
mountCellMonitor.mockClear()
monitorHandles.length = 0
mountPushToggle.mockClear()
mountQuickReply.mockClear()
mountTimeline.mockClear()
@@ -1390,4 +1401,63 @@ describe('TabApp — split-grid watch board (v1)', () => {
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()
})
})