fix(ui): close composer interaction gaps

This commit is contained in:
vyctorbrzezowski
2026-08-25 15:24:20 +00:00
parent 35ff8d8728
commit 87f508cf97
5 changed files with 82 additions and 5 deletions
@@ -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);
});
});
@@ -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,
);
@@ -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);
});
});
@@ -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();
+8 -3
View File
@@ -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`
<div class="new-session-page__draft" aria-busy=${String(this.submission.submitting)}>
${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(),
}