Skip to content

Menus: one shared Menu and Dropdown across the workspace, the chat's model/provider menu on it - #577

Merged
weishi-imbue merged 69 commits into
mainfrom
persimmon-herring
Sep 22, 2026
Merged

weishi-imbue merged 69 commits into
mainfrom
persimmon-herring

Conversation

@gdenisov

@gdenisov gdenisov commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Every menu in the workspace is now one shared Menu, the one dropdown is a shared Dropdown, and the chat's model card — renamed ModelProviderMenu — is the first consumer. Along the way: hover-opened submenus with a safe triangle, a sheet under every open menu, Escape everywhere, and a pass over the composer's chrome.

The shared menu (workspace_ui)

  • components/menu.tscreateMenu(options) owns opening, closing, the invisible sheet, Escape, placement and submenus; callers describe rows (action, submenu, value, check, divider, custom). The rules are the component's, not a setting: a menu opens on a click and closes on a press outside or Escape — never on a drift or a scroll, and a resize re-places it against its anchor rather than closing it; a row may open one submenu, which opens on hover (or keyboard focus) after a 40ms intent delay, is protected on the pointer's way across by a safe triangle, and closes when the pointer leaves the pair; a submenu cannot open another (held by the types).
  • components/dropdown.ts — the form control that looks like a menu and deliberately isn't one: picks a value and nothing else.
  • menu-position.ts (was flyout-position.ts) — placeMenu (moved out of the sidebar; gains above and an end alignment), placeSubmenu (first row aligned to the opening row, slides up only as far as it must), isInSafeTriangle. One gap, one margin.
  • Popovers move to a new --z-popover layer above the modal overlays, so a menu or dropdown opened from inside a modal paints over it. --z-dropdown is gone.
  • Two things the port surfaced: a menu's Escape listener leaked when its owner unmounted with it open (and then swallowed Escape for every menu after) — so Menu has dispose() and every owner calls it; and "New project" needs to show a failure under its rows, so an action row may keepsOpen.

Consumers

  • Chat: ModelBarModelProviderMenu (+ modelProviderMenuStyles). Keeps its rows — effort slider, fast switch, account list with controls, model list with search — and hands everything else to the library. Card/flyout vocabulary retired in file names, class names and test hooks (data-menu-part, data-menu-row). Picking a model closes the submenu and leaves the menu up. Model names truncate from the front (startTruncated, in the lib).
  • Chat: provider chooser's key picker → Dropdown. Its private trigger/backdrop/option styles are gone.
  • Shell: the rail's five menus, the tab kebab, the launcher filter are the same Menu as rows. The sidebar loses its own scrim/card/row/placement and its outside-press + Escape listeners; the tab kebab loses its hand-built DOM and scroll/resize close; the launcher loses its document listeners. Test hooks (.project-rail-menu, role="menuitem") kept, now on <body>.

The rest of the pass (earlier commits)

Menus on the 16px radius with a concentric 12px row highlight, and a firmer overlay shadow (2px contact under an 8px halo); one width (300px) for menu and submenus with the seam closed; small switches; a 6px effort rail with 2px dots on the thumb's stops, none under the thumb, each taking the colour of what it sits on (the surface colour behind the fill, the text colour ahead of it) and a brighter top to the ramp; tooltips above their trigger everywhere in the chat, the chip's title replaced by one that says what it does; account-row controls flush right with labels, a star that keeps its stroke, ticks that share one centre line; "Terminal View" → Source view, the terminal inset with an 8px corner and its bottom on the composer's line; composer shadow and face clipping gone; an even 8px band under the composer; the OpenRouter key address as a link.

A crispy-comments pass over every comment the branch added: incidental history, arguments addressed to a reviewer and section banners are gone; the reasons a maintainer needs stay.

The review pass (last commit)

A reviewed pass over the finished branch, all riding the shared Menu:

  • a submenu holding unfinished work survives the pointer drifting off (holdsSubmenuOpen): the chat card holds its submenu open over a rename mid-type, an armed "Remove?", the launch prompt and a typed model search
  • a pending hover-intent dies when the pointer settles on a plain row, instead of opening a submenu under a row the pointer already left
  • a submenu opened from keyboard focus paints at once
  • a menu taller than the window scrolls between the margins (the switcher with many projects)
  • a submenu that filled its cap keeps its height while its list is filtered shorter, so the model list no longer slides under the pointer with every keystroke
  • an open menu follows its anchor element through a window resize — the tab kebab hands the menu its button, so the menu follows the tab
  • an action row's trailing content sits beside the row's button rather than nested inside it: the switcher's pencil is a button of its own, legally
  • the dropdown's options borrow MENU_ROW_SLAB/MENU_ROW_FOCUS instead of restating them

Testing

  • cd system && npm run build && npm test — 127 (workspace_ui, nine of them new with the review pass) + 243 (system interface) + 521 (chat), all passing, lint-and-format included. New unit suites cover the Menu (open/close/sheet/Escape/rows, hover intent, leave grace, keepsOpen, closeSubmenu, dispose), the Dropdown, and the placement geometry.
  • cd system/apps/chat && uv run pytest — 1472 passed, 7 skipped, coverage 90.46%.
  • cd system/apps/system_interface && uv run pytest — 340 passed on macOS; the 16 remaining are liveness_test.py/routes_test.py setup errors (OSError: AF_UNIX path too long, a macOS temp-path limit for supervisord's socket), not this change. One e2e test needed its locator moved to the page, since the filter menu now portals to <body>.
  • python system/scripts/check_changelog_entries.py — ok; entries for chat, system_interface and dev.
  • Every migrated menu was opened in a real Chromium against the e2e workspace server after the port: the rail's switcher (sheet present, Escape closes), the tab kebab (4 items, a press outside closes), the chat's menu with its provider submenu on hover, the launcher's filter with its check rows. The review pass is covered by the new Menu unit tests (held submenus, the stray hover-intent, keyboard repaint, trailing beside the button, resize following, the height lock under synthetic measurements); its pointer behaviours have not had a browser pass of their own yet.

Chat-1 and others added 28 commits September 11, 2026 17:23
The provider and model flyouts were click-only, which the card's own comment
justified by the Model row's fetch (pi shells out to `pi --list-models`, up to
15s) and by the safe-triangle machinery a hover menu needs. Both are now paid
for, so the flyouts follow the desktop convention instead: getting INTO a menu
is a click, moving around inside one is not.

