refactor(workbar): rebuild the session workbar on Astryx primitives - #2158
Merged
Conversation
Astro-Han
force-pushed
the
refactor/session-workbar-astryx-redesign
branch
2 times, most recently
from
August 4, 2026 14:29
b1e3b57 to
9ce6741
Compare
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
force-pushed
the
refactor/session-workbar-astryx-redesign
branch
from
August 4, 2026 18:13
fca3cc6 to
23667c7
Compare
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ownborder-leftandbackground, a private count pill, and a 追踪 stylesheet reaching for a--maka-space-*/--maka-color-*namespacemaka-tokens.cssnever 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 ownai-chattemplate. 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-detailstops being a surface and becomes the frame holding both plates,.mainColumnbecomes 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 forcedoverflow: clipon 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:
wasCollapsedstoredcollapseda 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-resizingfor pointers only and every arrow key was restarting a 280ms ease under anaria-valuenowthat 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
mainand 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 test— 1685 pass, 0 fail.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-ledgerfixture, computed styles):x=49 w=783, radius12px,padding-top: 32pxx=836 w=400, same radius, same clearance, samey/height4pxof canvas (oklch(0.975 0 0))background: transparent,border: 0,box-shadow: none1187, no leftover gutter400 → 410in one frame (was400 → 405.7 → 408.1 → … → 410over 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
mainduring 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.