Skip to content

refactor(ui): move workbar resizing onto Astryx Resizable - #1863

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/ui-workbar-astryx-resizable
Aug 2, 2026
Merged

refactor(ui): move workbar resizing onto Astryx Resizable#1863
Astro-Han merged 8 commits into
mainfrom
refactor/ui-workbar-astryx-resizable

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

The session workbar ran a parallel implementation of what useResizable / ResizeHandle already provide: hand-rolled pointermove sizing in app-shell-layout-actions.ts, hand-rolled clamping in session-workbar-layout.ts, and a hand-rolled role="separator" div in chat-workbar.tsx.

useResizable now owns clamping and drag/keyboard sizing. That deletes app-shell-layout-actions.ts and its test, the workbarWidth state in useShellLayout, and the handle's CSS. ChatWorkbar takes a ResizableProps region instead of two event handlers.

Refs #1861 (slice 2 of 4). Scope is resize only: tabs, the artifact pane and the chat surface are untouched.

Persistence stays where it is, not on autoSaveId

The issue frames the storage choice as "migrate maka-session-workbar-width-v1 into autoSaveId, or let stored widths reset once". Both options assume autoSaveId, and measurement ruled it out.

Astryx persists from an effect keyed on size (useResizable.js:121), and ResizeHandle calls _onResizeMove on every pointermove (ResizeHandle.js:314). Measured in the live app by spying Storage.prototype.setItem: one drag gesture produced 90 synchronous localStorage writes. app-shell-effects.ts:131 already carries the note from when this exact bug was fixed for the sidebar resizer (PR-FE-BUG-HUNT-5), and the offending effect lives inside the library, so it cannot be debounced from the outside.

So useResizable keeps clamping and resizing, and the existing 200ms debounced effect keeps the storage — seeded back through defaultSize. Measured after: 1 write per drag. app-shell-effects.ts ends up byte-identical to main. This also matches what SessionListPanel already does (packages/ui/src/session-list-panel.tsx:78 passes resizable without autoSaveId), so the app keeps one storage convention rather than gaining a second astryx-resizable: namespace.

Consequence: the width stays on maka-session-workbar-width-v1, so existing widths survive, nothing resets, and no key is orphaned. The reset-once question the issue raises no longer applies.

Two deliberate splits of responsibility between session-workbar-layout.ts and useResizable:

  • Clamping is the hook's alone. readSessionWorkbarWidth does not clamp; a second bound here would duplicate authority over min/max. Verified: a stored 9999 hydrates to 600.
  • Rounding is still ours, at both ends. clampSize bounds without rounding. On the way in, a fractional stored width would otherwise survive hydration untouched; on the way out, a sub-pixel pointer delta would reach the CSS variable and storage as 479.70001220703125. Rounding _onResizeMove's delta is safe because that delta is the distance from where the pointer went down, not a per-move increment (useResizable.ts consumes it as dragStartSizeRef.current + delta), so quantising it cannot drift or create a dead zone.

Note on the keyboard

The issue describes the hand-rolled handle as "minus keyboard resizing", but it already had onWorkbarResizeHandleKeyDown with arrow/Home/End handling. The real win is that the step math, Enter-to-collapse, RTL handling and focus styling are no longer ours to maintain — not that keyboard resizing is new.

Review focus

pillPlacement="center" is load-bearing, not styling. Astryx offsets a side-placed horizontal grab zone with translateY(-50%) layered on top: 0; bottom: 0, which lifts the hit area half its height off the divider — measured live, the grab zone covered only y 36–428 of an y 36–820 handle, leaving the lower half undraggable. Centering keeps it full-height. Still unfixed on astryx HEAD; verify upstream before deleting the prop.

Landed from review

Findings from two independent review rounds, each checked against library source before acting and each mutation-verified. The first two were listed as known gaps in an earlier revision of this description; they are fixed, not deferred.

  • aria-valuenow exposed a raw float. Rounding used to live at the consumer, so the width existed in two versions — the hook's own state versus the CSS variable and storage — and app-shell.tsx handed both to the same component. ResizeHandle mirrors _size straight into aria-valuenow, so a screen reader announced 479.70001220703125 for a 480px panel. Moving the rounding to _onResizeMove collapses that to one number and is a net deletion.
  • A drag survived focus loss. The deleted hand-rolled handler cleaned up on window.blur; Astryx ends a drag only on pointerup / pointercancel / unmount (ResizeHandle.tsx:418-425). Cmd-Tab mid-drag and release outside the app left the listeners attached and body stuck at cursor: col-resize; user-select: none. useShellLayout now bridges blur into Astryx's own pointercancel path — those listeners exist only during a drag, so it is a no-op otherwise, and no second teardown path is re-grown.
  • Hydration lost the rounding that clamping used to carry. clampSessionWorkbarWidth rounded and clamped; dropping it in favour of the hook's bounds dropped the rounding with it, so a fractional stored width reached the panel, storage and aria-valuenow unchanged until the next drag. readSessionWorkbarWidth rounds again. These read functions had no test on main either — the new unit test pins the rounding, the deliberate absence of clamping, and the invalid-value fallbacks.
  • Two load-bearing lines had no coverage. pillPlacement="center" carries a six-line comment about an upstream bug and survived deletion with a green suite, as did the rounding fix; one pointer drag, grabbed at 90% of the handle's height with a fractional delta, kills both. Separately, which storage key wins — the central decision of this PR — was asserted nowhere, so switching to autoSaveId orphaned the stored width while every sizing assertion stayed green.

