feat(grid): split-grid v2 — 1×3/2×3, drag-to-quadrant, Ctrl+` cycle, maximize

Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
  #term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
  is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
  overlay while siblings stay live behind it; follows focus, resets on layout
  change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
  existing tab-drag dragIndex); grid-only.

Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 19:49:39 +02:00
parent 06814ba276
commit 5475b661ae
7 changed files with 324 additions and 24 deletions

View File

@@ -32,7 +32,9 @@
- **核心设计**: `activeIndex` 语义**不变**=聚焦格(keybar/voice/approval 全部零改动);新增 `gridLayout` + 派生 `visibleIndices`;每个 pane 包进 `.term-cell`(header + 终端 + 可选内联审批 footer);`applyLayout()` 独占 pane 显隐/grid class/cell 排序/占位符;`#term` CSS grid。**服务端/WS 协议零改动**。
- **文件**: `public/grid-layout.ts`(纯逻辑 + matchMedia + 持久化 + toolbar 分段控件)、`tabs.ts`(applyLayout/renderCell/renderInlineApprove/setFocused/setGridLayout/refitVisible + `activate()` 改为**board-aware**)、`terminal-session.ts`(`show({focus})` 4 格抢焦点 + `onFocus` 回调)、`main.ts`( toggle + refitVisible)、`style.css`(cell/grid/焦点环/pending 脉冲/内联审批/占位符/toggle + `.term-pane` position:relative flex )。新测 `test/grid-layout.test.ts` + `tabs.test.ts` 追加 split-grid
- **交叉验证抓修 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)` 加大全部补了回归测试
- **[~] 下一步**: v2(1×3/2×3 布局 tab 到格Ctrl+` 循环焦点、单格最大化)→ v3(可拖拽分隔条、只读 monitor 格走 `/live-sessions/:id/preview` 共享 PTY 缩放布局预设)。
- **[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 缩放布局预设)。
- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换点击移焦内联审批)已给用户看过并据此拍板方向
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)

View File

