Skip to content

fix(ui): read session modes off the composer footer, not the context drawer - #1910

Merged
Astro-Han merged 3 commits into
mainfrom
fix/composer-mode-footer-state
Aug 2, 2026
Merged

fix(ui): read session modes off the composer footer, not the context drawer#1910
Astro-Han merged 3 commits into
mainfrom
fix/composer-mode-footer-state

Conversation

@Astro-Han

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

Copy link
Copy Markdown
Contributor

Summary

The composer drawer's contract is context staged for the next send. Quotes, attachments and Skills are consumed when the message goes out, and drawerTokenCount tells the user how many such items are pending. Plan, Swarm and Graph are session-scoped modes that survive the send, so rendering them there inflated that count with things the send never consumes, and gave a persistent mode the visual weight of a transient attachment. Turning Plan on with nothing staged also mounted the whole drawer, growing the card by ~200px to hold one pill.

The three mode marks move to the tail of the footer's left controls, after the model and thinking pickers. Placing them last is the point: a mode turning on or off never nudges those two, which it would if the marks sat before them.

Once they landed next to + and permission, the colours they had inherited stopped being defensible, so the second commit fixes them too. blue / teal / cyan came from Astryx's stock Token palette — makaTheme.ts maps nothing onto it — while DESIGN.md states that Maka blue is the single product accent and lists "don't introduce another accent" and "don't turn every status into a coloured pill" under Don't. The hues were not carrying semantics either: the palette's meanings are info / success / warning / destructive, and "which mode this is" is not among them. Teal and cyan were a mode index, and they are close enough that Swarm and Graph were hard to separate at icon size.

So the marks are now ghost IconButtons — the same primitive as the + and permission buttons they sit beside — tinted with --accent over a currentColor-derived wash, so a disabled mark dims both layers together. Which mode a mark is comes from its icon. Clicking one leaves the mode, and its tooltip says so; a disabled reason replaces that tooltip and takes the button out of reach.

The + menu is unchanged and remains the switch that turns each mode on and off. The footer mark is the resting readout plus one nearby exit — the same reading as the permission icon two slots to its left.

Two judgement calls worth naming:

  • Graph moves with Plan and Swarm. The issue names the first two, but all three render from one block and share the same miscount; leaving Graph in the drawer would be a new inconsistency rather than a smaller diff.
  • The marks stay visible while streaming. The model pickers hide entirely mid-turn, but a session mode is exactly what a user needs to see while output is running. planModeDisabledReason and friends already disable the button and surface their reason in its tooltip, so nothing becomes actionable that shouldn't be.

One correction to the issue: it says onRemove is the only way to leave a mode from the composer. The + menu's checkbox items already toggle both directions, so moving the mark never risked stranding a user in a mode.

The Skill token beside these still uses purple, which is off-palette for the same reason. That predates this PR and is left alone here; worth its own issue.

Closes #1897

Verification

Before/after and all four colour candidates were captured from the built app driven through Playwright _electron on the fake backend, with the modes toggled through the real + menu, and composed with ImageMagick. Before: Plan + Swarm stack as full pills in the drawer, pushing the card ~200px taller. After: two accent-tinted icon buttons sit after Claude Sonnet 4.5 / 默认, card height unchanged, tooltip reading Plan 模式已启用,点击关闭.

  • npm run lint / format:check — clean
  • typecheck (@maka/desktop, all four projects) — clean
  • node scripts/check-dead-css.mjs --check — clean
  • @maka/ui unit — 240 pass, 0 fail
  • @maka/desktop unit — 1339 pass, 0 fail
  • desktop E2E — 85/85 (macOS), run again after the colour commit

Contract locks added to composer-quiet-chrome.test.tsx:

lock what it holds
modes render inside maka-composer-left-controls, at an index after maka-model-selection-controls the marks trail the model pair, so toggling never shifts it
the Plan mark carries lucide-list-todo, aria-label="Plan", and maka-composer-mode-button identified by icon, named for the mode, still the way out
the Plan mark carries no astryx-token a mode is not a coloured pill
modes alone mount no maka-composer-context-drawer a mode is not staged context
one attachment + two modes on → drawer badge reads 1 the count excludes modes

