feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)

When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.

Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.

- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
  matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
  applyLayout() owns show/hide + grid class + cell order + placeholders;
  board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
  delegate to it; renderCell/renderInlineApprove; refitVisible; notification
  suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
  keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
  pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).

Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 19:19:53 +02:00
parent e254918b1c
commit 06814ba276
9 changed files with 1171 additions and 16 deletions

View File

@@ -298,14 +298,276 @@ body {
background: var(--bg);
overflow: hidden;
}
.term-pane {
/* Each pane lives in a .term-cell wrapper (header + terminal + optional inline
* approve). In single mode the cell fills #term absolutely (only the focused one
* is shown) so the classic one-pane look is unchanged; split layouts turn #term
* into a CSS grid and the cells flow into tracks. */
.term-cell {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.term-pane {
position: relative;
flex: 1 1 auto;
min-height: 0;
padding: 6px 8px 0;
box-sizing: border-box;
overflow: hidden;
}
/* Cell header + inline-approve footer are hidden in single mode. */
.cell-head,
.cell-approve {
display: none;
}
/* ── Split-grid layouts (desktop watch board) ────────────────────────── */
#term.lay-split-2,
#term.lay-grid-4 {
display: grid;
gap: 6px;
padding: 6px;
}
#term.lay-split-2 {
grid-template-columns: 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 {
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 {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 9px;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border-bottom: 1px solid var(--border);
font-size: 12px;
cursor: pointer;
user-select: none;
}
.cell-name {
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-status {
margin-left: auto;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
.cell-status.cs-working {
color: var(--amber);
}
.cell-status.cs-waiting {
color: var(--accent);
}
.cell-status.cs-idle {
color: var(--green);
}
.cell-status.cs-stuck {
color: var(--red);
}
/* Focus ring — the quadrant that owns keyboard / keybar / voice / approvals. */
#term.lay-split-2 .term-cell.focused,
#term.lay-grid-4 .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) {
border-color: var(--amber);
animation: cell-pending 2.4s ease-in-out infinite;
}
@keyframes cell-pending {
0%,
100% {
box-shadow: 0 0 0 1px rgba(245, 177, 76, 0.35);
}
50% {
box-shadow:
0 0 0 1px rgba(245, 177, 76, 0.7),
0 0 26px -8px rgba(245, 177, 76, 0.6);
}
}
@media (prefers-reduced-motion: reduce) {
#term.lay-split-2 .term-cell.pending,
#term.lay-grid-4 .term-cell.pending {
animation: none;
}
}
/* Inline per-quadrant approve footer. */
#term.lay-split-2 .cell-approve,
#term.lay-grid-4 .cell-approve {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 9px;
background: rgba(245, 177, 76, 0.08);
border-top: 1px solid rgba(245, 177, 76, 0.35);
font-size: 11.5px;
}
.cell-approve-label {
color: var(--amber);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-approve-btns {
margin-left: auto;
display: flex;
gap: 6px;
}
.cell-approve-yes,
.cell-approve-no {
border: 0;
border-radius: 6px;
padding: 3px 11px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cell-approve-yes {
background: var(--green);
color: #06231a;
}
.cell-approve-no {
background: var(--surface-3);
color: var(--text-dim);
}
.cell-approve-yes:hover {
filter: brightness(1.1);
}
.cell-approve-no:hover {
color: var(--text);
}
/* Empty-slot placeholder (fewer sessions than the layout holds). */
.term-cell.slot-empty {
border: 1px dashed var(--border-strong);
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.02) 10px,
rgba(255, 255, 255, 0.02) 20px
);
align-items: center;
justify-content: center;
cursor: pointer;
}
.term-cell.slot-empty:hover {
border-color: var(--accent);
}
.slot-new {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: none;
border: 0;
color: var(--text-faint);
font-size: 12px;
font-family: var(--ui-font);
cursor: pointer;
}
.slot-new b {
font-size: 26px;
font-weight: 300;
line-height: 1;
color: var(--text-dim);
}
.term-cell.slot-empty:hover .slot-new {
color: var(--text-dim);
}
/* ── Grid-toggle segmented control (toolbar) ─────────────────────────── */
.grid-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 2px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
}
.grid-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.grid-toggle-btn:hover {
color: var(--text);
background: var(--surface-3);
}
.grid-toggle-btn[aria-pressed='true'] {
color: var(--on-accent);
background: var(--accent);
}
.grid-glyph {
display: grid;
gap: 1.5px;
width: 14px;
height: 12px;
}
.grid-glyph.gl-single {
grid-template-columns: 1fr;
}
.grid-glyph.gl-split-2 {
grid-template-columns: 1fr 1fr;
}
.grid-glyph.gl-grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.grid-glyph i {
background: currentColor;
border-radius: 1px;
}
/* Touch tablets clear the 1024px width gate but need bigger tap targets — mirror
* the project's existing coarse-pointer bump for the tab/key bars. */
@media (pointer: coarse) {
.grid-toggle {
gap: 3px;
}
.grid-toggle-btn {
width: 36px;
height: 34px;
}
}
/* ── Key bar (rounded chips) ─────────────────────────────────────── */
#keybar {
position: fixed;