@@ -16,15 +16,23 @@
*/
/** A screen layout for the terminal area. Ordered by pane capacity. */
export type GridLayout = 'single' | 'split-2' | 'grid-4'
export type GridLayout = 'single' | 'split-2' | 'row-3' | 'grid-4' | 'grid-6'
/** All layouts, in the order the toggle presents them. */
export const GRID_LAYOUTS: readonly GridLayout[] = ['single', 'split-2', 'grid-4']
/** All layouts, in the order the toggle presents them (by capacity). */
export const GRID_LAYOUTS: readonly GridLayout[] = [
'single',
'split-2',
'row-3',
'grid-4',
'grid-6',
]
const CAPACITY: Readonly<Record<GridLayout, number>> = {
single: 1,
'split-2': 2,
'row-3': 3,
'grid-4': 4,
'grid-6': 6,
}
/** How many panes a layout shows at once. */
@@ -57,6 +65,17 @@ export function isVisibleIndex(
return idx >= 0 && idx < Math.min(Math.max(0, tabCount), layoutCapacity(layout))
}
/**
* Match the split-grid focus-cycle keybinding (Ctrl+` forward, Ctrl+Shift+`
* reverse). Returns the cycle direction, or null if the event isn't the binding.
* Extracted from the DOM listener so it's unit-testable in isolation.
*/
export function matchFocusCycleKey(e: KeyboardEvent): 1 | -1 | null {
if (!e.ctrlKey || e.altKey || e.metaKey) return null
if (e.key !== '`' && e.code !== 'Backquote') return null
return e.shiftKey ? -1 : 1
}
/** Minimum viewport width (px) at which multi-pane layouts are offered. Four
* terminals need real width; below this the board falls back to 'single'. */
export const GRID_MIN_WIDTH = 1024
@@ -73,7 +92,9 @@ export function gridAllowed(): boolean {
const GRID_LAYOUT_KEY = 'web-terminal:grid-layout'
function isGridLayout(v: unknown): v is GridLayout {
return v === 'single' || v === 'split-2' || v === 'grid-4'
return (
v === 'single' || v === 'split-2' || v === 'row-3' || v === 'grid-4' || v === 'grid-6'
)
}
/** Load the persisted layout. Forced to 'single' when the screen is too narrow
@@ -114,7 +135,9 @@ export interface GridToggle {
const LAYOUT_META: Readonly<Record<GridLayout, { title: string; cells: number }>> = {
single: { title: 'Single pane', cells: 1 },
'split-2': { title: 'Side by side — compare two', cells: 2 },
'row-3': { title: '1×3 row — three across', cells: 3 },
'grid-4': { title: '2×2 watch board', cells: 4 },
'grid-6': { title: '2×3 grid — six sessions', cells: 6 },
}
/** A small CSS-drawn layout glyph (N cells), reliable across platforms. */

View File

@@ -20,7 +20,7 @@ import { mountDashboard } from './dashboard.js'
import { mountHistory } from './history.js'
import { mountShortcuts } from './shortcuts.js'
import { mountShareSession } from './share.js'
import { mountGridToggle } from './grid-layout.js'
import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js'
const paneHost = document.getElementById('term')
const tabs = document.getElementById('tabs')
@@ -59,6 +59,21 @@ document.addEventListener('visibilitychange', () => {
if (!document.hidden) app.refitVisible()
})
// v2: Ctrl+` cycles the focused quadrant in a split grid (Ctrl+Shift+` reverses).
// Capture phase so xterm doesn't swallow it; left untouched (no preventDefault)
// in single mode so the terminal keeps the keystroke.
document.addEventListener(
'keydown',
(e) => {
const dir = matchFocusCycleKey(e)
if (dir === null) return
if (app.getGridLayout() === 'single') return // let the terminal keep the key
e.preventDefault()
app.cycleFocus(dir)
},
{ capture: true },
)
// Toolbar utilities.
mountSearch(toolbar, {
find: (query, dir) => app.findInActive(query, dir),

View File

@@ -327,8 +327,9 @@ body {
}
/* ── Split-grid layouts (desktop watch board) ────────────────────────── */
#term.lay-split-2,
#term.lay-grid-4 {
/* Shared cell chrome keys off the single `grid` marker so it is independent of
* how many concrete layouts exist; each `lay-*` only sets the grid template. */
#term.grid {
display: grid;
gap: 6px;
padding: 6px;
@@ -337,20 +338,40 @@ body {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
#term.lay-row-3 {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr;
}
#term.lay-grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
#term.lay-split-2 .term-cell,
#term.lay-grid-4 .term-cell {
#term.lay-grid-6 {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
#term.grid .term-cell {
position: relative;
inset: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
}
#term.lay-split-2 .cell-head,
#term.lay-grid-4 .cell-head {
/* Maximized quadrant fills the whole grid as an ABSOLUTE overlay (taken out of
* grid flow so the siblings keep their placement — spanning grid tracks instead
* would shove auto-placed siblings into implicit rows, resizing their live PTYs).
* #term is a positioned containing block, so inset:0 covers the whole area. */
#term.grid .term-cell.maximized {
position: absolute;
inset: 0;
z-index: 4;
}
/* A tab dragged from the tab bar is hovering this quadrant. */
#term.grid .term-cell.drag-target {
outline: 2px dashed var(--accent);
outline-offset: -3px;
}
#term.grid .cell-head {
display: flex;
align-items: center;
gap: 8px;
@@ -361,6 +382,20 @@ body {
cursor: pointer;
user-select: none;
}
.cell-max {
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 2px 4px;
border-radius: 5px;
}
.cell-max:hover {
color: var(--text);
background: var(--surface-3);
}
.cell-name {
font-weight: 600;
color: var(--text);
@@ -388,16 +423,14 @@ body {
}
/* Focus ring — the quadrant that owns keyboard / keybar / voice / approvals. */
#term.lay-split-2 .term-cell.focused,
#term.lay-grid-4 .term-cell.focused {
#term.grid .term-cell.focused {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
var(--shadow);
}
/* Pending glow — a non-focused quadrant is waiting for approval. */
#term.lay-split-2 .term-cell.pending:not(.focused),
#term.lay-grid-4 .term-cell.pending:not(.focused) {
#term.grid .term-cell.pending:not(.focused) {
border-color: var(--amber);
animation: cell-pending 2.4s ease-in-out infinite;
}
@@ -413,15 +446,13 @@ body {
}
}
@media (prefers-reduced-motion: reduce) {
#term.lay-split-2 .term-cell.pending,
#term.lay-grid-4 .term-cell.pending {
#term.grid .term-cell.pending {
animation: none;
}
}
/* Inline per-quadrant approve footer. */
#term.lay-split-2 .cell-approve,
#term.lay-grid-4 .cell-approve {
#term.grid .cell-approve {
display: flex;
align-items: center;
gap: 8px;
@@ -548,10 +579,17 @@ body {
.grid-glyph.gl-split-2 {
grid-template-columns: 1fr 1fr;
}
.grid-glyph.gl-row-3 {
grid-template-columns: 1fr 1fr 1fr;
}
.grid-glyph.gl-grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.grid-glyph.gl-grid-6 {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.grid-glyph i {
background: currentColor;
border-radius: 1px;
@@ -566,6 +604,10 @@ body {
width: 36px;
height: 34px;
}
.cell-max {
padding: 6px 9px;
font-size: 15px;
}
}
/* ── Key bar (rounded chips) ─────────────────────────────────────── */

View File

@@ -109,6 +109,9 @@ export class TabApp {
// back via gridToggle. 'single' preserves the original one-pane behavior.
private gridLayout: GridLayout = 'single'
private gridToggle: GridToggle | null = null
// v2: when true, the focused quadrant is expanded to fill the grid (others stay
// live behind it). Follows the focused pane; reset on layout change / tab close.
private maximized = 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).
@@ -685,6 +688,22 @@ export class TabApp {
cell.style.display = 'none'
const head = this.buildCellHead()
head.addEventListener('pointerdown', () => this.setFocused(this.tabs.indexOf(entry)))
// v2: ⛶ maximize/restore this quadrant (focuses it first).
const maxBtn = document.createElement('button')
maxBtn.type = 'button'
maxBtn.className = 'cell-max'
maxBtn.title = 'Maximize / restore'
maxBtn.setAttribute('aria-label', 'Maximize or restore this pane')
maxBtn.addEventListener('pointerdown', (e) => e.stopPropagation())
maxBtn.addEventListener('click', (e) => {
e.stopPropagation()
const idx = this.tabs.indexOf(entry)
if (idx !== this.activeIndex) this.setFocused(idx)
this.toggleMaximize()
})
head.appendChild(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)
entry.cell = cell
this.paneHost.appendChild(cell)
@@ -764,6 +783,7 @@ export class TabApp {
closeTab(i: number): void {
if (i < 0 || i >= this.tabs.length) return
this.maximized = false // structural change exits maximize
const [entry] = this.tabs.splice(i, 1)
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
entry?.session.dispose() // removes session.el (the .term-pane)
@@ -905,6 +925,7 @@ export class TabApp {
return
}
this.gridLayout = layout
this.maximized = false // a layout change exits maximize
saveGridLayout(layout)
// Re-activate the focused pane under the new layout: activate() is board-aware,
// so if the capacity shrank and the pane is now off-board it gets pulled on;
@@ -931,12 +952,58 @@ export class TabApp {
})
}
/** v2: cycle the focused quadrant among the visible panes (wired to Ctrl+`). */
cycleFocus(dir: 1 | -1): void {
if (this.gridLayout === 'single') return
const cap = Math.min(this.tabs.length, layoutCapacity(this.gridLayout))
if (cap <= 1) return
const cur = this.activeIndex >= 0 && this.activeIndex < cap ? this.activeIndex : 0
this.setFocused((cur + dir + cap) % cap)
}
/** v2: expand/restore the focused quadrant to fill the grid (others stay live
* behind it via CSS grid-area + z-index). No-op in single mode. */
toggleMaximize(): void {
if (this.gridLayout === 'single') return
this.maximized = !this.maximized
this.updateHomeView()
}
/** 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 {
cell.addEventListener('dragover', (e) => {
if (this.dragIndex < 0 || this.gridLayout === 'single') return
e.preventDefault()
cell.classList.add('drag-target')
})
cell.addEventListener('dragleave', () => cell.classList.remove('drag-target'))
cell.addEventListener('drop', (e) => {
cell.classList.remove('drag-target')
if (this.dragIndex < 0 || this.gridLayout === 'single') return
e.preventDefault()
const moved = this.tabs[this.dragIndex]
this.moveTab(this.dragIndex, slotIndex())
this.dragIndex = -1
if (moved) this.setFocused(this.tabs.indexOf(moved))
})
}
/** Show/hide panes, set the grid class, order cells, and render placeholders.
* Single source of pane visibility (called via updateHomeView). */
private applyLayout(showHome: boolean): void {
const layout: GridLayout = showHome ? 'single' : this.gridLayout
this.paneHost.classList.remove('lay-single', 'lay-split-2', 'lay-grid-4')
this.paneHost.classList.remove(
'lay-single',
'lay-split-2',
'lay-row-3',
'lay-grid-4',
'lay-grid-6',
)
this.paneHost.classList.add(`lay-${layout}`)
// A single 'grid' marker keeps the shared cell CSS (headers, focus ring,
// inline approve, …) independent of how many layouts exist.
this.paneHost.classList.toggle('grid', layout !== 'single')
const cap = layoutCapacity(this.gridLayout)
const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap)
@@ -1003,8 +1070,17 @@ export class TabApp {
const idx = this.tabs.indexOf(entry)
const focused = idx === this.activeIndex
const grid = this.gridLayout !== 'single' && !this.homeForced && this.tabs.length > 0
const maximized = grid && this.maximized && focused
cell.classList.toggle('focused', grid && focused)
cell.classList.toggle('pending', grid && entry.session.pendingApproval)
// While a quadrant is maximized, a covered (non-focused) pane's amber pulse
// would bleed around the overlay — keep it silent until un-maximized.
cell.classList.toggle(
'pending',
grid && entry.session.pendingApproval && (!this.maximized || focused),
)
cell.classList.toggle('maximized', maximized)
const maxBtn = cell.querySelector<HTMLElement>('.cell-max')
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
const nameEl = cell.querySelector('.cell-name')
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)

View File

@@ -20,6 +20,7 @@ import {
loadGridLayout,
saveGridLayout,
mountGridToggle,
matchFocusCycleKey,
} from '../public/grid-layout.js'
const KEY = 'web-terminal:grid-layout'
@@ -62,11 +63,30 @@ describe('grid-layout: pure logic', () => {
it('layoutCapacity maps each layout to its pane count', () => {
expect(layoutCapacity('single')).toBe(1)
expect(layoutCapacity('split-2')).toBe(2)
expect(layoutCapacity('row-3')).toBe(3)
expect(layoutCapacity('grid-4')).toBe(4)
expect(layoutCapacity('grid-6')).toBe(6)
})
it('GRID_LAYOUTS is ordered single → split-2 → grid-4', () => {
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'grid-4'])
it('GRID_LAYOUTS is ordered by capacity', () => {
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'row-3', 'grid-4', 'grid-6'])
})
it('visibleIndices honors the higher-capacity layouts', () => {
expect(visibleIndices(9, 'row-3')).toEqual([0, 1, 2])
expect(visibleIndices(9, 'grid-6')).toEqual([0, 1, 2, 3, 4, 5])
expect(visibleIndices(4, 'grid-6')).toEqual([0, 1, 2, 3])
})
it('matchFocusCycleKey recognizes Ctrl+` (fwd) and Ctrl+Shift+` (rev), else null', () => {
const k = (init: KeyboardEventInit): KeyboardEvent => new KeyboardEvent('keydown', init)
expect(matchFocusCycleKey(k({ ctrlKey: true, key: '`' }))).toBe(1)
expect(matchFocusCycleKey(k({ ctrlKey: true, shiftKey: true, key: '`' }))).toBe(-1)
expect(matchFocusCycleKey(k({ ctrlKey: true, code: 'Backquote' }))).toBe(1)
expect(matchFocusCycleKey(k({ key: '`' }))).toBeNull() // no ctrl
expect(matchFocusCycleKey(k({ ctrlKey: true, metaKey: true, key: '`' }))).toBeNull() // meta excluded
expect(matchFocusCycleKey(k({ ctrlKey: true, altKey: true, key: '`' }))).toBeNull() // alt excluded
expect(matchFocusCycleKey(k({ ctrlKey: true, key: 'a' }))).toBeNull() // wrong key
})
it('visibleIndices returns the first min(tabCount, capacity) indices', () => {

View File

@@ -1268,4 +1268,126 @@ describe('TabApp — split-grid watch board (v1)', () => {
expect(NotificationMock).toHaveBeenCalled()
vi.unstubAllGlobals()
})
// ── v2: higher-capacity layouts, focus cycling, maximize, drag-to-quadrant ────
it('grid-6 shows up to six live panes', () => {
const { app, paneHost } = withTabs(6)
app.focusTab(0)
app.setGridLayout('grid-6')
expect(visibleReal(paneHost)).toHaveLength(6)
expect(paneHost.classList.contains('lay-grid-6')).toBe(true)
expect(paneHost.classList.contains('grid')).toBe(true)
})
it('row-3 shows exactly three panes', () => {
const { app, paneHost } = withTabs(5)
app.focusTab(0)
app.setGridLayout('row-3')
expect(visibleReal(paneHost)).toHaveLength(3)
})
it('cycleFocus moves the focus ring around the visible panes and wraps', () => {
const { app } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4') // 3 visible, active 0
app.cycleFocus(1)
expect(app.snapshot().findIndex((t) => t.active)).toBe(1)
app.cycleFocus(1)
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
app.cycleFocus(1) // wrap
expect(app.snapshot().findIndex((t) => t.active)).toBe(0)
app.cycleFocus(-1) // reverse wrap
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
})
it('cycleFocus is a no-op in single mode', () => {
const { app } = withTabs(3) // single mode, active 2
app.cycleFocus(1)
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
})
it('toggleMaximize marks the focused cell and toggling off clears it', () => {
const { app, paneHost } = withTabs(3)
app.setGridLayout('grid-4')
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
expect(paneHost.querySelector('.term-cell.maximized')!.classList.contains('focused')).toBe(true)
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
})
it('maximize follows the focused pane as you cycle', () => {
const { app, paneHost } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4')
app.toggleMaximize()
const first = FakeTerminalSession.instances[0]!.el.closest('.term-cell')
expect(first!.classList.contains('maximized')).toBe(true)
app.cycleFocus(1) // now tab 1 is focused
expect(first!.classList.contains('maximized')).toBe(false)
expect(FakeTerminalSession.instances[1]!.el.closest('.term-cell')!.classList.contains('maximized')).toBe(true)
})
it('changing layout exits maximize', () => {
const { app, paneHost } = withTabs(3)
app.setGridLayout('grid-4')
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
app.setGridLayout('split-2')
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
})
it('dragging an off-board tab onto a quadrant assigns it there and focuses it', () => {
const { app, paneHost, tabBar } = withTabs(5)
app.focusTab(0)
app.setGridLayout('grid-4') // tab 4 off-board
const dragged = FakeTerminalSession.instances[4]!
const targetCell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.term-cell')!
// Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0.
tabBar.querySelectorAll<HTMLElement>('.tab')[4]!.dispatchEvent(
new Event('dragstart', { bubbles: true }),
)
targetCell.dispatchEvent(new Event('dragover', { bubbles: true }))
targetCell.dispatchEvent(new Event('drop', { bubbles: true }))
const cell = dragged.el.closest<HTMLElement>('.term-cell')!
expect(cell.style.display).not.toBe('none') // now on-board
expect(cell.classList.contains('focused')).toBe(true) // and focused
})
it('closing a tab while a quadrant is maximized clears the maximized state', () => {
const { app, paneHost } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4')
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
app.closeTab(1) // close a different (non-focused) tab
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
})
it('drag-to-quadrant is a no-op in single mode (no reorder, no drag-target)', () => {
const { app, paneHost, tabBar } = withTabs(3) // default single layout
const before = app.snapshot().map((t) => t.title)
tabBar.querySelectorAll<HTMLElement>('.tab')[1]!.dispatchEvent(
new Event('dragstart', { bubbles: true }),
)
const cell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.term-cell')!
cell.dispatchEvent(new Event('dragover', { bubbles: true }))
cell.dispatchEvent(new Event('drop', { bubbles: true }))
expect(cell.classList.contains('drag-target')).toBe(false)
expect(app.snapshot().map((t) => t.title)).toEqual(before) // order unchanged
})
it('the ⛶ button focuses its quadrant and maximizes it in one click', () => {
const { app } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4')
const cell2 = FakeTerminalSession.instances[2]!.el.closest<HTMLElement>('.term-cell')!
const maxBtn = cell2.querySelector<HTMLButtonElement>('.cell-max')!
expect(maxBtn.textContent).toBe('⛶')
maxBtn.click()
expect(cell2.classList.contains('focused')).toBe(true) // focused the clicked quadrant
expect(cell2.classList.contains('maximized')).toBe(true) // and maximized it
expect(maxBtn.textContent).toBe('⤡') // glyph flips to restore
})
})