conversation-localization.test.tsx keeps its existing coverage — localized tooltips per mode, and the streaming case where the mark stays visible while its button goes inert — retargeted at the new position and primitive.

No new E2E: this is a renderer-only move, and the one E2E that reads the drawer (quote-companion) covers quotes, which still live there.

Third commit: what three-way review found

A subagent, Codex and kimi-coding/k3-256k reviewed this branch independently. Each found something the other two missed, and k3's mutation testing showed two of the contracts here were theatre. All findings were adjudicated against the code before any edit; the ones that survived:

The marks had no hover or press feedback. background: is a shorthand, so it carried an implicit background-image: none, and composer.css sits in the last cascade layer — that beat the ghost button's own :hover / :active overlay gradients from astryx-components. Measured through CDP rather than inferred: the mark resolved background-image: none on hover where the + beside it resolved a gradient, making the one control that leaves a mode the only dead one on the row. Now background-color, and so is the voice button, which is where the pattern was copied from and which carried the same bug. k3 argued this was not a finding because it matched the voice button's existing pattern; that the same bug exists elsewhere does not make it not a bug, so both are fixed.

Leaving a mode dropped focus on document.body, because the click unmounts the button. Removing a Skill token 200 lines up already hands focus back to the input; the modes now do the same.

An active mode with no change handler rendered a focusable, tooltipped button whose click was a silent no-op. Gated on the handler, as the + menu's row already was.

The marks were 13px where every sibling ghost icon on this toolbar — +, permission, mic, and the same three glyphs inside the + menu — is 15px.

Two locks did not lock anything. Mutation-verified: deleting the accent rule outright left 240 unit tests, 1339 desktop tests and the dead-CSS gate green; deleting the deactivate handler was equally invisible, because a static render is byte-identical with and without it. The accent rule is now pinned in the existing CSS text-contract seam (chat-shell-layout-contract.test.ts) together with a ban on the background shorthand for both marks, and "removal stays available" — the one thing #1897 asks for — is covered by one representative E2E journey over pointer, keyboard and the focus handoff.

The remaining assertions were scoped to the document rather than to the thing they name. The streaming case now reads the mark's own <button> and follows its aria-describedby to the reason; the badge matches its own text node; the drawer slice is bounded by the next footer landmark instead of the first </div>. Graph joins Plan and Swarm in the position and count cases.

Also simplified: the wrapper <span> is gone — data-mode rides the button, as the voice button's data-state already does — and activeModes folds back into one filter, removing the pending field and a whole spread-map pass.

Pushed back on two: aria-pressed does not apply (the mark only switches off, it never switches on), and renaming the accessible name to "退出 Plan Mode" is unnecessary since the action is carried by the tooltip while the label says what the control is.

Mutation evidence for the new locks

mutation result
delete the .maka-composer-mode-button rule CSS contract fails ✓
swap background-color back to the background shorthand CSS contract fails ✓
delete mode.onDeactivate() from the click E2E fails ✓
delete the requestAnimationFrame(focusInput) handoff E2E fails ✓
drop the handler gate on active unit contract fails ✓

Final: lint / format:check / typecheck / dead-css clean, @maka/ui 241, @maka/desktop 1340, E2E 86/86.

Review focus

Stories for the four states are in app-shell.stories.tsx (PlanModeOn, SwarmModeOn, PlanAndSwarmModeOn, ModeOnWithPendingAttachments). The last one is the interesting one: it is the split the issue is about — the badge counts the two attachments, Plan reads off the footer.

…drawer

The composer drawer's contract is context staged for the next send: quotes,
attachments and Skills are consumed when the message goes out, and its badge
counts how many such items are pending. Plan, Swarm and Graph are
session-scoped modes that survive the send, so rendering them there inflated
that count with things the send never consumes and gave a persistent mode the
visual weight of a transient attachment.