- Rows open their flyout after a 150ms hover-intent delay, and immediately on
  keyboard focus (`:focus-visible`, so a click's own toggle is not raced).
  Click still toggles -- it is the only way in on a touch screen.
- Every card row takes the hover, including the rows that open nothing, which
  therefore close what is open. Outside the card nothing changes: only a click
  takes the stack down.
- `isInSafeTriangle` (workspace_ui, pure and beside `placeFlyout`) is what
  stops the rows lying between the pointer and an open flyout from stealing it
  mid-travel. The wedge runs from the pointer's exit point on the owning row to
  the flyout's near edge, and expires after 400ms so a row reached diagonally
  can still be opened by parking on it.
- The offerable-model fetch now runs at most once per card-open, which is what
  makes the Model row cheap to pass over; the card's own open already warms it.
- Openable rows carry `aria-haspopup` and `aria-expanded`.

Verified in Fortress against the live chat: hover-open, the triangle holding
across a diagonal trip, a genuine row switch, close-on-inert-row, drift-away
leaving the stack up, click-outside closing it, and Tab opening a row's menu.
Two changes to how the composer card's flyouts behave, both consequences of
their now opening on hover.

1. A flyout closes when the pointer leaves the card-and-flyout stack. A menu
   summoned by hover has to be dismissed by hover, or it hangs over the
   transcript until something is clicked -- which is the very thing a hover
   menu is meant to spare the user. The CARD still takes a click to dismiss,
   because a click is what opened it. `SUBMENU_LEAVE_DELAY_MS` forgives the
   seam between the two boxes, which fires a leave before the matching enter.

2. A flyout's FIRST ROW now lines up with the row that opened it, instead of
   its base standing on that row's bottom edge. `placeFlyout` takes `rowTop`
   plus the content height and returns a `top`; it slides the box UP when the
   alignment would push it off the bottom, by exactly as much as it takes to
   fit and no further. Only a list too tall for the window at all is capped,
   and then it scrolls -- so the original reason for growing upward (a
   thousand-model catalog must not be squeezed into the space below a low row)
   still holds, by sliding rather than by anchoring.

   The offset between a box and its first row is its border AND its padding.
   `FLYOUT_PADDING` is both; counting only the padding lands every flyout a
   pixel low, which is how this was found.

`flyoutContentHeight` derives the wanted height from the row height that
already defines the ten-row cap, so the two cannot drift apart.

Verified in Fortress against the live chat: the Provider and Model menus each
land 0.0px off their row, a 420px-tall window slides the menu wholly on screen
without shrinking it, leaving the stack closes the menu, crossing the seam
does not, and a click outside still takes everything down.
Sweeping up from the chip and off the top of the card opened the Provider
menu ~150ms later, with the pointer already up the transcript. The card is
entered at the BOTTOM and left at the TOP, so Provider is the last row the
pointer touches on the way out -- and leaving cancelled the pending CLOSE
while leaving the pending OPEN to fire. A hover the pointer did not stay for
is not an intent to open, so `handleStackLeave` now cancels it unconditionally
(the close is still conditional on something being open).

Also moves the model search field ABOVE its list, where the typing starts,
rather than under the list it filters. Its old place was justified by the
grow-upward layout that the previous commit replaced. `SEARCH_WRAP` carries
its margin below it now; above, the flyout's own padding is the gap, which
also lets the field take the row-alignment line.

Verified in Fortress: a fast swipe out (up, and down through the chip) opens
nothing at 250ms/750ms/1.25s, resting on a row still opens it, swiping out
with a menu up closes it, the field lands 0.0px off the Model row above the
first model row, keeps focus, and stays put as typing filters the list. The
search field was exercised under a temporarily lowered row threshold, since
claude's catalog is too short to show it; the threshold was restored and the
rebuilt bundle hash matches the pre-experiment build byte for byte.
Measured off the mock: the highlight is inset 4px from the card's edges with a
4px radius -- the radius the card's own 8px corner leaves once you step 4px
inward, so the slab reads as concentric with the card rather than pasted into
it. The colours are unchanged; the mock's greys differ from our tokens by ~1%
alpha, which is the Display P3 profile on the screenshot, not a design change.

Changed in the shared recipe (`menuRowClass`) rather than forked per app, so
the chat card, its flyouts, the provider chooser, the tab kebab menu, the
rail's row menus and the launcher's filter menu all move together -- which
also means this restyles the workspace UI's own menus, not just chat's. The
two hand-rolled copies of the row shape (the flyout rows' selected/locked
variants, and the chooser's picker options) carry the same slab.

The width is spelled `w-[calc(100%-0.5rem)]`, and both alternatives are wrong:
`w-full` plus margins measures 100%+8px and overflows, while `auto` does not
fill, because a <button> shrink-to-fits even at `display: flex`. That last one
is not theoretical -- it pulled the flyout's highlight in behind the trailing
tick and left the tick outside the band, which is how it was caught.

`px-2` replaces `px-3`: the highlight moved inward by 4px, the text did not.

Verified in Fortress. Measured: card rows and both flyout rows at 4px radius,
4px inset, text still 12px from the card's inner edge, nothing overflowing;
the tab kebab menu's four rows and the launcher's filter row likewise. Seen:
the chat card with its provider flyout, and the tab menu with a row hovered.
The rail's row menu was not opened (right-click is not its trigger) and rests
on inheriting the same recipe call.
Four asks off the sketch and the follow-ups:

- The terminal-view label takes the faint role. Its measured colour in the
  sketch is #8c8c8c, which IS `--c-text-faint`, so this is the token rather
  than a new value. Hover now lifts one step to secondary, the way the model
  chip's does; switched ON still goes to full strength, being a state and not
  a hover.
- The switch gets a `sm` size (30x16 track, 12px knob) and the toggle uses it.
  Measured off the sketch at 30x17 with a ~13px knob.
- The model chip is at `gap-1`.
- `SUBMENU_HOVER_DELAY_MS` drops 150 -> 40, near enough to instant to feel like
  none. The two costs that argued for a long delay are both paid off now: the
  offerable-model fetch runs once per card-open rather than per hover, and a
  hover the pointer does not stay for is cancelled on leave rather than firing
  behind it. What the remaining 40ms buys is that a flick across two or three
  rows does not flash their flyouts up in turn.

A size is a track AND its knob travel, handed out together by `switchClass` /
`switchKnobClass` off one `SWITCH_SIZES` table. That is the whole point: this
component was scaled from the outside once before, with CSS `transform`
against a knob whose offset comes from a Tailwind `translate-x-[...]` utility.
The utility won and the knob ended up outside its track. Now the only way to
ask for a size is by name, so the two cannot disagree.

Verified in Fortress. Measured: label at rgb(140,140,140); small track 30x16
with a 12x12 knob 2px inside it; chip gap 4px; fast mode still on the 44x24
medium switch. Both sizes were also measured in their ON position -- via a
detached probe wearing the real classes, rather than by clicking the toggle
and flipping a live chat into terminal view -- and the knob lands 2px from the
far end in both. Behaviour: a menu is up within ~90ms of the pointer arriving,
and a flick out still leaves nothing behind.
The switch under the composer now reads "Source view" rather than "Terminal
View", with the aria-label following it so the switch announces what it says.

Only the copy moves. The component, its file, and the `terminal-view-toggle`
CSS class still say terminal, and so do the comments about what is behind the
card -- what the back face attaches to really is the agent's tmux session, and
renaming the code for a change of label would have touched ChatPanel, the
stylesheet and a test mock for nothing a reader gains. Worth doing as its own
pass if "source" becomes the word for that face everywhere.

The chat origin serves the built `static/` tree, which is not tracked, so the
label does not move on a running workspace until vite has run there.

Verified: `npm run build` clean (tsc included); the emitted
`chat-BZcuEU_z.js` carries "Source view" and no longer carries "Terminal
View"; the live origin at :8010 serves that bundle; open tabs refreshed via
`layout.py refresh chat`. Not yet looked at in a browser -- that is Gleb's
next look.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Off the sketch: the terminal stops filling the card edge to edge and sits in
it, with 4px of surface all round and a rounded corner, so it reads as an
object placed in the pane rather than as the pane's contents.

The corner is 8px because the gutter is 4px: that is what the pane's own 12px
corner leaves once you step 4px inward, so the two curves are concentric --
the same inset-and-shrink the menu rows use, one level out. The two numbers
are a pair; changing the gutter without changing the corner is what makes an
inset panel look pasted on.

An iframe clips its own painting to its border-radius, so the terminal's dark
background rounds with the frame and needs nothing from the page inside it.
`width`/`height`/`border` move from a `style` attribute on the iframe into
`.terminal-frame` beside the corner, because the corner is not a property the
frame owns on its own -- it only makes sense read next to the gutter that
`.chat-flip-back` holds it in.

The terminal loses 8px of width and height, which at a typical cell is under a
column; ttyd resizes the agent's tmux window to the client either way.

Verified in Fortress. Measured on the real chat page at 900x700: the frame
sits at (4, 4) at 892x634 -- 4px on the left, top and right, with the card's
own bottom edge above the under-bar -- and computes an 8px radius and no
border. A detached probe wearing the same classes, turned over so the back
face faces the camera, shows the dark content clipped by that corner rather
than square behind it. The label from the previous commit reads "Source view"
in the under-bar of that same page.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he chip

Three asks off the screenshot.

- The under-bar row sits in an even 8px band: 8px above it and 8px below,
  where it was 4 above and 24 below. The 24 was the page's bottom gutter
  inherited from the footer, which read as the row hugging the composer with
  an empty strip underneath rather than as a caption sitting in its own band.
  The pane gets 12px back.

- The composer's shadow is no longer cut off along its top edge. It is cast
  UPWARD on purpose (`0 -4px 20px`, negative y), so it spills out of the
  footer's box and into the transcript's -- and `.chat-transcript-area` is
  positioned while the footer was not, which puts the transcript in a later
  paint layer, its white background covering exactly the spill. Positioning
  the footer too puts it back on top: both are `z-index: auto`, so DOM order
  decides, and the footer comes second. Nothing moves; only the paint order
  changes.

- The model chip drops its native `title` for the workspace's own hover
  bubble, which is the one tooltip mechanism the rest of the workspace uses,
  and it now says what the button does -- "Change model or provider" --
  rather than naming the three things already written on the chip. It lands
  above the chip without asking for a new placement: `placeTooltip`'s default
  is centered-below and flips above when the bubble would overflow the
  bottom, which at the composer's own band it always does. One placement for
  every tooltip in the workspace is the point of that default, so this is the
  shared behaviour rather than an exception to it.

Verified in Fortress on the real chat page at 900x700. Measured: 8px above
the chip and 8px below it, the row still 30px; the footer computes
`position: relative`; the chip has no `title` attribute; the bubble reads
"Change model or provider" and its bottom sits 6px above the chip's top --
the shared tooltip gap. Photographed the composer's top edge before and
after: the halo above the border is flat white in the first and a visible
upward gradient in the second.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An open card left everything behind it live: the paperclip lit up and raised
its tooltip under the cursor on the way to the card, and a press outside both
dismissed the card AND actuated whatever it landed on. An invisible sheet over
the viewport, under the card, ends both -- it is the hit target for everything
that is not the card, so nothing behind it hovers and nothing behind it is
pressed.

It carries no dismissal of its own. A press on the scrim is a press outside
every `[data-model-popover]`, which is precisely the case
`handleOutsideMousedown` already closes the card on, and adding a second
closer would mean two things to keep agreeing about what "outside" means.
That also settles the press that closes the card: mousedown lands on the
scrim, the redraw takes the scrim away, and the mouseup lands on whatever is
underneath -- so the click event resolves to their common ancestor and never
reaches the button, which is why one press closes the card without also
opening the attach menu.

Same `--z-dropdown` layer as the card, rendered before it in the portal so
the card and its flyout paint on top by DOM order rather than by a z-index
that has to be kept one ahead. This is the shape the project rail's menus
already use (`project-rail-menu-scrim`), so there is now one answer in the
workspace to "what is behind an open menu".

The cost is that a wheel over the transcript does nothing while the card is
up, since the scrim is what the wheel lands on. That follows from blocking
the pointer at all, and matches the rail.

Verified in Fortress on the real chat page at 900x700, card open:
`elementFromPoint` over the attach button returns the scrim, not the button;
the scrim covers the full 900x700 viewport at z-index 1000, immediately
followed by the card at the same z-index; `elementFromPoint` over the card
still returns the card. Hovering the attach button's own coordinates for
700ms raises no tooltip -- the bubble is never even created. Clicking there
closes the card and removes the scrim, and the attach menu does not open.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"From openrouter.ai/keys." was an address in prose, which leaves the reader
retyping it into a browser. The lane model already has the field for this --
`signup_url`, "where to go to get a key in the first place" -- and the sign-in
panel already renders it as "Get one at <link>.", which is how the Opencode Go
lane says the same thing. OpenRouter just never filled it in.

So the URL moves out of the sentence and into the field, and the description
goes empty rather than repeating it: where the key comes from was the whole of
what that sentence said, and the panel now says it as something you can click.

The link opens in the system browser, not in a tab here, by the same route
every other external link in the workspace takes: `target="_blank"` from a
frame carrying `allow-popups allow-popups-to-escape-sandbox`, which reaches
the desktop app's `setWindowOpenHandler` -- it denies the window and hands
external URLs to `shell.openExternal`.

Verified in Fortress, walking to the panel as a user does (chip -> Provider ->
Add a provider -> OpenRouter): the lead renders "Get one at
openrouter.ai/keys." with the address as an <a> reading `openrouter.ai/keys`,
`href="https://openrouter.ai/keys"`, `target="_blank"`,
`rel="noopener noreferrer"`. `/api/lanes` serves the new `signup_url` after
the restart. The empty description leaves no gap in front of the sentence --
the leading space collapses, and the rendered text starts at "Get".

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Off the sketch. The rail doubles from 3px to 6px and its unfilled half takes
the hover fill (0.07 black) in place of the active one (0.11), so the green
sits on a lighter ground. The greens themselves are untouched --
`effortFillColor` still deepens with the level.

The ticks are 2x2 circles of the text colour at 75%, and they are now placed
by their CENTRE: `left` at the level's own fraction of the thumb's travel,
pulled back half the dot's width. They were laid out with `justify-between`,
which spaces the dots' BOXES between the container's edges and so leaves the
first and last centres half a dot inside the stops they mark. At 3px wide that
was a rounding error; at 2px it is half the dot.

The dot under the thumb is no longer drawn. The ball IS the mark for the level
it is parked on, and a second, smaller mark reading through it says there is
something there to land on when you are already on it. It is dropped from the
list rather than hidden in place because the list is keyed, and a keyed list
may not carry holes.

Verified in Fortress with the card open, a 5-stop slider parked on the middle
level. Measured: rail 5.99px tall, fully rounded, filling
`rgba(0, 0, 0, 0.07)` past the green. The thumb's travel puts stop centres at
6, 35, 64, 93 and 122px from the rail's left edge; the four dots measure at
6.01, 35.01, 93.01 and 122.01 -- every stop but 64, which is the one the thumb
is on. Each dot is 2x2, fully rounded, `oklab(... / 0.75)` of the text colour,
and centred on the rail's own midline at 3px.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tick on the selected model sat 56px short of the row's right edge. It is a
flex child pushed out by `ml-auto`, so it stops at the row's PADDING edge --
and the model row was built on a base carrying `pr-14`, a reserve for the
rename pencil and the bin. A model row draws neither; only an account row
does, and an account row has its own wider base (`pr-26`) precisely because
the two lists carry different controls. The reserve on the model row was for
controls that are never on it.

Dropping it puts the tick on the row's own `px-2` edge, 13px in from the
flyout's edge -- the same line the account rows' pinned tick sits on, so the
two lists' ticks now agree. Model names also get those 48px back, which is not
nothing for an `openrouter/ai21/jamba-large-1.7`.

`FLYOUT_ROW_BASE` goes with it: with the reserve gone it was `FLYOUT_ROW_SHAPE`
under another name. The comment on `FLYOUT_CHECK` was describing the pinned
sibling variant rather than itself, and now says what a flex tick actually
depends on -- that the row's padding is the truth about what else is in the
row.

Verified in Fortress, both flyouts open in turn: the model row's tick measures
13px in from the flyout's right edge, the provider row's tick 13px, where the
model one was 61px. The model row's own padding-right is 8px, and the tick
sits 7.99px inside it.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer is a border and nothing else now: no upward-cast shadow at rest,
no accent-tinted one on focus. It sits at the foot of its own pane rather than
floating over the transcript, so the elevation it was drawing was elevation it
does not have -- and focus has a border colour to say it with. The transition
names one property now, because only one changes.

The faces stop clipping. `.chat-flip-face` carried `overflow: hidden`, and the
face's bottom edge IS the composer's bottom edge, so the part of the shadow
that reached past it was cut square -- the bottom edge of the same shadow whose
top edge the transcript was painting over. Every child that needs clipping does
its own: the transcript scrolls inside its own scroller, the terminal frame
carries its own corner. A clip at the face's edge only ever caught what a child
cast past that edge.

That also retires the `relative` on the footer from
11c2090b7. It was there for exactly one reason -- to lift the footer out of
the transcript's paint layer so the upward shadow showed -- and with no shadow
to show there is nothing for it to do.

Verified in Fortress on the real chat page. `box-shadow` on
`.message-input-box` paints nothing in either state: unfocused and focused, the
computed value carries only Tailwind's transparent placeholders. `.chat-flip-front`
computes `overflow: visible`. To see the clip is really gone rather than merely
unused, hung a solid `0 14px 0 rgba(220,0,0,1)` off the composer and
photographed the card's bottom edge: the band paints in full, past the face and
over the under-bar, where it would have stopped dead at the card's bottom before.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the composer

Two asks.

The effort ramp's dark end moves from 30% lightness to 40%. At 30% the top of
the scale read as near-black -- switched off rather than turned up -- and 40%
is exactly what the stop below the top rendered on a five-level scale, which
is the brightest the ramp ever looked while still climbing. The hue and
saturation are untouched, so every level below the top lightens by the same
proportion rather than the ramp kinking at its end.

The terminal's gutter drops to zero along the bottom: `4px 4px 0`. The face's
bottom edge is where the composer's bottom edge is on the other face -- the
footer is the front face's last child and carries nothing under it -- so the
two faces now share that line, and turning the card over leaves it exactly
where the eye had it. The other three sides keep their 4px.

Verified in Fortress on the real chat page. The ramp: the slider's gradient
reads `rgb(62, 142, 105)` at the top stop, which is `hsl(152 39% 40%)` -- the
colour the old ramp produced at the stop below it -- and `rgb(95, 185, 143)`
at mid-scale where it read `rgb(78, 177, 131)` before. Driven with `input`
events only, never `change`, so nothing was committed to the agent. The
gutter: turned the card over and measured the frame at 4px from the top, left
and right of the face and 0 from the bottom, its bottom edge at y=654 -- the
same y the composer's bottom edge measures on the front face.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flyout tucked 4px under the card's right edge, which left a 1px line of
card showing between it and the highlight on the row that opened it. 4px was
the wrong landmark: the row's highlight is inset 4px from the card's CONTENT
box, and the card carries a 1px border outside that, so the row ends 5px in
from the card's edge, not 4. At 5 the flyout starts exactly where the
highlight stops and the two read as one band continuing sideways.

`FLYOUT_OVERLAP` is the number for both sides -- `placeFlyout` measures the
leading side from the card's left edge the same way -- so the flip case closes
its seam too.

The composer's under-bar drops `px-1`. The row already carries the composer's
own max-width, so the 4px was holding the model chip and the source-view
switch a hair inside the composer's edges rather than square with them.

Verified in Fortress on the real chat page. The flyout: its left edge and the
opening row's highlight now both land on x=498.99, where the highlight ended
at 498.99 and the flyout began at 499.98 before -- 5px in from the card's edge
rather than 4. The under-bar: `padding` computes 0 on both sides, the row's
left edge sits on the composer's (163.99), the chip's left edge is flush with
the composer's left edge and the switch's right edge with its right.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…front

The fast-mode row takes the `sm` switch, the one the composer's under-bar
already uses, so the card's only control stops being the loudest thing in a
column of values. Its tick joins `SWITCH_SIZES` rather than staying a number at
the call site: 12px in the 20px knob, 8px in the 12px one, the same rim either
way. A tick sized by hand would be the one part of a switch still free to
disagree with the size it is drawn in, which is exactly what the table exists
to prevent.

Model names now truncate from the FRONT. `openrouter/ai21/jamba-large-1.7` is a
path whose head repeats down the whole list and whose tail is the only part
that tells one row from another -- dropping the tail leaves a column of
`openrouter/ai21/jamba-la...`, which is no list at all. `direction: rtl` on the
name box moves the ellipsis: the line's end lands on the LEFT, which is the
side the box overflows, so a long name hangs its beginning off there. The name
itself goes in a `<bdi>`, which keeps it one isolated left-to-right run --
without that, a name ending in a neutral character (`(thinking)`) has its
bracket reordered to the far left, and openrouter ids are full of neutrals.
`text-align: left` is for the names that DO fit, which an rtl box would
otherwise push against its right edge.

Both places a model name appears take it: the card's Model row and every row of
the list. The provider row keeps ordinary truncation -- a provider is a word,
not a path.

The flyout is already 300px wide (`FLYOUT_WIDTH`), so nothing moved there.

Verified in Fortress with the card open. The switch measures 30x16 with a 12px
knob, matching the under-bar's. A long name injected into the list renders
`...nrouter/ai21/jamba-large-1.7-instruct-preview` -- ellipsis in front, tail
intact -- and the deliberately awkward `...de-sonnet-4.5-20260<cyrillic>
(thinking)` keeps its bracket at the end, which is the bidi case the `<bdi>` is
there for. Short names still sit left. The name box computes
`direction: rtl`, `text-align: left`, `text-overflow: ellipsis`, 274px wide
inside the 300px flyout.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hat keeps its size

The three controls now end where the row ends. The bin takes the last lane --
the one the tick occupies at rest -- and the tick stands down for the hover:
a control group that stops a slot short to leave a mark room reads as
misaligned, and the tick is the one thing there that can afford to go, since
the row's own selected fill still says which account is current and the tick
comes straight back when the pointer leaves.

Each control says what it is. Three same-sized glyphs are a guessing game
without labels, so the star, the pencil and the bin carry the workspace's hover
bubble -- "Set as default" ("Remove as default" when it already is), "Rename",
"Delete" -- beside the fuller aria-labels they already had.

The default star is filled in CSS rather than by the icon helper's `filled`,
which swaps the stroke out for the fill and so draws a glyph a stroke-width
smaller all round: the marked row's star read as the smaller of the two stars
in the list, when it is the one meant to stand out. Filling the outlined glyph
keeps one silhouette and changes only what is inside it.

At rest that star sits BESIDE the tick, one lane in, so a row's marks read as a
pair at its end rather than as a mark and a gap. On hover it steps out to the
control lane, which is the only way a group flush with the row's end can keep
its own order. The alternative -- the star owning the last lane in both states
and the tick moving in behind it -- would hold it still, at the cost of putting
the row's primary state second.

The row's right reserve follows the controls in: `pr-26` to `pr-20`, which is
the outermost lane plus its own width, so the padding says exactly what is in
the row. Provider names get the other 24px.

Verified in Fortress with the provider flyout open, measuring from the
flyout's right edge. At rest: tick at 13px, nothing else shown. Hovering a row:
bin 13px, pencil 37px, star 61px, tick gone. The pencil's bubble reads
"Rename". The default star (its class applied in the DOM -- no account is
currently the default, and setting one would have changed a real setting)
measures 37px at rest and 61px hovered, and computes `fill` AND `stroke` both
`rgb(47, 107, 79)` at 2px, which is the outline's own silhouette filled in.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ake the last lane

A row dropped its highlight the moment the pointer reached the controls that
highlight had just revealed. The controls are SIBLINGS of the row button --
buttons cannot nest -- so by CSS's reckoning a pointer on the bin is not on the
row at all. The fill moves to the wrapper's `group-hover/conn`, which is the
box that actually contains all four.

The default star's resting lane now depends on whether the row carries a tick.
Beside the tick when there is one, so the row's marks read as a pair at its
end; in the tick's own lane when there is not, because a lone mark stopping a
slot short of the row's end reads as misaligned rather than as room held for
something absent. Only one account is ever the default, so there is no column
of stars for the two lanes to break.

`ROW_STAR_PINNED` becomes `rowStarPinnedClass(hasTick)` -- the same shape as
`switchClass(size)` next door, a class asked for by what decides it rather than
picked from two constants at the call site.

Verified in Fortress with the provider flyout open, measuring from its right
edge. The fill on a row that is not the current account: transparent with the
pointer elsewhere, `rgba(0, 0, 0, 0.07)` with the pointer on the row, and the
same `rgba(0, 0, 0, 0.07)` with the pointer on that row's bin, which is the
case that was failing. The star: 37px with a tick (beside one sitting at 13px),
13px without -- the tick's own lane. Photographed both in one shot, a starred
current row above a starred row with no tick.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tick was a bare 13px glyph; the star is the same 13px glyph centred in a
20px button. Two boxes ending on the same pixel, but the button's padding puts
its glyph 3.5px further in, so the tick and the star never shared a centre
line -- which is invisible until they stack, and a starred row above a ticked
one stacks them.

Both ticks take the button's box: the account rows' pinned one and the model
rows' `ml-auto` one. Boxing only the first would have fixed the column at the
cost of the two lists' ticks disagreeing with each other, which is the
alignment the previous pass went to some trouble for.

Verified in Fortress, measuring each glyph by the centre of its own `<svg>`
from the flyout's right edge. Everything now lands on one 24px grid: at rest,
tick 23 and star 47; a star with no tick beside it 23, the same line the tick
above it sits on, where it was 19.5 against 23 before. Hovered: bin 23, pencil
47, star 71. The model list's tick: 23.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every tooltip in the chat now sits over its trigger rather than under it. Under
it is where the next row is: the menus are lists, the controls that raise these
bubbles live on the rows, and a bubble below the pointer covers exactly the
rows being chosen between -- which is the same argument the project rail
already makes one axis over for its `right` placement.

`placeTooltip` gains `above` as a third placement, the mirror of the default:
centred over the trigger with the same 6px gap, flipped BELOW when it would
overflow the top and there is room down there, then clamped. Each placement
prefers its own side and gives way only when that side has nothing, so a
caller's choice holds wherever it can.

It is an addition, not a change of default: every caller that does not ask for
it -- all of the system interface's -- keeps the centred-below behaviour it has
today, rebuilt or not. Moving the DEFAULT is the one-line version of this, and
it would change the shell's tooltips too, which is a change to the live
workspace UI and belongs in that flow rather than in a chat rebuild.

The chat asks for `above` at each of its fifteen call sites rather than through
a local default, which is repetitive but is how the rail spells its own
exception, and leaves no hidden per-app state deciding where a bubble lands.

Verified in Fortress on the real chat page: "Attach files" over the paperclip
and "Delete" over a row's bin, both 6.21px above their trigger and the second
centred on it to within a hundredth of a pixel, with the bubble clear of the
rows below. The new placement's arithmetic is covered by four cases beside the
existing ones in `hoverTooltip.test.ts` -- written, not run, per the standing
instruction to hold tests until the work is called done.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card was 340 and its flyouts 300, so a flyout slid out of a card 40px
wider than itself -- which is visible at the seam the two share, and the seam
is the one place the eye is already looking.

340 was for the effort slider's travel, and that turned out not to depend on it:
the slider is a fixed `w-32`, 128px whatever the card is, so the extra width was
going to the row values rather than to the thing it was reserved for.

Verified in Fortress: the card measures 300, the slider still 128 with its full
travel, and neither the Provider row's value ("Rename (Claude Code)") nor the
Model row's is truncating at that width.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One line, reflowed: `FLYOUT_CHECK` fits its print width now that it is a single
utility string, and the frontends' `lint-and-format` test is what says so.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate names entries after the branch, and wants one per project: `chat` for
the app's own changes, and the synthetic `dev` bucket for `system/libs/workspace_ui`,
which has no pyproject.toml of its own and so falls to it.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`components/menu.ts` grows from the floating-menu chrome into the menu itself:
`createMenu(options)` owns opening, closing, the sheet, Escape, placement, and
submenus, and callers describe rows. The rules are the component's, not the
caller's -- a menu opens on a click and closes on a press outside or Escape,
never on a drift, a scroll or a resize; a row may open one submenu, which opens
on hover (or keyboard focus) behind a safe triangle and closes when the pointer
leaves the pair; a submenu cannot open another, held by the types. Every menu
in the workspace is about to be this one.

`components/dropdown.ts` is the form control that looks like a menu and is
deliberately not one: it picks a value and nothing else.

`flyout-position.ts` becomes `menu-position.ts` and takes `placeMenu` (out of
the sidebar, where the tab menu was importing it from) beside the renamed
`placeSubmenu` and the safe triangle, with one gap and one margin for all.

Popovers move to a new `--z-popover` layer above the modal overlays, because a
popover is the most recently opened thing on screen and one opened from inside
a modal has to paint over it. `--z-dropdown` goes; nothing sits under a modal
any more.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ker is a Dropdown

The chat's model card becomes `ModelProviderMenu`, built on the shared Menu: the
component keeps its rows -- the effort slider, the fast switch, the account
list with its controls, the model list with its search -- and hands the
opening, closing, sheet, Escape, placement and hover-opened submenus to the
library. Its styles module follows the rename, and the flyout/card vocabulary
goes: submenu and menu, in the file names, the class names and the test hooks
(`data-menu-part`, `data-menu-row`).

The shell's five rail menus, the tab kebab and the launcher's filter are the
same Menu described as rows. The sidebar loses its own scrim, card, row recipe
and placement; the tab kebab loses its hand-built DOM and its scroll/resize
listeners -- with a sheet under it, nothing behind it can scroll, and a menu
opened by a click closes on a click or Escape and on nothing else; the launcher
loses its document listeners. All three keep their test hooks
(`.project-rail-menu`, `role="menuitem"`), which now live on <body> where the
menus portal to.

The provider chooser's key picker is the shared Dropdown, and its private
trigger, backdrop and option styles go with it.

Two things the port surfaced. A menu's Escape listener leaked when its owner
unmounted with the menu open, and would then swallow Escape for every menu
after it -- so a Menu has `dispose()`, and every owner calls it from
`onremove`. And a menu that closes on every pick cannot show "New project"'s
failure under its rows, so an action row may ask to `keepsOpen`.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… touches

The system interface joins the chat and the dev bucket: its rail, tab and
launcher menus are the shared Menu now, which is a change to it.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not under its section

The filter is the shared menu now, which portals to <body>; the rows the test
clicks are no longer descendants of the section that opened them.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gdenisov gdenisov changed the title chat: hover menus for the model card, and a pass over the composer's chrome Menus: one shared Menu and Dropdown across the workspace, the chat's model/provider menu on it Sep 12, 2026
…mments

The menu card moves from the 8px radius (lg) to the 16px one (xl), the
step the radius scale reserves for the largest surfaces; the row highlight
follows to 12px so it stays concentric 4px in. The dropdown's list and the
model submenu's rows restate the slab and move with it. base.css's note on
the scale now says menus live on xl.

The comments this branch added lose their incidental history (the 30% ramp,
the switch that was once scaled by a transform), the arguments addressed to
a reviewer, the point-in-time facts and the dashed section banners. What a
maintainer needs -- why mousedown, why rtl plus bdi, why the row's width is
explicit -- stays, shorter.

Verified: cd system && npm test -- 118 + 243 + 520 passing, lint-and-format
included; prettier reports every touched file unchanged.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gdenisov and others added 29 commits September 17, 2026 12:41
# Conflicts:
#	system/apps/chat/frontend/src/views/ChatPanel.ts
#	system/apps/chat/frontend/src/views/MessageInput.ts
#	system/apps/chat/frontend/src/views/ModelProviderMenu.redraw.test.ts
#	system/apps/chat/frontend/src/views/ModelProviderMenu.test.ts
#	system/apps/chat/frontend/src/views/ModelProviderMenu.ts
#	system/apps/chat/frontend/src/views/accountRow.ts
… fetch warns again

Picking a speed is picking one of three, which this menu already does twice over,
and a modal opened from the menu covered the mode it was changing. The chooser
becomes the Fast Mode row's submenu, carrying everything the modal did: the three
modes with the sentence that says what each one does, the ticked current one,
auto's editable turn limit, and a row that makes the chat's mode what new chats
start in.

It STAYS UP on a pick, unlike the model list, because picking auto is usually
followed by setting the limit it runs to and the default row reads off whichever
mode was just picked. A half-typed limit holds it open against a drifting pointer
(`holdsSubmenuOpen`), the same way a half-typed rename holds the provider submenu.

These are the menu's only two-line rows: three modes is a short list, and what
separates them is not their names but what each one does -- a sentence that cannot
ride the row's own line and would be lost in a tooltip on a list you are choosing
from. "Use <mode> for new chats" is a row rather than a checkbox because the choice
is one-way: the way to undo it is to make another mode the default, and the tick
says it already is.

`fastModeDetail` and the mode list move to `models/FastMode.ts`, beside
`fastModeLabel`, since the view that held them is gone. The menu's own test now
mocks only FastMode's backend-backed half, so the words the rows read are the real
ones.

Separately: restore the `console.warn` on a failed model-options fetch, dropped
when the bar was ported to the shared menu. The picker falls back to the whole
catalog on failure, which looks like success.

Verified in Fortress against the recording mngr: the submenu opens from the Fast
Mode row, picking On moves the row to On and takes auto's limit row away, picking
Auto brings it back, and typing 2 into it rewrites every mention of the limit --
all with the submenu and the menu still up.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s stay out

Three fixes to the fast-mode submenu, all visible in one screenshot.

The mode rows carried a `w-full` beside the shared slab's own
`w-[calc(100%-0.5rem)]`. Two width utilities on one element are settled by the
order Tailwind emits them in, not by the order they were written, and the slab's
own comment warns about this exact class: the row ran 8px past the slab's margins,
which put its trailing tick in a different lane from the row below it. The row now
takes the slab's width, like every other row here.

The tick sits at the row's vertical centre rather than on its first line. On a
three-line Auto row, level-with-the-mode read as floating.

"Use <mode> for new chats" is a small switch rather than a tick. The switch travels
ONE way: exactly one mode is what new chats start in, so there is no "off" to
return to -- turning this one off would leave the question unanswered. On, it
states the setting and is inert (the shared switch's `disabled:` treatment); off,
it is how the setting is moved here. That is what the chooser's checkbox did
before, said in the menu's own vocabulary. `SUBMENU_ROW_INERT`, added for the tick
version and now unused, goes with it.

The turn-limit field keeps its steppers out. Chromium's user-agent sheet fades
`::-webkit-inner-spin-button` to nothing until the field is hovered or focused,
which leaves a number field looking like a text field: the one affordance saying
"this is a number you can nudge" only appears once you have gone looking for it. An
author `opacity` outranks the UA sheet's. The field widens to 72px and drops its
right padding, since the arrows sit at the content box's right edge. Firefox has no
equivalent hook; this is the Chromium build the workspace ships.

Measured in Fortress: the mode tick and the switch both end at the same x (856.5),
the tick's centre and the Auto row's agree to a pixel, and the switch reads off and
live on a mode that is not the default, on and inert on one that is.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Standard speed always." / "Fast for the first 5 turns, then standard." / "Fast
mode always.", in place of sentences that named the whole chat twice over.

The old auto line ran to 54 characters and wrapped in a 300px submenu, which left
the three rows at two different heights and the list looking ragged. All three now
hold on one line, so the rows are one height and the submenu is shorter by the
wrapped line.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gap either side of the arrows is the STEPPER's margin, not the field's
padding. The arrows are the last box in the content area and the number is
right-aligned against them, so padding can only move the pair together -- the
arrows' own `margin-left` is the one thing that puts space between them and the
digits. The field's right padding comes back at the same time, so the arrows are
not flush against the border either.

Checked in Fortress at rest -- not hovered, not focused, which is how the row is
usually read -- at one digit and at three.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A row's second line is a label, not a sentence: nothing else in these menus ends
in a full stop, and three of them stacked read as prose in a list of choices.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ds the setting

The switch reads "is what new chats start in the mode I am looking at". It is live
whenever the two differ, and pressing it moves the setting here -- which is how the
setting reaches all three modes, one row at a time. On the mode that already holds
it there is nothing left to press, and it was marked natively `disabled` for that,
which fades it to 50%: the setting washed out at the exact moment it read as set.

That is the wrong meaning. `switchClass` takes an `isInert` flag for a switch
ALREADY at the only position it can hold -- full colour, no pointer -- and keeps
its `disabled:` fade for the other thing, a switch that cannot be used yet because
what it toggles has not loaded. The cursor is a parameter rather than a second
class string because two `cursor-*` utilities on one element are settled by the
order Tailwind emits them in, not by the order the caller wrote them.

Measured in Fortress: opacity 1 and the accent green in both states, cursor
`default` on the mode that holds the setting and `pointer` on one that does not.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem: four tests in ModelProviderMenu.test.ts opened with the same three
lines arranging a model that supports fast mode, its catalog and its choice --
two of them added by the fast-submenu work. The rest of the file's setup is
already factored (OPUS, ACCOUNT, catalogOf, the beforeEach), so this was the one
place the convention lapsed, and the reader had to diff three lines by eye to
see that the tests differ only in what they assert.

Fix: `withFastModel()` beside `catalogOf`, called from all four. It stays out of
the beforeEach because most of the file runs on a model with no fast mode and
asserts the row's absence.
… open

Problem: the fast-mode chooser's move into a submenu added a `fast` branch to
the menu's `holdsSubmenuOpen` -- a half-typed turn limit keeps the submenu up
against a drifting pointer, the way a half-typed rename holds the provider
submenu -- and nothing exercised it. The shared Menu's tests cover the
mechanism, but the menu could have reported no work for the fast key, or fallen
through to the model search's predicate, and every test still passed.

Fix: a test that types into the limit field, takes the pointer off the submenu
past the leave grace and finds it still up, then files the number and finds the
same drift closes it. Removing the branch fails it.
…modal

Problem: fast-mode-limit.ts's module docstring said a mode the user picks comes
from "the model picker's modal", but the chooser is now a submenu of that menu
and FastModeModal.ts is gone. That docstring is where a reader goes to find out
who calls chooseFastMode, so it pointed at a file that no longer exists.

Fix: name the Fast Mode submenu instead.
Problem: the fast submenu's `limitDraft` was cleared only by the turn-limit
field's own `onchange`/`onblur` and by the menu's submenu-change and close
hooks. Picking `off` or `on` hits none of them -- removing a focused element
fires no blur, and on macOS a press on a `<button>` does not move focus off the
field at all -- so the draft outlived the field it was typed into. With it,
`holdsSubmenuOpen("fast")` answered true for the rest of the menu's life: the
submenu could no longer be dismissed by the pointer leaving, and the shared
menu's `isSubmenuHeld()` also stopped Provider and Model opening on hover.

Fix: the mode row clears `limitDraft` where the pick is made, inside the
existing "not already this mode" guard -- pressing the mode the chat is in
leaves the field on screen, so the draft has to survive that. The menu's test
now follows a pick with a pointer drift and asserts the submenu goes.
Problem: `FAST_MODES` arrived carrying "Off", "Auto" and "On" -- the same three
names `fastModeLabel` hardcodes a dozen lines above it in the same file, so
renaming a mode would have moved the chooser's rows and left the row that opens
them reading the old name. It also left the menu fetching a bare name with
`FAST_MODES.find(...)?.label ?? ""`: a lookup that cannot actually miss, since
`FastModeMode` is a closed union, but whose shape says it can and whose empty
default would have rendered "Use  for new chats".

Fix: `FAST_MODE_LABELS`, a record keyed by the mode, is where a mode is named;
`FAST_MODES` keeps only what it was really carrying, the order the chooser
offers them in. `fastModeLabel` reads its base name off the record and appends
auto's "(off now)", and the menu indexes the record instead of searching, which
is total and needs no fallback.
Problem: the README still said the fast row "opens a small chooser where the
mode, auto's turn limit and the default for new chats are set". That chooser
was a modal (`views/FastModeModal.ts`) and it is gone: the row now opens a
submenu of the model/provider menu.

Fix: name the submenu, keeping the rest of the sentence and everything it lists.
…han copying it

Problem: `FAST_LIMIT_ROW` spelled out `flex h-8 items-center gap-2 px-3` --
exactly `ROW_STATIC` -- while its own docstring claimed "the same height and
padding as a menu row" and the default row right below it composes `ROW_STATIC`.
The two rows are drawn to read as a pair, but only one of them would have
followed a change to the shared shape.

Fix: interpolate `ROW_STATIC`, as `FAST_DEFAULT_ROW` and the submenu rows above
already do. Same utilities emitted, one source for the shape.
Problem: the rule between the mode list and the two settings under it borrowed
`menuDividerClass()` but not the `role="separator"` the shared menu puts on
every divider row it draws itself. Inside the submenu card, which the menu
gives `role="menu"`, that left it an unlabelled generic child where every other
rule in the workspace's menus is a separator.

Fix: give it the role, and say why the rule is drawn here at all rather than
pushed as a `divider` row.
…le that has them all

Problem: `FAST_MODES` wrote the three modes out a second time, directly under
`FAST_MODE_LABELS` -- a `Record<FastModeMode, string>`, which enumerates every
mode by construction. The list was the one spelling of the set the compiler does
not check: a mode added to `FastModeMode` fails to compile until it has a label,
but compiles fine while missing from `FAST_MODES`, and the fast-mode chooser maps
over `FAST_MODES` to build its rows -- so the new mode would simply not be
offered.

Fix: read the list off the label table. The table's key order is the chooser's row
order, which its docstring now says.
…y falling through

Problem: `fastModeDetail` checked for off and on and let auto fall off the end of
the function, so the compiler checked nothing about the set of modes. A fourth
mode added to `FastModeMode` would have landed in that fall-through and been given
auto's sentence -- "Fast for the first 5 turns, then standard" -- in the chooser,
with nothing to say so.

Fix: a switch with all three modes named. A mode with no case is a compile error.
Same three strings out.
…odule

Problem: the armed-switch badge was dressed twice by hand -- on the composer chip
and on that account's row in the provider list -- with the same rounded fill, the
same accent pair and the same helper size written out in two files. The two are
one mark about one fact, so they could drift apart; and neither string lived in
`modelProviderMenuStyles.ts`, where the rest of this menu's classes do, which left
both outside `style-modules.test.ts` -- the check that exists because Tailwind
emits nothing at all for a colour token this app does not define.

Fix: `NEXT_BADGE` in the style module, composed at both sites with the spacing and
weight each needs beside its own text. Same utilities emitted, and the pill's
colours are now checked.
Problem: replacing "opens a small chooser where ..." with "opens a submenu where
..." shortened the sentence without re-filling the lines after it, leaving a
58-character line in a paragraph otherwise filled to 72-80.

Fix: re-fill the three lines. Wording unchanged.
Problem: the merge's ModelBar -> ModelProviderMenu rename ran through two prose
comments in ModelProviderMenu.redraw.test.ts without re-filling the lines after
it, leaving a 101-character line in a header paragraph filled to 88-94 and a
104-character line in a body comment filled to 81-94.

Fix: re-fill both paragraphs to their own column. Wording unchanged -- only
where the lines break.
Problem: "Use <mode> for new chats" was built twice in fastModeSubmenu -- once
as the row's visible label, once as the switch's aria-label. The switch is a
bare button with only a knob in it, so that attribute IS the accessible name of
the words beside it; two copies of the literal let the two drift on the next
copy edit with nothing to catch it.

Fix: bind the sentence once beside `currentLabel`, which it is built from, and
use it in both places.
The comments this work added carried three things the skill takes out.

Rejected alternatives argued at length. Three docstrings in `models/FastMode.ts`
spent their second half explaining what a list of mode names would have cost --
which is a defence of the shape against a reviewer, not something a maintainer
needs. They now say what the shape IS and stop.

The same fact stated three times. "Two utilities for the same property on one
element are settled by the order Tailwind emits them in" appeared at each of the
three recipes it constrains. It moves to the module header, beside the other
Tailwind gotcha already there, and the three sites say only what they do.

Incidental history. `FAST_ROW_SHAPE` narrated the 8px the row used to run past its
slab; the stepper comment recounted the padding that did not work. Both now state
the rule without the bug that produced it. Two test comments that restated the
assertion under them are gone.

Nothing about behaviour changed.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The earlier passes covered the branch as it stood at the time; this one goes over
all of it, the shared menu library included. 28 files, net 174 comment lines gone,
and no code: with comments stripped, every file is byte-identical before and after.

Four patterns came out.

Rejected alternatives argued at length. `dropdown.ts`'s header opened with a
rebuttal to a reviewer who would ask why this is not the menu; `MENU_ROW_SLAB`
costed out two width strategies it did not take; `SLIDER_TICK_SHAPE` argued against
`justify-between`; `rowStarPinnedClass` argued against the icon's own `filled` and
against a mark one slot short. Each now states the rule and stops.

One fact stated at every site it constrains. The safe triangle, the sheet, the
submenu height lock, `dispose`'s listener leak, the `pi --list-models` warm-on-open,
"mithril re-asserts `value` every redraw", the Tailwind emit-order rule -- each was
explained two or three times. Each is now explained once, at the place that owns it.

Incidental history. Comments that recounted the bug instead of stating the rule:
the composer shadow the face's clip used to trim, the 8px a row used to run past its
slab, the padding that did not hold the digits off the steppers, the redraw test's
list of original symptoms.

Point-in-time facts. Inventories of call sites ("both switches in the chat are
`sm`: ..."), counts, and file headers enumerating their own contents.

Dashed section banners are gone from the eight files that had them (they were all
pre-existing, in the system interface's views and three e2e/lane modules). The label
survives as a plain one-line comment, which is what the skill says a banner conveys
nothing more than; `menu.ts`'s existing plain one-liners were already in that form
and are untouched.

One staleness fix on the way past: the redraw test said the component takes a
document-level mousedown listener, which stopped being true when the shared menu
took dismissal over with its sheet.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…chip

A task-notification is something the agent was TOLD, not something the user
said, and the only part of it worth reading is its one `<summary>` line. It
rendered as a collapsed chip on the user's rail -- right-aligned, as if the
person had typed it, and hiding the summary behind a disclosure with nothing
else inside.

It becomes a `NOTICE`: a tick, the lead in medium weight ("Background task
completed:"), then the summary as plain secondary text, on the agent's rail. The
decision is the backend's, like every other display decision (the frontend never
sniffs message text): the detector lifts the `<summary>` into `display_body` and
drops a zero exit code, which every ordinary completion carries and which says
nothing "completed" has not. A non-zero one stays -- that is the whole news.

`isHiddenUserMessage` asked "is this off the user rail", which was the same set as
"does this draw no row" right up until a kind drew its own row on the agent's. It
now asks the question it was named for, and `renderUserMessage` asks it through the
same predicate rather than restating the condition.

Verified in Fortress against a transcript carrying a real notification: the row
reads `Background task completed: Background command "Wait for the assembly report"
completed`, with no exit code. The notice lands where a stop-hook chip lands in the
same transcript, so its placement is unchanged.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	system/apps/chat/frontend/src/views/ChatPanel.ts
#	system/apps/chat/frontend/src/views/ModelBar.ts
#	system/apps/chat/frontend/src/views/modelCardStyles.ts
#	system/apps/chat/imbue/chat/test_e2e.py
#	system/apps/system_interface/frontend/src/views/DockviewWorkspace.ts
#	system/apps/system_interface/frontend/src/views/NewTabLauncher.test.ts
#	system/apps/system_interface/frontend/src/views/NewTabLauncher.ts
#	system/apps/system_interface/frontend/src/views/Sidebar.test.ts
#	system/apps/system_interface/frontend/src/views/Sidebar.ts
#	system/apps/system_interface/imbue/system_interface/test_e2e.py
#	system/libs/workspace_ui/README.md
#	system/libs/workspace_ui/src/components/hoverTooltip.ts
#	system/libs/workspace_ui/src/components/menu.ts
Main rebuilt the workspace UI as a desktop -- windows, a taskbar, a launcher --
and deleted the project rail, the tab strip and the New Tab launcher this branch
had rebuilt on the shared Menu. That whole changelog entry described screens the
merge removed, so it goes.

The library entry's two claims that outlived their truth: the shared menu was
"the Menu every menu in the workspace now is", which it is not -- the desktop
carries its own `system_interface/views/Menu.ts`, composing the shared card and
row recipes but not the component -- and `placeMenu` no longer comes "out of the
sidebar", there being no sidebar.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch built one shared menu component; main, in parallel, rewrote the
workspace UI as a desktop and gave it a second one. Two menu systems in one
workspace was the thing this branch exists to end, so the desktop's goes.

`views/Menu.ts` is deleted with everything it restated: its `placeMenu` and
anchor types (`workspace_ui/menu-position` has them, with `above` besides), its
`FloatingCard` (no caller but its own Menu), and its `MenuItem` / `MenuEntry` /
`MENU_DIVIDER` row vocabulary. `anchorForEvent` and `anchorForPoint` move to
`menu-position` beside the `MenuAnchor` they build.

The five menus -- a window's, a taskbar entry's, a shortcut's, the Desktops
tray's, and a desktop's -- become ONE `createMenu` in the App. The App still says
which menu is open and what it was opened for, and its rows follow from that;
opening, placement, Escape, the sheet and dismissal are the component's, with
`onClose` keeping the two from drifting. `WindowMenu.ts` builds `MenuRow[]` with
the same rows, order, labels and actions.

Three consequences worth naming. The `isInsideTrigger` callbacks go: a press on
an open menu's own button lands on the sheet and closes it there, which is the
toggle they were written to produce. Dismissal is a sheet press or Escape rather
than a document `pointerdown` listener. And the cards take the shared 16px radius
and the `--z-popover` layer.

`MenuOptions.extraClass` now says it is read per render -- the App leans on that
to wear all five marker names from one instance, and a future reader hoisting it
to a const at creation would silently collapse them to one.

Verified in Fortress: the window menu hangs under its kebab with its divider
before Close, and the Desktops tray menu flips above its anchor at the bottom of
the screen. Both at the shared 176px floor the old card's `min-w-44` set.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last menu in the workspace still drawing its own card. It composed the shared
class strings but hand-rolled everything around them: a nudge-back-onto-the-screen
placement, a document `click` listener and a document `keydown` listener, and the
`stopPropagation` guards that existed only to keep those two from firing on the
menu itself. All of it is the component's, so all of it goes.

`MenuState` loses its `x`/`y` -- `anchorForPoint` makes the anchor and
`createMenu` owns the placement -- leaving only which row's menu is up, with
`onClose` keeping that in step with the component. The three rows keep their
labels, order and actions; their `data-menu-item` hooks become the row `key`s the
component renders as `data-menu-row`. Neither verb clears the menu any more: a
picked action row closes it itself.

Verified in Fortress: a right-click on a rail row opens the card at the pointer
with Rename / Stop chat / a divider / Delete chat in danger red, and Escape takes
it down.

That leaves `createMenu` as the only menu in the workspace. The one other file
composing the chrome by hand is `dropdown.ts`, which borrows the row shape on
purpose and is deliberately not a menu.

Co-authored-by: Sculptor <sculptor@imbue.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	system/apps/chat/frontend/src/views/ModelBar.ts
#	system/apps/chat/frontend/src/views/ModelProviderMenu.redraw.test.ts
#	system/apps/chat/frontend/src/views/ModelProviderMenu.test.ts
#	system/apps/chat/frontend/src/views/ProviderChooserModal.ts
#	system/apps/chat/frontend/src/views/providerSignInStyles.ts
@weishi-imbue
weishi-imbue merged commit 3a3f6be into main Sep 22, 2026
2 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.

2 participants