mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(ui): release dictation composer before remote close
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user