Move the three mode marks to the tail of the footer's left controls, after the
model and thinking pickers, so toggling a mode never shifts those two. They are
icon-only Astryx Tokens now — the label stays as the accessible name and
Astryx's own remove button is the way out — and the disabled reasons keep
surfacing on the wrapper's title. The + menu remains the switch that turns each
mode on and off; the footer mark is the resting readout plus one nearby exit.

The issue names Plan and Swarm; Graph shares the same block and the same
miscount, so it moves with them.

Closes #1897
The three marks inherited blue / teal / cyan from the drawer Tokens they
replaced. Those hues are Astryx stock palette — makaTheme.ts maps nothing onto
Astryx's Token colours — and DESIGN.md is explicit that Maka blue is the single
product accent and that another accent must not be introduced. Nor were the
hues carrying semantics: the palette's meanings are info / success / warning /
destructive, and none of them is "which mode this is". Teal and cyan were
serving as a mode index, and they sit close enough that Swarm and Graph were
hard to tell apart at icon size anyway.

Which mode a mark is now comes from its icon. All three share the one accent,
which is what the colour is for: this is an active state.

Moving them out of the drawer also moved them next to + and permission, where a
coloured pill read as a foreign object on a row of ghost icon buttons — and
"don't turn every status into a coloured pill" is on the same Don't list. They
are ghost IconButtons now, the same primitive as their two neighbours, tinted
with --accent over a currentColor-derived wash so a disabled mark dims both
layers together. Clicking one leaves the mode; the tooltip says so, and a
disabled reason replaces it and takes the button out of reach.

Refs #1897
…eal locks

Three-way review (subagent, Codex, k3-256k) found defects each of the other two
missed, and mutation testing proved two contracts this branch claimed were not
locked at all. Adjudicated and fixed at the owner:

The marks had no hover or press feedback. `background:` is a shorthand, so it
carried an implicit `background-image: none`, and composer.css sits in the last
cascade layer — that beat the ghost button's own :hover / :active overlay
gradients from `astryx-components`. Measured, not inferred: the mark resolved
`background-image: none` on hover where the + beside it resolved a gradient, so
the one control that leaves a mode was the only dead one on the row. It is
`background-color` now, and so is the voice button, which is where the pattern
was copied from and which had the same bug.

Leaving a mode dropped focus on `document.body`, because the click unmounts the
button. Removing a Skill token 200 lines up already hands focus back to the
input; the modes now do the same.

An active mode with no change handler rendered a focusable, tooltipped button
whose click was a silent no-op. It is gated on the handler, as the + menu's
row already was.

The marks were 13px where every sibling ghost icon on the toolbar — +,
permission, mic, and the same three glyphs in the + menu — is 15px.

On the locks, mutation testing showed two were theatre. Deleting the accent rule
outright left 240 unit tests, 1339 desktop tests and the dead-CSS gate green, so
the rule is now pinned in the existing CSS text-contract seam, along with a ban
on the `background` shorthand for both marks. Deleting the deactivate handler
was equally invisible: a static render is byte-identical with and without it, so
"removal stays available" — the one thing #1897 asks for — is now one
representative E2E journey covering pointer, keyboard, and the focus handoff.

The remaining assertions were scoped to the thing they name rather than to the
rest of the document: the streaming case reads the mark's own `<button>` and
follows its `aria-describedby` to the reason, the badge matches its own text
node, and the drawer slice is bounded by the next footer landmark instead of the
first `</div>`. Graph joins Plan and Swarm in the position and count cases.

Also drops the wrapper `<span>` — `data-mode` rides the button, as the voice
button's `data-state` already does — and folds `activeModes` back into one
filter, which removes the `pending` field and a whole spread-map pass.

