diff --git a/CHANGELOG.md b/CHANGELOG.md index 482c7a9..2205fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ For subset counts use different phrasing (e.g. "fourteen new #83 cases"). ## Unreleased +- **feat(ui): the desktop worktree sidebar can now be collapsed / expanded, handing the terminal the full window width on demand (issue #99)** — the 240px sidebar (worktree list + git panels) squeezed the xterm's visible columns whenever an agent TUI wanted the whole screen, and the only remedy was a permanent layout change. PR1 of `docs/plans/feature-v0.6.0-webui-sidebar-responsive/` (v0.6.0): the collapsed state is a single `.collapsed` class on `#app` — CSS drops `#sidebar` to `width: 0` + `overflow: hidden` and `#main`'s `flex: 1` absorbs the space, so **JS never reads or writes a width** and the state cannot drift into a second, conflicting state variable (plan §3.1; R1). The toggle is a two-state chevron button (`<<` / `>>`) hosted in `#header` to the LEFT of the instance tabs, visible in both states and reserving no sidebar width (R2) — the hierarchy is untouched: `#sidebar` and `#main` stay siblings under `#app`, the header stays inside `#main` (plan §3.2). The preference persists under `localStorage` key `mw.ui.sidebarCollapsed` — the file's first localStorage use — with the default dispatched by viewport (R14, landed in PR3): a missing key, unparseable value or throwing storage (private mode, old browsers) degrades to that viewport-dispatched default, unpersisted, via try/catch on both read and write (R3/R14). `Ctrl/Cmd+B` toggles from the first document-level keydown listener: `key.toLowerCase() === 'b'` so Caps Lock / Ctrl+Shift+B still match, `event.repeat` ignored, `event.altKey` refused so Ctrl+Alt+B (AltGr on several layouts) reaches the layout instead of the sidebar, and events whose target has a `dialog` ancestor (PR #110 review: with a native `` modal up front, focus sits on ITS controls and the sidebar must not toggle invisibly behind it) or sits in an `input` / `textarea` / `[contenteditable]` (the modal form fields; the tab-rename input already stops propagation at the target) never trigger. The terminal carries no iframe, and it does not need the guard for the keys that produce data: the pty renderer draws xterm into a same-document div, and the vendored `xterm.js` `_keyDown` evaluates each key via `evaluateKeyboardEvent` and then calls `cancel(e, !0)` — unconditional preventDefault + stopPropagation — on the paths that SEND DATA (Ctrl+B arrives at the TUI as `\u0002`) or that set the result's cancel flag (Tab/Enter/Escape); those keydowns die inside xterm and never reach this parent listener. Combos that produce NO data and no cancel flag (Ctrl+Shift+B, Ctrl+Alt+B, macOS Cmd+B) are NOT cancelled and DO bubble here — by design, since Ctrl/Cmd+Shift+B is an intended toggle (Caps Lock), and the guard below is their backstop (that is the whole of R16). The input/textarea/contenteditable guard's real duty is the hosts that bubble without cancelling: the modal form fields. True cross-frame isolation exists only for the opencode / reasonix / dsh web-UI iframes, which never deliver keydown to the parent document — a security-model boundary deliberately not routed around with postMessage. The chevron icons flip via `toggleAttribute("hidden", …)` rather than an IDL `.hidden = …` assignment — `` is an SVGElement and owns no `hidden` IDL property, so the expando form silently never matched the UA's `[hidden]` rule — and the attribute still needs its explicit consumer `#sidebar-toggle-btn svg[hidden] { display: none !important }`, because the UA presentational hint is namespaced to HTML elements and `svg[hidden]` otherwise computes to `display: inline` and keeps occupying layout (both bugs caught across review rounds: the icons would have rendered stacked in every state). The button carries `aria-label`, `aria-controls="sidebar"` and `aria-expanded` in the conventional ARIA direction (`true` = the sidebar region is expanded). The collapsed rule also sets `visibility: hidden` on `#sidebar`: `width: 0` + `overflow: hidden` only clip, leaving the sidebar buttons reachable by Tab with an invisible focus ring, and `visibility` lifts them out of the focus/hit-testing order with zero JS. After the class flip the active xterm is re-fit on the next animation frame — fixed order "class first, then fit", so `xterm-addon-fit` measures the post-reflow box and no scrollbar / misaligned TUI survives the toggle; the embedded web-UI iframes (opencode / reasonix / dsh) reflow with their container and need no counterpart (R12). The collapse is fully orthogonal to the staged/unstaged git accordion — `gitExpandedSection` is untouched by the new path and vice versa, now pinned behaviourally by driving the shipped `toggleGitSection` against the shipped collapse toggle in both directions (R13). Pinned by `TestSidebarCollapseAnchors` (served-asset text anchors: the collapsed CSS rule block with its four declarations, the key name, the button-before-tabs position inside `#header`, the keydown guards, the toggleAttribute/aria hooks, the class-before-fit ordering, the no-width-math and no-hidden-expando forbidden lists, and the no-git-state-overlap slice) and by seventeen #99 scenarios in `testdata/sidebar_collapse.test.mjs` (localStorage round-trip / missing-key / garbage-value / throwing-storage defaults, the class+icon+persist toggle wiring on the attribute-model DOM stub, the Ctrl/Cmd/case/repeat/AltGr/input keydown guards, the dialog-ancestor and textarea-host guard pass-throughs, the frame-deferred xterm refit, and the accordion-orthogonality interleave), sliced from the shipped `index.html` and run under `node --test` via `TestSidebarCollapseHandling`. Plan: `docs/plans/feature-v0.6.0-webui-sidebar-responsive/` (see the PR3/PR4 entries below). + +- **feat(ui): the Web UI now tracks the dynamic viewport and compensates the iOS soft keyboard, so the page keeps its height on mobile URL-bar changes and the terminal input stays visible while typing (issue #100 L1)** — PR2 of `docs/plans/feature-v0.6.0-webui-sidebar-responsive/` (v0.6.0). R5, the height/width basis: `#app` keeps a `100vh` declaration as the old-Safari fallback and upgrades to `100dvh` inside `@supports (height: 100dvh)` — source order fallback-first — same-origin declarations of equal specificity resolve last-wins, so modern browsers take the later `@supports` block over the fallback, while browsers without `dvh` support (old Safari) evaluate the condition to false and drop the whole block, keeping the `100vh` baseline; swapped, modern browsers would silently stay on static `100vh` and the jump would return. `dvh` tracks the mobile URL bar's show/hide with zero JS. Width is `100%` instead of `100vw`: `vw` counts the scrollbar gutter, so a vertical scrollbar pushed the flex row into horizontal overflow. The viewport meta gains `viewport-fit=cover`, and `#app` consumes all four `env(safe-area-inset-*)` insets as padding — border-box sizing insets the children rather than growing the box, the notch (left/right in iPhone landscape) and the Home Indicator (bottom) no longer cover content, and every desktop browser reports all four insets as 0, making this a literal no-op there. R6, the iOS soft keyboard: iOS Safari does not resize the layout viewport when the keyboard opens, `body, html` are `overflow: hidden` (the covered strip cannot be scrolled back into view), and the `interactive-widget=resizes-content` viewport directive stays deliberately unintroduced — Chromium-only, deferred by plan §3.5/§8. Instead `initVisualViewportHandling()` listens to `window.visualViewport`'s `resize` and `scroll` and pins `#app`'s inline height to the visible bottom edge (`offsetTop + height`) in layout coordinates while anything is covered; the narrowing walks down the flex column, the terminal host's existing `ResizeObserver` fires, and xterm re-fits — the input area stays above the keyboard. When nothing is covered the inline height is cleared and the CSS cascade (`100dvh` / `100vh` fallback) owns sizing again. Two guard rails keep the pin honest (both CSSOM View facts): the occlusion sentinel is `document.documentElement.clientHeight`, not `window.innerHeight` — `vv.height` excludes scrollbars pinned to the visual viewport while `innerHeight` includes the classic scrollbar gutter, so an `innerHeight` comparison would pin spuriously by the gutter's size; and pinch / double-tap zoom (`vv.scale != 1` — `vv.height` shrinks WITH the scale and fires resize) opts out of pinning entirely, clearing any inline height instead of collapsing the UI toward the zoomed rectangle. The `scroll` listener, separately, only re-pins an existing pin: a pure pan never introduces one, because under `body/html overflow: hidden` a pan already parks rows off the unreachable top edge and growing `#app` toward the panned bottom would bake the layout to that geometry. Environments without `visualViewport` (older browsers) degrade to a no-op: no listeners, no inline style. R12 with PR1: the collapse owns the `.collapsed` class and this layer owns an inline height — two disjoint state writes on `#app`, verified together (collapse + keyboard simultaneously) so the layout does not jump when both apply. Pinned by `TestViewportAndHeightAnchors` (served-asset text anchors: the viewport meta slice with `viewport-fit=cover`, the `#app` rule block with `100vh`/`100%` and the safe-area padding, the `@supports` block ordered after the fallback, the `interactive-widget` ban scoped to the meta tag, and the `visualViewport` listener/degradation slices with the zoom short-circuit and the scroll re-pin gate) and by seven new issue #100 L1 scenarios in `testdata/viewport_height.test.mjs` (degradation no-op, keyboard open/close narrowing and clearing, scroll gating — a pure pan introduces no pin, an existing pin is re-pinned, pinch / double-tap zoom opts out and clears, init-time apply, and the collapse+keyboard coexistence interleave), sliced from the shipped `index.html` and run under `node --test` via `TestViewportHeightHandling`. The visual items of plan §5.2 (no layout jump across URL-bar / keyboard transitions) are layout behaviors the string/behavior lines cannot measure — they stay on the manual checklist per the plan's §6 UA-hint principle. + +- **feat(ui): the Web UI now adapts to narrow and middle viewports — at ≤768px the sidebar becomes an off-canvas drawer with a tap-to-close mask over the terminal, and at 769–1024px it narrows to a 200px cap, so iPhones and iPad Split View get a usable layout instead of a crushed terminal (issue #100 L2)** — PR3 of `docs/plans/feature-v0.6.0-webui-sidebar-responsive/` (v0.6.0). R7, the breakpoint/drawer layer: three viewport tiers with 768px belonging to the narrow one (the integer tiers stay defined as max-width:768px / 769–1024px / >1024px per PRD-CHANGE §3.6), and BOTH render forms of the sidebar stay driven by the one PR1 `.collapsed` class on `#app` — the plan's §3.1 single state machine, no second state variable. PR1's zero-width collapse rule (selector and its four declarations byte-identical) is scoped into the seamless complement of the drawer's media query — `@media not all and (max-width: 768px)`, true exactly when width > 768px, fractions included — so on the narrow tier the same class renders the off-canvas drawer instead of a zero-width strip: `#sidebar` becomes `position: absolute` across `#main` (which keeps the full width underneath), the closed drawer slides out with `transform: translateX(-100%)` plus `visibility: hidden` lifting its buttons out of the Tab order (same reasoning as the desktop rule), and the OPEN drawer inherits the base rule's full `var(--sidebar-width)` width with the initial `visibility: visible` — the §3.1 "invisible drawer" trap (an open drawer inheriting `width: 0`/`visibility: hidden`) is closed by scoping the desktop rule away, not by piling overrides. `#app` gains `position: relative` as the drawer/mask positioning containing block — verified a no-op against every pre-existing positioned element (`#connection-overlay` is `position: fixed`, viewport-relative regardless; `#status-bar` / `#terminal-controls` / `#empty-state` / `#dsh-remote-mask` already resolve against their own closer `position: relative` ancestors). PR #110 review (B2) fixed what that containing block implied for the drawer itself: absolutely positioned boxes resolve against #app's PADDING box, so the drawer's top:0/bottom:0 bled across the safe-area bands — on a notched iPhone the drawer's first content (the sidebar title with the New/Import buttons) sat inside the status-bar band, invisible and untappable, and the bottom panel pressed into the Home Indicator ("the notch no longer covers content" was NOT yet true for the drawer). The narrow-tier #sidebar rule now consumes the four env(safe-area-inset-*) insets as content padding — the box stays full-bleed (iOS drawer convention, and the mask deliberately gets no such padding: no content to protect, and a tap anywhere outside must still close the drawer), border-box keeps the width at var(--sidebar-width), and landscape left/right insets are covered too. The mask is a new `