refactor(composer): drop the branch picker and rebuild the project picker on Selector - #2217
Merged
Conversation
The branch chip let a composer menu run `git checkout` against the user's real working tree. Three facts make that the wrong control: - It only existed before the first message. Both pickers are gated on `!activeSession`, and the workspace picker is the sole project entry point, so the branch menu was reachable exactly when the branch was least likely to be wrong — the user had just come from a terminal or an editor — and gone for the rest of the session, when switching is actually wanted. - It usually refused. `checkoutBranch` guarded a dirty worktree, which is the normal state of a repository someone is working in, so the common outcome was open menu → pick → failure toast. - The capability already lives where it belongs. The runtime has a shell, and subagents get isolated git worktree leases (`SubagentWorkspaceBinding`), whose contract already says the child may check out another branch inside its lease. Peer tools agree on the split: cloud agents (Jules, Codex, Devin, Claude Code cloud sessions) pick a branch because they clone one to build a sandbox from; local agents either use the branch you are on (Cursor's default) or create a worktree (Claude Code local sessions). None of them switches the branch under your editor. The current branch stays visible as read-only trigger context in the project chip's tooltip and accessible name. This removes the renderer state, the IPC pair, and the main-process git-branch module along with the UI, rather than leaving an uncalled channel behind for a use case that does not exist yet.
The project chip's lifetime never matched the row it sat in. It decides where a NEW chat starts and is fixed the moment the first message creates the session — `!activeSession` gates it, and the picker is the only entry point that sets a project, so nothing changes it afterwards. Everything else in the composer's footer control row persists for the whole session; the model beside it can change on every message. A control that vanishes on send breaks that row's implicit contract, and that mismatch, not the crowding, is what made the chip read as misplaced. The empty-chat hero has exactly the same lifetime, so the picker moves there: under the greeting, above the composer. Reading order now follows decision order — greeting, where, what — and the chip leaves with the surface it belongs to instead of disappearing out of a persistent toolbar. - `EmptyChatHero` grows a `workspaceSlot`; `ChatView` takes the picker and fills that slot only on the no-session branch. `emptyOverride` still wins over the whole hero, so the onboarding surface keeps showing no picker — the composer is hidden there anyway. - The menu opens 'below' now that the trigger sits high on the canvas. Opening 'above' would drop it into the empty upper canvas, which is the anchoring complaint that moved these controls in the first place. - Renamed to `WorkspacePicker` / `maka-workspace-picker` and moved its CSS from composer.css to hero.css, since neither the component nor its styles belong to the composer any more. The composer-side placement assertions are replaced by lifetime assertions on ChatView: rendered while the chat is a draft, gone once a session exists, and never shown under `emptyOverride`.
Picking a project is choosing a value — one selection out of a catalogue, with a current one — which is what `Selector` is for and what the model picker beside it already uses. This was a `DropdownMenu`, an action menu, and it could only be one because the catalogue shared a surface with the actions (add project, relink, no project). That mismatch was visible. Measured on the live app, the two dropdowns disagreed on weight (400 vs 500) and colour: menu rows resolved to `rgb(23, 23, 23)` against the model list's `oklch(0.17 0.005 286)` — two dropdowns from one library that did not read as relatives. The colour was the deeper of the two. Astryx's neutral theme sits on the app Theme wrapper, closer to these controls than Maka's palette on <html>, so `--color-text-primary` fell back to the neutral default; the rebinding that fixes it existed only on `.maka-model-selection-controls`. It now lives in astryx-mount.css as one rule listing each seam, rather than a copy per control that would drift on the next palette change. Both surfaces' option rows now report identical computed colour, weight, size and padding. The actions become ordinary options after a divider, following `ModelPicker`'s `leadingOption` precedent for product values that are not catalogue entries. Search turns on past eight projects — the height the old scroll region was cut to, and the point where scanning starts to cost more than typing. Two deliberate consequences, both noted at the code: - A long catalogue pushes the actions under the fold, where the old menu pinned them below a scrolling list. Switching is the common act and stays on top; search reaches the rest. - The trigger loses its tooltip — Selector has no such slot — so the current branch now rides only in the accessible name. It stays read-only context either way.
Astryx `Selector` defaults to a bordered field, which reads as an input asking to be filled in rather than as a chip carrying a current value. Alone on the hero, under the greeting, that box was the loudest thing on an otherwise calm surface — and it disagreed with the model picker in the composer below, which is the same component made quiet. That quieting already existed: model-switcher.css has a ghost-trigger rule covering the model and thinking selectors, with the geometry note explaining why it declares no height (Selector sizes off `--size-element-sm`, and forcing a height desyncs the chip from its inert `ModelChipStatic` twin). The workspace picker joins that selector list rather than getting a second copy of the same treatment. Measured on the live app: the trigger is 28px, the same height as the model chip in the composer — `size="sm"` derives it, nothing pins it. `md` was tried and rejected: it only adds 4px of padding, since Selector sizes do not change the label's type, so it reads as a looser box without reading as more legible. Width sizes to the project name (`max-content`) instead of holding a column the way the footer pair does, capped so a long name cannot span the greeting above it. Also drops the leading divider when there are no projects yet — on first run the menu opened with a rule above its first row, dividing nothing.
Two things the Codex picker gets right, taken as far as Astryx's own design language goes. Every row now carries an icon. `Add project` and `No project` had none, so their labels started at the icon column while the projects' labels started past it — one list on two text axes, which reads as broken rather than as a separate group. They take `+` and `×`, and the `×` also says what the row means: not using a project is a deliberate way to work, not a missing setting. Search is always on rather than appearing past eight projects. The threshold made the same control answer the keyboard differently on different days, as a catalogue crossed it; the model picker beside it has never had one. Not adopted, and noted at the code: Codex names that row "work without a project", which says the act instead of the empty value. Selector paints its trigger from the selected option's own label with no slot to shorten it, so that phrasing would also become the label sitting alone under the greeting — a seven-character negative sentence as the calmest surface in the product. The icon carries the same meaning at trigger length. Codex's search field also has a leading magnifier; Astryx renders that input itself, and reaching into it would be styling against the library rather than with it.
Add project and No project scrolled away with the catalogue: Astryx Selector scrolls its list as one region and has no footer slot, so a long catalogue pushed the two actions under the fold — the DropdownMenu this replaced kept them on screen by scrolling only the catalogue. Stick the two rows to the bottom of the list in CSS and paint their backdrop with the list's own ::after rather than with the rows: the rows' background carries Astryx's hover and keyboard-highlight tints, and an opaque colour on them would win the cascade and silently kill both states. The divider between the two actions goes — pinned against the scrolling catalogue they already read as one footer. Verified in Storybook against an 11-project catalogue: the footer holds at every scroll offset, hover and aria-activedescendant highlights still paint on it, and a search that filters both actions out leaves an ordinary scrolling list with no leftover backdrop.
On the empty-chat hero the picker floated in the gap between the greeting and the composer card, belonging to neither surface. Astryx ChatComposer has a header row above the input that nothing used; the picker goes there, and the row is passed only while the chat is a draft, so it still leaves the screen the moment the first message creates the session. This keeps what moving it off the footer bought — the footer's other controls persist for the whole session, and a chip that vanishes on send broke that row's contract — without floating the control on the canvas. The menu now opens upward, into the canvas the draft state leaves empty: downward it covered the input the user is about to type in, and on a short window it ran past the frame. The palette seam and the pinned-footer rules follow the picker to a wrapper class in composer.css. Verified in Storybook: the header row renders in the draft story and is absent from every active-session story, the menu opens above the trigger and stays inside the frame, and the pinned actions still hold.
The header row above the input kept the picker on the card, but grew the card by a whole row for the draft state alone — a layout jump on every new task. Put it back in the footer, at the end of the send-context group after the model and thinking pickers. The objection that moved it out of this row was that the row holds session-long controls, which a picker that vanishes on send would break. That was the wrong reading of the row: model, thinking level and permission mode are all parameters of the send about to happen, and so is the project. Last in the group is what makes the shorter life cheap — when the first message unmounts it, nothing to its left moves. The gate goes back to the composer, which already knows whether a session owns it, so the host passes the picker unconditionally as before. Verified in Storybook: the draft card is 94px again (122px with the header row), the menu still opens upward with its actions pinned, and no active-session story renders the picker.
Review found the pinned footer covering catalogue rows. The backdrop was a fixed 61px — two rows plus the divider and padding — but the actions are ordinary searchable options, so a query matching only one of them left a box 29px taller than what it held. It painted over the rows behind it and, being positioned, swallowed their clicks. Put the two actions in a title-less section instead. Selector renders that as one role="group", so the sticky box is as tall as whatever it contains and the whole construction collapses to two rules: stick the group, and draw its top border unless it is the list's first child. The divider option, the hardcoded 28px/4px metrics, the four :has() offset chains and the ::after backdrop all go — 47 lines of CSS for 40. The background stays off the option rows, which is what lets Astryx keep painting hover and keyboard-highlight on them, and the group carries the popover's bottom radius so the menu no longer squares its corners. Also restore the menu's width cap, which the DropdownMenu this replaced had and the rebuild dropped: Astryx gives the popover a min-width and no ceiling, so one long project name grew it past the window edge. Verified in Storybook across the states the old form failed in: one action matched (group 33px, catalogue clickable), both matched, projects only (no group), no project matched (group first, no border), keyboard highlight on a pinned row, and a 65-character name (menu capped at 320px). New contract test pins the group-not-rows, background-off-rows and width-cap properties; each was confirmed to fail when broken.
Astro-Han
force-pushed
the
feat/composer-header-context
branch
from
August 5, 2026 09:17
2c12d21 to
2cbd5f4
Compare
Astro-Han
marked this pull request as ready for review
August 5, 2026 09:23
sunheyi6
added a commit
to sunheyi6/maka-agent
that referenced
this pull request
Aug 5, 2026
…rn-steer Resolve composer.tsx import conflict between apache#1954 queued-input helpers and apache#2217 WorkspacePicker: keep both.
Astro-Han
added a commit
that referenced
this pull request
Aug 5, 2026
The pinned actions were sticky inside the menu panel, which is itself the scroller: when the wheel reached the catalogue's end, overscroll chained into the page behind the popover and dragged the whole inline-rendered panel — pinned actions included — with it. Restructure instead of re-pinning: the catalogue renders in its own scroll region (`.maka-workspace-picker-scroll`, the pre-Selector design from before #2217) and the two actions sit after it in normal flow, physically outside the scroller. The panel lifts Astryx's 300px cap (max-height: none, overflow: visible) so it can never scroll on its own; the region owns the height budget (224px of catalogue + the actions ≈ the old 300px ceiling) and `overscroll-behavior: contain` stops the wheel from chaining past its ends. The sticky rules, the group backdrop, and the padding restoration all die with the construction they propped up; the divider between catalogue and actions stays on the group's top border, dropped on first run when the group is the menu's first child. The CSS pinning contract test is rewritten to pin the new construction: exactly one scroller under the menu (the catalogue region, with the height budget), no sticky anywhere, no backdrop painted on the group or rows, and the window cap intact. Verified in Storybook: the actions' rect is constant while the region scrolls and after a wheel over it; keyboard roving reaches the actions (they were unreachable while the menu accidentally rendered two copies of the catalogue during this refactor — the single-copy tree roves maka-agent → … → 添加项目 → 无项目 and stops).
Astro-Han
added a commit
that referenced
this pull request
Aug 5, 2026
…nu family (#2287) * refactor(ui): move the workspace picker onto the composer's ghost-menu family The composer footer mixed two component families for one toolbar role after #2230 moved the model and thinking pickers onto ghost-button DropdownMenus: +/permission/model/thinking were ghost buttons, while the project picker stayed an Astryx Selector field disguised by ~60 lines of product CSS that cleared field chrome state by state. That is why the chip never matched its neighbours — Selector's own label weight and ink against the Button's, no tooltip (Selector has no such slot, so the current git branch rode in the accessible name alone), and a focus fill with no accent ring (#2230 kept the quiet Selector overlay until it got its own migration; this is that migration). The picker is now a chevronless ghost-button DropdownMenu like the model chip beside it, so resting, hover, focus, disabled and tooltip chrome all derive from one Button: - Trigger: FolderOpen icon + current project (or 无项目), tooltip with the current branch (`选择项目 · <branch>`), accessible name unchanged. - Rows: project list with the current value's plain check and the relink status on unavailable projects, then 添加项目 / 无项目 pinned to the menu's bottom edge as one role=group box (the CSS pinning contract moves from [role=listbox] to [role=menu]). - Search dies with the Selector, matching the model menu's precedent: the panel keeps its 300px scroll and Astryx first-character typeahead. - Pending (switch in flight) locks the trigger with a spinner and every row, like the model switcher. Also removes the now-dead Selector overlay rules and the searchPlaceholder copy; the palette seam comment now covers popovers, not just value controls. * test(storybook): cover the project picker's pending state The NewChatComposer story covers the picker's resting state and its menu; nothing covered `pending: true` — the trigger locks with a spinner and every row disables, matching the model switcher's mid-switch treatment. Add a variant that passes `workspacePicker.pending`. * fix(ui): confine the project menu's scroll to the catalogue rows The pinned actions were sticky inside the menu panel, which is itself the scroller: when the wheel reached the catalogue's end, overscroll chained into the page behind the popover and dragged the whole inline-rendered panel — pinned actions included — with it. Restructure instead of re-pinning: the catalogue renders in its own scroll region (`.maka-workspace-picker-scroll`, the pre-Selector design from before #2217) and the two actions sit after it in normal flow, physically outside the scroller. The panel lifts Astryx's 300px cap (max-height: none, overflow: visible) so it can never scroll on its own; the region owns the height budget (224px of catalogue + the actions ≈ the old 300px ceiling) and `overscroll-behavior: contain` stops the wheel from chaining past its ends. The sticky rules, the group backdrop, and the padding restoration all die with the construction they propped up; the divider between catalogue and actions stays on the group's top border, dropped on first run when the group is the menu's first child. The CSS pinning contract test is rewritten to pin the new construction: exactly one scroller under the menu (the catalogue region, with the height budget), no sticky anywhere, no backdrop painted on the group or rows, and the window cap intact. Verified in Storybook: the actions' rect is constant while the region scrolls and after a wheel over it; keyboard roving reaches the actions (they were unreachable while the menu accidentally rendered two copies of the catalogue during this refactor — the single-copy tree roves maka-agent → … → 添加项目 → 无项目 and stops). * test(ui): pin the picker's menu structure, lock, and load-bearing CSS Follow-up from two external reviews of #2287: the behaviors that mattered most were verified manually, not by tests. Pin them. - SSR structure tests: the catalogue renders exactly once, inside its own scroll region with the actions after it (the mid-PR duplicate-catalogue regression that made keyboard roving unreachable would now go red); the unavailable project's relink row; the current-value check on the selected project and on the no-project row; first run renders no scroll region at all. - SSR lock test mirroring chat-model-switcher: pending locks the trigger (aria-disabled + aria-busy) and every menu row — an aria-disabled trigger still opens on ArrowDown, so the lock must ride on the items. Reverse at-rest assertion included. - CSS contract: the panel "may lift" the 300px cap is now "must lift" (max-height: none + overflow: visible are required — Astryx's cap comes with overflow-y: auto, which scrolls the actions with the panel again), the scroller must declare overscroll-behavior: contain (the chaining fix), and the :not(:first-child) divider rule is pinned. Mutation check: dropping overscroll-behavior: contain now fails the contract (was green before). Also trims what the design review found: the stale header doc (panel "keeps its 300px scroll" — it no longer scrolls at all; typeahead is now described accurately for CJK labels), Check aligned to the family's size 14, the dead .maka-workspace-picker-actions class, and the ::-webkit-scrollbar block that standard scrollbar-color/width override in Chromium 121+.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The new-chat draft state carried two pickers in the composer footer — project and git branch. This reduces that to one control and rebuilds it on the design system.
SubagentWorkspaceBinding. The main-process git branch IPC goes with it; the current branch stays as read-only trigger context.Selector, the same primitive as the model picker. It was aDropdownMenu— an action menu — which showed: its rows came out at weight 400 in#171717beside the model list's weight 500 in the theme foreground. The palette seam that caused it now lists each Astryx value-control mount in one rule. The trigger is a quiet ghost chip at the model chip's 28px, not a bordered field.添加项目/无项目pinned to the bottom edge while only the catalogue scrolls.Selectorhas no footer slot, so the two actions ride in a title-less section — onerole="group"— and CSS sticks that box. Its height follows its contents, so a search matching one action pins one row; the background stays on the group so Astryx keeps painting hover and keyboard-highlight on the rows.Reviewed by four independent passes (first principles, CSS/cascade, test quality, dead code). Their surviving findings are fixed in the last commit: the pinned-footer backdrop covered catalogue rows whenever a search matched only one action, the rebuild had dropped the old menu's width cap, and several comments still described placements this branch had abandoned.
Refs #1044.
Verification
@maka/uitests 323 passed;@maka/ui+@maka/desktoptypecheck;lint;format:check;check-dead-css;check:release.product-shell-official-appshell--new-chat-composer), against an 11-project catalogue: the pinned actions hold at every scroll offset; hover andaria-activedescendanthighlights still paint on them; searches that match both actions, one action, only projects, or no project each behave; a 65-character name leaves the menu capped at 320px; and no active-session story renders the picker.workspace-picker-menu-contract.test.tspins the three properties that fail silently — the sticky box is the group and not a row, no background lands on an option row, and the menu keeps a width cap. Each assertion was confirmed to fail when the rule it guards is broken.mainand untouched by this PR: 15 E2E specs, and 12 main-process project-root tests that assert on tmpdir paths.