Verification

npm run lint, npm run format:check, npm run typecheck, npm run build — clean.
npm run -w apps/desktop test — 1304 pass / 0 fail. Full desktop Playwright suite — 69 passed.

session-workbar.spec.ts pins the sizing contract. Each assertion targets a specific silent failure — wrong config that still compiles and still renders — and each is mutation-checked:

assertion mutation it catches
aria-orientation="vertical" direction="vertical" — arrow keys keep working (ArrowLeft falls through the ArrowUp branch) while mouse drag breaks entirely
aria-valuenow 400 → 410 on ArrowLeft dropping isReversed — direction inverts, gives 390
computed width: 410px width={400} — severs width state from render; every ARIA assertion still passed before this line existed
drag grabbed at 90% handle height → width: 431px dropping pillPlacement="center" — the lower half of the divider stops responding to the pointer
aria-valuenow 431 after a 20.5px drag dropping the Math.round on _onResizeMove — the panel reads 431px while the screen reader announces 430.5
width and user-select hold across blur mid-drag dropping the blur guard — the panel keeps tracking a button-less pointer and body stays unselectable
stored maka-session-workbar-width-v1 is 431, no astryx-resizable: key persisting through autoSaveId — the user's stored width is orphaned, every assertion above stays green
readSessionWorkbarWidth('400.5')401 dropping the hydrate rounding — a fractional width reaches the panel and aria-valuenow on restart

Behaviour measured in the live Electron app (throwaway specs, deleted before commit):

  • Grab zone — hit area {x: 832, y: 36, w: 16, h: 784} against a handle of {x: 840, y: 36, h: 784}: full-height, centered on the divider, 16px wide vs. the 8px ::after it replaces.
  • Drag — 400 → 480 dragging 80px toward the start; clamps at 600 dragging further; clamps at 320 dragging the other way.
  • Keyboard — from 600: ArrowRight 590, ArrowLeft 600, Shift+ArrowRight 550, Shift+ArrowLeft 600, Home 320, End 600.
  • Persistence — 1 write per drag, to maka-session-workbar-width-v1, with no astryx-resizable: key created. Killed the Electron process and relaunched against the same userData dir: width preserved.
  • Rounding — a sub-pixel drag now yields --maka-session-workbar-width: 480px, a stored 480, and an aria-valuenow of 480.

Appearance changes, both from Astryx's own styles rather than ours:

  • Hover — a 3px × 32px grip pill on the divider, replacing the full-height var(--ring) line. Different shape, consistent with every other Astryx resize surface.
  • Focus:focus-visible gives a 2px --color-accent outline (ResizeHandle.tsx:93), stronger than the 1px --ring line it replaces.

Not run: the narrow-breakpoint hide is still only covered by the existing chat-shell-layout-contract CSS assertions, not by a live measurement.

Known gaps, not in this PR

All upstream or pre-existing; none are introduced here.

  • A vertical separator responds to ArrowUp/ArrowDown. Astryx folds ArrowDown into ArrowRight and ArrowUp into ArrowLeft and calls preventDefault (ResizeHandle.tsx:439-455), so the workbar resizes on the vertical arrows too; the window splitter pattern reserves those for a horizontal splitter. The deleted handler ignored them, so this is a behaviour change — but it is not fixable from the call site: handleKeyDown runs unconditionally after any consumer onKeyDown and does not check defaultPrevented (ResizeHandle.tsx:569-571). Intercepting it would mean wrapping the handle in exactly the kind of parallel implementation this PR removes. Affects every Astryx resize surface in the app equally; belongs upstream, alongside the pillPlacement bug.
  • Bounds are declared twice, in session-workbar-layout.ts:3-5 and again in chat-detail.css:24-26. Raising SESSION_WORKBAR_MAX_WIDTH alone would be silently capped by the CSS.
  • The Suspense fallback ignores the stored width, so a cold start with a non-default width paints 400px and then jumps. SessionWorkbarFallback never received a width on main either.
  • SessionListPanel does not round its onWidthChange value, so maka-chat-list-width-v1 can hold the same kind of float this PR just fixed for the workbar. SideNav shares the unrounded aria-valuenow for the same reason.

The workbar ran a parallel implementation of useResizable/ResizeHandle:
hand-rolled pointermove math in app-shell-layout-actions.ts, hand-rolled
clamping and localStorage in session-workbar-layout.ts, and a hand-rolled
separator in chat-workbar.tsx.