Refs #1897
@Astro-Han Astro-Han added the bug Something isn't working label Aug 2, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 12:06
@Astro-Han
Astro-Han merged commit 4a82e58 into main Aug 2, 2026
5 checks passed
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Opening + → Skills mounted a fully transparent panel: its header, rows and
status text overlapped the composer placeholder and the transcript behind it,
with no bounds visible.

`packages/ui/src/styles.css` built that panel's surface from `--popover`,
`--popover-foreground` and `--shadow-maka-panel` — three custom properties
nothing in the repo defines and none with a `var()` fallback. All three
declarations dropped at computed-value time, so the panel kept its stacking and
radius from the two tokens that do exist and lost its fill. (The same rule named
`.maka-composer-mention-panel`, which has had no consumer since `/` moved to the
Astryx trigger menu.)

The panel could only lose its background because it was the one composer float
that owned a surface at all. But the deeper problem is that it existed: `/` and
+ produced the same draft chip through two different UIs — an Astryx trigger
menu on one side, a hand-rolled multi-select panel on the other — so they never
looked alike and the product owned a popover surface standing beside Astryx's.

So + now opens the `/` menu by typing the trigger for the user, and
`ComposerSkillPicker` is deleted: 238 lines, every `.maka-composer-skill-panel*`
and `.maka-composer-skill-trigger*` rule in two CSS files, and nine copy keys
that only it read. One menu, one presentation, one contract.

`useTriggerMenu` recognizes `/` only at a line start or after a space or
newline, so + inserts the space itself when the draft ends in a word — or in a
chip, which `insertToken` anchors with U+00A0. Both characters go in one
`insertText`, so the editor sees a single input event and a single undo step.
The caret is moved to the end first: the menu round trip leaves a stale
selection collapsed at offset 0, and measured, the slash landed in front of the
draft rather than after it.

Dropped with the panel: checkbox multi-select (`/` twice selects two Skills) and
"select all", which contradicted the panel's own hint that leaving the selection
empty lets Maka match Skills automatically.

Also on this surface: staged-Skill chips rendered as `<Token color="purple">`, a
hue `makaTheme.ts` maps nothing onto and one `DESIGN.md` puts on its Don't list.
They draw in the neutral Token now, identified by a sparkle, as #1910 did for
the mode marks.

Closes #1912
Astro-Han added a commit that referenced this pull request Aug 2, 2026
…hips (#1915)

* fix(ui): open the `/` Skill menu from + instead of a second panel

Opening + → Skills mounted a fully transparent panel: its header, rows and
status text overlapped the composer placeholder and the transcript behind it,
with no bounds visible.

`packages/ui/src/styles.css` built that panel's surface from `--popover`,
`--popover-foreground` and `--shadow-maka-panel` — three custom properties
nothing in the repo defines and none with a `var()` fallback. All three
declarations dropped at computed-value time, so the panel kept its stacking and
radius from the two tokens that do exist and lost its fill. (The same rule named
`.maka-composer-mention-panel`, which has had no consumer since `/` moved to the
Astryx trigger menu.)

The panel could only lose its background because it was the one composer float
that owned a surface at all. But the deeper problem is that it existed: `/` and
+ produced the same draft chip through two different UIs — an Astryx trigger
menu on one side, a hand-rolled multi-select panel on the other — so they never
looked alike and the product owned a popover surface standing beside Astryx's.

So + now opens the `/` menu by typing the trigger for the user, and
`ComposerSkillPicker` is deleted: 238 lines, every `.maka-composer-skill-panel*`
and `.maka-composer-skill-trigger*` rule in two CSS files, and nine copy keys
that only it read. One menu, one presentation, one contract.

`useTriggerMenu` recognizes `/` only at a line start or after a space or
newline, so + inserts the space itself when the draft ends in a word — or in a
chip, which `insertToken` anchors with U+00A0. Both characters go in one
`insertText`, so the editor sees a single input event and a single undo step.
The caret is moved to the end first: the menu round trip leaves a stale
selection collapsed at offset 0, and measured, the slash landed in front of the
draft rather than after it.

Dropped with the panel: checkbox multi-select (`/` twice selects two Skills) and
"select all", which contradicted the panel's own hint that leaving the selection
empty lets Maka match Skills automatically.

Also on this surface: staged-Skill chips rendered as `<Token color="purple">`, a
hue `makaTheme.ts` maps nothing onto and one `DESIGN.md` puts on its Don't list.
They draw in the neutral Token now, identified by a sparkle, as #1910 did for
the mode marks.

Closes #1912

* fix(ui): keep the + Skills entry out of reach when no Skill exists

The entry types `/` into the draft, so an empty catalog turned one click into
three surprises: a stray slash in the user's draft, an empty "no skills
available" menu, and — because that menu is a light-dismiss popover — the next
click anywhere on the composer footer swallowed as a dismissal. Reported as the
permission and + buttons "not responding".

Measured, not inferred. `elementFromPoint` at both buttons still resolved inside
their own SVG, and pointerdown/mousedown/click all arrived with
defaultPrevented=false, which ruled out the overlay explanation first suspected.
The empty menu on the popover stack was what consumed the click.

Disable the entry when the catalog is empty, and say why on the row itself. The
reason uses Astryx's `description` prop rather than `aria-description`: the
latter never reaches the DOM through DropdownMenuItem (the assertion caught it),
and a grey row whose reason only assistive tech can read still answers nothing
for the user looking at it. The panel this replaced showed the same sentence.

* refactor(ui): stage Skills as inline chips in the draft

A Skill chosen from `/` is now an inline `ChatComposerToken` in the input,
which is Astryx's own contract for the trigger menu (`onSelect` returning a
token) and already how `@` file mentions work here.

