diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 556af58..510c2e9 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -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)` 加大。全部补了回归测试。 - **[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` 纯函数单测。 -- **[~] 下一步**: 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 切换、点击移焦、内联审批)已给用户看过并据此拍板方向。 ### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08) diff --git a/public/grid-layout.ts b/public/grid-layout.ts index 0abd651..3d5ce8d 100644 --- a/public/grid-layout.ts +++ b/public/grid-layout.ts @@ -303,6 +303,8 @@ export function splitForLayout(splits: GridSplits, layout: GridLayout): TrackSpl const stored = splits[layout] const ok = stored !== undefined && + stored !== null && // a null entry ({"grid-4":null}) is valid JSON but not a TrackSplit + typeof stored === 'object' && Array.isArray(stored.cols) && stored.cols.length === cols && Array.isArray(stored.rows) && diff --git a/public/grid-presets.ts b/public/grid-presets.ts index 2a525c1..ad44245 100644 --- a/public/grid-presets.ts +++ b/public/grid-presets.ts @@ -7,7 +7,7 @@ * 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 { name: string @@ -186,7 +186,7 @@ export function mountGridPresets(toolbar: HTMLElement, hooks: PresetHooks): Pres const mql = typeof window !== 'undefined' && typeof window.matchMedia === 'function' - ? window.matchMedia('(min-width: 1024px)') + ? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`) : null mql?.addEventListener('change', refresh) refresh() diff --git a/public/tabs.ts b/public/tabs.ts index 2166eb1..fb2890b 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -128,6 +128,9 @@ export class TabApp { private maximized = false // v3: persisted per-layout column/row track fractions (draggable splitters). 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 // jump back to Sessions/Projects without closing anything). Reset whenever a // tab is activated. Irrelevant while no tab is open (home shows regardless). @@ -659,7 +662,14 @@ export class TabApp { sessionId, ...(cwd !== undefined ? { cwd } : {}), ...(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: () => { entry.hasActivity = true @@ -1134,6 +1144,9 @@ export class TabApp { /** Replace the gutter handles for the current layout (none in single mode). */ 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()) if (layout === 'single') return const { cols, rows } = layoutTracks(layout) @@ -1187,6 +1200,7 @@ export class TabApp { const base = splitForLayout(this.gridSplits, layout) const startFractions = (axis === 'col' ? base.cols : base.rows).slice() const total = startFractions.reduce((a, b) => a + b, 0) + this.draggingGutter = true try { handle.setPointerCapture(e.pointerId) } catch { @@ -1199,6 +1213,7 @@ export class TabApp { this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta)) } const up = (): void => { + this.draggingGutter = false handle.removeEventListener('pointermove', move) handle.removeEventListener('pointerup', up) handle.removeEventListener('pointercancel', up) diff --git a/test/grid-layout.test.ts b/test/grid-layout.test.ts index 645d018..55cca2c 100644 --- a/test/grid-layout.test.ts +++ b/test/grid-layout.test.ts @@ -165,6 +165,11 @@ describe('grid-layout: resizable splitters (v3)', () => { 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', () => { saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) diff --git a/test/tabs.test.ts b/test/tabs.test.ts index a1395c6..bcf1dec 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -1526,4 +1526,41 @@ describe('TabApp — split-grid watch board (v1)', () => { const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!) 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('.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('.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') + }) })