mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user