mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-17 21:03:59 +00:00
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
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:
parent
bba8e3957a
commit
c67410a787
14 changed files with 602 additions and 70 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
80
studio/frontend/src/features/chat/utils/dictation-send.ts
Normal file
80
studio/frontend/src/features/chat/utils/dictation-send.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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))); }
|
||||
|
|
|
|||
61
studio/frontend/tests/dictation-outcome.test.ts
Normal file
61
studio/frontend/tests/dictation-outcome.test.ts
Normal 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);
|
||||
});
|
||||
158
studio/frontend/tests/dictation-send.test.ts
Normal file
158
studio/frontend/tests/dictation-send.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue