From c67410a787409e8d356294efead2f59e2a286af4 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:34:55 -0700 Subject: [PATCH] Stop and send buttons on the chat dictation bar (#7683) * feat(studio): stop and send buttons on the dictation bar Replace the check/cross pair in the chat recording bar with a stop button and the normal send button. - Stop ends the recording and leaves the transcript in the composer - Send transcribes and submits the message in one action - Escape discards a recording without transcribing * fix(studio): grey stop button and roomier dictation bar spacing - Neutral grey in both themes; --secondary read as brand green on light and blended into --card on dark - Wider gap between stop and send, more padding around the waveform * fix(studio): skip dictation Escape when a dialog claimed it Drops preventDefault and bails on defaultPrevented so Escape closing an open overlay no longer also cancels the recording. * fix(studio): rebalance dictation bar spacing Tighten the gap between the waveform and the timer, and push the timer further from the stop button. * fix(studio): tighten recording buttons, enlarge dictate icon * fix(studio): nudge dictate icon up to 24px * fix(studio): back dictate icon down to 23px * revert(studio): keep dictate icon at 20px * fix(studio): nudge dictate icon to 21px * fix(studio): set dictate icon to 22px * fix(studio): address review on dictation send - Empty transcript no longer submits a pre-recording draft; the send compares against the text at session start, so a final result matching the browser engine's interim still counts as new - Disable dictation send while an attachment uploads, matching the regular send, which handleSubmit would otherwise reject silently - Cover the send decision with unit tests * fix(studio): enlarge composer mic and send controls, tuck send into the corner The mic and send arrow are filled glyphs that span their whole viewBox, so at the shared --ui-icon-size they read smaller than the stroked icons beside them. Both now opt out of that size and stay slightly above it, still tracking the UI font scale. Send, queue, cancel and dictation stop circles go from 32px to 36px, matching the plus button, and the send circle now sits 10px from the right and bottom edges so it tucks evenly into the composer's 32px corner. Compare gets the same treatment. Also drops two comments describing the discard button this branch removed. * fix(studio): give the send circle a 12px corner gap Gemini-style breathing room rather than the tighter tuck: the circle sits 12px from the right and bottom edges in both composers, still even on both axes inside the 32px corner. * fix(studio): restore the composer plus to its pre-#7400 size #7400 folded every glyph with a 16px or larger base into --ui-icon-size, which took the composer plus from 22px down to 16px at the default UI font size. It now renders at 1.375x the token, which is the same curve a 22px base follows, so the size is back where it was and only its scaling changed. * fix(studio): round the icon button hovers and tidy temporary chat Hover backgrounds on the sidebar search and collapse buttons and on the chat header's temporary chat, research and run settings buttons are now circles. The sidebar pair squares off at 28px, its previous width, so the spacing between them does not move. The header group also loses 4px of gap: gap-1 plus the 2px nudge that pushed the run settings button right, leaving 4px between the circles. Temporary chat drops two redundant markers: the greeting mascot, so the title stands alone, and the green Temporary pill, since the header toggle already turns green and the welcome copy explains the mode. * fix(studio): tighten the dictation send gate and skip partial transcripts Send was only gated on disabled and pending attachments, so it stayed live in states handleSubmit rejects: an active research run, and a running response or prompt queue holding an attachment, pending audio or an image overlay. The intent was already spent by then, so the click quietly acted as stop. It now covers each of those, leaving out the text check since the transcript supplies it. Both engines can also end holding a partial transcript, the browser one through a recognition error and the local model one when a segment fails to transcribe. That text still belongs in the composer, but send no longer fires on it. The adapters flag the failure and the recording bar reads it after the session is gone. * fix(studio): scope the dictation send to its own thread and outcome The composer is reused across thread switches, so a send pressed in one thread could land after a move to another and submit that thread's draft. The pending send now records the composer it started in and drops the intent when it no longer matches. The failure flag moves off reportTranscriptionError, which is also the catch for the speculative model preload: that can fail transiently without costing any audio, and a complete transcript would then refuse to send. Only a lost segment marks the outcome partial now. Escape stays live while transcribing. Cancel aborts the in-flight request and both buttons are disabled, so it is the only way out of a stalled one. * fix(studio): key the pending dictation send on the thread list item referenceThreadId moves from null to the remote id when a new chat first persists, so the composite identity changed under the same composer and dropped a perfectly good send. The thread list item id does not move: the adapter initializes with the id the runtime passes in and returns it as the remote id, so both sides are the same string. * fix(studio): keep the branch chevrons on size-[36px] Renaming the send circles to size-9 also caught the two branch picker chevrons, which are glyphs, not buttons. index.css scales svg.size-[36px] through the 36px curve and has no size-9 selector, so they froze at 36px while the counter beside them kept tracking the UI font size. Measured against the built stylesheet: at scale 1.25 the class renders 40.5px and size-9 renders 36px, at 0.75 it is 27px against the same 36px. Every other size-9 on this branch is a button, which the glyph rules do not touch. * fix(studio): gate the dictation send on a published transcript Comparing composer text could not tell speech from an edit: the plus menu stays open while recording, so inserting a saved prompt looked like dictated text and a silent recording would submit it. Both engines publish exactly one final transcript, each behind a guard, so they now record that directly and the send requires it. The text comparison stays as well, so a transcript that never reached this composer still cannot send the draft that was already there. * fix(studio): hold the dictation send while an upload finishes The plus stays live during transcription, so an attachment started after send was pressed made handleSubmit reject a submit whose intent was already spent. The intent is now kept until the upload lands, and the effect reruns on it, so the message sends by itself. Session start is tracked explicitly with that dependency added: the base text is captured once per recording rather than on every rerun, and a new recording clears a send still held for an upload rather than firing it on the next stop. * fix(studio): one gate for the dictation send, and a cancel touch can reach The button gate and the pending send disagreed: an attachment added after the press could finish uploading before transcription, clearing the hold while leaving content that cannot be queued, so the submit bounced and the intent was gone. Both now read one predicate, dictationSendBlocked, and a pending send waits on it rather than being spent. Nothing-to-send is settled first, so a silent or cancelled recording never leaves an intent pending. Stop also discards on a second press once transcription starts, which is what Compare's dictation button already does. Escape was otherwise the only way to abort a stalled request, and touch has no way to press it. * fix(studio): drop a held dictation send when the composer is edited The recording bar is gone while a send waits on a block, so the hold has no indicator and no cancel. Replacing the transcript with another draft still satisfied the base-text comparison, so clearing the block sent that draft. The hold now lasts only as long as the transcript it was pressed for: the text is recorded when the send is first held, and any edit releases it. * fix(studio): read the reactive composer text in the dictation send effect The effect reruns on composerText to notice edits, so it should read that rather than a fresh getState of the same value. Also clears the lint error the extra dependency raised. --------- Co-authored-by: Unsloth --- .../frontend/src/components/app-sidebar.tsx | 4 +- .../assistant-ui/chat-dictation-bar.tsx | 116 +++++++++---- .../src/components/assistant-ui/thread.tsx | 127 ++++++++++++-- .../chat/adapters/dictation-outcome.ts | 42 +++++ .../adapters/studio-dictation-adapter.tsx | 6 +- .../studio-model-dictation-adapter.ts | 10 ++ .../studio-web-speech-dictation-adapter.ts | 9 + .../frontend/src/features/chat/chat-page.tsx | 18 +- studio/frontend/src/features/chat/index.ts | 4 + .../src/features/chat/shared-composer.tsx | 8 +- .../src/features/chat/utils/dictation-send.ts | 80 +++++++++ studio/frontend/src/index.css | 29 +++- .../frontend/tests/dictation-outcome.test.ts | 61 +++++++ studio/frontend/tests/dictation-send.test.ts | 158 ++++++++++++++++++ 14 files changed, 602 insertions(+), 70 deletions(-) create mode 100644 studio/frontend/src/features/chat/adapters/dictation-outcome.ts create mode 100644 studio/frontend/src/features/chat/utils/dictation-send.ts create mode 100644 studio/frontend/tests/dictation-outcome.test.ts create mode 100644 studio/frontend/tests/dictation-send.test.ts diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 575eda76d0..e51628c30e 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -1261,7 +1261,7 @@ export function AppSidebar() { useChatSearchStore.getState().open(); closeMobileIfOpen(); }} - className="inline-flex h-[33px] w-[28px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + className="inline-flex size-[28px] cursor-pointer items-center justify-center rounded-full text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" aria-label={t("shell.navigation.search")} > @@ -1285,7 +1285,7 @@ export function AppSidebar() {