From cd97114f87c7449d8bf1cdbb2699b3e86127658a Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 11 Jul 2026 19:58:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(grid):=20v3a=20=E2=80=94=20read-only=20mon?= =?UTF-8?q?itor=20quadrants=20(no=20shared-PTY=20shrink)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- public/cell-monitor.ts | 82 ++++++++++++++++++++++++++++++++++++++ public/style.css | 32 ++++++++++++++- public/tabs.ts | 66 ++++++++++++++++++++++++++++++- test/cell-monitor.test.ts | 83 +++++++++++++++++++++++++++++++++++++++ test/tabs.test.ts | 70 +++++++++++++++++++++++++++++++++ vitest.config.ts | 1 + 6 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 public/cell-monitor.ts create mode 100644 test/cell-monitor.test.ts diff --git a/public/cell-monitor.ts b/public/cell-monitor.ts new file mode 100644 index 0000000..fe586cf --- /dev/null +++ b/public/cell-monitor.ts @@ -0,0 +1,82 @@ +/** + * public/cell-monitor.ts β€” read-only "monitor" view of a session in a grid cell. + * + * A monitored quadrant renders the session's current screen via periodic + * GET /live-sessions/:id/preview snapshots (RingBuffer.tail on the server) into a + * read-only xterm. It NEVER attaches a WebSocket and NEVER sends a resize, so β€” + * unlike a live interactive 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 that is using it full-screen (the cross-device + * shrink the split-grid design flagged). + * + * The snapshot is scaled with a CSS transform to fit the cell, like the launcher + * thumbnails, so the real screen stays readable regardless of the cell size. + */ + +import { Terminal } from '@xterm/xterm' +import { fetchPreview, PREVIEW_CLEAR, PREVIEW_THEME } from './preview-grid.js' + +/** How often a monitored quadrant re-fetches the session's screen snapshot. */ +const MONITOR_POLL_MS = 2000 + +export interface CellMonitor { + dispose(): void +} + +/** Scale the rendered term to fit `host`, top-left anchored (no upscaling). */ +function fitMonitor(host: HTMLElement, screen: HTMLElement): void { + const w = screen.offsetWidth + const h = screen.offsetHeight + if (w === 0 || h === 0 || host.clientWidth === 0 || host.clientHeight === 0) return + const scale = Math.min(1, host.clientWidth / w, host.clientHeight / h) + screen.style.transformOrigin = 'top left' + screen.style.transform = `scale(${scale})` +} + +/** + * Mount a polling read-only preview of `sessionId` into `host`. Returns a handle + * whose dispose() stops the poll and tears down the terminal. Best-effort: a + * failed fetch just skips that tick (no throw). + */ +export function mountCellMonitor(host: HTMLElement, sessionId: string): CellMonitor { + const term = new Terminal({ + disableStdin: true, + cursorBlink: false, + fontFamily: 'Menlo, Consolas, monospace', + fontSize: 12, + scrollback: 0, + theme: PREVIEW_THEME, + }) + term.open(host) + + let disposed = false + let timer: ReturnType | null = null + + const poll = async (): Promise => { + const p = await fetchPreview(sessionId) + if (disposed) return + if (p) { + const cols = Math.max(2, p.cols) + const rows = Math.max(2, p.rows) + if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows) + term.reset() + term.write(PREVIEW_CLEAR + p.data, () => { + const screen = host.firstElementChild + if (screen instanceof HTMLElement) fitMonitor(host, screen) + }) + } + if (!disposed) timer = setTimeout(() => void poll(), MONITOR_POLL_MS) + } + void poll() + + return { + dispose(): void { + disposed = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } + term.dispose() + }, + } +} diff --git a/public/style.css b/public/style.css index ba47dc9..010117e 100644 --- a/public/style.css +++ b/public/style.css @@ -396,6 +396,35 @@ body { color: var(--text); background: var(--surface-3); } +.cell-monitor-btn { + border: 0; + background: transparent; + color: var(--text-faint); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 2px 4px; + border-radius: 5px; + opacity: 0.7; +} +.cell-monitor-btn:hover { + color: var(--text); + background: var(--surface-3); + opacity: 1; +} +.cell-monitor-btn.active { + color: var(--accent); + opacity: 1; +} +/* Read-only monitor preview fills the cell below the header. */ +.cell-monitor { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + position: relative; + padding: 6px 8px 0; + box-sizing: border-box; +} .cell-name { font-weight: 600; color: var(--text); @@ -604,7 +633,8 @@ body { width: 36px; height: 34px; } - .cell-max { + .cell-max, + .cell-monitor-btn { padding: 6px 9px; font-size: 15px; } diff --git a/public/tabs.ts b/public/tabs.ts index 5bfaeae..cd9c41f 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -42,6 +42,7 @@ import { loadGridLayout, saveGridLayout, } from './grid-layout.js' +import { mountCellMonitor, type CellMonitor } from './cell-monitor.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' @@ -70,6 +71,11 @@ interface TabEntry { // Split-grid: the .term-cell wrapper around session.el (header + terminal + // optional inline-approve footer). One per tab; the grid lays these out. cell: HTMLDivElement | null + // v3: when true, this quadrant shows a read-only polled preview (monitor mode) + // instead of the live pane, so it never drives the shared PTY size. + monitor: boolean + monitorHandle: CellMonitor | null + monitorEl: HTMLElement | null // A4: live timeline handle while this tab's timeline is open (disposed on // switch/close). Telemetry is NOT cached here β€” refreshTab reads the single // source of truth session.telemetry (review #15). @@ -678,6 +684,9 @@ export class TabApp { hasActivity: false, el: null, cell: null, + monitor: false, + monitorHandle: null, + monitorEl: null, timelineHandle: null, } // Wrap the pane in a grid cell (header + terminal + optional inline-approve). @@ -701,7 +710,19 @@ export class TabApp { if (idx !== this.activeIndex) this.setFocused(idx) this.toggleMaximize() }) - head.appendChild(maxBtn) + // v3: πŸ‘ monitor toggle β€” read-only preview (no PTY resize) vs live pane. + const monBtn = document.createElement('button') + monBtn.type = 'button' + monBtn.className = 'cell-monitor-btn' + monBtn.textContent = 'πŸ‘' + monBtn.title = 'Monitor (read-only) / go live' + monBtn.setAttribute('aria-label', 'Toggle read-only monitor for this pane') + monBtn.addEventListener('pointerdown', (e) => e.stopPropagation()) + monBtn.addEventListener('click', (e) => { + e.stopPropagation() + this.toggleMonitor(this.tabs.indexOf(entry)) + }) + head.append(monBtn, maxBtn) // v2: drag a tab from the tab bar onto this quadrant to assign it here. this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry)) cell.append(head, session.el) @@ -786,6 +807,7 @@ export class TabApp { this.maximized = false // structural change exits maximize const [entry] = this.tabs.splice(i, 1) entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab + if (entry) this.stopMonitor(entry) // v3: stop the monitor poll + preview term entry?.session.dispose() // removes session.el (the .term-pane) entry?.cell?.remove() // also drop the grid cell wrapper if (this.editingIndex === i) this.editingIndex = -1 @@ -969,6 +991,36 @@ export class TabApp { this.updateHomeView() } + /** v3: flip a quadrant between live (interactive) and read-only monitor mode. */ + private toggleMonitor(idx: number): void { + const entry = this.tabs[idx] + if (!entry || this.gridLayout === 'single') return + entry.monitor = !entry.monitor + this.updateHomeView() // applyLayout starts/stops the monitor + } + + /** Begin polling a read-only preview into the cell (idempotent). */ + private startMonitor(entry: TabEntry, cell: HTMLDivElement): void { + if (entry.monitorHandle || entry.session.id === null) return + const host = document.createElement('div') + host.className = 'cell-monitor' + cell.insertBefore(host, entry.session.el) + entry.monitorEl = host + entry.monitorHandle = mountCellMonitor(host, entry.session.id) + } + + /** Stop monitoring and tear down the preview (idempotent). */ + private stopMonitor(entry: TabEntry): void { + if (entry.monitorHandle) { + entry.monitorHandle.dispose() + entry.monitorHandle = null + } + if (entry.monitorEl) { + entry.monitorEl.remove() + entry.monitorEl = null + } + } + /** v2: make a grid cell a drop target for a tab dragged from the tab bar β€” * dropping assigns that tab to this cell's slot (grid only) and focuses it. */ private wireCellDropTarget(cell: HTMLElement, slotIndex: () => number): void { @@ -1013,11 +1065,19 @@ export class TabApp { if (!cell) return const visible = !showHome && this.isVisible(idx) cell.style.order = String(idx) // grid places visible cells in tab order - if (visible) { + if (visible && entry.monitor && entry.session.id !== null && layout !== 'single') { + // Monitor mode: keep the live pane hidden (so it never fits/resizes the + // shared PTY) and show a polled read-only preview instead. cell.style.display = 'flex' + entry.session.hide() + this.startMonitor(entry, cell) + } else if (visible) { + cell.style.display = 'flex' + this.stopMonitor(entry) entry.session.show({ focus: idx === this.activeIndex }) } else { cell.style.display = 'none' + this.stopMonitor(entry) entry.session.hide() } this.renderCell(entry) @@ -1081,6 +1141,8 @@ export class TabApp { cell.classList.toggle('maximized', maximized) const maxBtn = cell.querySelector('.cell-max') if (maxBtn) maxBtn.textContent = maximized ? '‑' : 'β›Ά' + const monBtn = cell.querySelector('.cell-monitor-btn') + if (monBtn) monBtn.classList.toggle('active', grid && entry.monitor) const nameEl = cell.querySelector('.cell-name') if (nameEl) nameEl.textContent = this.displayTitle(entry, idx) diff --git a/test/cell-monitor.test.ts b/test/cell-monitor.test.ts new file mode 100644 index 0000000..10b8687 --- /dev/null +++ b/test/cell-monitor.test.ts @@ -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: '', + 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('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() + }) +}) diff --git a/test/tabs.test.ts b/test/tabs.test.ts index 01bf808..6cd0597 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -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 }> = [] +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 + 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() + }) }) diff --git a/vitest.config.ts b/vitest.config.ts index aae3566..4a5d9f5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ 'public/terminal-session.ts', 'public/tabs.ts', 'public/grid-layout.ts', + 'public/cell-monitor.ts', 'public/preview-grid.ts', 'public/title-util.ts', 'public/voice-commands.ts',