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