fix(grid): v3 review fixes — split-null crash, monitor race, drag-safe gutters

Adversarial review (4 lenses → per-finding verify) of v3 confirmed 6 issues:

- HIGH: splitForLayout({"grid-4":null}) threw a TypeError — null passed the
  `!== undefined` guard, then null.cols threw. Since splitForLayout runs on every
  grid render, one corrupt localStorage entry would brick the tab UI. Now guards
  `!== null && typeof === 'object'`.
- MED: a monitor toggled before the session finished attaching (id still null)
  showed the button active while the pane stayed live and sent a resize, and did
  not self-correct. onSessionId now reconciles a pending monitor once the id lands.
- MED: renderGutters destroyed + recreated handles on every applyLayout, dropping
  the drag listeners if a re-render fired mid-drag. A draggingGutter flag now skips
  the rebuild while dragging.
- LOW: grid-presets hardcoded the 1024px breakpoint → now uses GRID_MIN_WIDTH.

Regression tests added (null-split no-throw, attach reconcile, drag survives a
concurrent re-render). typecheck + build:web clean, 1615 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 20:25:04 +02:00
parent 007e598802
commit 733c8a8318
6 changed files with 68 additions and 4 deletions

View File

@@ -34,7 +34,12 @@
- **交叉验证抓修 3 真缺陷**:**HIGH** `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-boardmoveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大全部补了回归测试 - **交叉验证抓修 3 真缺陷**:**HIGH** `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-boardmoveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大全部补了回归测试
- **[x] v2 DONE(2026-07-11)** orchestrator 独立复验全绿:typecheck config 干净build:web 干净、**全套 1579 测试**(+13),覆盖率 grid-layout 95%/tabs 93%。加了:**1×3(row-3)/2×3(grid-6)布局**、**Ctrl+`/Ctrl+Shift+` 循环焦点**(main.ts capture keydown cycleFocus,单格模式不吞键)、**每格最大化 ⛶**(`.maximized` 覆盖层)、** tab 到格**(wireCellDropTarget 复用 dragIndex)。用单个 `grid` 标记类承载共享 cell 样式(不再枚举每个 lay-*)。 - **[x] v2 DONE(2026-07-11)** orchestrator 独立复验全绿:typecheck config 干净build:web 干净、**全套 1579 测试**(+13),覆盖率 grid-layout 95%/tabs 93%。加了:**1×3(row-3)/2×3(grid-6)布局**、**Ctrl+`/Ctrl+Shift+` 循环焦点**(main.ts capture keydown cycleFocus,单格模式不吞键)、**每格最大化 ⛶**(`.maximized` 覆盖层)、** tab 到格**(wireCellDropTarget 复用 dragIndex)。用单个 `grid` 标记类承载共享 cell 样式(不再枚举每个 lay-*)。
- **交叉验证抓修 3 真缺陷**:**HIGH** 最大化原用 `grid-column/row: 1/-1` **span 网格**是错的:CSS Grid 把兄弟格挤进隐式行→"最大化"格变成细条(非全屏)+ 兄弟格 box 变化触发 ResizeObserver→**给后台 live PTY 发错误 resize(伪 SIGWINCH)**评审在** headless Chrome 里实测复现**。修:`.maximized` `position:absolute; inset:0; z-index:4` 脱离网格流做覆盖层。**独立 Playwright 复验**:最大化格填满 #term(1000×700)、兄弟格尺寸 0px 变化,对照组证实旧规则确会 891×572 细条+兄弟塌成 52px。**MED** 最大化时被盖住的 pending amber 脉冲外溢;修:`(!maximized || focused)` 。**LOW** `.cell-max` coarse-pointer 触控尺寸均补回归测试;并把 Ctrl+` 键匹配抽成 `matchFocusCycleKey` 纯函数单测 - **交叉验证抓修 3 真缺陷**:**HIGH** 最大化原用 `grid-column/row: 1/-1` **span 网格**是错的:CSS Grid 把兄弟格挤进隐式行→"最大化"格变成细条(非全屏)+ 兄弟格 box 变化触发 ResizeObserver→**给后台 live PTY 发错误 resize(伪 SIGWINCH)**评审在** headless Chrome 里实测复现**。修:`.maximized` `position:absolute; inset:0; z-index:4` 脱离网格流做覆盖层。**独立 Playwright 复验**:最大化格填满 #term(1000×700)、兄弟格尺寸 0px 变化,对照组证实旧规则确会 891×572 细条+兄弟塌成 52px。**MED** 最大化时被盖住的 pending amber 脉冲外溢;修:`(!maximized || focused)` 。**LOW** `.cell-max` coarse-pointer 触控尺寸均补回归测试;并把 Ctrl+` 键匹配抽成 `matchFocusCycleKey` 纯函数单测
- **[~] 下一步**: v3(可拖拽分隔条只读 monitor 格走 `/live-sessions/:id/preview` 免共享 PTY 缩放布局预设)。 - **[x] v3 DONE(2026-07-11)** 三个特性,orchestrator 独立复验全绿(typecheck config + build:web 干净、**全套 1615 测试**、全局覆盖率 89% stmts/82% branch 80 门槛)。分三 commit:
- **v3a 只读 monitor (cd97114)**:每格 👁 切换 liveread-only`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,** attach WS不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrinkmonitor live pane 保持 hidden;toggle-off/ tab/离开 grid 皆清理
- **v3b 可拖拽分隔条(007e598)**:格间 gutter 拖拽调每布局 col/row fr 比例(`adjustSplit` 纯函数:相邻轨道互让clamp 0.3守恒),内联 grid-template + 持久化 `web-terminal:grid-splits`;拖拽中 reposition 不重建 handle
- **v3c 布局预设(007e598)**:`public/grid-presets.ts` toolbar 下拉,/应用/删命名的 布局+split(`web-terminal:grid-presets`);外点/Esc 同名替换XSS-safe(textContent)。
- **交叉验证抓修 4 类真缺陷(6 confirmed,2 dup)**:**HIGH** `splitForLayout({'grid-4':null})` TypeError(`null!==undefined` 过守卫后读 `null.cols`),每次渲染都炸整个 tab UI 砖掉;修:守卫加 `!==null && typeof==='object'`。**MED** monitor attach (id=null)切换:按钮显 active 但格仍 live 且发 resize,且不自愈;修:`onSessionId` 到达时 reconcile(re-render 使 startMonitor 生效)。**MED** `renderGutters` 拖拽中被并发 applyLayout 销毁重建丢拖拽监听;修:`draggingGutter` 标志跳过重建。**LOW** grid-presets 硬编码 1024改用 `GRID_MIN_WIDTH`均补回归测试( null-split 不抛reconnect-reconcile拖拽中重渲染 handle 存活)。
- **✅ 全部阶段(v1+v2+v3)完成**:5 commit(06814ba/5475b66/cd97114/007e598 + fix)。每阶段 = 实现→typecheck/test/build 绿→**并行多 lens 对抗式 review workflow + 逐条 verify**→ confirmed复验commitmaximize HIGH 几何缺陷经** headless Chrome(Playwright)实测**证实修复服务端/WS 协议零改动;单格模式行为不变
- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换点击移焦内联审批)已给用户看过并据此拍板方向 - **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换点击移焦内联审批)已给用户看过并据此拍板方向
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08) ### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)

View File

@@ -303,6 +303,8 @@ export function splitForLayout(splits: GridSplits, layout: GridLayout): TrackSpl
const stored = splits[layout] const stored = splits[layout]
const ok = const ok =
stored !== undefined && stored !== undefined &&
stored !== null && // a null entry ({"grid-4":null}) is valid JSON but not a TrackSplit
typeof stored === 'object' &&
Array.isArray(stored.cols) && Array.isArray(stored.cols) &&
stored.cols.length === cols && stored.cols.length === cols &&
Array.isArray(stored.rows) && Array.isArray(stored.rows) &&

View File

@@ -7,7 +7,7 @@
* the current arrangement and an apply() via hooks, keeping tabs.ts lean. * the current arrangement and an apply() via hooks, keeping tabs.ts lean.
*/ */
import { type GridLayout, type TrackSplit, gridAllowed } from './grid-layout.js' import { type GridLayout, type TrackSplit, gridAllowed, GRID_MIN_WIDTH } from './grid-layout.js'
export interface GridPreset { export interface GridPreset {
name: string name: string
@@ -186,7 +186,7 @@ export function mountGridPresets(toolbar: HTMLElement, hooks: PresetHooks): Pres
const mql = const mql =
typeof window !== 'undefined' && typeof window.matchMedia === 'function' typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(min-width: 1024px)') ? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`)
: null : null
mql?.addEventListener('change', refresh) mql?.addEventListener('change', refresh)
refresh() refresh()

