Skip to content

refactor(composer): drop the branch picker and rebuild the project picker on Selector - #2217

Merged
Astro-Han merged 9 commits into
mainfrom
feat/composer-header-context
Aug 5, 2026
Merged

refactor(composer): drop the branch picker and rebuild the project picker on Selector#2217
Astro-Han merged 9 commits into
mainfrom
feat/composer-header-context

Conversation

@Astro-Han

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

Copy link
Copy Markdown
Contributor

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.

  • Branch picker removed. It only existed before the first message, refused to act on a dirty worktree, and duplicated what the agent already does through SubagentWorkspaceBinding. The main-process git branch IPC goes with it; the current branch stays as read-only trigger context.
  • Project picker rebuilt on Astryx Selector, the same primitive as the model picker. It was a DropdownMenu — an action menu — which showed: its rows came out at weight 400 in #171717 beside 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.
  • Menu grammar borrowed from Codex: every row on one text axis, search always on, and 添加项目 / 无项目 pinned to the bottom edge while only the catalogue scrolls. Selector has no footer slot, so the two actions ride in a title-less section — one role="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.
  • Placement: unchanged in the end. The hero canvas and a composer header row were both tried; the hero floated the control between the greeting and the card, and the header row grew the card from 94px to 122px for the draft state alone. It stays at the end of the footer's send-context group, after the model — the project is a parameter of this send like the model is, and last in the group means nothing moves when the first message unmounts it.

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/ui tests 323 passed; @maka/ui + @maka/desktop typecheck; lint; format:check; check-dead-css; check:release.
  • Storybook (product-shell-official-appshell--new-chat-composer), against an 11-project catalogue: the pinned actions hold at every scroll offset; hover and aria-activedescendant highlights 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.ts pins 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.
  • Palette seam verified with CDP computed styles, not by eye: the picker's and the model list's option rows report identical colour, weight, size and padding.
  • Pre-existing failures, confirmed on main and untouched by this PR: 15 E2E specs, and 12 main-process project-root tests that assert on tmpdir paths.

@Astro-Han Astro-Han changed the title refactor(composer): give the workspace picker a home that matches its lifetime refactor(composer): drop the branch picker and rebuild the project picker on Selector Aug 5, 2026
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
Astro-Han force-pushed the feat/composer-header-context branch from 2c12d21 to 2cbd5f4 Compare August 5, 2026 09:17
@Astro-Han
Astro-Han marked this pull request as ready for review August 5, 2026 09:23
@Astro-Han
Astro-Han merged commit e3c2b9b into main Aug 5, 2026
12 checks passed
@Astro-Han
Astro-Han deleted the feat/composer-header-context branch August 5, 2026 09:24
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+.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant