From 4e7d9bcd134f5bb654187f3f509528d4119d5977 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Tue, 25 Aug 2026 05:16:42 -0300 Subject: [PATCH] fix(ui): release dictation composer before remote close --- ui/src/i18n/locales/en.ts | 2 - .../pages/chat/chat-composer-actions.test.ts | 36 ++++ .../chat/components/chat-composer-controls.ts | 66 ++++-- .../chat/components/chat-composer-view.ts | 24 +-- ui/src/pages/chat/composer-dictation.test.ts | 195 ++++-------------- ui/src/pages/chat/composer-dictation.ts | 107 ++-------- .../composer-dictation-control.test.ts | 15 +- .../new-session/composer-dictation-control.ts | 13 +- ui/src/pages/new-session/composer.test.ts | 6 +- ui/src/pages/new-session/composer.ts | 4 + ui/src/pages/new-session/new-session-page.ts | 1 + ui/src/styles/chat/layout.css | 8 - 12 files changed, 174 insertions(+), 303 deletions(-) diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 70f9b19ca631..5296de7f79b1 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -6010,8 +6010,6 @@ export const en: TranslationMap = { dictationFinalizing: "Finishing dictation…", dictationListening: "Listening…", dictationStopAndKeep: "Stop and keep text", - dictationFinalizationTimedOut: - "Dictation stopped before the last partial transcript could be finalized.", dictationProviderUnavailable: "No transcription provider is configured for dictation. Choose one in Settings to dictate.", realtimeTalkCancellationRejected: "Realtime output cancellation was not accepted.", diff --git a/ui/src/pages/chat/chat-composer-actions.test.ts b/ui/src/pages/chat/chat-composer-actions.test.ts index d064e8147142..bc50c0f4ceef 100644 --- a/ui/src/pages/chat/chat-composer-actions.test.ts +++ b/ui/src/pages/chat/chat-composer-actions.test.ts @@ -108,6 +108,42 @@ describe("renderChatComposer controls", () => { expect(handleClick).not.toHaveBeenCalled(); }); + it("keeps Stop and Send visually stable while dictation finalizes", () => { + const container = document.createElement("div"); + const dictation = { + active: true, + connecting: false, + finalizing: true, + locksComposer: true, + finishActive: vi.fn(), + } as unknown as ComposerDictationController; + render( + renderChatPrimaryActions({ + canAbort: false, + canSend: true, + connected: true, + draft: "preexisting draft", + isBusy: false, + steerNowEnabled: false, + sending: false, + dictation, + onSend: vi.fn(), + }), + container, + ); + + const stop = container.querySelector(".chat-send-btn--dictating"); + const send = container.querySelector(".chat-send-btn--dictation-commit"); + expect(stop?.getAttribute("aria-label")).toBe("Stop and keep text"); + expect(stop?.disabled).toBe(false); + expect(stop?.getAttribute("aria-disabled")).toBe("true"); + expect(stop?.querySelector("rect")).not.toBeNull(); + expect(send?.classList.contains("chat-send-btn--send")).toBe(true); + expect(send?.disabled).toBe(false); + expect(send?.getAttribute("aria-disabled")).toBe("true"); + expect(send?.querySelector("path")?.getAttribute("d")).toBe("M12 19V5m-7 7 7-7 7 7"); + }); + it.each([ { name: "empty idle", diff --git a/ui/src/pages/chat/components/chat-composer-controls.ts b/ui/src/pages/chat/components/chat-composer-controls.ts index 66c7aef94652..058caa65efe4 100644 --- a/ui/src/pages/chat/components/chat-composer-controls.ts +++ b/ui/src/pages/chat/components/chat-composer-controls.ts @@ -302,11 +302,9 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) { const holding = props.dictation?.locksComposer === true; const startsDictationDirectly = props.dictation !== undefined && props.onToggleVoice === undefined; - const label = finalizing - ? t("chat.composer.dictationFinalizing") - : active - ? t("chat.composer.dictationStopAndKeep") - : (props.idleLabel ?? t("chat.composer.startVoiceInput")); + const label = active + ? t("chat.composer.dictationStopAndKeep") + : (props.idleLabel ?? t("chat.composer.startVoiceInput")); const tooltip = props.dictation && !startsDictationDirectly && !(active || finalizing) ? t("chat.composer.voiceGestureHint") @@ -318,14 +316,16 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) { ${props.microphonePicker} @@ -360,7 +358,7 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) { `; } -export function renderComposerDictationSubmitAction( +export function renderComposerDictationSendAction( dictation: ComposerDictationController, onSend: () => void, onPointerDown?: (event: PointerEvent) => void, @@ -378,22 +376,46 @@ export function renderComposerDictationSubmitAction( > `; } +export function renderComposerDictationStatus(dictation?: ComposerDictationController) { + if (!dictation?.active) { + return nothing; + } + return html` +
+
+ + ${dictation.connecting + ? t("chat.composer.dictationConnecting") + : dictation.finalizing + ? t("chat.composer.dictationFinalizing") + : t("chat.composer.dictationListening")} + +
+
+ `; +} + export function renderChatPrimaryActions(props: ChatRunControlsProps) { const hasComposedContent = Boolean(props.draft.trim() || props.hasAttachments); const steersActiveRun = props.followUpMode === "steer"; @@ -479,7 +501,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) { `; const dictationSendAction = props.dictation - ? renderComposerDictationSubmitAction( + ? renderComposerDictationSendAction( props.dictation, () => props.onSend(), props.onPrimaryActionPointerDown, diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index bc0b11fe39f5..6092a506f34a 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -16,7 +16,10 @@ import { renderChatAttachmentInputs, } from "./chat-attachments.ts"; import type { ChatRunControlsProps } from "./chat-composer-controls.ts"; -import { renderChatPrimaryActions } from "./chat-composer-controls.ts"; +import { + renderChatPrimaryActions, + renderComposerDictationStatus, +} from "./chat-composer-controls.ts"; import { focusComposerFromChrome, paneDomId } from "./chat-composer-dom.ts"; import { renderChatGoal } from "./chat-composer-goal.ts"; import { renderChatComposerPlusMenu } from "./chat-composer-plus-menu.ts"; @@ -351,24 +354,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ` : nothing} -
- ${dictation?.active - ? html` -
- - ${dictation.connecting - ? t("chat.composer.dictationConnecting") - : dictation.finalizing - ? t("chat.composer.dictationFinalizing") - : t("chat.composer.dictationListening")} - -
- ` - : nothing} -
- + ${renderComposerDictationStatus(dictation)} ${renderChatAttachmentInputs({ ...props, disabled: !canCompose })} ${props.realtimeTalkVideoStream ? html` diff --git a/ui/src/pages/chat/composer-dictation.test.ts b/ui/src/pages/chat/composer-dictation.test.ts index 0748069ba3c1..896772e24a1e 100644 --- a/ui/src/pages/chat/composer-dictation.test.ts +++ b/ui/src/pages/chat/composer-dictation.test.ts @@ -203,6 +203,40 @@ describe("ComposerDictationController", () => { controller.dispose(); }); + it("commits and unlocks immediately while remote close finishes in background", async () => { + let resolveClose = () => undefined; + const close = new Promise((resolve) => { + resolveClose = resolve; + }); + request = vi.fn(async (method: string) => { + if (method === "talk.session.create") { + return { + sessionId: "dictation-1", + transcriptionSessionId: "dictation-1", + audio: { inputEncoding: "g711_ulaw", inputSampleRateHz: 8000 }, + }; + } + if (method === "talk.session.close") { + return close; + } + return { ok: true }; + }); + const { controller, onCommit, target } = createHarness(); + await startHold(target); + emit({ transcriptionSessionId: "dictation-1", type: "partial", text: "keep this now" }); + + const committed = controller.finishActive(); + + expect(controller.active).toBe(false); + expect(controller.locksComposer).toBe(false); + expect(onCommit).toHaveBeenCalledWith("keep this now"); + await expect(committed).resolves.toBe(true); + expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }); + resolveClose(); + await Promise.resolve(); + controller.dispose(); + }); + it("consumes the click tail when a hold falls back to unavailable dictation", async () => { const { controller, onDictationUnavailable, onTap, target } = createHarness({ dictationAvailable: false, @@ -371,13 +405,11 @@ describe("ComposerDictationController", () => { final: true, }); await commitLatched(controller, target); - expect(controller.finalizing).toBe(true); + expect(controller.finalizing).toBe(false); + expect(onCommit).toHaveBeenCalledWith("hello world"); await waitForFast(() => expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), ); - await vi.advanceTimersByTimeAsync(1500); - - await waitForFast(() => expect(onCommit).toHaveBeenCalledWith("hello world")); expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }); expect(order.indexOf("talk.session.appendAudio")).toBeLessThan( order.indexOf("talk.session.close"), @@ -395,9 +427,7 @@ describe("ComposerDictationController", () => { await waitForFast(() => expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), ); - await vi.advanceTimersByTimeAsync(1500); - - await waitForFast(() => expect(onCommit).toHaveBeenCalledWith("yes yes")); + expect(onCommit).toHaveBeenCalledWith("yes yes"); controller.dispose(); }); @@ -422,131 +452,11 @@ describe("ComposerDictationController", () => { await waitForFast(() => expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), ); - await vi.advanceTimersByTimeAsync(1500); - - await waitForFast(() => expect(onCommit).toHaveBeenCalledWith("hello world")); + expect(onCommit).toHaveBeenCalledWith("hello world"); controller.dispose(); }); - it("keeps listening for a final transcript after close is acknowledged", async () => { - const { controller, onCommit, target } = createHarness(); - await startHold(target); - - await commitLatched(controller, target); - await waitForFast(() => - expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), - ); - emit({ - transcriptionSessionId: "dictation-1", - type: "transcript", - text: "late final", - final: true, - }); - await vi.advanceTimersByTimeAsync(1000); - emit({ - transcriptionSessionId: "dictation-1", - type: "transcript", - text: "second late", - final: true, - }); - await vi.advanceTimersByTimeAsync(1499); - expect(onCommit).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - - await waitForFast(() => expect(onCommit).toHaveBeenCalledWith("late final second late")); - controller.dispose(); - }); - - it("waits beyond the quiet interval for the first post-close transcript", async () => { - const { controller, onCommit, target } = createHarness(); - await startHold(target); - - await commitLatched(controller, target); - await waitForFast(() => - expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), - ); - await vi.advanceTimersByTimeAsync(1600); - expect(onCommit).not.toHaveBeenCalled(); - emit({ - transcriptionSessionId: "dictation-1", - type: "transcript", - text: "slow first final", - final: true, - }); - await vi.advanceTimersByTimeAsync(1500); - - await waitForFast(() => expect(onCommit).toHaveBeenCalledWith("slow first final")); - expect(controller.finalizing).toBe(false); - expect(controller.locksComposer).toBe(false); - controller.dispose(); - }); - - it("extends the finalization window while partial transcript activity continues", async () => { - const { controller, onCommit, target } = createHarness(); - await startHold(target); - - await commitLatched(controller, target); - await waitForFast(() => - expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), - ); - await vi.advanceTimersByTimeAsync(1400); - emit({ transcriptionSessionId: "dictation-1", type: "partial", text: "still finalizing" }); - await vi.advanceTimersByTimeAsync(200); - emit({ - transcriptionSessionId: "dictation-1", - type: "transcript", - text: "still finalizing", - final: true, - }); - await vi.advanceTimersByTimeAsync(1499); - expect(onCommit).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - - await waitForFast(() => expect(onCommit).toHaveBeenCalledWith("still finalizing")); - controller.dispose(); - }); - - it("inserts a pending partial when finalization fails", async () => { - const { controller, onCommit, onError, target } = createHarness(); - await startHold(target); - emit({ transcriptionSessionId: "dictation-1", type: "partial", text: "keep the partial" }); - - const committed = controller.finishActive(); - await vi.advanceTimersByTimeAsync(10_000); - - await expect(committed).resolves.toBe(true); - expect(onCommit).toHaveBeenCalledWith("keep the partial"); - expect(onError).toHaveBeenCalledWith( - "Dictation stopped before the last partial transcript could be finalized.", - { kind: "interrupted", preservesText: true }, - ); - controller.dispose(); - }); - - it("surfaces provider errors raised while final text is draining", async () => { - const { controller, onCommit, onError, target } = createHarness(); - await startHold(target); - - await commitLatched(controller, target); - await waitForFast(() => - expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), - ); - emit({ - transcriptionSessionId: "dictation-1", - type: "error", - message: "provider drain failed", - }); - await vi.advanceTimersByTimeAsync(1500); - - expect(onError).toHaveBeenCalledWith("provider drain failed", { - kind: "interrupted", - preservesText: false, - }); - expect(onCommit).not.toHaveBeenCalled(); - controller.dispose(); - }); - - it("reports whether finalization committed a transcript", async () => { + it("reports whether the immediate snapshot committed a transcript", async () => { const withTranscript = createHarness(); await startHold(withTranscript.target); emit({ @@ -556,33 +466,16 @@ describe("ComposerDictationController", () => { final: true, }); const committed = withTranscript.controller.finishActive(); - await vi.advanceTimersByTimeAsync(1500); await expect(committed).resolves.toBe(true); withTranscript.controller.dispose(); const withoutTranscript = createHarness(); await startHold(withoutTranscript.target); const empty = withoutTranscript.controller.finishActive(); - await vi.advanceTimersByTimeAsync(10_000); await expect(empty).resolves.toBe(false); withoutTranscript.controller.dispose(); }); - it("ignores extra clicks while final text is draining", async () => { - const { controller, onTap, target } = createHarness(); - await startHold(target); - await commitLatched(controller, target); - target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - - expect(onTap).not.toHaveBeenCalled(); - await waitForFast(() => - expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), - ); - await vi.advanceTimersByTimeAsync(10_000); - controller.dispose(); - }); - it("buffers microphone audio while the transcription session is being created", async () => { const order: string[] = []; let resolveCreate: (result: { @@ -644,12 +537,11 @@ describe("ComposerDictationController", () => { expect(order.indexOf("talk.session.appendAudio")).toBeLessThan( order.indexOf("talk.session.close"), ); - await vi.advanceTimersByTimeAsync(10_000); expect(onCommit).not.toHaveBeenCalled(); controller.dispose(); }); - it("closes a late session after latched Stop", async () => { + it("closes a late session in background after latched Stop", async () => { let resolveCreate: (result: { sessionId: string; transcriptionSessionId: string; @@ -691,10 +583,7 @@ describe("ComposerDictationController", () => { await waitForFast(() => expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "late-session" }), ); - expect(onError).toHaveBeenCalledWith( - "The Gateway returned an unsupported dictation audio format.", - { kind: "interrupted", preservesText: false }, - ); + expect(onError).not.toHaveBeenCalled(); controller.dispose(); }); @@ -738,7 +627,6 @@ describe("ComposerDictationController", () => { await waitForFast(() => expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }), ); - await vi.advanceTimersByTimeAsync(1500); expect(onCommit).toHaveBeenCalledWith("keep recording"); controller.dispose(); }); @@ -772,7 +660,6 @@ describe("ComposerDictationController", () => { expect(harness.onError).toHaveBeenCalledTimes(1); expect(harness.controller.finalizing).toBe(false); expect(harness.controller.locksComposer).toBe(false); - await vi.advanceTimersByTimeAsync(10_000); expect(harness.onError).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "dictation-1" }); harness.controller.dispose(); diff --git a/ui/src/pages/chat/composer-dictation.ts b/ui/src/pages/chat/composer-dictation.ts index ea545335da8b..fdd2fccf2794 100644 --- a/ui/src/pages/chat/composer-dictation.ts +++ b/ui/src/pages/chat/composer-dictation.ts @@ -13,8 +13,6 @@ import { RealtimeTalkLevelSignal } from "./realtime-talk-level.ts"; const HOLD_ARM_DELAY_MS = 200, HOLD_PROGRESS_MS = 600; -const FINAL_TRANSCRIPT_QUIET_MS = 1500; -const FINAL_TRANSCRIPT_MAX_WAIT_MS = 10_000; const DICTATION_ENCODING = "g711_ulaw"; const DICTATION_SAMPLE_RATE_HZ = 8000; const MAX_PENDING_AUDIO_SAMPLES = DICTATION_SAMPLE_RATE_HZ * 10; @@ -119,11 +117,6 @@ class ComposerDictationSession { private transcriptionSessionId: string | null = null; private readonly finalTranscripts: string[] = []; private currentPartial = ""; - private trailingFinalDrain: { - resolve: () => void; - quietTimer: ReturnType | null; - maxTimer: ReturnType; - } | null = null; private startPromise: Promise | null = null; private readonly pendingAudio: Float32Array[] = []; private pendingAudioSamples = 0; @@ -133,7 +126,6 @@ class ComposerDictationSession { private discarded = false; private closed = false; private failed = false; - private gatewayDisconnected = false; constructor( private readonly client: GatewayBrowserClient, @@ -196,7 +188,11 @@ class ComposerDictationSession { } } - async finish(): Promise { + transcriptSnapshot(): string { + return this.transcriptIncludingPartial(); + } + + async finish(): Promise { await this.stopCapture(); await this.startPromise?.catch((error: unknown) => { if (!isAbortError(error)) { @@ -205,25 +201,7 @@ class ComposerDictationSession { }); await this.appendChain; await this.closeRemote(); - if (!this.sessionId) { - this.cleanupEvents(); - return ""; - } - if (this.gatewayDisconnected || this.failed) { - this.cleanupEvents(); - return this.transcriptIncludingPartial(); - } - // A provider can emit several final utterances after close is acknowledged. - // Keep listening until the final stream has stayed quiet for a bounded span. - await this.waitForTrailingFinalDrain(); - if (this.currentPartial) { - this.reportFailure(t("chat.composer.dictationFinalizationTimedOut")); - } - const transcript = this.failed - ? this.transcriptIncludingPartial() - : this.finalTranscripts.join(" ").trim(); this.cleanupEvents(); - return transcript; } async cancel(): Promise { @@ -236,10 +214,6 @@ class ComposerDictationSession { } markGatewayDisconnected(): boolean { - // The relay cannot emit another transcript after its transport is gone. - // Resolving any drain avoids retaining the composer in finalization. - this.gatewayDisconnected = true; - this.resolveTrailingFinalDrain(); return this.hasTranscript(); } @@ -291,7 +265,6 @@ class ComposerDictationSession { if (payload.type === "partial" && typeof payload.text === "string") { this.currentPartial = payload.text.trim(); this.callbacks.onPartial(this.currentPartial); - this.resetTrailingFinalDrain(); return; } if (payload.type === "transcript" && typeof payload.text === "string") { @@ -299,13 +272,11 @@ class ComposerDictationSession { if (payload.final !== true) { this.currentPartial = text; this.callbacks.onPartial(text); - this.resetTrailingFinalDrain(); return; } if (text) { this.finalTranscripts.push(text); this.currentPartial = ""; - this.resetTrailingFinalDrain(); } this.callbacks.onPartial(""); return; @@ -329,7 +300,6 @@ class ComposerDictationSession { return; } this.failed = true; - this.resolveTrailingFinalDrain(); this.callbacks.onError(message, this.hasTranscript()); } @@ -357,7 +327,6 @@ class ComposerDictationSession { } private cleanupEvents(): void { - this.resolveTrailingFinalDrain(); this.closed = true; this.unsubscribe?.(); this.unsubscribe = null; @@ -373,47 +342,6 @@ class ComposerDictationSession { .catch(() => undefined); return this.closePromise; } - - private waitForTrailingFinalDrain(): Promise { - return new Promise((resolve) => { - const hasCompleteTranscript = this.finalTranscripts.length > 0 && !this.currentPartial; - this.trailingFinalDrain = { - resolve, - quietTimer: hasCompleteTranscript - ? globalThis.setTimeout(() => this.resolveTrailingFinalDrain(), FINAL_TRANSCRIPT_QUIET_MS) - : null, - maxTimer: globalThis.setTimeout( - () => this.resolveTrailingFinalDrain(), - FINAL_TRANSCRIPT_MAX_WAIT_MS, - ), - }; - }); - } - - private resetTrailingFinalDrain(): void { - if (!this.trailingFinalDrain) { - return; - } - if (this.trailingFinalDrain.quietTimer !== null) { - globalThis.clearTimeout(this.trailingFinalDrain.quietTimer); - } - this.trailingFinalDrain.quietTimer = globalThis.setTimeout( - () => this.resolveTrailingFinalDrain(), - FINAL_TRANSCRIPT_QUIET_MS, - ); - } - - private resolveTrailingFinalDrain(): void { - if (!this.trailingFinalDrain) { - return; - } - if (this.trailingFinalDrain.quietTimer !== null) { - globalThis.clearTimeout(this.trailingFinalDrain.quietTimer); - } - globalThis.clearTimeout(this.trailingFinalDrain.maxTimer); - this.trailingFinalDrain.resolve(); - this.trailingFinalDrain = null; - } } export class ComposerDictationController { @@ -686,31 +614,30 @@ export class ComposerDictationController { } } - private async stop(options: { commit: boolean }): Promise { + private stop(options: { commit: boolean }): Promise { if (this.phase === "idle" || this.phase === "stopping") { - return false; + return Promise.resolve(false); } const wasActive = this.active; this.clearGesture(); const session = this.session; if (!session) { this.reset(); - return false; + return Promise.resolve(false); } this.setPhase("stopping"); - const transcript = options.commit ? await session.finish() : (await session.cancel(), ""); - const ownsSession = this.session === session; - if (ownsSession) { - this.session = null; - } - const committed = Boolean( - options.commit && transcript && wasActive && ownsSession && !this.disposed, - ); + const transcript = options.commit ? session.transcriptSnapshot() : ""; + const committed = Boolean(options.commit && transcript && wasActive && !this.disposed); + this.session = null; + this.reset(); if (committed) { this.options.onCommit(transcript); } - this.reset(); - return committed; + // UI ownership ends at the operator action. Provider close can be slow, but + // it must never retain the composer in a transient finalizing state. + const close = options.commit ? session.finish() : session.cancel(); + void close.catch(() => undefined); + return Promise.resolve(committed); } private reset(): void { diff --git a/ui/src/pages/new-session/composer-dictation-control.test.ts b/ui/src/pages/new-session/composer-dictation-control.test.ts index 5755a3fd8696..4aa9efe840a4 100644 --- a/ui/src/pages/new-session/composer-dictation-control.test.ts +++ b/ui/src/pages/new-session/composer-dictation-control.test.ts @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render } from "lit"; +import { html, render } from "lit"; import { beforeEach, describe, expect, it, vi } from "vitest"; const dictationHarness = vi.hoisted(() => ({ @@ -169,13 +169,22 @@ describe("NewSessionDictationControl", () => { controller.active = true; controller.partial = "spoken"; expect(control.previewDraft()).toBe("draft spoken"); - render(control.render("agent-a"), container); + render(html`${control.renderStatus()}${control.render("agent-a")}`, container); + expect(container.querySelector(".agent-chat__dictation-status")?.textContent).toContain( + "Listening", + ); container.querySelector(".chat-send-btn--dictating")?.click(); await vi.waitFor(() => expect(onMessage).toHaveBeenCalledWith("draft spoken task")); expect(onSubmit).not.toHaveBeenCalled(); controller.active = true; - render(control.render("agent-a"), container); + controller.finalizing = true; + render(html`${control.renderStatus()}${control.render("agent-a")}`, container); + const stop = container.querySelector(".chat-send-btn--dictating"); + const send = container.querySelector(".chat-send-btn--dictation-commit"); + expect(stop?.querySelector("rect")).not.toBeNull(); + expect(send?.querySelector("path")?.getAttribute("d")).toBe("M12 19V5m-7 7 7-7 7 7"); + controller.finalizing = false; container.querySelector(".chat-send-btn--dictation-commit")?.click(); await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); expect(controller.finishActive).toHaveBeenCalledTimes(2); diff --git a/ui/src/pages/new-session/composer-dictation-control.ts b/ui/src/pages/new-session/composer-dictation-control.ts index c91079ed88d1..36e9ebad077d 100644 --- a/ui/src/pages/new-session/composer-dictation-control.ts +++ b/ui/src/pages/new-session/composer-dictation-control.ts @@ -3,7 +3,8 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { loadSettings, patchSettings } from "../../app/settings.ts"; import { t } from "../../i18n/index.ts"; import { - renderComposerDictationSubmitAction, + renderComposerDictationSendAction, + renderComposerDictationStatus, renderComposerVoiceButton, renderMicrophonePicker, } from "../chat/components/chat-composer-controls.ts"; @@ -53,6 +54,10 @@ export class NewSessionDictationControl { : undefined; } + renderStatus() { + return renderComposerDictationStatus(this.dictation ?? undefined); + } + render(ownerKey: string) { if (this.owner?.key !== ownerKey) { this.owner = { key: ownerKey }; @@ -72,8 +77,8 @@ export class NewSessionDictationControl { dictationAvailable: this.devicePicker.dictationStatus === "ready", realtimeTalkActive: false, onCommit: (transcript: string) => { - // Route changes replace draft ownership while finalization is asynchronous. - // Object identity keeps even an A -> B -> A transition from accepting A's result. + // Route changes replace draft ownership. Object identity keeps even an + // A -> B -> A transition from accepting the prior route's snapshot. if (!ownsDraft() || !this.options.canCommit()) { return; } @@ -125,7 +130,7 @@ export class NewSessionDictationControl { }), onDirectDictationStart: () => this.options.textarea.captureSelection(), })} - ${renderComposerDictationSubmitAction(dictation, () => { + ${renderComposerDictationSendAction(dictation, () => { if (ownsDraft() && this.options.canCommit()) { this.options.onSubmit(); } diff --git a/ui/src/pages/new-session/composer.test.ts b/ui/src/pages/new-session/composer.test.ts index e09e3f070287..f50d6c295f80 100644 --- a/ui/src/pages/new-session/composer.test.ts +++ b/ui/src/pages/new-session/composer.test.ts @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render } from "lit"; +import { html, render, type TemplateResult } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js"; import { createDeferred } from "../../../../test/helpers/promise.ts"; @@ -29,6 +29,7 @@ function renderComposer( blockedSubmitNotice?: string; dictationActive?: boolean; dictationPreview?: string; + dictationStatus?: TemplateResult; terminalAction?: { canStart: boolean; disabledReason?: string; @@ -81,6 +82,7 @@ function renderComposer( blockedSubmitNotice: overrides.blockedSubmitNotice, dictationActive: overrides.dictationActive, dictationPreview: overrides.dictationPreview, + dictationStatus: overrides.dictationStatus, terminalAction: overrides.terminalAction, submitting: overrides.submitting ?? false, textareaController, @@ -415,12 +417,14 @@ describe("new-session composer keyboard submission", () => { message: "Existing draft", dictationActive: true, dictationPreview: "Existing draft spoken words", + dictationStatus: html`
Listening…
`, onSubmit, }); const textarea = composer.querySelector("textarea"); expect(textarea?.value).toBe("Existing draft spoken words"); expect(textarea?.readOnly).toBe(true); + expect(composer.querySelector(".agent-chat__dictation-status")?.textContent).toBe("Listening…"); expect(composer.querySelector(".new-session-page__start-submit")).toBeNull(); textarea?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); expect(onSubmit).not.toHaveBeenCalled(); diff --git a/ui/src/pages/new-session/composer.ts b/ui/src/pages/new-session/composer.ts index 37970c76abe4..05cc7aff5f11 100644 --- a/ui/src/pages/new-session/composer.ts +++ b/ui/src/pages/new-session/composer.ts @@ -56,6 +56,7 @@ type NewSessionComposerOptions = { blockedSubmitNotice?: string; dictationActive?: boolean; dictationPreview?: string; + dictationStatus?: TemplateResult; terminalAction?: { canStart: boolean; disabledReason?: string; @@ -484,6 +485,7 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) { class="agent-chat__input${options.dictationActive ? " agent-chat__input--dictating" : ""}" > ${renderChatAttachmentInputs(attachmentProps)} ${renderAttachmentPreview(attachmentProps)} +
${options.dictationStatus ?? nothing}
${skillMenuVisible @@ -598,6 +600,7 @@ export function renderNewSessionDraftComposer(options: { blockedSubmitNotice?: string; dictationActive?: boolean; dictationPreview?: string; + dictationStatus?: TemplateResult; terminalAction?: { canStart: boolean; disabledReason?: string; @@ -655,6 +658,7 @@ export function renderNewSessionDraftComposer(options: { blockedSubmitNotice: options.blockedSubmitNotice, dictationActive: options.dictationActive, dictationPreview: options.dictationPreview, + dictationStatus: options.dictationStatus, terminalAction: options.terminalAction, submitting: options.submitting, textareaController: options.textareaController, diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 5c0e6c6bbaf2..97a49fad7114 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -554,6 +554,7 @@ export class NewSessionPage extends OpenClawLightDomElement { blockedSubmitNotice: this.submission.blockedSubmitNotice(), dictationActive: this.dictation.active, dictationPreview: this.dictation.previewDraft(), + dictationStatus: this.dictation.renderStatus(), context: this.context, isCatalogTarget: catalog.isTarget(this.data), draftOwnerKey: this.routeOwnerKey(), diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 3dc6c83a0342..753e1f944354 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -5109,14 +5109,6 @@ button.chat-pr__diff { stroke: none; } -.agent-chat__input .chat-send-btn--dictation-commit > svg { - width: 17px; - height: 17px; - fill: none; - stroke: currentColor; - stroke-width: 2.25px; -} - .agent-chat__video-preview { position: absolute; left: 50%;