|
| 1 | +--- |
| 2 | +title: "Web UI Split-Pane Resize Bug — Multi-Layer Layout Failure" |
| 3 | +date: 2026-04-08 |
| 4 | +category: ui-bugs |
| 5 | +tags: |
| 6 | + - css |
| 7 | + - layout |
| 8 | + - split-pane |
| 9 | + - flexbox |
| 10 | + - fastapi |
| 11 | + - alpine-js |
| 12 | + - htmx |
| 13 | + - sse |
| 14 | + - split-js |
| 15 | + - tmux |
| 16 | + - sigwinch |
| 17 | + - playwright |
| 18 | + - test-isolation |
| 19 | +severity: high |
| 20 | +component: studyctl web UI (FastAPI + Alpine.js PWA) |
| 21 | +symptoms: |
| 22 | + - Dragging the split-pane divider resized the terminal panel but not the dashboard panel |
| 23 | + - Visual gaps appeared between dashboard content and the drag gutter after resize |
| 24 | + - Tests passed in headless and headed Playwright but the bug was visible to the user |
| 25 | + - Dashboard content height remained at intrinsic content height regardless of panel size |
| 26 | +root_cause: > |
| 27 | + Three compounding layout failures plus an architectural incompatibility. |
| 28 | + (1) calc(100vh - 60px) hardcoded header height at 60px when actual was ~63.5px. |
| 29 | + (2) Inner session-dashboard content did not flex-fill the Split.js panel. |
| 30 | + (3) Activity feed was wrapped in a classless <div hx-ext="sse"> — CSS targeted |
| 31 | + the nested .activity-feed instead of the wrapper flex child. |
| 32 | + (4) Split-pane resize sent SIGWINCH to tmux, corrupting the Claude Code agent session. |
| 33 | + Resolved by replacing split-pane with sidebar activity feed + full terminal + status bar. |
| 34 | +--- |
| 35 | + |
| 36 | +# Split-Pane Resize Bug — Multi-Layer CSS/Flex Failure |
| 37 | + |
| 38 | +## Problem |
| 39 | + |
| 40 | +The web UI study session view used a Split.js split-pane layout with dashboard (top) and terminal (bottom) panels. Dragging the divider resized the terminal but the dashboard panel appeared not to resize — a visible gap appeared between the dashboard content and the divider. |
| 41 | + |
| 42 | +Tests passed in all environments (headless Chromium, headed Chromium) because they measured `offsetHeight` of the panel element, which Split.js updated correctly. The bug was in the visual rendering of content *inside* the panel. |
| 43 | + |
| 44 | +## Investigation Steps |
| 45 | + |
| 46 | +1. Built data-driven test infrastructure with `_measure_all()` JS function capturing all DOM metrics via Playwright. Tests measured `offsetHeight`, `getBoundingClientRect()`, and computed styles for every element in the split layout. |
| 47 | + |
| 48 | +2. Discovered tests produced **false positives** without real ttyd — the terminal iframe was absent, and the layout engine computed simpler dimensions that satisfied assertions. Switched all layout tests (R01-R12) to use real tmux + ttyd fixtures. |
| 49 | + |
| 50 | +3. Added `dashboard_fill_ratio` measurement (content height / panel height). Even with this, the ratio was 1.0 because `session-dashboard` was correctly filling the panel — the gap was *inside* the session-dashboard, between the last content element and the bottom of the flex container. |
| 51 | + |
| 52 | +4. Added diagnostic background colours (red=panel, blue=session-dashboard, green=activity-feed) and measured every child element. Found the activity feed was stuck at exactly 200px (`min-height`) despite having `flex: 1` applied. |
| 53 | + |
| 54 | +5. Discovered the third child of session-dashboard had `class: ""` — it was the `<div hx-ext="sse">` wrapper, not `.activity-feed`. CSS `flex: 1` was applied to `.activity-feed` (nested inside the wrapper), not the wrapper itself. |
| 55 | + |
| 56 | +6. After fixing all CSS issues, discovered that drag-resizing the terminal iframe sent SIGWINCH to the tmux session, corrupting the Claude Code agent running in the adjacent pane. |
| 57 | + |
| 58 | +## Root Cause |
| 59 | + |
| 60 | +### RC-1: Hardcoded Header Height Mismatch |
| 61 | + |
| 62 | +```css |
| 63 | +/* Broke layout when header rendered at 63.5px, not 60px */ |
| 64 | +.split-container { |
| 65 | + height: calc(100vh - 60px); |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +Fix: `position: absolute; inset: 0` inside a `position: relative` content-area — fills exactly regardless of header height. |
| 70 | + |
| 71 | +### RC-2: Inner Content Not Stretching |
| 72 | + |
| 73 | +Split.js correctly set panel `offsetHeight`, but `.session-dashboard` was a flex column that sized to content rather than filling its parent. Fix: `flex: 1; min-height: 0` on the session-dashboard. |
| 74 | + |
| 75 | +### RC-3: Classless SSE Wrapper as Unintended Flex Child |
| 76 | + |
| 77 | +```html |
| 78 | +<div hx-ext="sse"> <!-- no class — actual flex child --> |
| 79 | + <div class="activity-feed"> <!-- CSS targeted this, not the wrapper --> |
| 80 | +``` |
| 81 | + |
| 82 | +Fix: target the wrapper directly: |
| 83 | + |
| 84 | +```css |
| 85 | +.split-dashboard .session-dashboard > [hx-ext="sse"] { |
| 86 | + flex: 1 1 0px; |
| 87 | + min-height: 0; |
| 88 | + display: flex; |
| 89 | + flex-direction: column; |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +### RC-4: SIGWINCH Corruption (Architectural) |
| 94 | + |
| 95 | +Resizing the terminal iframe triggered SIGWINCH in the tmux session, corrupting the Claude Code agent. This is a fundamental incompatibility between dynamically resized iframes and interactive agent sessions. |
| 96 | + |
| 97 | +## Solution |
| 98 | + |
| 99 | +Replaced the split-pane architecture entirely: |
| 100 | + |
| 101 | +``` |
| 102 | +┌─────────────────────────────────────┐ |
| 103 | +│ Header / Nav │ |
| 104 | +├──────────────┬──────────────────────┤ |
| 105 | +│ Sidebar │ Full-screen │ |
| 106 | +│ Nav buttons │ Terminal (ttyd) │ |
| 107 | +│ Activity │ │ |
| 108 | +│ Feed (SSE) │ │ |
| 109 | +├──────────────┴──────────────────────┤ |
| 110 | +│ Status Bar: timer ⏸↺■ topic ⚡7 ✓○▲│ |
| 111 | +└─────────────────────────────────────┘ |
| 112 | +``` |
| 113 | + |
| 114 | +- **Sidebar**: fixed width, nav buttons + scrollable activity feed (SSE-driven) |
| 115 | +- **Content area**: 100% terminal iframe — no resize, no SIGWINCH after initial load |
| 116 | +- **Bottom status bar**: timer + pause/reset/stop + topic + energy + counters |
| 117 | + |
| 118 | +Split.js and `splitLayout()` Alpine component remain for the body-double view (where the timer iframe doesn't interact with tmux). |
| 119 | + |
| 120 | +### Test Infrastructure Built |
| 121 | + |
| 122 | +- **STUDYCTL_SESSION_DIR** env var for test isolation — tests never touch `~/.config/studyctl/` |
| 123 | +- **18 Playwright E2E tests** with real tmux + ttyd |
| 124 | +- **`_measure_all()`** JS function captures all DOM metrics in one call |
| 125 | +- **`results.json`** written after every run for agent loop consumption |
| 126 | +- **16 pure verifier functions** (`verify_R01` through `verify_R16`) |
| 127 | + |
| 128 | +## Key Learnings |
| 129 | + |
| 130 | +1. **Test what users see, not what the DOM reports.** `offsetHeight` was correct throughout — the bug was that content didn't fill the allocated space. The right metric was fill ratio, not panel height. |
| 131 | + |
| 132 | +2. **`min-height: 0` is mandatory for scrollable flex children.** Without it, the browser's minimum content size constraint overrides `flex: 1`. |
| 133 | + |
| 134 | +3. **Avoid `calc(100vh - Npx)` magic numbers.** `position: absolute; inset: 0` inside a positioned parent is robust and measurement-free. |
| 135 | + |
| 136 | +4. **Target the actual flex child, not its descendant.** HTMX/Alpine inject wrapper elements that become unintended flex children. Always verify the DOM hierarchy in DevTools before writing flex CSS. |
| 137 | + |
| 138 | +5. **SIGWINCH and agent sessions are incompatible.** Any UI widget that resizes a terminal iframe sends SIGWINCH to every process in the underlying tmux session. Keep terminal iframes at a stable size after initial load. |
| 139 | + |
| 140 | +6. **False positives emerge without real dependencies.** Layout tests without a live ttyd produced false positives because the iframe was absent. Always test against the full dependency stack. |
| 141 | + |
| 142 | +## Prevention Strategies |
| 143 | + |
| 144 | +- **Real dependencies in tests**: Use real tmux + ttyd in layout tests. If a dependency can't start in CI, skip rather than fake. |
| 145 | +- **Name every flex child**: If a component is rendered inside a flex container, its outermost element must have an explicit class. |
| 146 | +- **SIGWINCH complexity threshold**: If adding a feature requires cross-process signal propagation, evaluate whether the feature justifies the coupling. |
| 147 | +- **Machine-readable results**: Emit `results.json` for any test measuring quantitative layout values. |
| 148 | +- **Isolation fixtures**: Every test touching session state must use `STUDYCTL_SESSION_DIR` via a session-scoped fixture. |
| 149 | + |
| 150 | +## Related Documentation |
| 151 | + |
| 152 | +- [Four-bug-fix solutions](../integration-issues/web-ui-four-bug-fix-pomodoro-auth-ttyd-cleanup.md) — predecessor session fixes (pomodoro, auth, ttyd cleanup) |
| 153 | +- [Data-driven test design spec](../../superpowers/specs/2026-04-07-data-driven-split-pane-tests-design.md) — autoagent-inspired test infrastructure design |
| 154 | +- [C4 Architecture](../../architecture-c4.md) — updated to reflect sidebar+terminal+status bar layout |
| 155 | +- [tmux session management](../../internal/solutions/tmux-session-management-and-ci-issues.md) — SIGWINCH context and tmux test isolation patterns |
| 156 | +- [CI workflow failures](../test-failures/ci-workflow-failures-schema-init-pytest-markers.md) — false positive patterns and marker discipline |
0 commit comments