Skip to content

refactor(workbar): rebuild the session workbar on Astryx primitives - #2158

Merged
Astro-Han merged 11 commits into
mainfrom
refactor/session-workbar-astryx-redesign
Aug 4, 2026
Merged

refactor(workbar): rebuild the session workbar on Astryx primitives#2158
Astro-Han merged 11 commits into
mainfrom
refactor/session-workbar-astryx-redesign

Conversation

@Astro-Han

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

Copy link
Copy Markdown
Contributor

Summary

The session workbar was the one surface in the sessions column that did not speak the design system's vocabulary: a hand-rolled <aside> with its own border-left and background, a private count pill, and a 追踪 stylesheet reaching for a --maka-space-* / --maka-color-* namespace maka-tokens.css never declares — so every declaration there silently resolved to its hard-coded fallback.

This rebuilds the shell on the components Astryx already ships (Card, Section, Badge, Banner, EmptyState, MetadataList, ResizeHandle), following the right-hand panel in its own ai-chat template. What stays in CSS is what Astryx cannot know: the width the user dragged, the header/body grid split that keeps the body a scroll parent, and the timeline's geometry — now on real tokens.

Two things went further than the shell pass, because the redesign made them false:

The column is a plate, not a region. It was given a surface-tone seam against the conversation, mirroring the one the sidebar supposedly makes. Neither exists: on darwin light the rail, the conversation and the workbar all resolve to oklch(1 0 0), and the only configuration where the step is real is darwin dark, where a glass override happens to lift the content surface. The column had replaced a hairline with nothing. What the shell actually separates surfaces with is a gap — the content plate floats on the canvas with a margin on three sides, and that margin is the seam. So the workbar becomes a plate of the same kind and the canvas between them is the fourth side. A gap has no palette to be wrong in.

That moved the paint down one level: .maka-panel-detail stops being a surface and becomes the frame holding both plates, .mainColumn becomes the conversation plate, and each plate pays for its own titlebar clearance. Nothing hangs above the frame's padding box any more — which is what had forced overflow: clip on it, after the overhang made the first focus anywhere inside scroll the whole frame up by 32px and leave it there.

The collapse animation only ever worked once. Every collapse after the first tore the column down 5ms into the slide and ran the 280ms animation on an empty box. The cause was the pair of booleans, not the timing: wasCollapsed stored collapsed a second time, and clearing the leave flag on every width transition end — including the expand's, where it is already clear — left a same-value update queued that React replayed over the render-phase update arming the next collapse. One state now answers the actual question ("has the column finished sliding off"), written only by the event that decides it, in the direction that just ended.

Also here: both column toggles are one button in the titlebar; the rail's toggle reaches Astryx's stored width instead of only flipping the product's boolean; and keyboard resizing joins the drag gate, since Astryx marks data-resizing for pointers only and every arrow key was restarting a 280ms ease under an aria-valuenow that already announced the new width.

Scope note: this is the shell pass. 文件 / 浏览器 / 任务台账 / 追问引用 keep their current interiors; 追踪 came along because its fragmentation was visible on screen.

Verification

Rebased onto current main and re-run in full:

  • npm run lint, npm run format:check, node scripts/check-dead-css.mjs, node scripts/check-story-annotations.mjs — clean.
  • npm --workspace @maka/desktop run typecheck (preload + main + renderer + storybook) — clean.
  • npm --workspace @maka/desktop run test1685 pass, 0 fail.
  • Playwright, full suite — 71 passed, 1 failed. The failure is providers.spec.ts › adds a catalog provider through the canonical API-key setup page, which fails only on this machine: outbound network is restricted and a 网络错误 toast intercepts the click. It passes in CI, and all CI checks are green.
  • build-storybook + smoke:storybook — passed, 68 manifest checks and 79 catalog renders.

Live-window measurements (task-ledger fixture, computed styles):

value
conversation plate x=49 w=783, radius 12px, padding-top: 32px
workbar plate x=836 w=400, same radius, same clearance, same y/height
gutter between them 4px of canvas (oklch(0.975 0 0))
frame background: transparent, border: 0, box-shadow: none
collapsed conversation reclaims the full 1187, no leftover gutter
keyboard resize 400 → 410 in one frame (was 400 → 405.7 → 408.1 → … → 410 over 280ms)

Review focus

