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:
@@ -34,7 +34,12 @@
|
|||||||
- **交叉验证抓修 3 真缺陷**:**HIGH** — `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex 但 `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-board→moveTab 上板逻辑折进 `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-board→moveTab 上板逻辑折进 `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)**:每格 👁 切换 live↔read-only。`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,**不 attach WS、不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrink。monitor 时 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→复验→commit。maximize 的 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)
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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] } })
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user