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

@@ -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)