It used to be a selection held beside the draft and shown in the context
drawer. That made one fact — "this send invokes this Skill" — into two sources
of truth, and every draft operation grew a Skill-shaped twin: per-session
persistence, prompt history, blocked-send recovery and revision rollback each
carried and restored the selection separately. `useComposerSkillDraft`, three
`ComposerHandle` methods, `TurnRevisionDraft.previousComposerSkills` and the
`skillIds` argument threaded from the composer through the shell all existed
only to keep the twin in step.

The chip serializes to `/skill:<id>` — the invocation grammar Runtime already
parses and the TUI already submits — so the text carries it and all of that
goes away. Sending is `onSend(text)`.

By id rather than the scope-aware ref: the ref cannot be spelled in this
grammar, ids are unique within a scan, and across the gap between choosing and
sending a ref is no less stale than an id. A draft rewritten from outside
(session switch, history recall, revision rollback) comes back with the token
as plain text rather than as a chip — same text, same send, and the same
degradation `@` chips already have.

* fix(ui): stop the composer's `/` menu at a readable width

`useTriggerMenu` gives its popover a 180px floor and no ceiling, so the menu
sizes to its widest row — and our rows carry a second line (`<id> ·
<description>` for a Skill, the full path for a file) that never wraps. A Skill
whose description is a sentence stretched the menu past the composer it is
anchored to and on toward the window edge.

Cap it at 420px, the width the model switcher on the same footer already
chose, so the two floats over that footer agree. The row's two lines already
carry `overflow: hidden` + `text-overflow: ellipsis` and were only waiting for
a constraint to act on. `.astryx-trigger-menu` is the component's documented
theming hook, not an internal class.

The `user-only` E2E fixture now carries a sentence-length description, which
is what the new journey measures: menu narrower than the composer, and the
second line overflowing its own box rather than widening it.

* fix(ui): give the `/` menu's second line to the description

The row read `<id> · <description>` on one non-wrapping line, so the id spent
a dozen characters of the only line that tells two Skills apart — on a string
nobody types here, since the menu is how you avoid typing it. The id stays
searchable and is still what the chip serializes to; it just leaves the row.

Kept visible rather than moved to a tooltip: every menu item in Astryx —
dropdown, radio, checkbox, submenu — carries its explanation as a
`description` line, and none carries one on hover. A `/` menu is driven with
↑↓, so a hover-only description would be invisible to the way it is used.