useResizable now owns the width — clamping, drag, arrow-key sizing and
persistence — so the actions module, its keyboard step math, the width
state in useShellLayout, the debounced persistence effect and the handle's
CSS all go away.

The legacy maka-session-workbar-width-v1 key is deliberately not migrated.
Migrating it would mean writing into Astryx's private storage slot and
carrying the code plus its tests forever, to save users a single drag; a
stored width resets once instead.

pillPlacement="center" works around an Astryx 0.2.0 bug where a
side-placed horizontal grab zone gets translateY(-50%) on top of
top/bottom: 0, leaving only the divider's upper half grabbable.

The workbar journey now pins the sizing contract that fails silently:
arrow-key resizing, the isReversed direction, and the min/max bounds.
@Astro-Han
Astro-Han force-pushed the refactor/ui-workbar-astryx-resizable branch from 1e3ce4a to 8830cb1 Compare August 1, 2026 18:32
…mirror

The workbar journey asserted only aria-* on the separator, which mirrors
useResizable state rather than the rendered panel. Mutation-checked: hard-coding
`width={400}` in app-shell.tsx severs width state from render and every
assertion still passed.

Adds a computed-width assertion, plus aria-orientation — a `direction="vertical"`
typo keeps arrow keys working (ArrowLeft falls through the ArrowUp branch) while
silently breaking mouse drag, so neither the width nor the valuenow assertion
catches it. Both mutations now fail the test.

Also drops a stale factory count from the stable-actions contract comment and
untangles the useShellLayout doc comment, which claimed every value routes
through useAppShellPersistenceEffects and then immediately contradicted itself.
…ffect

Astryx's autoSaveId persists from an effect keyed on size, and ResizeHandle
drives _onResizeMove on every pointermove — measured at 90 synchronous
localStorage writes for a single drag gesture. app-shell-effects.ts already
carries the note from when that exact bug was fixed for the sidebar resizer.

useResizable keeps clamping and pointer/keyboard resizing; storage moves back
onto the same 200ms trailing debounce as the session list, seeded through
defaultSize. Measured after: 1 write per drag.

This also retires the reset-once decision. The width stays on
maka-session-workbar-width-v1, so existing widths survive, no key is orphaned,
and the app keeps one storage convention instead of two.

readSessionWorkbarWidth deliberately does not clamp — useResizable clamps what
it is handed, so a second clamp would duplicate authority over the bounds.
Verified: a stored 9999 hydrates to 600.
clampSize bounds the size but does not round it, and the old
clampSessionWorkbarWidth did both. Deleting it in favour of the hook's clamping
dropped the rounding with it, so a sub-pixel pointer delta reached the CSS
variable and localStorage as 479.70001220703125.

Rounds once, where the width leaves useShellLayout, so both consumers are
covered. aria-valuenow still shows the raw float: ResizeHandle renders it
straight from _size, which is Astryx's to fix and affects SideNav equally.

Also drops a duplicated rationale comment from app-shell-effects.ts — the
decision it explains lives at the useResizable call site — and tightens the
E2E comment block.
Rounding `workbar.size` on the way out left the hook holding a fractional
width, which ResizeHandle mirrors straight into `aria-valuenow`: a 20.4px
drag announced 350.4000244140625 while the panel measured 350px. Rounding
the pointer delta instead keeps the hook's own state integral, so the CSS
variable, storage and the announced value all report one number.

`_onResizeMove` receives the distance from pointerdown rather than a
per-move increment, so this quantises the drag without accumulating drift.

Pinned by a pointer drag in the workbar journey, which also covers the
`pillPlacement` workaround that had no drag exercising it.
Astryx ends a drag on pointerup, pointercancel or unmount, but not on
focus loss. Cmd+Tab mid-drag and release outside the app leaves its window
listeners attached and `body` stuck at `cursor: col-resize; user-select:
none`, so the panel keeps tracking a button-less pointer and nothing in
the UI is selectable until the user clicks somewhere.

The hand-rolled resize this branch deleted cleaned up on blur. Routing
blur into Astryx's own cancel path restores that without re-growing a
second teardown alongside the library's.
`useResizable` clamps the `defaultSize` it is handed but never rounds it, and
the deleted `clampSessionWorkbarWidth` did both. A fractional stored width
therefore survived hydration and reached the panel, storage and
`aria-valuenow` unchanged until the next drag.

Clamping stays the hook's job — a second bound here would duplicate authority
over min/max. Rounding does not: this is the one entry point reading a width
the app did not produce, and every other entry already keeps it integral.

The read functions had no coverage on main either; the new test pins rounding,
the deliberate absence of clamping, and the invalid-value fallbacks.
Which key the width lands on is the load-bearing decision of #1861 — Astryx's
`autoSaveId` writes synchronously on every committed size, ~90 writes per drag
— and it was the one part of the change no assertion touched. Switching to
`autoSaveId` orphaned the stored width while every existing sizing assertion
stayed green.
@Astro-Han
Astro-Han merged commit 6750068 into main Aug 2, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant