diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 9b3c7aa79e..6bd99be663 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -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<{
{/* Center the greeting (sloth + title) over the composer. */}
- {showGreetingSloth && (
+ {/* Temporary chat keeps the title on its own, no mascot. */}
+ {showGreetingSloth && !incognito && (
(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(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["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.
-
+
) : (
<>
{/* Starts dictation; the recording bar then covers the input row and owns
- the stop and discard actions. */}
+ the stop and send actions. */}
-
+ {/* size-[22px] is the fallback; unsloth-dictate-icon sets the size. */}
+
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 ? (
) : (
-
+
)}
@@ -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"
>
-
+
) : 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"
>
-
+
)}
diff --git a/studio/frontend/src/features/chat/adapters/dictation-outcome.ts b/studio/frontend/src/features/chat/adapters/dictation-outcome.ts
new file mode 100644
index 0000000000..0eaabfbf1d
--- /dev/null
+++ b/studio/frontend/src/features/chat/adapters/dictation-outcome.ts
@@ -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;
+}
diff --git a/studio/frontend/src/features/chat/adapters/studio-dictation-adapter.tsx b/studio/frontend/src/features/chat/adapters/studio-dictation-adapter.tsx
index d637a32b0d..2dc36bb242 100644
--- a/studio/frontend/src/features/chat/adapters/studio-dictation-adapter.tsx
+++ b/studio/frontend/src/features/chat/adapters/studio-dictation-adapter.tsx
@@ -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. */
diff --git a/studio/frontend/src/features/chat/adapters/studio-model-dictation-adapter.ts b/studio/frontend/src/features/chat/adapters/studio-model-dictation-adapter.ts
index 8546e3eeab..780b26f791 100644
--- a/studio/frontend/src/features/chat/adapters/studio-model-dictation-adapter.ts
+++ b/studio/frontend/src/features/chat/adapters/studio-model-dictation-adapter.ts
@@ -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 {
diff --git a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
index fb0e4fc0ab..23236831e9 100644
--- a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
+++ b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
@@ -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");
});
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index 81d3c3ed7d..01f1bb0630 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -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" && (
-
-
- Temporary
-
- )}
{view.mode !== "compare" && currentProjectId && (
+
{view.mode === "single" && contextUsage ? (
@@ -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"
>
-
+
) : (
@@ -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"
>
-
+
)}
diff --git a/studio/frontend/src/features/chat/utils/dictation-send.ts b/studio/frontend/src/features/chat/utils/dictation-send.ts
new file mode 100644
index 0000000000..984195c766
--- /dev/null
+++ b/studio/frontend/src/features/chat/utils/dictation-send.ts
@@ -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);
+}
diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css
index f18be92f94..e6dfb075dd 100644
--- a/studio/frontend/src/index.css
+++ b/studio/frontend/src/index.css
@@ -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))); }
diff --git a/studio/frontend/tests/dictation-outcome.test.ts b/studio/frontend/tests/dictation-outcome.test.ts
new file mode 100644
index 0000000000..05f7e037c7
--- /dev/null
+++ b/studio/frontend/tests/dictation-outcome.test.ts
@@ -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);
+});
diff --git a/studio/frontend/tests/dictation-send.test.ts b/studio/frontend/tests/dictation-send.test.ts
new file mode 100644
index 0000000000..fbf5be3943
--- /dev/null
+++ b/studio/frontend/tests/dictation-send.test.ts
@@ -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,
+ );
+});