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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user