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:
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) ─────────────────────────────────────── */
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user