fix(ui): release dictation composer before remote close

This commit is contained in:
vyctorbrzezowski
2026-08-25 05:16:42 -03:00
parent 1a8c959767
commit 4e7d9bcd13
12 changed files with 174 additions and 303 deletions
-2
View File
@@ -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.",
@@ -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<HTMLButtonElement>(".chat-send-btn--dictating");
const send = container.querySelector<HTMLButtonElement>(".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",
@@ -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) {
<openclaw-tooltip .content=${tooltip}>
<button
class=${active
? `chat-send-btn chat-send-btn--dictating${finalizing ? " chat-send-btn--dictation-finalizing" : ""}`
? "chat-send-btn chat-send-btn--dictating"
: `chat-send-btn chat-send-btn--voice${props.dictation && !startsDictationDirectly ? " chat-send-btn--hold-enabled" : ""}${arming ? " chat-send-btn--dictation-arming" : ""}`}
type="button"
@pointerdown=${(event: PointerEvent) => props.onDictationPointerDown?.(event)}
@click=${(event: MouseEvent) => {
if (active) {
event.preventDefault();
void props.dictation?.finishActive();
if (!finalizing) {
void props.dictation?.finishActive();
}
return;
}
if (startsDictationDirectly) {
@@ -341,18 +341,16 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) {
}
}}
@contextmenu=${(event: MouseEvent) => props.dictation?.handleContextMenu(event)}
?disabled=${finalizing ||
(!active && (!props.connected || props.sending || props.isBusy))}
?disabled=${!active && (!props.connected || props.sending || props.isBusy)}
aria-disabled=${String(finalizing)}
aria-label=${label}
>
${finalizing
? icons.loader
: active
? icons.stop
: html`
${icons.mic}
<span class="agent-chat__control-label">${label}</span>
`}
${active
? icons.stop
: html`
${icons.mic}
<span class="agent-chat__control-label">${label}</span>
`}
</button>
</openclaw-tooltip>
${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(
>
<openclaw-tooltip .content=${t("chat.runControls.send")}>
<button
class="chat-send-btn chat-send-btn--dictation-commit"
class="chat-send-btn chat-send-btn--send chat-send-btn--dictation-commit"
type="button"
@pointerdown=${onPointerDown}
@click=${async () => {
if (dictation.finalizing) {
return;
}
await dictation.finishActive();
onSend();
}}
?disabled=${dictation.finalizing}
aria-disabled=${String(dictation.finalizing)}
aria-label=${t("chat.runControls.send")}
>
${icons.check}
${icons.arrowUp}
</button>
</openclaw-tooltip>
`;
}
export function renderComposerDictationStatus(dictation?: ComposerDictationController) {
if (!dictation?.active) {
return nothing;
}
return html`
<div class="agent-chat__composer-status-stack">
<div
class=${`agent-chat__dictation-status${dictation.finalizing ? " agent-chat__dictation-status--finalizing" : ""}`}
>
<span class="agent-chat__dictation-phase">
${dictation.connecting
? t("chat.composer.dictationConnecting")
: dictation.finalizing
? t("chat.composer.dictationFinalizing")
: t("chat.composer.dictationListening")}
</span>
</div>
</div>
`;
}
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) {
</openclaw-tooltip>
`;
const dictationSendAction = props.dictation
? renderComposerDictationSubmitAction(
? renderComposerDictationSendAction(
props.dictation,
() => props.onSend(),
props.onPrimaryActionPointerDown,
@@ -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) {
</div>
`
: nothing}
<div class="agent-chat__composer-status-stack">
${dictation?.active
? html`
<div
class=${`agent-chat__dictation-status${dictation.finalizing ? " agent-chat__dictation-status--finalizing" : ""}`}
>
<span class="agent-chat__dictation-phase">
${dictation.connecting
? t("chat.composer.dictationConnecting")
: dictation.finalizing
? t("chat.composer.dictationFinalizing")
: t("chat.composer.dictationListening")}
</span>
</div>
`
: nothing}
</div>
${renderComposerDictationStatus(dictation)}
${renderChatAttachmentInputs({ ...props, disabled: !canCompose })}
${props.realtimeTalkVideoStream
? html`
+41 -154
View File
@@ -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<void>((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();
+17 -90
View File
@@ -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<typeof globalThis.setTimeout> | null;
maxTimer: ReturnType<typeof globalThis.setTimeout>;
} | null = null;
private startPromise: Promise<void> | 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<string> {
transcriptSnapshot(): string {
return this.transcriptIncludingPartial();
}
async finish(): Promise<void> {
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<void> {
@@ -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<void> {
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<boolean> {
private stop(options: { commit: boolean }): Promise<boolean> {
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 {
@@ -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<HTMLButtonElement>(".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<HTMLButtonElement>(".chat-send-btn--dictating");
const send = container.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".chat-send-btn--dictation-commit")?.click();
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(controller.finishActive).toHaveBeenCalledTimes(2);
@@ -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();
}
+5 -1
View File
@@ -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`<div class="agent-chat__dictation-status">Listening…</div>`,
onSubmit,
});
const textarea = composer.querySelector<HTMLTextAreaElement>("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();
+4
View File
@@ -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)}
<div class="agent-chat__composer-lede">${options.dictationStatus ?? nothing}</div>
<div class="agent-chat__composer-input-row">
<div class="agent-chat__composer-combobox">
${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,
@@ -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(),
-8
View File
@@ -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%;