The seam change moves where the app paints, so it touches every view, not just chat. Verified by hand on the live window: chat, collapsed, stacked (<990px), 扩展/技能 (rebuilt on main during this PR's life), 设置 (its own panel rules, unaffected), data-home-surface, and dark mode.

Test quality was the weak spot every review round agreed on: the E2E fixture caps all transitions to 0.01ms, so nothing in the suite could ever watch an animation — which is exactly where the collapse defect lived. There is now a test that lifts that cap for itself, samples every frame, and collapses twice (the first collapse was the one that worked). It was confirmed red against the previous implementation. The seam assertion was also replaced: it used to compare the workbar's tone to the sidebar's, which paints the same value the conversation does, so "matches" and "has no boundary" were the same assertion. It now measures the gap.

Known and deliberately not addressed here: crossing the 990px breakpoint mid-collapse does not shorten the running transition (CSS cannot interrupt one; cosmetic), and the left rail has the same keyboard-resize lag, which predates this branch.

@Astro-Han
Astro-Han force-pushed the refactor/session-workbar-astryx-redesign branch 2 times, most recently from b1e3b57 to 9ce6741 Compare August 4, 2026 14:29
@Astro-Han
Astro-Han marked this pull request as ready for review August 4, 2026 18:08
The workbar was the one surface in the sessions column that did not speak
the design system's vocabulary: a hand-rolled `<aside>` with its own
border and background, a private count pill, and a trace tab whose entire
stylesheet reached for a `--maka-space-*` / `--maka-color-*` namespace the
token sheet never declared, so every value there silently resolved to a
hard-coded fallback.

The panel is now an Astryx Card whose tab bodies are Sections, the counts
are Badges, and the column divider belongs to the ResizeHandle
(`hasDivider`) rather than to a border the panel drew itself — one rule
between the columns, pinned by a computed-style assertion in the workbar
journey.

The trace tab follows the same move where it shows most: a failed read is
a Banner with its retry beside it instead of an unstyled `<button>`, an
empty session gets the centred EmptyState every other tab uses, and the
totals strip is a labelled MetadataList, which retires the three
suffix-word copy keys it used to concatenate by hand.

Layout CSS that Astryx cannot know — the dragged width, the header/body
split, the timeline's own geometry — stays, now on real tokens.
The workbar reads four bridges at once (tasks, artifacts, inspector,
sessions), which is why the shell had no story: the panel this PR
rebuilds was reviewable only by launching the app. Product/Session
Workbar mocks all four in one decorator and renders each tab through the
real shell — seven states across tasks, files and trace, including the
three the e2e fixture cannot reach because its session has never run a
turn.

Rendering through the shell is what lets the group replace rather than
join the per-panel stories. FIDELITY.md asks for exactly that: import the
wrapper instead of retyping it, and let a story earn its place by
rendering pixels no other story renders. So Product/Artifact Pane and the
hand-written boxes that would have propped up separate trace and
task-ledger groups are gone, along with the Artifact Pane frame's
`data-maka-e2e-fixture` — it pauses every animation and transition via
base.css, something only the E2E harness does, and
capability-audit-strip.stories.tsx already removed it for that reason.

product-smoke-manifest.json names none of the retired story ids, so the
manifest pass is unaffected. The smoke run here is 68 manifest checks and
79 catalog renders.
Two defects the redesign left in place, both visible against the left
column in the same screenshot.

The seam. The workbar stood on the conversation plate's own surface and
was fenced off from it by a 1px rule drawn by the resize handle. The
shell separates columns tonally everywhere else — the sidebar's border is
set to `transparent` on purpose and the seam you see on the left is
`--background` meeting the plate — so the right edge was speaking a
second, contradictory language. It also could not run the column's full
height: the shared handle rule starts every handle below the titlebar so
the window drag strip cannot swallow its grab zone, which left a line
beginning 36px down beside one running edge to edge.

The workbar now paints `--background`, the tone the SideNav paints, and
draws no rule. Its surface reaches the plate's top edge while its content
keeps the plate's titlebar clearance; painting only below that clearance
would put a hard horizontal edge across the top of the column, which is
the same mismatched seam turned 90°. The clearance is now a named custom
property on the plate so the two rules cannot drift apart. The plate's
`overflow: hidden` rounds the outer corners.

The toggle. One control, clicked twice, moved ~30px down and left between
the two clicks: collapsed it rendered in the titlebar, expanded it handed
off to a second button inside the workbar's tab row. It now stays in the
titlebar in both states, mirroring the sidebar toggle directly opposite
it. That row is also the only one that already reserves
`env(titlebar-area-*)` on both sides, so the button clears the macOS
traffic lights and the Windows caption strip without either platform
being named — anywhere else that reservation would have to be restated.

Verified in a live window: no vertical hairline anywhere in the column,
the workbar's background equals the sidenav's, its box equals the plate's
box, and the toggle reports the same rect collapsed and expanded. The
stacked <990px variant keeps its top rule, where tone alone cannot carry
a seam. E2E pins all of it; the spec cannot run on this machine right now
(electron.launch times out during fixture setup on unrelated specs and on
a clean tree alike).
The sidebar's toggle was Astryx's `SideNavCollapseButton`, connected across
the tree by a `handleRef`: a click reached SideNav's internal collapse
state, which called `onCollapsedChange`, which set the shell state that was
already being handed back down as `isCollapsed`. Three hops to a setter the
shell holds directly, through a component that is not the authority.

Nothing the component contributed survived the trip either. The shell
overrode its label and its icon, so its defaults never rendered. Its
`isCollapsible` guard cannot fail here — it reads `handleRef.current`
during render, which is `null` on the first pass and falls back to `true`.
Its mobile branch is unreachable behind `mobileNav={{ breakpoint: 'none' }}`.
And because that ref read happens in the render body rather than through
state, the button only ever re-rendered because the SHELL re-rendered it
with a fresh label — the shell's own state was already doing the work.

Both toggles are now one local `ChromeColumnToggle`: icon, tooltip,
accessible name and `aria-expanded` all read from the same boolean, and the
click calls the shell's setter. `handleRef` / `collapseHandleRef` and the
`SideNavImperativeCollapseHandle` plumbing are gone from the shell, the
titlebar, the sidebar panel and the app-shell story. SideNav keeps
`collapsible={{ isCollapsed, onCollapsedChange, hasButton: false }}`, which
stays the single inbound path — a collapse SideNav initiates on its own is
still reported through it.

Verified live, with the fixture's motion freeze lifted: expand → keyboard
ArrowRight → collapse → expand keeps the chosen 270px, `data-sidebar-state`
tracks every step, and both titlebar toggles report the right
`aria-expanded`. The rail still eases 260 → 48 over --duration-large across
~30 sampled frames, so removing the imperative path did not remove the
animation. Desktop 1631 + ui 317 unit tests, four typecheck projects, lint,
format, dead-CSS and the Storybook smoke all pass.
Collapsing was instantaneous because the column was simply not rendered
while collapsed — there was no element left to interpolate a width on.
Reordering it so the width animates first and the teardown follows needs
one box that exists on BOTH sides of the toggle, which is the same thing
`.maka-sidenav-motion` is on the other side of the shell. The rail can
carry its own width because SideNav is never unmounted; this column is, so
the box has to outlive it.

`.maka-workbar-motion` is that box: it owns the animated width, the clip,
and the plate's titlebar bleed, and it is an empty zero-width element while
the panel is shut. The column keeps a fixed width inside it, so on the way
out it slides off rather than reflowing its tab row into itself frame by
frame — `min-width: 320px` there stopped being only a resize clamp. The
resize handle stays outside the box, because it sits a pixel to the LEFT of
the column and would be the one thing the clip cut off.

Teardown is driven by `transitionend` rather than a duration restated in
JS. Every path this app has for suppressing motion — OS reduced-motion, the
e2e-fixture attribute — caps `transition-duration` to 0.01ms instead of
setting `transition: none`, deliberately, so the event still fires and this
cannot become a teardown that never runs. The entering direction needs no
such watch and no Effect: the box is already mounted, so widening it is a
style change, and "collapsed changed" is read by comparing props during
render.

The column still unmounts. Task polling and a live embedded browser must
not keep running behind a closed panel; only the empty box stays.

Measured live with the fixture's motion freeze lifted: collapse walks 400 →
330 → 269 → … → 0 over 16 frames and the column unmounts at the end of it;
expand walks 0 → 71 → 131 → … → 400 over 37; the column reads 400px in
every frame it is mounted for, in both directions, so nothing reflows.
Settled shut, the box is present at 0px with neither column nor handle in
the tree. Drag still lands exactly (ArrowLeft 410, pointer 431, stored 431,
no `astryx-resizable:` key) and the transition reads `none 0s` mid-drag, so
the column does not trail the cursor. Geometry contracts unchanged: no
vertical hairline, background equals the sidenav's, box equals the plate's,
stacked <990px keeps its top rule. Desktop 1631 unit tests, four typecheck
projects, lint, format, dead-CSS and the Storybook smoke pass.
The column's teardown was scheduled on the motion box's width transition
ending, on the argument that every motion suppression in this app caps
`transition-duration` rather than removing the transition. The two rules
this branch added were the exceptions: the stacked layout under 990px and
the drag gate both wrote `transition: none`, which sets
`transition-property: none` and CANCELS the transition instead of
shortening it. No `transitionend` arrives, `isLeaving` never clears, and
the panel reads shut while the column behind it keeps its task
subscription and any live embedded browser.

Measured at 900px before: the box reached 0 and the column stayed
mounted at width 0 indefinitely, through a re-widen. After: the column
and its resize handle are gone once the box lands. The drag gate has the
same shape reached a different way — grabbing the handle while the panel
is already closing — and now leaves the running transition alone.

A CSS contract holds it, since this is exactly the kind of edit that
looks harmless: no rule targeting the box may cancel its transition. The
same test holds the two halves of "the panel slides, its contents do
not", which no rendered test can see — the E2E fixture caps every
transition on purpose.
Unifying the two column toggles turned the rail's button into a plain
boolean flip, which is one of the two pieces of state a collapsed rail
has. The other lives in Astryx's `useResizable` behind `resizable`, and
dragging the handle past its threshold zeroes that width and reports the
collapse outward. Flipping only the boolean back leaves the rail
expanded over a stored width of 0.

Measured: rail at 260, dragged shut, re-opened from the titlebar — the
separator came back announcing `aria-valuenow="0"` and the rail sat at
its 180px floor, so the next drag started from zero rather than from
where the user left it. `toggle()` is the one call that moves both; with
it the rail comes back at 260 and announces 260.

The button stays as it is — one component for both columns was the point
and it holds. What differs is behind it: the two columns do not have the
same state owner, so they cannot share the same setter.
The workbar column reaches the plate's top edge by hanging above the
plate's padding box. To an `overflow: hidden` plate that overhang is
scrollable overflow, so the first focus inside the column — the resize
handle is one Tab away — scrolled the whole plate up by the clearance
and left it there, with every row sliding under the titlebar strip.

`overflow: clip` clips identically and is not a scroll container, which
is what the plate always meant: a frame, never a scrollport.

Caught by the E2E's own seam assertion once it ran after a focus. Kept
there, with a CSS contract naming the reason so a later `hidden` does
not quietly bring the scroll back.
The column was torn down 5ms into the slide on every collapse after the
first, and the 280ms animation then ran on an empty box. Measured, with
the fixture's transition cap lifted: arm at t+0, unmounted at t+5, no
`transitionend` in between.

The cause was the pair of booleans, not the timing. `wasCollapsed` stored
`collapsed` a second time, and clearing the leave flag on EVERY width
transition end — including the expand's, where it is already clear — left
a same-value update queued that React replayed over the render-phase
update arming the next collapse.

One state answers the actual question: has the column finished sliding
off. Its only writer is the event that decides it, and it writes the
direction that just ended, so an expand rearms the next collapse instead
of poisoning it. No render-phase reconciliation left.

The resize handle now goes with `collapsed` rather than with the column:
a handle still live during the slide let a drag write a width the user
could not see and would only meet at the next expand.

Pinned by an E2E that lifts the fixture's global 0.01ms cap for itself and
watches the frames — twice, because the first collapse was the one that
worked. Verified red on the previous implementation.
Astryx sets `data-resizing` only while a pointer is down; its arrow-key
path commits and ends in one tick, so the drag gate never saw it. Every
keypress restarted the 280ms ease: a 10px step arrived over 400 → 405.7 →
408.1 → … → 410 while `aria-valuenow` already announced the new width,
and holding the key crawled. Precise keyboard sizing is the whole reason
the separator exists.

The focused handle is the keyboard half of "the user is sizing this right
now", so it joins the same gate. Scoped away from the collapsed state so
a handle that still holds focus cannot flatten the collapse itself —
measured after: the step lands in one frame, the collapse still eases.

Also corrects a comment that still named the plate `overflow: hidden`.
The seam this column was given did not exist. It was meant to be a
surface-tone step against the conversation, mirroring the one the sidebar
makes — but the sidebar makes no such step either. Measured on darwin
light: rail, conversation and workbar all resolve to `oklch(1 0 0)`. The
only configuration where the step is real is darwin dark, where a glass
override happens to lift the content surface. Everywhere else the column
had no visible boundary at all, having replaced a hairline that did.

What the shell actually separates surfaces with is a gap. The content
plate is a rounded surface floating on the canvas with a margin on three
sides; that margin IS the seam. So the workbar becomes a plate of the
same kind, and the canvas between them is the fourth side of the one the
conversation already has. A gap has no palette to be right in.

That moves the paint down one level: `.maka-panel-detail` stops being a
surface and becomes the frame holding both plates, and `.mainColumn` —
which every view renders into — becomes the conversation plate. Each
plate pays for its own titlebar clearance, so nothing hangs above the
frame's padding box any more, which is what forced `overflow: clip` on
it and what scrolled the whole frame on the first focus. The clip stays
as insurance, and the contract now also refuses the negative margin.

The gutter belongs to the workbar plate and leaves with it, easing on the
same curve as the width so it closes rather than snapping. Stacked under
990px it turns with the stack, which retires the border and shadow that
stood in for a seam there.
@Astro-Han
Astro-Han force-pushed the refactor/session-workbar-astryx-redesign branch from fca3cc6 to 23667c7 Compare August 4, 2026 18:13
@Astro-Han
Astro-Han merged commit 0938455 into main Aug 4, 2026
11 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