* refactor(core): keep one copy of the /skill: token grammar

Runtime and the TUI each carried a byte-identical `SKILL_INVOCATION_TOKEN_SOURCE`,
which held only because nobody edited one of them. The renderer needs the same
grammar to render a draft, and adding a third copy is not an option, so the
string moves to core — which Runtime, the TUI and the UI package all already
depend on — and both existing sites import it.

* fix(ui): redraw staged Skill chips after a draft is restored

`ChatComposerInput` rebuilds the editor from the string on every external value
change, which is right for text and lossy for tokens: the chip spans go and the
draft comes back as the `/skill:<id>` text they serialize to. Upstream declares
a `deserialize` hook for exactly this and never calls it (facebook/astryx #4655).

The draft still sends the Skill, but the user is looking at an internal id where
they left a chip, and the path is the ordinary one: any session that ever staged
a Skill hits it on the way back. Staging a Skill as a chip is only worth doing if
the chip is still there afterwards.

So redraw them. Nothing is recovered that was not already in the string — the
draft stays the single source of truth and only its rendering is restored, which
is what keeps this deletable in one piece when upstream deserializes.

Gated three ways: only for a write that came through `textPort` (typing clears
the flag, so a half-typed `/skill:` never seizes into a chip under the caret),
only on the single-text-node DOM that write produces, and never mid-composition.
The flag survives a render because the two inputs do not arrive together: a
session switch swaps the draft immediately and the Skill catalog for the new
session lands a render or two later. A token whose id is not in that catalog
stays text — no chip should promise a Skill that will not resolve.

* fix(ui): retry a pending chip redraw instead of consuming it

`redrawSkillTokens` was called behind a `mentionSkills.length` pre-gate and its
flag cleared whether or not it succeeded, so every transient bail — the catalog
still in flight, an active IME composition, a DOM shape not yet settled — lost
the redraw for good. Clearing the flag only on success drops the pre-gate, makes
each of those a retry, and leaves the one permanent case (a token whose id is
not in the catalog) exactly as it was.

`applyText` now clears `caretToEndRef` alongside it. Both are armed by
`textPort.setValue` and re-armed right after, but a write React bails out of —
the new draft equals the old one, so no commit and no effect — left them armed
until some unrelated later render, where the caret jumped to the end mid-word.

* test: cut the Skill E2E back to what only a window can show

Four of the eleven journeys in `composer-skill-invocation.spec.ts` were paying
an Electron boot for something cheaper:

- the Backspace-away-from-the-chip test guarded Maka's own `isCaretAtContentStart`
  until this branch deleted it; what is left is `useChatComposerTokens`, vendored;
- the menu-width test read a `max-width` back out of CSS through a live window,
  and its `menuWidth < composerWidth` arm turned on the window size rather than
  on the rule. The fixture description lengthened to feed it goes back too;
- the select-from-slash test's unique half is "picking produces a chip that
  serializes to `/skill:<id>`", which `chip-only send` already asserts and then
  sends;
- the empty-catalog entry test is a string assertion on rendered markup, and
  moves to the Composer SSR test that already renders the + menu.

What is left grows instead: the restore journey now stages two Skills with one
of them mid-draft, which is the only thing that exercises the redraw's
back-to-front walk, and sends afterwards, which is the only thing that proves
the U+00A0 anchors survive `composerWireText`.

The + journey keeps its trigger-boundary halves and loses the three
`waitForTimeout(300)` sleeps: Astryx's post-dismiss click guard has no
observable end, so retry the click until + answers rather than sleep past it.

Also gone: `PINNED_OFF_RULER`, empty since the count badge left, along with its
loop and its explanation of a future exception; two `doesNotMatch` assertions on
a class this branch deleted, which can no longer fail; and comments describing
the `/` menu's second line as `<id> · <description>` after it became the
description alone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ui): move the Plan and Swarm mode tokens out of the composer context drawer

1 participant