From 87f508cf979e77d7789e577ee4b9c4521544a111 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Tue, 25 Aug 2026 15:24:20 +0000 Subject: [PATCH] fix(ui): close composer interaction gaps --- .../components/chat-picker-overlay.test.ts | 36 +++++++++++++++++++ .../chat/components/chat-picker-overlay.ts | 5 ++- .../composer-dictation-control.test.ts | 31 +++++++++++++++- .../new-session/composer-dictation-control.ts | 4 +++ ui/src/pages/new-session/new-session-page.ts | 11 ++++-- 5 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 ui/src/pages/chat/components/chat-picker-overlay.test.ts diff --git a/ui/src/pages/chat/components/chat-picker-overlay.test.ts b/ui/src/pages/chat/components/chat-picker-overlay.test.ts new file mode 100644 index 000000000000..dd63b79923a6 --- /dev/null +++ b/ui/src/pages/chat/components/chat-picker-overlay.test.ts @@ -0,0 +1,36 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it } from "vitest"; +import { + handleChatComposerDropdownShow, + markPointerOpenedChatComposerDropdown, + restorePointerOpenedChatComposerTrigger, +} from "./chat-picker-overlay.ts"; + +describe("chat picker overlay", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("does not restore pointer focus after keyboard input takes over", () => { + const dropdown = document.createElement("wa-dropdown"); + const trigger = document.createElement("button"); + trigger.slot = "trigger"; + dropdown.append(trigger); + document.body.append(dropdown); + + dropdown.addEventListener("wa-show", handleChatComposerDropdownShow); + dropdown.dispatchEvent(new Event("wa-show")); + dropdown.addEventListener("pointerdown", markPointerOpenedChatComposerDropdown); + trigger.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + + trigger.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, composed: true, key: "Enter" }), + ); + + dropdown.addEventListener("wa-after-show", restorePointerOpenedChatComposerTrigger); + dropdown.dispatchEvent(new Event("wa-after-show")); + + expect(trigger.hasAttribute("data-chat-pointer-restored-focus")).toBe(false); + }); +}); diff --git a/ui/src/pages/chat/components/chat-picker-overlay.ts b/ui/src/pages/chat/components/chat-picker-overlay.ts index 9a477335bfb6..07dd8a947d00 100644 --- a/ui/src/pages/chat/components/chat-picker-overlay.ts +++ b/ui/src/pages/chat/components/chat-picker-overlay.ts @@ -87,7 +87,10 @@ function ensureChatComposerPickerDismissal(): void { (node): node is HTMLElement => node instanceof HTMLElement && node.localName === "wa-dropdown", ); - dropdown?.removeAttribute(POINTER_OPENED_PICKER_ATTRIBUTE); + if (dropdown) { + pointerOpenedDropdowns.delete(dropdown); + dropdown.removeAttribute(POINTER_OPENED_PICKER_ATTRIBUTE); + } }, true, ); 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 634f46d22380..b37bae5b9a68 100644 --- a/ui/src/pages/new-session/composer-dictation-control.test.ts +++ b/ui/src/pages/new-session/composer-dictation-control.test.ts @@ -7,12 +7,17 @@ const dictationHarness = vi.hoisted(() => ({ onCommit: (transcript: string) => void; onTap: () => void; }, - controllers: [] as Array<{ active: boolean; handlePointerDown: () => void }>, + controllers: [] as Array<{ + active: boolean; + locksComposer: boolean; + handlePointerDown: () => void; + }>, })); vi.mock("../chat/composer-dictation.ts", () => ({ ComposerDictationController: class { active = false; + locksComposer = false; constructor(options: { onCommit: (transcript: string) => void; onTap: () => void }) { dictationHarness.options = options; @@ -133,4 +138,28 @@ describe("NewSessionDictationControl", () => { expect(insertTranscript).not.toHaveBeenCalled(); expect(onMessage).not.toHaveBeenCalled(); }); + + it("publishes whether dictation currently locks draft submission", () => { + const control = new NewSessionDictationControl({ + textarea: { captureSelection: vi.fn(), insertTranscript: vi.fn() } as never, + getClient: () => ({}) as never, + isConnected: () => true, + canCommit: () => true, + onMessage: vi.fn(), + onError: vi.fn(), + onClearError: vi.fn(), + requestUpdate: vi.fn(), + }); + + control.render("agent-a"); + expect(control.locked).toBe(false); + + const controller = dictationHarness.controllers[0]; + if (!controller) { + throw new Error("expected dictation controller"); + } + controller.locksComposer = true; + + expect(control.locked).toBe(true); + }); }); diff --git a/ui/src/pages/new-session/composer-dictation-control.ts b/ui/src/pages/new-session/composer-dictation-control.ts index c6f02c3633fc..88a5e9de44ec 100644 --- a/ui/src/pages/new-session/composer-dictation-control.ts +++ b/ui/src/pages/new-session/composer-dictation-control.ts @@ -38,6 +38,10 @@ export class NewSessionDictationControl { this.devicePicker = new ComposerMicrophonePicker(options.requestUpdate); } + get locked(): boolean { + return this.dictation?.locksComposer === true; + } + dispose(): void { this.owner = null; this.dictation?.dispose(); diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 089507ec7356..f15c7d865b95 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -537,6 +537,8 @@ export class NewSessionPage extends OpenClawLightDomElement { const worktreeNameInvalid = this.place.worktree && !isWorktreeNameValid(this.place.worktreeName); const capabilities = this.submission.capabilities; + const voiceControl = this.dictation.render(this.routeOwnerKey()); + const dictationLocked = this.dictation.locked; return html`
${this.renderTargetBar()} @@ -561,7 +563,7 @@ export class NewSessionPage extends OpenClawLightDomElement { agent: this.place.selectedAgent(), agentId: this.place.agentId, attachmentDraft: this.submission.attachmentDraft, - canSubmit: !this.submission.submitting && this.submission.canSubmit(), + canSubmit: !this.submission.submitting && !dictationLocked && this.submission.canSubmit(), submitDisabledReason: this.submission.submitDisabledReason(), blockedSubmitNotice: this.submission.blockedSubmitNotice(), context: this.context, @@ -589,11 +591,14 @@ export class NewSessionPage extends OpenClawLightDomElement { requestUpdate: () => this.requestUpdate(), submitting: this.submission.submitting, textareaController: this.submission.composerTextarea, - voiceControl: this.dictation.render(this.routeOwnerKey()), + voiceControl, messageLocked: Boolean(this.submission.pendingPlacement.sessionKey), terminalAction: this.submission.showStartInTerminal() ? { - canStart: !this.submission.submitting && this.submission.canSubmit("terminal"), + canStart: + !this.submission.submitting && + !dictationLocked && + this.submission.canSubmit("terminal"), disabledReason: this.submission.submitBlock("terminal")?.reason, onStart: () => void this.submission.startInTerminal(), }