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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -396,6 +396,35 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--surface-3);
|
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 {
|
.cell-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -604,7 +633,8 @@ body {
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
.cell-max {
|
.cell-max,
|
||||||
|
.cell-monitor-btn {
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
loadGridLayout,
|
loadGridLayout,
|
||||||
saveGridLayout,
|
saveGridLayout,
|
||||||
} from './grid-layout.js'
|
} from './grid-layout.js'
|
||||||
|
import { mountCellMonitor, type CellMonitor } from './cell-monitor.js'
|
||||||
|
|
||||||
const TABS_KEY = 'web-terminal:tabs'
|
const TABS_KEY = 'web-terminal:tabs'
|
||||||
const ACTIVE_KEY = 'web-terminal:active'
|
const ACTIVE_KEY = 'web-terminal:active'
|
||||||
@@ -70,6 +71,11 @@ interface TabEntry {
|
|||||||
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
|
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
|
||||||
// optional inline-approve footer). One per tab; the grid lays these out.
|
// optional inline-approve footer). One per tab; the grid lays these out.
|
||||||
cell: HTMLDivElement | null
|
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
|
// A4: live timeline handle while this tab's timeline is open (disposed on
|
||||||
// switch/close). Telemetry is NOT cached here — refreshTab reads the single
|
// switch/close). Telemetry is NOT cached here — refreshTab reads the single
|
||||||
// source of truth session.telemetry (review #15).
|
// source of truth session.telemetry (review #15).
|
||||||
@@ -678,6 +684,9 @@ export class TabApp {
|
|||||||
hasActivity: false,
|
hasActivity: false,
|
||||||
el: null,
|
el: null,
|
||||||
cell: null,
|
cell: null,
|
||||||
|
monitor: false,
|
||||||
|
monitorHandle: null,
|
||||||
|
monitorEl: null,
|
||||||
timelineHandle: null,
|
timelineHandle: null,
|
||||||
}
|
}
|
||||||
// Wrap the pane in a grid cell (header + terminal + optional inline-approve).
|
// 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)
|
if (idx !== this.activeIndex) this.setFocused(idx)
|
||||||
this.toggleMaximize()
|
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.
|
// v2: drag a tab from the tab bar onto this quadrant to assign it here.
|
||||||
this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry))
|
this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry))
|
||||||
cell.append(head, session.el)
|
cell.append(head, session.el)
|
||||||
@@ -786,6 +807,7 @@ export class TabApp {
|
|||||||
this.maximized = false // structural change exits maximize
|
this.maximized = false // structural change exits maximize
|
||||||
const [entry] = this.tabs.splice(i, 1)
|
const [entry] = this.tabs.splice(i, 1)
|
||||||
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
|
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?.session.dispose() // removes session.el (the .term-pane)
|
||||||
entry?.cell?.remove() // also drop the grid cell wrapper
|
entry?.cell?.remove() // also drop the grid cell wrapper
|
||||||
if (this.editingIndex === i) this.editingIndex = -1
|
if (this.editingIndex === i) this.editingIndex = -1
|
||||||
@@ -969,6 +991,36 @@ export class TabApp {
|
|||||||
this.updateHomeView()
|
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 —
|
/** 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. */
|
* dropping assigns that tab to this cell's slot (grid only) and focuses it. */
|
||||||
private wireCellDropTarget(cell: HTMLElement, slotIndex: () => number): void {
|
private wireCellDropTarget(cell: HTMLElement, slotIndex: () => number): void {
|
||||||
@@ -1013,11 +1065,19 @@ export class TabApp {
|
|||||||
if (!cell) return
|
if (!cell) return
|
||||||
const visible = !showHome && this.isVisible(idx)
|
const visible = !showHome && this.isVisible(idx)
|
||||||
cell.style.order = String(idx) // grid places visible cells in tab order
|
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'
|
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 })
|
entry.session.show({ focus: idx === this.activeIndex })
|
||||||
} else {
|
} else {
|
||||||
cell.style.display = 'none'
|
cell.style.display = 'none'
|
||||||
|
this.stopMonitor(entry)
|
||||||
entry.session.hide()
|
entry.session.hide()
|
||||||
}
|
}
|
||||||
this.renderCell(entry)
|
this.renderCell(entry)
|
||||||
@@ -1081,6 +1141,8 @@ export class TabApp {
|
|||||||
cell.classList.toggle('maximized', maximized)
|
cell.classList.toggle('maximized', maximized)
|
||||||
const maxBtn = cell.querySelector<HTMLElement>('.cell-max')
|
const maxBtn = cell.querySelector<HTMLElement>('.cell-max')
|
||||||
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
|
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')
|
const nameEl = cell.querySelector('.cell-name')
|
||||||
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)
|
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)
|
||||||
|
|||||||
83
test/cell-monitor.test.ts
Normal file
83
test/cell-monitor.test.ts
Normal file
@@ -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: '<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('<CLEAR>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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -74,6 +74,15 @@ vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalS
|
|||||||
const renderTelemetryGauge = vi.fn()
|
const renderTelemetryGauge = vi.fn()
|
||||||
vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge }))
|
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()
|
const mountPushToggle = vi.fn()
|
||||||
vi.mock('../public/push.js', () => ({ mountPushToggle }))
|
vi.mock('../public/push.js', () => ({ mountPushToggle }))
|
||||||
|
|
||||||
@@ -157,6 +166,8 @@ beforeEach(() => {
|
|||||||
document.body.replaceChildren()
|
document.body.replaceChildren()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
renderTelemetryGauge.mockClear()
|
renderTelemetryGauge.mockClear()
|
||||||
|
mountCellMonitor.mockClear()
|
||||||
|
monitorHandles.length = 0
|
||||||
mountPushToggle.mockClear()
|
mountPushToggle.mockClear()
|
||||||
mountQuickReply.mockClear()
|
mountQuickReply.mockClear()
|
||||||
mountTimeline.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(cell2.classList.contains('maximized')).toBe(true) // and maximized it
|
||||||
expect(maxBtn.textContent).toBe('⤡') // glyph flips to restore
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
|||||||
'public/terminal-session.ts',
|
'public/terminal-session.ts',
|
||||||
'public/tabs.ts',
|
'public/tabs.ts',
|
||||||
'public/grid-layout.ts',
|
'public/grid-layout.ts',
|
||||||
|
'public/cell-monitor.ts',
|
||||||
'public/preview-grid.ts',
|
'public/preview-grid.ts',
|
||||||
'public/title-util.ts',
|
'public/title-util.ts',
|
||||||
'public/voice-commands.ts',
|
'public/voice-commands.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user