fix(ui): keep microphone input settings usable on narrow screens (#101377)

Merged via squash.

Prepared head SHA: 17329d4f30
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Reviewed-by: @fuller-stack-dev
This commit is contained in:
Jason (Json)
2026-07-07 01:06:40 -06:00
committed by GitHub
parent 55fa22b482
commit d8ffb1f307
11 changed files with 410 additions and 332 deletions
+70 -15
View File
@@ -68,6 +68,20 @@ async function installTalkBrowserFixtures(page: Page) {
});
}
async function installBlockedMicrophoneFixture(page: Page) {
await page.addInitScript(() => {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
enumerateDevices: async () => [],
getUserMedia: async () => {
throw new DOMException("Permission denied", "NotAllowedError");
},
},
});
});
}
describeControlUiE2e("Control UI browser Talk", () => {
beforeAll(async () => {
server = await startControlUiE2eServer();
@@ -104,23 +118,21 @@ describeControlUiE2e("Control UI browser Talk", () => {
try {
await page.goto(`${server.baseUrl}chat`);
await page.setViewportSize({ width: 320, height: 720 });
const microphoneTrigger = page.getByRole("button", { name: "Microphone input" });
await microphoneTrigger.click();
const microphoneMenu = page.getByRole("group", { name: "Microphone input" });
const menuBounds = await microphoneMenu.boundingBox();
expect(menuBounds).not.toBeNull();
expect(menuBounds?.x ?? 0).toBeGreaterThanOrEqual(8);
expect((menuBounds?.x ?? 0) + (menuBounds?.width ?? 0)).toBeLessThanOrEqual(312);
await page.getByRole("button", { name: "USB Audio Interface" }).click();
await expect
.poll(() => page.evaluate(() => document.activeElement?.getAttribute("aria-label")))
.toBe("Microphone input");
await microphoneTrigger.click();
await page.keyboard.press("Escape");
.poll(() => page.getByRole("button", { name: "Microphone input" }).count())
.toBe(0);
const settings = page.getByRole("button", { name: "Chat settings" });
await settings.click();
const settingsDialog = page.getByRole("dialog", { name: "Chat settings" });
const microphoneSelect = settingsDialog.locator('[data-talk-select="microphone"] select');
await expect
.poll(() => page.evaluate(() => document.activeElement?.getAttribute("aria-label")))
.toBe("Microphone input");
await expect.poll(() => microphoneMenu.count()).toBe(0);
.poll(async () =>
(await microphoneSelect.locator("option").allTextContents()).map((label) => label.trim()),
)
.toEqual(["System default", "Built-in Microphone", "USB Audio Interface"]);
await microphoneSelect.selectOption("usb");
await settings.click();
await expect.poll(() => settingsDialog.isVisible()).toBe(false);
await page.getByRole("button", { name: "Start voice input" }).click();
const createRequest = await gateway.waitForRequest("talk.client.create");
@@ -184,4 +196,47 @@ describeControlUiE2e("Control UI browser Talk", () => {
await browser.close();
}
});
it("keeps blocked microphone guidance readable in a narrow viewport", async () => {
const browser = await chromium.launch({ executablePath: chromiumExecutablePath });
const context = await browser.newContext();
const page = await context.newPage();
await installMockGateway(page);
await installBlockedMicrophoneFixture(page);
try {
await page.setViewportSize({ width: 320, height: 720 });
await page.goto(`${server.baseUrl}chat`);
await page.getByRole("button", { name: "Chat settings" }).click();
const settingsDialog = page.getByRole("dialog", { name: "Chat settings" });
await settingsDialog.getByRole("button", { name: "Refresh: Microphone input" }).click();
const permissionAlert = settingsDialog.getByRole("alert");
await expect.poll(() => permissionAlert.isVisible()).toBe(true);
const [settingsBounds, alertBounds] = await Promise.all([
settingsDialog.boundingBox(),
permissionAlert.boundingBox(),
]);
expect(settingsBounds).not.toBeNull();
expect(alertBounds).not.toBeNull();
expect(settingsBounds?.width ?? 0).toBeGreaterThanOrEqual(280);
expect(settingsBounds?.x ?? 0).toBeGreaterThanOrEqual(8);
expect((settingsBounds?.x ?? 0) + (settingsBounds?.width ?? 0)).toBeLessThanOrEqual(312);
expect(alertBounds?.x ?? 0).toBeGreaterThanOrEqual(settingsBounds?.x ?? 0);
expect((alertBounds?.x ?? 0) + (alertBounds?.width ?? 0)).toBeLessThanOrEqual(
(settingsBounds?.x ?? 0) + (settingsBounds?.width ?? 0),
);
expect(alertBounds?.y ?? 0).toBeGreaterThanOrEqual(settingsBounds?.y ?? 0);
expect((alertBounds?.y ?? 0) + (alertBounds?.height ?? 0)).toBeLessThanOrEqual(
(settingsBounds?.y ?? 0) + (settingsBounds?.height ?? 0),
);
await expect
.poll(() => permissionAlert.textContent())
.toContain("Microphone access is blocked.");
} finally {
await context.close();
await browser.close();
}
});
});
+41
View File
@@ -70,6 +70,13 @@ function createProps(overrides: Record<string, unknown> = {}): ChatControlsProps
voice: "marin",
vadThreshold: "",
},
realtimeTalkInputDevices: [
{ deviceId: "built-in", label: "Built-in Microphone" },
{ deviceId: "usb", label: "USB Audio Interface" },
],
realtimeTalkInputDeviceId: "built-in",
onRealtimeTalkInputRefresh: () => undefined,
onRealtimeTalkInputSelect: () => undefined,
onRealtimeTalkOptionsChange: () => undefined,
...overrides,
} as unknown as ChatControlsProps;
@@ -90,6 +97,7 @@ describe("chat composer settings", () => {
),
).toEqual(["Chat", "Voice"]);
expect(container.querySelector('[aria-label="Voice options"]')).not.toBeNull();
expect(container.querySelector('[data-talk-select="microphone"] select')).not.toBeNull();
});
it("keeps voice options editable from Settings", () => {
@@ -108,6 +116,39 @@ describe("chat composer settings", () => {
expect(onRealtimeTalkOptionsChange).toHaveBeenCalledWith({ voice: "cedar" });
});
it("keeps microphone selection in Voice settings", () => {
const container = document.createElement("div");
const onRealtimeTalkInputSelect = vi.fn();
render(renderChatControls(createProps({ onRealtimeTalkInputSelect })), container);
const microphone = container.querySelector<HTMLSelectElement>(
'[data-talk-select="microphone"] select',
);
expect(microphone).toBeInstanceOf(HTMLSelectElement);
if (!(microphone instanceof HTMLSelectElement)) {
throw new Error("expected microphone select");
}
expect(microphone.value).toBe("built-in");
microphone.value = "usb";
microphone.dispatchEvent(new Event("change", { bubbles: true }));
expect(onRealtimeTalkInputSelect).toHaveBeenCalledWith("usb");
});
it("refreshes microphone access from Voice settings", () => {
const container = document.createElement("div");
const onRealtimeTalkInputRefresh = vi.fn();
render(renderChatControls(createProps({ onRealtimeTalkInputRefresh })), container);
const refresh = container.querySelector<HTMLButtonElement>(
'button[aria-label="Refresh: Microphone input"]',
);
expect(refresh).toBeInstanceOf(HTMLButtonElement);
refresh?.click();
expect(onRealtimeTalkInputRefresh).toHaveBeenCalledOnce();
});
it("keeps the composer control cluster limited to model and Settings controls", () => {
const container = document.createElement("div");
render(renderChatControls(createProps()), container);
+12 -35
View File
@@ -355,15 +355,6 @@ export class ChatPane extends LitElement {
});
return;
}
if (state.realtimeTalkInputOpen) {
event.preventDefault();
state.realtimeTalkInputOpen = false;
state.requestUpdate();
void this.updateComplete.then(() => {
this.querySelector<HTMLButtonElement>(".agent-chat__talk-caret")?.focus();
});
return;
}
if (!state.chatMobileControlsOpen) {
return;
}
@@ -384,13 +375,6 @@ export class ChatPane extends LitElement {
changed = true;
}
});
if (state.realtimeTalkInputOpen) {
const inputPicker = this.querySelector(".agent-chat__talk-input-picker");
if (!inputPicker || !path.includes(inputPicker)) {
state.realtimeTalkInputOpen = false;
changed = true;
}
}
if (changed) {
state.requestUpdate();
}
@@ -824,11 +808,6 @@ export class ChatPane extends LitElement {
realtimeTalkStatus: state.realtimeTalkStatus,
realtimeTalkDetail: state.realtimeTalkDetail,
realtimeTalkConversation: state.realtimeTalkConversation,
realtimeTalkInputOpen: state.realtimeTalkInputOpen,
realtimeTalkInputDevices: state.realtimeTalkInputDevices,
realtimeTalkInputDeviceId: state.realtimeTalkInputDeviceId,
realtimeTalkInputLoading: state.realtimeTalkInputLoading,
realtimeTalkInputError: state.realtimeTalkInputError,
connected: state.connected,
canSend: state.connected && !selectedSessionArchived,
disabledReason,
@@ -876,8 +855,14 @@ export class ChatPane extends LitElement {
sessionsResult: state.sessionsResult,
stream: state.chatStream,
realtimeTalkOptions: state.realtimeTalkOptions,
realtimeTalkInputDevices: state.realtimeTalkInputDevices,
realtimeTalkInputDeviceId: state.realtimeTalkInputDeviceId,
realtimeTalkInputLoading: state.realtimeTalkInputLoading,
realtimeTalkInputError: state.realtimeTalkInputError,
canOpenRealtimeTalkSettings,
onRefresh: () => handleChatManualRefresh(state),
onRealtimeTalkInputRefresh: () => void state.refreshRealtimeTalkInputs(true),
onRealtimeTalkInputSelect: state.selectRealtimeTalkInput,
onRealtimeTalkOptionsChange: state.updateRealtimeTalkOptions,
onOpenRealtimeTalkSettings: () => {
if (!canOpenRealtimeTalkSettings) {
@@ -886,7 +871,12 @@ export class ChatPane extends LitElement {
this.context.navigate("communications", { search: "?section=talk" });
},
onSettingsChange: state.applySettings,
onSettingsOpenChange: state.setChatMobileControlsOpen,
onSettingsOpenChange: (open, options) => {
state.setChatMobileControlsOpen(open, options);
if (open) {
void state.refreshRealtimeTalkInputs(false);
}
},
onToggleCronSessions: () => {
state.sessionsHideCron = !state.sessionsHideCron;
state.requestUpdate?.();
@@ -924,19 +914,6 @@ export class ChatPane extends LitElement {
this.context.navigate("sessions", { search: `?${search.toString()}` });
},
onToggleRealtimeTalk: () => void state.toggleRealtimeTalk(),
onToggleRealtimeTalkInput: () => {
state.realtimeTalkInputOpen = !state.realtimeTalkInputOpen;
state.requestUpdate?.();
if (state.realtimeTalkInputOpen) {
void state.refreshRealtimeTalkInputs(true);
}
},
onRealtimeTalkInputSelect: (deviceId) => {
state.selectRealtimeTalkInput(deviceId);
void this.updateComplete.then(() => {
this.querySelector<HTMLButtonElement>(".agent-chat__talk-caret")?.focus();
});
},
onDismissError: () => {
dismissChatError(state as never);
state.requestUpdate?.();
+62
View File
@@ -20,6 +20,10 @@ import {
type ChatRealtimeState,
} from "./chat-realtime.ts";
function mediaDevice(kind: MediaDeviceKind, deviceId: string, label: string): MediaDeviceInfo {
return { kind, deviceId, label, groupId: "", toJSON: () => ({}) } as MediaDeviceInfo;
}
function createState(): ChatRealtimeState {
const settings = loadSettings();
const state = {
@@ -46,6 +50,7 @@ describe("chat realtime microphone selection", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("keeps the selected input in memory when persistence fails and shares it across panes", async () => {
@@ -69,4 +74,61 @@ describe("chat realtime microphone selection", () => {
);
expect(sessionStart).toHaveBeenCalledOnce();
});
it("does not reject a persisted input from incomplete passive discovery", async () => {
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn(async () => [
mediaDevice("audioinput", "built-in", "Built-in Microphone"),
]),
},
});
const state = createState();
state.settings = { ...state.settings, gatewayUrl: "ws://passive-discovery.example" };
state.selectRealtimeTalkInput("usb-mic");
await state.refreshRealtimeTalkInputs(false);
expect(state.realtimeTalkInputDeviceId).toBe("usb-mic");
expect(state.realtimeTalkInputError).toBeNull();
});
it("reports a missing persisted input after successful permissioned discovery", async () => {
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn(async () => [
mediaDevice("audioinput", "built-in", "Built-in Microphone"),
]),
},
});
const state = createState();
state.settings = { ...state.settings, gatewayUrl: "ws://permissioned-discovery.example" };
state.selectRealtimeTalkInput("usb-mic");
await state.refreshRealtimeTalkInputs(true);
expect(state.realtimeTalkInputDeviceId).toBe("usb-mic");
expect(state.realtimeTalkInputError).toContain("The selected microphone is unavailable");
});
it("keeps permission guidance when permissioned discovery is incomplete", async () => {
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn(async () => [
mediaDevice("audioinput", "built-in", "Built-in Microphone"),
mediaDevice("audioinput", "", ""),
]),
getUserMedia: vi.fn(async () => {
throw new DOMException("denied", "NotAllowedError");
}),
},
});
const state = createState();
state.settings = { ...state.settings, gatewayUrl: "ws://blocked-discovery.example" };
state.selectRealtimeTalkInput("usb-mic");
await state.refreshRealtimeTalkInputs(true);
expect(state.realtimeTalkInputError).toContain("Microphone access is blocked");
});
});
+2 -3
View File
@@ -43,7 +43,6 @@ export type ChatRealtimeState = {
realtimeTalkDetail: string | null;
realtimeTalkConversation: RealtimeTalkConversationEntry[];
realtimeTalkOptions: RealtimeTalkOptions;
realtimeTalkInputOpen: boolean;
realtimeTalkInputDevices: RealtimeTalkInputDevice[];
realtimeTalkInputDeviceId: string;
realtimeTalkInputLoading: boolean;
@@ -74,7 +73,6 @@ export function createInitialChatRealtimeState(inputDeviceId = "") {
realtimeTalkDetail: null,
realtimeTalkConversation: [],
realtimeTalkOptions: createDefaultRealtimeTalkOptions(),
realtimeTalkInputOpen: false,
realtimeTalkInputDevices: [] as RealtimeTalkInputDevice[],
realtimeTalkInputDeviceId: inputDeviceId,
realtimeTalkInputLoading: false,
@@ -118,6 +116,8 @@ async function refreshRealtimeTalkInputs(
state.realtimeTalkInputDevices = result.devices;
state.realtimeTalkInputDeviceId = currentRealtimeTalkInput(state);
const selectedDeviceMissing =
requestPermission &&
result.warning === null &&
state.realtimeTalkInputDeviceId.length > 0 &&
result.devices.length > 0 &&
!result.devices.some((device) => device.deviceId === state.realtimeTalkInputDeviceId);
@@ -158,7 +158,6 @@ export function attachChatRealtimeActions(state: ChatRealtimeState) {
};
saveSettings(state.settings);
state.realtimeTalkInputError = null;
state.realtimeTalkInputOpen = false;
state.requestUpdate();
};
state.toggleRealtimeTalk = async () => {
+43 -61
View File
@@ -669,6 +669,8 @@ function renderVoiceOptions(overrides: Partial<ChatRealtimeTalkOptionsProps> = {
vadThreshold: "",
},
onRealtimeTalkOptionsChange: () => undefined,
onRealtimeTalkInputRefresh: () => undefined,
onRealtimeTalkInputSelect: () => undefined,
...overrides,
}),
container,
@@ -1277,40 +1279,6 @@ describe("chat composer workbench", () => {
expect(voiceButton).not.toBeNull();
expect(voiceButton?.closest(".agent-chat__composer-input-row")).not.toBeNull();
expect(container.querySelector('button[aria-label="Talk settings"]')).toBeNull();
});
it("exposes the microphone input picker state from its own callback contract", () => {
const onToggleRealtimeTalkInput = vi.fn();
const collapsed = renderChatView({
onToggleRealtimeTalkInput,
realtimeTalkInputOpen: false,
});
const collapsedButton = collapsed.querySelector<HTMLButtonElement>(
'button[aria-label="Microphone input"]',
);
expect(collapsedButton?.getAttribute("aria-expanded")).toBe("false");
collapsedButton?.click();
expect(onToggleRealtimeTalkInput).toHaveBeenCalledOnce();
const expanded = renderChatView({
onToggleRealtimeTalkInput: () => undefined,
onRealtimeTalkInputSelect: () => undefined,
realtimeTalkInputOpen: true,
});
const expandedButton = expanded.querySelector<HTMLButtonElement>(
'button[aria-label="Microphone input"]',
);
const menu = expanded.querySelector<HTMLElement>(
'[role="group"][aria-label="Microphone input"]',
);
expect(expandedButton?.getAttribute("aria-expanded")).toBe("true");
expect(expandedButton?.getAttribute("aria-controls")).toBe(menu?.id);
});
it("does not render a dead microphone input button without its callback", () => {
const container = renderChatView({ realtimeTalkInputOpen: true });
expect(container.querySelector('button[aria-label="Start voice input"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Microphone input"]')).toBeNull();
});
});
@@ -1827,60 +1795,71 @@ describe("chat voice controls", () => {
expect(onSend).not.toHaveBeenCalled();
});
it("shows every available microphone in the input picker", () => {
it("shows every available microphone in Voice settings", () => {
const onRealtimeTalkInputSelect = vi.fn();
const container = renderChatView({
realtimeTalkInputOpen: true,
const container = renderVoiceOptions({
realtimeTalkInputDevices: [
{ deviceId: "built-in", label: "MacBook Microphone" },
{ deviceId: "usb", label: "USB Audio Interface" },
],
realtimeTalkInputDeviceId: "usb",
onToggleRealtimeTalkInput: () => undefined,
onRealtimeTalkInputSelect,
});
const options = Array.from(
container.querySelectorAll<HTMLButtonElement>(".agent-chat__talk-input-option"),
const microphone = container.querySelector<HTMLSelectElement>(
'[data-talk-select="microphone"] select',
);
expect(options.map((option) => option.textContent?.trim())).toEqual([
"System default",
"MacBook Microphone",
"USB Audio Interface",
]);
expect(options.map((option) => option.getAttribute("aria-pressed"))).toEqual([
"false",
"false",
"true",
]);
options[1]?.click();
expect(microphone).toBeInstanceOf(HTMLSelectElement);
expect(
Array.from(microphone?.options ?? []).map((option) => option.textContent?.trim()),
).toEqual(["System default", "MacBook Microphone", "USB Audio Interface"]);
expect(microphone?.value).toBe("usb");
if (!(microphone instanceof HTMLSelectElement)) {
throw new Error("expected microphone select");
}
microphone.value = "built-in";
microphone.dispatchEvent(new Event("change", { bubbles: true }));
expect(onRealtimeTalkInputSelect).toHaveBeenCalledWith("built-in");
});
it("keeps a persisted microphone visible before discovery can name it", () => {
const container = renderVoiceOptions({
realtimeTalkInputDeviceId: "persisted-microphone",
});
const microphone = container.querySelector<HTMLSelectElement>(
'[data-talk-select="microphone"] select',
);
expect(microphone).toBeInstanceOf(HTMLSelectElement);
expect(
Array.from(microphone?.options ?? []).map((option) => ({
label: option.textContent?.trim(),
value: option.value,
})),
).toEqual([
{ label: "System default", value: "" },
{ label: "Microphone 1", value: "persisted-microphone" },
]);
expect(microphone?.value).toBe("persisted-microphone");
});
it("shows microphone loading, empty, and error states without hiding System default", () => {
const loading = renderChatView({
realtimeTalkInputOpen: true,
const loading = renderVoiceOptions({
realtimeTalkInputLoading: true,
onToggleRealtimeTalkInput: () => undefined,
onRealtimeTalkInputSelect: () => undefined,
});
expect(loading.textContent).toContain("System default");
expect(loading.textContent).toContain("Loading microphones");
expect(loading.textContent).not.toContain("No additional microphones found");
const empty = renderChatView({
realtimeTalkInputOpen: true,
onToggleRealtimeTalkInput: () => undefined,
const empty = renderVoiceOptions({
onRealtimeTalkInputSelect: () => undefined,
});
expect(empty.textContent).toContain("No additional microphones found");
const error = renderChatView({
realtimeTalkInputOpen: true,
const error = renderVoiceOptions({
realtimeTalkInputError: "Microphone access is blocked.",
onToggleRealtimeTalkInput: () => undefined,
onRealtimeTalkInputSelect: () => undefined,
});
expect(error.textContent).toContain("Microphone access is blocked.");
@@ -2007,6 +1986,9 @@ describe("chat voice controls", () => {
expect(
voiceOptions.querySelector('[data-talk-select="sensitivity"] > span')?.textContent?.trim(),
).toBe(t("chat.composer.talkSensitivity"));
expect(
voiceOptions.querySelector('[data-talk-select="microphone"] > span')?.textContent?.trim(),
).toBe(t("chat.composer.microphoneInput"));
expect(
voiceOptions.querySelector<HTMLInputElement>(".agent-chat__talk-options-primary input")
?.placeholder,
-15
View File
@@ -40,7 +40,6 @@ import {
} from "./components/chat-thread.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts";
import type { RealtimeTalkConversationEntry } from "./realtime-talk-conversation.ts";
import type { RealtimeTalkInputDevice } from "./realtime-talk-input.ts";
import type { RealtimeTalkStatus } from "./realtime-talk.ts";
import type { ChatRunUiStatus } from "./run-lifecycle.ts";
import type { CompactionStatus, FallbackStatus } from "./tool-stream.ts";
@@ -72,11 +71,6 @@ export type ChatProps = {
realtimeTalkStatus?: RealtimeTalkStatus;
realtimeTalkDetail?: string | null;
realtimeTalkConversation?: RealtimeTalkConversationEntry[];
realtimeTalkInputOpen?: boolean;
realtimeTalkInputDevices?: RealtimeTalkInputDevice[];
realtimeTalkInputDeviceId?: string;
realtimeTalkInputLoading?: boolean;
realtimeTalkInputError?: string | null;
connected: boolean;
canSend: boolean;
disabledReason: string | null;
@@ -118,8 +112,6 @@ export type ChatProps = {
onCompact?: () => void | Promise<void>;
onOpenSessionCheckpoints?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onToggleRealtimeTalkInput?: () => void;
onRealtimeTalkInputSelect?: (deviceId: string) => void;
onDismissError?: () => void;
onDismissRealtimeTalkError?: () => void;
onAbort?: () => void;
@@ -236,11 +228,6 @@ export function renderChat(props: ChatProps) {
realtimeTalkStatus: props.realtimeTalkStatus,
realtimeTalkDetail: props.realtimeTalkDetail,
realtimeTalkConversation: props.realtimeTalkConversation,
realtimeTalkInputOpen: props.realtimeTalkInputOpen,
realtimeTalkInputDevices: props.realtimeTalkInputDevices,
realtimeTalkInputDeviceId: props.realtimeTalkInputDeviceId,
realtimeTalkInputLoading: props.realtimeTalkInputLoading,
realtimeTalkInputError: props.realtimeTalkInputError,
composerControls: props.composerControls,
getDraft: props.getDraft,
onDraftChange: props.onDraftChange,
@@ -250,8 +237,6 @@ export function renderChat(props: ChatProps) {
onSend: props.onSend,
onCompact: props.onCompact,
onToggleRealtimeTalk: props.onToggleRealtimeTalk,
onToggleRealtimeTalkInput: props.onToggleRealtimeTalkInput,
onRealtimeTalkInputSelect: props.onRealtimeTalkInputSelect,
onDismissRealtimeTalkError: props.onDismissRealtimeTalkError,
onAbort: props.onAbort,
onQueueRemove: props.onQueueRemove,
@@ -40,11 +40,9 @@ import {
import { exportChatMarkdown } from "../export.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts";
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
import type { RealtimeTalkInputDevice } from "../realtime-talk-input.ts";
import type { RealtimeTalkStatus } from "../realtime-talk.ts";
import { CHAT_RUN_STATUS_TOAST_DURATION_MS, type ChatRunUiStatus } from "../run-lifecycle.ts";
import type { CompactionStatus, FallbackStatus } from "../tool-stream.ts";
import { renderRealtimeTalkInputPicker } from "./chat-realtime-controls.ts";
const COMPACTION_TOAST_DURATION_MS = 5000;
const FALLBACK_TOAST_DURATION_MS = 8000;
@@ -94,11 +92,6 @@ type ChatComposerProps = {
realtimeTalkStatus?: RealtimeTalkStatus;
realtimeTalkDetail?: string | null;
realtimeTalkConversation?: RealtimeTalkConversationEntry[];
realtimeTalkInputOpen?: boolean;
realtimeTalkInputDevices?: RealtimeTalkInputDevice[];
realtimeTalkInputDeviceId?: string;
realtimeTalkInputLoading?: boolean;
realtimeTalkInputError?: string | null;
composerControls?: TemplateResult | typeof nothing;
getDraft?: () => string;
onDraftChange: (next: string) => void;
@@ -108,8 +101,6 @@ type ChatComposerProps = {
onSend: () => void;
onCompact?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onToggleRealtimeTalkInput?: () => void;
onRealtimeTalkInputSelect?: (deviceId: string) => void;
onDismissRealtimeTalkError?: () => void;
onAbort?: () => void;
onQueueRemove: (id: string) => void;
@@ -2183,7 +2174,6 @@ export function renderChatComposer(props: ChatComposerProps) {
const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel(state);
const slashMenuListboxId = paneDomId(props.paneId, "slash-menu-listbox");
const slashMenuAnnouncementId = paneDomId(props.paneId, "slash-active-announcement");
const talkInputMenuId = paneDomId(props.paneId, "talk-input");
return html`
${renderChatQueue({
@@ -2438,27 +2428,6 @@ export function renderChatComposer(props: ChatComposerProps) {
>
</div>
<div class="agent-chat__composer-actions">
${props.onToggleRealtimeTalkInput
? html`
<div class="agent-chat__talk-input-picker">
<openclaw-tooltip .content=${t("chat.composer.microphoneInput")}>
<button
class="agent-chat__input-btn agent-chat__talk-caret ${props.realtimeTalkInputOpen
? "agent-chat__input-btn--open"
: ""}"
@click=${props.onToggleRealtimeTalkInput}
aria-label=${t("chat.composer.microphoneInput")}
aria-controls=${talkInputMenuId}
aria-expanded=${props.realtimeTalkInputOpen ? "true" : "false"}
?disabled=${!canCompose || props.realtimeTalkActive}
>
${icons.chevronDown}
</button>
</openclaw-tooltip>
${renderRealtimeTalkInputPicker(props, talkInputMenuId)}
</div>
`
: nothing}
${renderChatPrimaryActions(runControlsProps)}
</div>
</div>
@@ -16,6 +16,7 @@ import {
normalizeAgentId,
parseAgentSessionKey,
} from "../../../lib/sessions/session-key.ts";
import type { RealtimeTalkInputDevice } from "../realtime-talk-input.ts";
import { renderChatModelControls, type ChatModelControlsProps } from "./chat-model-controls.ts";
import { renderRealtimeTalkOptions, type RealtimeTalkOptions } from "./chat-realtime-controls.ts";
@@ -36,9 +37,15 @@ type ChatControlsProps = {
sessionsResult: SessionsListResult | null;
stream: string | null;
realtimeTalkOptions?: RealtimeTalkOptions;
realtimeTalkInputDevices?: RealtimeTalkInputDevice[];
realtimeTalkInputDeviceId?: string;
realtimeTalkInputLoading?: boolean;
realtimeTalkInputError?: string | null;
canOpenRealtimeTalkSettings?: boolean;
onOpenRealtimeTalkSettings?: () => void;
onRefresh: () => Promise<void> | void;
onRealtimeTalkInputRefresh?: () => void;
onRealtimeTalkInputSelect?: (deviceId: string) => void;
onRealtimeTalkOptionsChange?: (next: Partial<RealtimeTalkOptions>) => void;
onSettingsChange: (next: UiSettings) => void;
onSettingsOpenChange: (
@@ -338,7 +345,13 @@ export function renderChatControls(props: ChatControlsProps) {
<span class="chat-settings-popover__label">${t("chat.voiceSettings")}</span>
${renderRealtimeTalkOptions({
realtimeTalkOptions: props.realtimeTalkOptions,
realtimeTalkInputDevices: props.realtimeTalkInputDevices,
realtimeTalkInputDeviceId: props.realtimeTalkInputDeviceId,
realtimeTalkInputLoading: props.realtimeTalkInputLoading,
realtimeTalkInputError: props.realtimeTalkInputError,
onRealtimeTalkOptionsChange: props.onRealtimeTalkOptionsChange,
onRealtimeTalkInputRefresh: props.onRealtimeTalkInputRefresh,
onRealtimeTalkInputSelect: props.onRealtimeTalkInputSelect,
canOpenRealtimeTalkSettings: props.canOpenRealtimeTalkSettings,
onOpenRealtimeTalkSettings: props.onOpenRealtimeTalkSettings,
embedded: true,
@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
import type { RealtimeTalkInputDevice } from "../realtime-talk-input.ts";
@@ -27,19 +28,16 @@ export type RealtimeTalkOptions = {
export type ChatRealtimeTalkOptionsProps = {
realtimeTalkOptions?: RealtimeTalkOptions;
onRealtimeTalkOptionsChange?: (next: Partial<RealtimeTalkOptions>) => void;
canOpenRealtimeTalkSettings?: boolean;
onOpenRealtimeTalkSettings?: () => void;
embedded?: boolean;
};
type ChatRealtimeTalkInputProps = {
realtimeTalkInputOpen?: boolean;
realtimeTalkInputDevices?: RealtimeTalkInputDevice[];
realtimeTalkInputDeviceId?: string;
realtimeTalkInputLoading?: boolean;
realtimeTalkInputError?: string | null;
onRealtimeTalkOptionsChange?: (next: Partial<RealtimeTalkOptions>) => void;
onRealtimeTalkInputRefresh?: () => void;
onRealtimeTalkInputSelect?: (deviceId: string) => void;
canOpenRealtimeTalkSettings?: boolean;
onOpenRealtimeTalkSettings?: () => void;
embedded?: boolean;
};
type ChatRealtimeTalkConversationProps = {
@@ -49,7 +47,7 @@ type ChatRealtimeTalkConversationProps = {
};
function renderNativeTalkSelect(params: {
id: "sensitivity" | "voice";
id: "microphone" | "sensitivity" | "voice";
label: string;
value: string;
options: TalkSelectOption[];
@@ -95,6 +93,86 @@ function getTalkSensitivityOptions(): TalkSelectOption[] {
];
}
function renderRealtimeTalkInputSetting(props: ChatRealtimeTalkOptionsProps) {
if (!props.onRealtimeTalkInputSelect) {
return nothing;
}
const devices = props.realtimeTalkInputDevices ?? [];
const selectedDeviceId = props.realtimeTalkInputDeviceId?.trim() ?? "";
const selectedDeviceKnown = devices.some((device) => device.deviceId === selectedDeviceId);
const options = [
{ label: t("chat.composer.systemDefaultMicrophone"), value: "" },
...devices.map((device) => ({ label: device.label, value: device.deviceId })),
...(selectedDeviceId && !selectedDeviceKnown
? [
{
label: t("chat.composer.microphoneFallback", {
number: String(devices.length + 1),
}),
value: selectedDeviceId,
},
]
: []),
];
const refreshLabel = `${t("common.refresh")}: ${t("chat.composer.microphoneInput")}`;
return html`
<div class="agent-chat__talk-input-setting">
<div class="agent-chat__talk-input-control">
${renderNativeTalkSelect({
id: "microphone",
label: t("chat.composer.microphoneInput"),
value: selectedDeviceId,
options,
onSelect: props.onRealtimeTalkInputSelect,
})}
${props.onRealtimeTalkInputRefresh
? html`
<openclaw-tooltip .content=${refreshLabel}>
<button
type="button"
class="agent-chat__talk-input-refresh"
aria-label=${refreshLabel}
?disabled=${props.realtimeTalkInputLoading}
@click=${props.onRealtimeTalkInputRefresh}
>
${props.realtimeTalkInputLoading ? icons.loader : icons.refresh}
</button>
</openclaw-tooltip>
`
: nothing}
</div>
${props.realtimeTalkInputLoading
? html`
<div
class="agent-chat__talk-input-message agent-chat__talk-input-message--loading"
role="status"
aria-live="polite"
>
<span class="agent-chat__talk-input-spinner" aria-hidden="true">${icons.loader}</span>
<span>${t("chat.composer.loadingMicrophones")}</span>
</div>
`
: nothing}
${!props.realtimeTalkInputLoading && devices.length === 0 && !props.realtimeTalkInputError
? html`<div class="agent-chat__talk-input-message" role="status">
${t("chat.composer.noMicrophones")}
</div>`
: nothing}
${props.realtimeTalkInputError
? html`<div
class="agent-chat__talk-input-message agent-chat__talk-input-message--error"
role="alert"
>
<span class="agent-chat__talk-input-message-icon" aria-hidden="true"
>${icons.alertTriangle}</span
>
<span>${props.realtimeTalkInputError}</span>
</div>`
: nothing}
</div>
`;
}
export function renderRealtimeTalkOptions(props: ChatRealtimeTalkOptionsProps) {
const options = props.realtimeTalkOptions;
const onChange = props.onRealtimeTalkOptionsChange;
@@ -131,6 +209,7 @@ export function renderRealtimeTalkOptions(props: ChatRealtimeTalkOptionsProps) {
options: getTalkSensitivityOptions(),
onSelect: (vadThreshold) => onChange({ vadThreshold }),
})}
${renderRealtimeTalkInputSetting(props)}
</div>
${props.onOpenRealtimeTalkSettings
? html`
@@ -153,77 +232,6 @@ export function renderRealtimeTalkOptions(props: ChatRealtimeTalkOptionsProps) {
`;
}
export function renderRealtimeTalkInputPicker(props: ChatRealtimeTalkInputProps, menuId: string) {
if (!props.realtimeTalkInputOpen || !props.onRealtimeTalkInputSelect) {
return nothing;
}
const selectedDeviceId = props.realtimeTalkInputDeviceId ?? "";
const devices = props.realtimeTalkInputDevices ?? [];
const renderOption = (deviceId: string, label: string) => {
const selected = selectedDeviceId === deviceId;
return html`
<button
type="button"
class="agent-chat__talk-input-option ${selected
? "agent-chat__talk-input-option--selected"
: ""}"
aria-pressed=${selected ? "true" : "false"}
@click=${() => props.onRealtimeTalkInputSelect?.(deviceId)}
>
<span>${label}</span>
${selected
? html`<span class="agent-chat__talk-input-check" aria-hidden="true"
>${icons.check}</span
>`
: nothing}
</button>
`;
};
return html`
<div
class="agent-chat__talk-input-menu"
id=${menuId}
role="group"
aria-label=${t("chat.composer.microphoneInput")}
>
<div class="agent-chat__talk-input-heading">
<span>${t("chat.composer.microphoneInput")}</span>
${props.realtimeTalkInputLoading
? html`<span class="agent-chat__talk-input-spinner" aria-hidden="true"
>${icons.loader}</span
>`
: nothing}
</div>
<div class="agent-chat__talk-input-options">
${renderOption("", t("chat.composer.systemDefaultMicrophone"))}
${repeat(
devices,
(device) => device.deviceId,
(device) => renderOption(device.deviceId, device.label),
)}
</div>
${props.realtimeTalkInputLoading && devices.length === 0
? html`<div class="agent-chat__talk-input-message" role="status" aria-live="polite">
${t("chat.composer.loadingMicrophones")}
</div>`
: nothing}
${!props.realtimeTalkInputLoading && devices.length === 0 && !props.realtimeTalkInputError
? html`<div class="agent-chat__talk-input-message" role="status">
${t("chat.composer.noMicrophones")}
</div>`
: nothing}
${props.realtimeTalkInputError
? html`<div
class="agent-chat__talk-input-message agent-chat__talk-input-message--error"
role="alert"
>
${props.realtimeTalkInputError}
</div>`
: nothing}
</div>
`;
}
export function renderRealtimeTalkConversation(props: ChatRealtimeTalkConversationProps) {
const entries = props.realtimeTalkConversation ?? [];
if (entries.length === 0) {
+79 -92
View File
@@ -1389,33 +1389,6 @@ openclaw-chat-page {
background: color-mix(in srgb, var(--danger, #ef4444) 9%, transparent);
}
/* Split talk control: mic toggles Talk, the narrow caret opens microphone inputs
(mirrors a mic-with-dropdown control; negative margin keeps the pair reading
as one unit without a wrapper border). */
.agent-chat__talk-group {
display: inline-flex;
align-items: center;
}
.agent-chat__talk-group .agent-chat__talk-toggle {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.agent-chat__talk-group .agent-chat__talk-caret {
min-width: 20px;
width: 20px;
margin-left: -2px;
padding: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.agent-chat__talk-group .agent-chat__talk-caret svg {
width: 13px;
height: 13px;
}
.agent-chat__talk-status {
display: flex;
align-items: center;
@@ -1710,107 +1683,116 @@ openclaw-chat-page {
background: color-mix(in srgb, var(--text) 6%, transparent);
}
.agent-chat__talk-input-picker {
display: inline-flex;
position: relative;
}
.agent-chat__talk-input-menu {
position: absolute;
inset-inline-start: 8px;
bottom: 52px;
z-index: 95;
width: min(320px, calc(100% - 16px));
padding: 6px;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-elevated) 96%, var(--card));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
}
.agent-chat__talk-input-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 8px 6px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
}
.agent-chat__talk-input-options {
.agent-chat__talk-input-setting {
display: grid;
gap: 2px;
max-height: min(260px, calc(100vh - 180px));
overflow-y: auto;
grid-column: 1 / -1;
gap: 6px;
}
.agent-chat__talk-input-option {
display: flex;
.agent-chat__talk-input-control {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
gap: 6px;
align-items: end;
}
.agent-chat__talk-input-refresh {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 32px;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.78rem;
line-height: 1;
text-align: left;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--input) 88%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card) 92%, var(--bg-elevated) 8%);
color: var(--muted);
cursor: pointer;
}
.agent-chat__talk-input-option:hover,
.agent-chat__talk-input-option:focus-visible {
border-color: color-mix(in srgb, var(--border) 76%, transparent);
.agent-chat__talk-input-refresh:hover:not(:disabled),
.agent-chat__talk-input-refresh:focus-visible {
border-color: color-mix(in srgb, var(--text) 18%, var(--input));
background: var(--bg-hover);
color: var(--text);
outline: none;
}
.agent-chat__talk-input-option--selected {
color: var(--text-strong);
background: color-mix(in srgb, var(--text) 6%, transparent);
.agent-chat__talk-input-refresh:focus-visible {
box-shadow: var(--focus-ring);
}
.agent-chat__talk-input-refresh:disabled {
cursor: wait;
opacity: 0.75;
}
.agent-chat__talk-input-refresh svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.agent-chat__talk-input-refresh:disabled svg {
animation: chat-run-status-spin 0.9s linear infinite;
}
.agent-chat__talk-input-check,
.agent-chat__talk-input-spinner {
display: inline-flex;
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.agent-chat__talk-input-check {
color: var(--accent);
}
.agent-chat__talk-input-spinner {
flex: 0 0 14px;
color: var(--muted);
animation: chat-run-status-spin 0.9s linear infinite;
}
.agent-chat__talk-input-check svg,
.agent-chat__talk-input-spinner svg {
width: 100%;
height: 100%;
}
.agent-chat__talk-input-message {
padding: 7px 8px;
padding: 0 2px;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.4;
}
.agent-chat__talk-input-message--loading {
display: flex;
align-items: center;
gap: 7px;
}
.agent-chat__talk-input-message--error {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 8px 9px;
border: 1px solid color-mix(in srgb, var(--danger) 22%, var(--border));
border-radius: var(--radius-sm);
background: var(--danger-subtle);
color: var(--text);
}
.agent-chat__talk-input-message-icon {
display: inline-flex;
width: 14px;
height: 14px;
flex: 0 0 14px;
margin-top: 1px;
color: var(--danger);
}
.agent-chat__talk-input-message-icon svg {
width: 100%;
height: 100%;
}
.agent-chat__input-divider {
width: 1px;
height: 16px;
@@ -2631,6 +2613,7 @@ openclaw-chat-page {
box-shadow: var(--focus-ring);
}
.agent-chat__talk-input-refresh:disabled svg,
.agent-chat__talk-input-spinner {
animation: none;
}
@@ -3318,6 +3301,10 @@ openclaw-chat-page {
grid-template-columns: minmax(0, 1fr);
}
.agent-chat__talk-input-setting {
order: -1;
}
.chat-controls__session {
min-width: 120px;
max-width: none;