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

82
public/cell-monitor.ts Normal file
View 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()
},
}
}

View File

@@ -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;
}

View File

@@ -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<HTMLElement>('.cell-max')
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
const monBtn = cell.querySelector<HTMLElement>('.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)