View File

@@ -128,6 +128,9 @@ export class TabApp {
private maximized = false private maximized = false
// v3: persisted per-layout column/row track fractions (draggable splitters). // v3: persisted per-layout column/row track fractions (draggable splitters).
private gridSplits: GridSplits = {} private gridSplits: GridSplits = {}
// v3: true while a gutter is being dragged, so a concurrent applyLayout does
// not destroy-and-recreate the handle (which would drop the drag listeners).
private draggingGutter = false
// When true, the home chooser is overlaid on top of the open tabs (so you can // When true, the home chooser is overlaid on top of the open tabs (so you can
// jump back to Sessions/Projects without closing anything). Reset whenever a // jump back to Sessions/Projects without closing anything). Reset whenever a
// tab is activated. Irrelevant while no tab is open (home shows regardless). // tab is activated. Irrelevant while no tab is open (home shows regardless).
@@ -659,7 +662,14 @@ export class TabApp {
sessionId, sessionId,
...(cwd !== undefined ? { cwd } : {}), ...(cwd !== undefined ? { cwd } : {}),
...(initialInput !== undefined ? { initialInput } : {}), ...(initialInput !== undefined ? { initialInput } : {}),
onSessionId: () => this.persist(), onSessionId: () => {
this.persist()
// v3: a monitor toggled before attach completed (session.id was null, so
// applyLayout's monitor branch fell through to the live pane) engages now
// that the id is available — reconcile instead of waiting for the next
// unrelated structural re-render.
if (entry.monitor && this.isVisible(this.tabs.indexOf(entry))) this.updateHomeView()
},
// onActivity only fires for hidden (inactive) panes (see TerminalSession). // onActivity only fires for hidden (inactive) panes (see TerminalSession).
onActivity: () => { onActivity: () => {
entry.hasActivity = true entry.hasActivity = true
@@ -1134,6 +1144,9 @@ export class TabApp {
/** Replace the gutter handles for the current layout (none in single mode). */ /** Replace the gutter handles for the current layout (none in single mode). */
private renderGutters(layout: GridLayout): void { private renderGutters(layout: GridLayout): void {
// Never rebuild handles mid-drag — the dragged element owns the pointer
// listeners and would lose them (existing handles stay valid for this layout).
if (this.draggingGutter) return
this.paneHost.querySelectorAll('.grid-gutter').forEach((g) => g.remove()) this.paneHost.querySelectorAll('.grid-gutter').forEach((g) => g.remove())
if (layout === 'single') return if (layout === 'single') return
const { cols, rows } = layoutTracks(layout) const { cols, rows } = layoutTracks(layout)
@@ -1187,6 +1200,7 @@ export class TabApp {
const base = splitForLayout(this.gridSplits, layout) const base = splitForLayout(this.gridSplits, layout)
const startFractions = (axis === 'col' ? base.cols : base.rows).slice() const startFractions = (axis === 'col' ? base.cols : base.rows).slice()
const total = startFractions.reduce((a, b) => a + b, 0) const total = startFractions.reduce((a, b) => a + b, 0)
this.draggingGutter = true
try { try {
handle.setPointerCapture(e.pointerId) handle.setPointerCapture(e.pointerId)
} catch { } catch {
@@ -1199,6 +1213,7 @@ export class TabApp {
this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta)) this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta))
} }
const up = (): void => { const up = (): void => {
this.draggingGutter = false
handle.removeEventListener('pointermove', move) handle.removeEventListener('pointermove', move)
handle.removeEventListener('pointerup', up) handle.removeEventListener('pointerup', up)
handle.removeEventListener('pointercancel', up) handle.removeEventListener('pointercancel', up)

View File

@@ -165,6 +165,11 @@ describe('grid-layout: resizable splitters (v3)', () => {
expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2')) expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2'))
}) })
it('splitForLayout falls back to default for a null stored entry (no throw)', () => {
// {"grid-4":null} is valid JSON a corrupt/tampered store could hold.
expect(splitForLayout({ 'grid-4': null } as never, 'grid-4')).toEqual(defaultSplit('grid-4'))
})
it('saveGridSplits + loadGridSplits round-trip', () => { it('saveGridSplits + loadGridSplits round-trip', () => {
saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })
expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })

View File

@@ -1526,4 +1526,41 @@ describe('TabApp — split-grid watch board (v1)', () => {
const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!) const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!)
expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2) expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2)
}) })
it('a re-render during a gutter drag does not destroy the dragged handle', () => {
const { app, paneHost } = withTabs(4)
app.focusTab(0)
app.setGridLayout('grid-4')
vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({
width: 800,
height: 600,
left: 0,
top: 0,
right: 800,
bottom: 600,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
const gutter = paneHost.querySelector<HTMLElement>('.grid-gutter-col')!
gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true }))
app.toggleMaximize() // triggers applyLayout → renderGutters (must skip while dragging)
expect(paneHost.contains(gutter)).toBe(true) // same handle survives
gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true }))
gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr')
})
it('a monitor toggled before attach engages once the session id arrives', () => {
const { app } = withTabs(3) // fresh sessions — ids are null until attached
app.focusTab(0)
app.setGridLayout('grid-4')
const inst = FakeTerminalSession.instances[1]!
inst.el.closest('.term-cell')!.querySelector<HTMLButtonElement>('.cell-monitor-btn')!.click()
expect(mountCellMonitor).not.toHaveBeenCalled() // id still null → not engaged
inst.id = 'sess-1' // simulate 'attached'
;(inst.cbs as { onSessionId?: (id: string) => void }).onSessionId?.('sess-1')
expect(mountCellMonitor).toHaveBeenCalled()
expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1')
})
}) })