Stop and send buttons on the chat dictation bar (#7683)
Some checks failed
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Security audit / pytest tests/security (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Unsloth GGUF CI / JSON, images (push) Waiting to run
Mac Studio API CI / Unsloth API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Unsloth Updating Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Windows Unsloth GGUF CI / JSON, images (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled

* 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 <michaelhan@Michaels-MacBook-Pro.local>
This commit is contained in:
Michael Han 2026-08-01 16:34:55 -07:00 committed by GitHub
parent bba8e3957a
commit c67410a787
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 602 additions and 70 deletions

View file

@ -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")}
>
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-icon" />
@ -1285,7 +1285,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
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.aria.closeSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />

View file

@ -7,7 +7,7 @@ import {
subscribeDictationLevel,
} from "@/features/chat";
import { useAui, useAuiState } from "@assistant-ui/react";
import { CheckIcon, XIcon } from "lucide-react";
import { ArrowUpIcon, SquareIcon } from "lucide-react";
import { type FC, useEffect, useRef, useState } from "react";
import { TooltipIconButton } from "./tooltip-icon-button";
@ -35,14 +35,23 @@ function formatElapsed(ms: number): string {
}
/**
* Recording UI shown in place of the composer input: a live waveform with
* discard and confirm on the right. Confirm transcribes; discard keeps the
* existing composer text.
* Recording UI shown in place of the composer input: a live waveform with stop
* and send on the right. Stop transcribes into the composer for editing; send
* transcribes and submits. Escape discards without transcribing, as does a
* second press of stop once transcription is under way.
*/
export const ChatDictationBar: FC = () => {
export const ChatDictationBar: FC<{
/** Transcribe, then submit the composer. Falls back to stop when absent. */
onSend?: () => void;
/** Send is unavailable (e.g. an attachment is still uploading). */
sendDisabled?: boolean;
}> = ({ onSend, sendDisabled }) => {
const aui = useAui();
const isDictating = useAuiState((s) => s.composer.dictation != null);
const [transcribing, setTranscribing] = useState(false);
// Which button started transcription, so only it shows the spinner.
const [transcribing, setTranscribing] = useState<"stop" | "send" | null>(
null,
);
const [elapsed, setElapsed] = useState(0);
const transcribingRef = useRef(false);
// Extra slot: newest sample lands here; the last visible bar slides toward it.
@ -152,38 +161,73 @@ export const ChatDictationBar: FC = () => {
unsub();
cancelAnimationFrame(raf);
transcribingRef.current = false;
setTranscribing(false);
setTranscribing(null);
setElapsed(0);
barsRef.current = new Array(BAR_COUNT + 1).fill(0);
};
}, [isDictating]);
// No discard button, so Escape drops a recording without transcribing. It
// stays live while transcribing too, where cancel aborts the request.
useEffect(() => {
if (!isDictating) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
// defaultPrevented: an open dialog or menu already claimed this Escape.
if (event.key !== "Escape" || event.defaultPrevented) {
return;
}
cancelActiveStudioDictation();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isDictating]);
if (!isDictating) {
return null;
}
const discard = () => {
cancelActiveStudioDictation();
// Freeze the timer + waveform while the transcription round trip runs.
const freeze = (source: "stop" | "send") => {
transcribingRef.current = true;
setTranscribing(source);
};
const confirm = () => {
// Freeze the timer + waveform before the transcription round trip completes
// and the session ends.
transcribingRef.current = true;
setTranscribing(true);
// Transcript lands in the composer, ready to edit. A second press while
// transcribing discards it instead, as Compare's does: Escape is otherwise
// the only way out, which touch has no way to press.
const stop = () => {
if (transcribing !== null) {
cancelActiveStudioDictation();
return;
}
freeze("stop");
aui.composer().stopDictation();
};
// Same transcription, then the message submits on its own.
const send = () => {
if (sendDisabled) return;
if (!onSend) {
stop();
return;
}
freeze("send");
onSend();
};
return (
<fieldset
// order-2 places the bar in the input's slot after the left "+" tools.
className="unsloth-dictation-bar order-2 m-0 flex min-w-0 flex-1 items-center gap-2 border-0 p-0"
// No row gap: the wave padding and the timer margin set the spacing.
className="unsloth-dictation-bar order-2 m-0 flex min-w-0 flex-1 items-center gap-0 border-0 p-0"
aria-label="Voice recording"
>
<div
ref={rowRef}
aria-hidden="true"
className="unsloth-dictation-wave grid h-10 min-w-0 flex-1 items-center overflow-hidden px-2"
className="unsloth-dictation-wave grid h-10 min-w-0 flex-1 items-center overflow-hidden pl-4 pr-2"
style={{
gridTemplateColumns: `repeat(${BAR_COUNT}, minmax(1px, 3px))`,
justifyContent: "space-between",
@ -196,33 +240,43 @@ export const ChatDictationBar: FC = () => {
/>
))}
</div>
<span className="shrink-0 tabular-nums text-sm text-muted-foreground">
<span className="mr-4 shrink-0 tabular-nums text-sm text-muted-foreground">
{formatElapsed(elapsed)}
</span>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center gap-2.5">
<TooltipIconButton
type="button"
tooltip="Discard recording"
aria-label="Discard recording"
tooltip={
transcribing !== null ? "Cancel transcription" : "Stop recording"
}
aria-label={
transcribing !== null ? "Cancel transcription" : "Stop recording"
}
variant="ghost"
onClick={discard}
className="size-8 rounded-full text-muted-foreground hover:text-foreground"
onClick={stop}
// Neutral grey in both themes: --secondary is brand green on the
// default light palette, and too close to --card on dark.
className="size-9 rounded-full bg-accent text-foreground hover:bg-accent/70 dark:bg-white/10 dark:hover:bg-white/[0.16]"
>
<XIcon className="size-5" />
{transcribing === "stop" ? (
<Spinner className="size-3.5" />
) : (
<SquareIcon className="size-3 fill-current" />
)}
</TooltipIconButton>
<TooltipIconButton
type="button"
tooltip={transcribing ? "Transcribing…" : "Stop and transcribe"}
aria-label="Stop and transcribe"
tooltip={transcribing === "send" ? "Transcribing…" : "Send message"}
aria-label="Send message"
variant="default"
onClick={confirm}
disabled={transcribing}
className="size-8 rounded-full"
onClick={send}
disabled={transcribing !== null || sendDisabled}
className="aui-composer-send size-9 rounded-full"
>
{transcribing ? (
<Spinner className="size-4" />
{transcribing === "send" ? (
<Spinner className="size-[18px]" />
) : (
<CheckIcon className="size-5" />
<ArrowUpIcon className="unsloth-send-icon aui-composer-send-icon size-[21px] stroke-2" />
)}
</TooltipIconButton>
</div>

View file

@ -103,12 +103,18 @@ import { PROMPT_QUEUE_STOP_EVENT } from "@/features/chat/utils/prompt-queue-boun
import {
PLUS_MENU_ORDER,
composerDraftKey,
dictationFailed,
dictationProducedTranscript,
readComposerDraft,
type PlusMenuItemId,
usePlusMenuPrefsStore,
writeComposerDraft,
} from "@/features/chat";
import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message";
import {
dictationSendBlocked,
shouldSubmitDictation,
} from "@/features/chat/utils/dictation-send";
import { listThreadDocuments } from "@/features/rag/api/rag-api";
import { ThreadDocumentsBar } from "@/features/rag/components/thread-documents-bar";
import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button";
@ -1365,7 +1371,8 @@ const ThreadWelcome: FC<{
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-9 px-4">
{/* Center the greeting (sloth + title) over the composer. */}
<div className="flex flex-row items-center justify-center gap-[15px]">
{showGreetingSloth && (
{/* Temporary chat keeps the title on its own, no mascot. */}
{showGreetingSloth && !incognito && (
<MascotImg
src={currentEmojiSrc}
className="size-[44px] -translate-y-[2px]"
@ -1835,6 +1842,94 @@ const Composer: FC<{
[],
);
// Recording bar's send: stop dictating, then submit once the transcript
// lands. Going through the form keeps queueing, indexing holds and draft
// clearing identical to a typed send.
const formRef = useRef<HTMLFormElement | null>(null);
const sendAfterDictationRef = useRef(false);
const dictationBaseTextRef = useRef("");
const dictationComposerRef = useRef("");
// Thread switches reuse this composer, so the send has to know where it
// started to avoid submitting the destination thread's draft. The list item
// id, not referenceThreadId: that one moves from null to the remote id when
// a new chat first persists, which is the same composer.
const composerIdentity = threadListItemId ?? "";
const sendAfterDictation = useCallback(() => {
sendAfterDictationRef.current = true;
dictationComposerRef.current = composerIdentity;
aui.composer().stopDictation();
}, [aui, composerIdentity]);
// One gate for the recording bar's send: it greys the button out, and holds
// a pending send when the composer changes under it after the press.
const dictationBlocked = dictationSendBlocked({
composerDisabled: Boolean(disabled),
uploading: hasPendingAttachments,
researchActive: isResearchActive,
runActive: threadIsRunning || promptQueueActive,
queueDisabled: Boolean(disableQueue),
hasOverlay: Boolean(overlay),
hasAttachments,
hasPendingAudio,
});
const wasDictatingRef = useRef(false);
// Composer text while a send waits on dictationBlocked, so an edit can drop it.
const heldTextRef = useRef<string | null>(null);
useEffect(() => {
if (isDictating) {
if (wasDictatingRef.current) return;
wasDictatingRef.current = true;
// A new recording supersedes a send still held for an upload.
sendAfterDictationRef.current = false;
heldTextRef.current = null;
// Text at session start is the dictation base. Anchor on it, not on the
// text when send was pressed: the browser engine streams interim results
// into the composer, so a final matching its interim would look unchanged.
dictationBaseTextRef.current = aui.composer().getState().text;
return;
}
wasDictatingRef.current = false;
if (!sendAfterDictationRef.current) return;
// A partial transcript (a failed chunk, or an engine error after one
// landed) belongs in the composer, but must not send half a message.
// Silence, a thread switch mid-transcription, or a plus-menu insertion
// with no speech: keep the draft, submit nothing. Settled before the hold
// below, so nothing to send never leaves an intent pending.
const text = composerText;
const sendable =
!dictationFailed() &&
shouldSubmitDictation({
originComposer: dictationComposerRef.current,
currentComposer: composerIdentity,
producedTranscript: dictationProducedTranscript(),
baseText: dictationBaseTextRef.current,
text,
});
if (!sendable) {
sendAfterDictationRef.current = false;
heldTextRef.current = null;
return;
}
// The plus stays live while transcribing, so an upload or an attachment
// can appear after the press. Keep the intent until the composer accepts
// a submit again, rather than spending it on one that would bounce.
if (dictationBlocked) {
// The bar is gone by now, so the hold is invisible. It lasts only as
// long as the transcript it was pressed for: editing hands control
// back, rather than sending that edit when the block clears.
if (heldTextRef.current === null) {
heldTextRef.current = text;
} else if (heldTextRef.current !== text) {
sendAfterDictationRef.current = false;
heldTextRef.current = null;
}
return;
}
sendAfterDictationRef.current = false;
heldTextRef.current = null;
formRef.current?.requestSubmit();
}, [isDictating, aui, composerIdentity, dictationBlocked, composerText]);
const handleSubmit = useCallback(
(event: Parameters<NonNullable<ComponentProps<"form">["onSubmit"]>>[0]) => {
if (isResearchActive) {
@ -2024,7 +2119,13 @@ const Composer: FC<{
{isDictating ? (
// The recording UI replaces the input and send controls; only the
// left plus stays visible alongside it.
<ChatDictationBar />
<ChatDictationBar
onSend={sendAfterDictation}
// Every state handleSubmit rejects, since it would reject after
// transcription with the send intent already spent. Text presence
// is left out: the transcript supplies it.
sendDisabled={dictationBlocked}
/>
) : (
<>
<ComposerPrimitive.Input
@ -2086,6 +2187,7 @@ const Composer: FC<{
return (
<PromptQueueContext.Provider value={queueContextValue}>
<ComposerPrimitive.Root
ref={formRef}
className="aui-composer-root relative flex w-full flex-col"
aria-disabled={disabled}
onSubmit={handleSubmit}
@ -3645,7 +3747,7 @@ const ComposerRightControls: FC<{
<div className="aui-composer-action-wrapper flex shrink-0 items-center gap-1.5">
<ReasoningToggle side={menuSide} />
{/* Starts dictation; the recording bar then covers the input row and owns
the stop and discard actions. */}
the stop and send actions. */}
<ComposerPrimitive.If dictation={false}>
<TooltipIconButton
tooltip="Dictate"
@ -3655,7 +3757,8 @@ const ComposerRightControls: FC<{
className="size-8 rounded-full text-foreground"
onClick={startDictation}
>
<MicIcon className="size-5" />
{/* size-[22px] is the fallback; unsloth-dictate-icon sets the size. */}
<MicIcon className="unsloth-dictate-icon size-[22px]" />
</TooltipIconButton>
</ComposerPrimitive.If>
<AuiIf
@ -3674,13 +3777,13 @@ const ComposerRightControls: FC<{
// disabled only once a send is parked.
disabled={disabled || pendingSend}
onClick={(event) => onSendClick?.(event)}
className="aui-composer-send ml-1.5 size-8 rounded-full"
className="aui-composer-send ml-1.5 size-9 rounded-full"
aria-label="Send message"
>
{pendingSend ? (
<Spinner className="size-[18px]" />
) : (
<ArrowUpIcon className="aui-composer-send-icon size-[21px] stroke-2" />
<ArrowUpIcon className="unsloth-send-icon aui-composer-send-icon size-[21px] stroke-2" />
)}
</TooltipIconButton>
</ComposerPrimitive.Send>
@ -3695,10 +3798,10 @@ const ComposerRightControls: FC<{
size="icon"
disabled={disabled || queueDisabled}
onClick={onQueueClick}
className="aui-composer-send ml-1.5 size-8 rounded-full"
className="aui-composer-send ml-1.5 size-9 rounded-full"
aria-label="Queue message"
>
<ArrowUpIcon className="aui-composer-send-icon size-[21px] stroke-2" />
<ArrowUpIcon className="unsloth-send-icon aui-composer-send-icon size-[21px] stroke-2" />
</TooltipIconButton>
</AuiIf>
) : null}
@ -3707,7 +3810,7 @@ const ComposerRightControls: FC<{
type="button"
variant="default"
size="icon"
className="aui-composer-cancel ml-1.5 size-8 rounded-full"
className="aui-composer-cancel ml-1.5 size-9 rounded-full"
aria-label={researchStopping ? "Stopping research" : "Stop research"}
disabled={researchStopping}
onClick={stop}
@ -3727,7 +3830,7 @@ const ComposerRightControls: FC<{
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
className="aui-composer-cancel size-9 rounded-full"
aria-label="Stop generating"
onClick={stop}
>
@ -3743,10 +3846,10 @@ const ComposerRightControls: FC<{
size="icon"
disabled={queueDisabled}
onClick={onQueueClick}
className="aui-composer-send size-8 rounded-full"
className="aui-composer-send size-9 rounded-full"
aria-label="Queue message"
>
<ArrowUpIcon className="aui-composer-send-icon size-[21px] stroke-2" />
<ArrowUpIcon className="unsloth-send-icon aui-composer-send-icon size-[21px] stroke-2" />
</TooltipIconButton>
)}
</div>

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* What the dictation that just ended actually produced.
*
* The recording bar's send needs both answers, and cannot get them from the
* composer: text can change while recording (the plus menu can insert a saved
* prompt), and a partial transcript still lands there. Module state, not
* session state, because the bar reads it once the session is gone.
*/
let producedTranscript = false;
let failed = false;
/** Start a session. Clears the previous session's result. */
export function beginDictationSession(): void {
producedTranscript = false;
failed = false;
}
/** A final transcript was published to the composer. */
export function markDictationTranscript(): void {
producedTranscript = true;
}
/** Whether the last dictation published any transcript. */
export function dictationProducedTranscript(): boolean {
return producedTranscript;
}
/**
* A transcript chunk, or the session itself, failed. Both engines can still
* publish what did transcribe, so the text is partial rather than absent.
*/
export function markDictationFailed(): void {
failed = true;
}
/** Whether the last dictation reported a failure. */
export function dictationFailed(): boolean {
return failed;
}

View file

@ -14,9 +14,9 @@ import {
StudioWebSpeechDictationAdapter,
} from "./studio-web-speech-dictation-adapter";
// The one live dictation session, so the recording bar's discard (X) can cancel
// it without going through assistant-ui (which only exposes stop, i.e.
// transcribe). Cancelling emits no transcript, so composer text is untouched.
// The one live dictation session, so Escape can discard it without going
// through assistant-ui (which only exposes stop, i.e. transcribe). Cancelling
// emits no transcript, so composer text is untouched.
let activeSession: StudioDictationSession | null = null;
/** Discard the current dictation without transcribing. Safe to call when idle. */

View file

@ -13,6 +13,11 @@ import {
import type { DictationAdapter } from "@assistant-ui/react";
import { toast } from "sonner";
import { startDictationLevelMeter } from "./dictation-level";
import {
beginDictationSession,
markDictationFailed,
markDictationTranscript,
} from "./dictation-outcome";
import {
type StudioDictationSession,
isMissingDeviceError,
@ -265,6 +270,7 @@ export class StudioModelDictationAdapter implements DictationAdapter {
if (!StudioModelDictationAdapter.isSupported()) {
throw new Error("Recording is not supported in this browser.");
}
beginDictationSession();
// Pin the model, language, and linked chat chosen when recording began, so a
// mid-session settings change or thread switch cannot affect later segments
@ -362,6 +368,7 @@ export class StudioModelDictationAdapter implements DictationAdapter {
stream = null;
const corrected = transcript ? applyDictationDictionary(transcript) : "";
if (reason !== "cancelled" && corrected) {
markDictationTranscript();
for (const callback of speechCallbacks) {
callback({ transcript: corrected, isFinal: true });
}
@ -403,6 +410,9 @@ export class StudioModelDictationAdapter implements DictationAdapter {
} catch (error) {
if (!cancelled && !abortController.signal.aborted) {
// Keep transcribed segments, but never hide that part was lost.
// Only a lost segment is partial: the model preload shares this
// reporter and can fail without costing any audio.
markDictationFailed();
reportTranscriptionError(error);
}
} finally {

View file

@ -12,6 +12,11 @@ import type { DictationAdapter } from "@assistant-ui/react";
import { toast } from "sonner";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { startDictationLevelMeter } from "./dictation-level";
import {
beginDictationSession,
markDictationFailed,
markDictationTranscript,
} from "./dictation-outcome";
/** Chat open while dictating, so the saved dictation can link back to it. */
export function activeDictationChatId(): string | undefined {
@ -139,6 +144,7 @@ export class StudioWebSpeechDictationAdapter implements DictationAdapter {
throw new Error("Speech recognition is not supported in this browser.");
}
beginDictationSession();
const recognition = new SpeechRecognitionAPI();
recognition.lang = this.language ?? resolveDictationLanguage();
recognition.continuous = this.continuous;
@ -274,6 +280,7 @@ export class StudioWebSpeechDictationAdapter implements DictationAdapter {
stream = null;
const transcript = reason === "cancelled" ? "" : finalTranscript;
if (transcript) {
markDictationTranscript();
for (const callback of speechCallbacks) {
callback({ transcript, isFinal: true });
}
@ -371,6 +378,8 @@ export class StudioWebSpeechDictationAdapter implements DictationAdapter {
} else {
toast.error(description);
}
// Any finalized chunks stay in the composer, but must not send alone.
markDictationFailed();
finish("error");
});

View file

@ -3280,16 +3280,6 @@ export function ChatPage({
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[var(--studio-chat-control-height,34px)]"
/>
)}
{incognito && view.mode === "single" && (
<div className="flex h-[var(--studio-chat-control-height,34px)] shrink-0 items-center gap-1.5 self-center rounded-full bg-primary/10 px-2.5 font-medium text-ui-13 text-primary">
<HugeiconsIcon
icon={BubbleChatTemporaryIcon}
strokeWidth={2}
className="size-3.5"
/>
<span>Temporary</span>
</div>
)}
{view.mode !== "compare" && currentProjectId && (
<nav
aria-label="Project location"
@ -3357,7 +3347,7 @@ export function ChatPage({
</div>
) : null}
</div>
<div className="pointer-events-auto ml-auto flex items-center gap-2">
<div className="pointer-events-auto ml-auto flex items-center gap-1">
{view.mode === "single" && contextUsage ? (
<ContextUsageBar
used={contextUsage.totalTokens}
@ -3377,7 +3367,7 @@ export function ChatPage({
type="button"
onClick={toggleIncognito}
className={cn(
"flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-[12px] transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
incognito
? "bg-primary/10 text-primary hover:bg-primary/15"
: "text-nav-fg hover:bg-nav-surface-hover hover:text-black dark:hover:text-white",
@ -3415,7 +3405,7 @@ export function ChatPage({
closeArtifactSurface();
openResearchPanel(latestResearchRun.id);
}}
className="relative flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:text-white"
className="relative flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-full text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:text-white"
aria-label="Open research activity"
aria-pressed={openResearchRunId === latestResearchRun.id}
>
@ -3443,7 +3433,7 @@ export function ChatPage({
useResearchRunStore.getState().closePanel();
setSettingsOpen(true);
}}
className="flex size-[var(--studio-chat-control-height,34px)] translate-x-[2px] cursor-pointer items-center justify-center rounded-[12px] text-nav-fg 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="flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-full text-nav-fg 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="Open run settings"
>
<HugeiconsIcon

View file

@ -144,6 +144,10 @@ export {
useChatProjects,
} from "./hooks/use-chat-projects";
export { subscribeDictationLevel } from "./adapters/dictation-level";
export {
dictationFailed,
dictationProducedTranscript,
} from "./adapters/dictation-outcome";
export {
StudioDictationAdapter,
cancelActiveStudioDictation,

View file

@ -2298,7 +2298,7 @@ export function SharedComposer({
onClick={startDictation}
aria-label="Dictate"
>
<MicIcon className="size-4" />
<MicIcon className="unsloth-dictate-icon size-4" />
</TooltipIconButton>
) : (
<TooltipIconButton
@ -2347,7 +2347,7 @@ export function SharedComposer({
type="button"
variant="default"
size="icon"
className="ml-1.5 size-8 rounded-full"
className="ml-1.5 size-9 rounded-full"
onClick={stop}
>
<SquareIcon className="size-3 fill-current" />
@ -2358,12 +2358,12 @@ export function SharedComposer({
side="bottom"
variant="default"
size="icon"
className="ml-1.5 size-8 rounded-full"
className="ml-1.5 size-9 rounded-full"
onClick={send}
disabled={!canSend}
aria-label="Send message"
>
<ArrowUpIcon className="size-[22px] stroke-2" />
<ArrowUpIcon className="unsloth-send-icon size-[22px] stroke-2" />
</TooltipIconButton>
)}
</div>

View file

@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Whether the recording bar's send should submit once dictation ends.
*
* Only when this recording actually added text: silence and failed
* transcription leave a pre-recording draft in place rather than sending it
* half-finished.
*
* @param before composer text captured when send was pressed
* @param after composer text once the session ended
*/
export function dictationProducedText(before: string, after: string): boolean {
const trimmed = after.trim();
return trimmed.length > 0 && trimmed !== before.trim();
}
/**
* Every state the composer's submit rejects.
*
* One definition for both jobs: greying out the recording bar's send, and
* deciding whether a pending send waits rather than being spent on a submit
* that would bounce. Text presence is not here, since the transcript supplies
* it after the button is pressed.
*/
export function dictationSendBlocked(state: {
/** The composer itself is unavailable. */
composerDisabled: boolean;
/** An attachment is still uploading. */
uploading: boolean;
/** A deep research run owns the composer. */
researchActive: boolean;
/** A response is streaming or the prompt queue is going. */
runActive: boolean;
/** This composer never queues, it asks the user to wait. */
queueDisabled: boolean;
/** An image edit overlay is open. */
hasOverlay: boolean;
/** Only text can be queued, so these block while a run is active. */
hasAttachments: boolean;
hasPendingAudio: boolean;
}): boolean {
if (state.composerDisabled || state.uploading || state.researchActive) {
return true;
}
if (!state.runActive) return false;
return (
state.queueDisabled ||
state.hasOverlay ||
state.hasAttachments ||
state.hasPendingAudio
);
}
/**
* Whether a pending dictation send may submit now.
*
* Three ways it must not. The composer is reused across thread switches, so a
* send pressed in one thread can land after a move to another, where it would
* submit that thread's draft. The plus menu stays open to the user while
* recording and can insert a saved prompt, which changes the text without any
* speech. And silence leaves whatever was already there.
*/
export function shouldSubmitDictation(input: {
/** Composer identity when send was pressed. */
originComposer: string;
/** Composer identity now that the session has ended. */
currentComposer: string;
/** The engine published a final transcript. */
producedTranscript: boolean;
/** Composer text at session start. */
baseText: string;
/** Composer text now. */
text: string;
}): boolean {
if (input.originComposer !== input.currentComposer) return false;
if (!input.producedTranscript) return false;
return dictationProducedText(input.baseText, input.text);
}

View file

@ -1683,8 +1683,10 @@ html[data-chat-font] .aui-root {
@apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] resize-none overflow-y-auto bg-transparent pl-2 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
}
/* Same 12px corner gap as the single composer, over this surface's px-1
and no pb. */
.composer-action-wrapper {
@apply relative mx-2 mb-2 flex items-center justify-between;
@apply relative mx-2 mb-3 flex items-center justify-between;
}
.composer-footer-note {
@ -1692,7 +1694,9 @@ html[data-chat-font] .aui-root {
font-family: var(--font-sans);
}
/* Pill composer; own classes so compare-mode keeps its stacked layout. */
/* Pill composer; own classes so compare-mode keeps its stacked layout. py
12px matches the send circle's gap to the right edge, so it sits evenly
inside the 32px corner. */
.unsloth-composer-surface {
@apply relative flex w-full flex-col rounded-[32px] bg-background dark:bg-card px-3 py-3 outline-none transition-shadow;
container-type: inline-size;
@ -1748,8 +1752,8 @@ html[data-chat-font] .aui-root {
.unsloth-composer-line .aui-composer-action-wrapper {
order: 3;
margin-left: auto;
/* Inset the send circle from the edge, Gemini-style. */
margin-right: -0.125rem;
/* 12px surface + 4px line padding minus this = 12px edge gap. */
margin-right: -0.25rem;
}
.unsloth-composer-line[data-expanded="true"] .unsloth-composer-input {
@ -2867,6 +2871,23 @@ html[data-chat-font] .aui-root {
& svg.size-\[20px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[21px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[22px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
/* Mic and send arrow sit above the shared glyph size: both fill their
viewBox, so they read smaller than the stroked icons beside them. Keep
after the size-[NN] rules they override. */
& svg.unsloth-dictate-icon {
width: calc(var(--ui-icon-size) * 1.2);
height: calc(var(--ui-icon-size) * 1.2);
}
& svg.unsloth-send-icon {
width: calc(var(--ui-icon-size) * 1.15);
height: calc(var(--ui-icon-size) * 1.15);
}
/* Composer plus keeps its pre-#7400 22px base (1.375x the token is the same
curve a 22px glyph would follow), so only its scaling changed. */
& .unsloth-composer-plus svg {
width: calc(var(--ui-icon-size) * 1.375);
height: calc(var(--ui-icon-size) * 1.375);
}
& svg.size-\[36px\] { width: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); height: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); }
& svg.w-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
& svg.h-3 { height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import {
beginDictationSession,
dictationFailed,
dictationProducedTranscript,
markDictationFailed,
markDictationTranscript,
} from "../src/features/chat/adapters/dictation-outcome.ts";
test("a fresh session has produced nothing and failed at nothing", () => {
beginDictationSession();
assert.equal(dictationProducedTranscript(), false);
assert.equal(dictationFailed(), false);
});
test("a published transcript is visible after the session ends", () => {
beginDictationSession();
markDictationTranscript();
assert.equal(dictationProducedTranscript(), true);
});
test("a reported failure is visible after the session ends", () => {
beginDictationSession();
markDictationFailed();
assert.equal(dictationFailed(), true);
});
// A partial transcript is both: text was published, and some was lost.
test("a partial transcript reports both", () => {
beginDictationSession();
markDictationTranscript();
markDictationFailed();
assert.equal(dictationProducedTranscript(), true);
assert.equal(dictationFailed(), true);
});
// The recording bar reads these once the session is gone, so they have to
// survive until the next one starts rather than clearing on end.
test("both survive until the next session starts", () => {
beginDictationSession();
markDictationTranscript();
markDictationFailed();
beginDictationSession();
assert.equal(dictationProducedTranscript(), false);
assert.equal(dictationFailed(), false);
});
test("repeated marks in one session stay set", () => {
beginDictationSession();
markDictationTranscript();
markDictationTranscript();
markDictationFailed();
markDictationFailed();
assert.equal(dictationProducedTranscript(), true);
assert.equal(dictationFailed(), true);
});

View file

@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import {
dictationProducedText,
dictationSendBlocked,
shouldSubmitDictation,
} from "../src/features/chat/utils/dictation-send.ts";
test("a transcript into an empty composer sends", () => {
assert.equal(dictationProducedText("", "hello there"), true);
});
test("a transcript appended to a draft sends", () => {
assert.equal(dictationProducedText("draft", "draft hello there"), true);
});
test("silence with an empty composer sends nothing", () => {
assert.equal(dictationProducedText("", ""), false);
});
test("silence keeps a pre-recording draft instead of sending it", () => {
assert.equal(dictationProducedText("draft", "draft"), false);
});
// Anchored on the text at session start, so a final result identical to the
// interim the browser engine already streamed in still counts as produced.
test("a final transcript matching its interim still sends", () => {
assert.equal(dictationProducedText("", "hello there"), true);
assert.equal(dictationProducedText("draft", "draft hello there"), true);
});
test("a whitespace-only transcript does not count as text", () => {
assert.equal(dictationProducedText("draft", "draft "), false);
assert.equal(dictationProducedText("", " "), false);
});
const base = {
originComposer: "item-1",
currentComposer: "item-1",
producedTranscript: true,
baseText: "",
text: "hello there",
};
test("a transcript submits in the composer the send started in", () => {
assert.equal(shouldSubmitDictation(base), true);
});
// The composer is reused across thread switches, so a send that lands after
// the move would otherwise submit the destination thread's draft.
test("a thread switch during transcription drops the send", () => {
assert.equal(
shouldSubmitDictation({
...base,
currentComposer: "item-2",
text: "someone else's draft",
}),
false,
);
});
// The identity has to survive a new chat's first persist, which moves
// activeThreadId from null to the remote id without changing the composer.
test("hydrating a new chat keeps the pending send alive", () => {
assert.equal(shouldSubmitDictation(base), true);
});
test("silence in the original composer sends nothing", () => {
assert.equal(
shouldSubmitDictation({
...base,
producedTranscript: false,
baseText: "draft",
text: "draft",
}),
false,
);
});
// The plus menu stays open while recording: inserting a saved prompt changes
// the composer without any speech, which text alone cannot tell apart.
test("a menu insertion with no transcript sends nothing", () => {
assert.equal(
shouldSubmitDictation({
...base,
producedTranscript: false,
text: "an inserted saved prompt",
}),
false,
);
});
test("a menu insertion alongside a real transcript still sends", () => {
assert.equal(
shouldSubmitDictation({ ...base, text: "an inserted prompt hello there" }),
true,
);
});
const open = {
composerDisabled: false,
uploading: false,
researchActive: false,
runActive: false,
queueDisabled: false,
hasOverlay: false,
hasAttachments: false,
hasPendingAudio: false,
};
test("an idle composer accepts the dictation send", () => {
assert.equal(dictationSendBlocked(open), false);
});
test("the composer's own unavailable states block regardless of a run", () => {
assert.equal(dictationSendBlocked({ ...open, composerDisabled: true }), true);
assert.equal(dictationSendBlocked({ ...open, uploading: true }), true);
assert.equal(dictationSendBlocked({ ...open, researchActive: true }), true);
});
// Plain text queues while a response runs, which is what the bar exists for.
test("a running response alone does not block", () => {
assert.equal(dictationSendBlocked({ ...open, runActive: true }), false);
});
// Only text can be queued, so these block a send that would have to queue.
test("non-queueable content blocks only while a run is active", () => {
for (const key of [
"queueDisabled",
"hasOverlay",
"hasAttachments",
"hasPendingAudio",
] as const) {
assert.equal(dictationSendBlocked({ ...open, [key]: true }), false);
assert.equal(
dictationSendBlocked({ ...open, runActive: true, [key]: true }),
true,
);
}
});
// The case the button gate cannot cover: an attachment added after the press,
// whose upload finishes before transcription does.
test("an attachment completing mid-transcription blocks a queued send", () => {
assert.equal(
dictationSendBlocked({
...open,
runActive: true,
uploading: false,
hasAttachments: true,
}),
true,
);
});