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:
82
public/cell-monitor.ts
Normal file
82
public/cell-monitor.ts
Normal file
@@ -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<typeof setTimeout> | null = null
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user