Menus: one shared Menu and Dropdown across the workspace, the chat's model/provider menu on it - #577
Merged
Merged
Conversation
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>
…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>
# 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
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.
Every menu in the workspace is now one shared
Menu, the one dropdown is a sharedDropdown, and the chat's model card — renamedModelProviderMenu— 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.ts—createMenu(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(wasflyout-position.ts) —placeMenu(moved out of the sidebar; gainsaboveand anendalignment),placeSubmenu(first row aligned to the opening row, slides up only as far as it must),isInSafeTriangle. One gap, one margin.--z-popoverlayer above the modal overlays, so a menu or dropdown opened from inside a modal paints over it.--z-dropdownis gone.Menuhasdispose()and every owner calls it; and "New project" needs to show a failure under its rows, so an action row maykeepsOpen.Consumers
ModelBar→ModelProviderMenu(+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).Dropdown. Its private trigger/backdrop/option styles are gone.Menuas 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
titlereplaced 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:holdsSubmenuOpen): the chat card holds its submenu open over a rename mid-type, an armed "Remove?", the launch prompt and a typed model searchtrailingcontent sits beside the row's button rather than nested inside it: the switcher's pencil is a button of its own, legallyMENU_ROW_SLAB/MENU_ROW_FOCUSinstead of restating themTesting
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-formatincluded. 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 areliveness_test.py/routes_test.pysetup 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 forchat,system_interfaceanddev.