From fdd19e3017ea721c26ec33faf5bd77e785abe871 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 13:55:56 +0100 Subject: [PATCH] feat(ui): merge the voice level meter into the stop-voice pill and disambiguate the stop controls (#102786) * feat(ui): merge voice level meter into the stop-voice pill and disambiguate stop controls * docs(web): describe the live voice pill control in Talk mode * test(ui): capture dark-mode voice control proof in talk e2e * fix(ui): keep the live voice pill wide at phone widths * fix(ui): show the stop glyph instead of a fake listening meter when a talk session errors * test(ui): assemble the mock talk client secret so scanners do not flag it --- docs/web/control-ui.md | 2 +- .../e2e/browser-talk-start-stop.e2e.test.ts | 143 ++++++++++++++++- ui/src/pages/chat/chat-composer.test.ts | 10 +- ui/src/pages/chat/chat-view.test.ts | 34 +++- ui/src/pages/chat/components/chat-composer.ts | 45 +++++- .../chat/components/chat-voice-activity.ts | 101 ++++++------ ui/src/styles/chat/layout.css | 150 ++++++++++++++---- 7 files changed, 384 insertions(+), 101 deletions(-) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index aaade71d038..c1317a91287 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -261,7 +261,7 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi Persistent provider, model, voice, transport, reasoning effort, exact VAD threshold, silence duration, and prefix padding defaults live in **Settings → Communications → Talk**; changing them requires `operator.admin` access. Configuring Gateway relay forces the backend relay path; configuring WebRTC keeps the session client-owned and fails instead of silently falling back to relay if the provider cannot create a browser session. - The Talk control itself is the microphone button in the composer toolbar. Its caret lists **System default** and every microphone exposed by the browser, including USB, Bluetooth, and virtual inputs. The selected device ID stays browser-local and is never sent to the Gateway; if that exact device disappears, Talk asks you to choose another input instead of silently recording from a different microphone. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `talk.client.toolCall`. + The Talk control itself is the microphone button in the composer toolbar. Its caret lists **System default** and every microphone exposed by the browser, including USB, Bluetooth, and virtual inputs. The selected device ID stays browser-local and is never sent to the Gateway; if that exact device disappears, Talk asks you to choose another input instead of silently recording from a different microphone. While Talk is live, the microphone button becomes a pill showing the live input-level meter; clicking it stops voice input, and hovering it reveals the stop glyph. Screen readers announce `Connecting voice input...`, `Listening...`, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `talk.client.toolCall`. Stopping a running agent response stays a separate square **Stop** control next to the pill. Maintainer live smoke: `OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts` verifies the OpenAI backend WebSocket bridge, OpenAI browser WebRTC SDP exchange, Google Live constrained-token browser WebSocket setup, and the Gateway relay browser adapter with fake microphone media. The command prints provider status only and does not log secrets. diff --git a/ui/src/e2e/browser-talk-start-stop.e2e.test.ts b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts index b69f204db76..89452d0d412 100644 --- a/ui/src/e2e/browser-talk-start-stop.e2e.test.ts +++ b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts @@ -1,4 +1,6 @@ // Control UI E2E tests cover browser Talk start and stop through a real page. +import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { chromium, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { @@ -92,6 +94,14 @@ async function installTalkBrowserFixtures(page: Page) { }); } +async function captureComposerProof(page: Page, fileName: string) { + const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "voice-controls"); + await mkdir(artifactDir, { recursive: true }); + await page + .locator(".agent-chat__composer-shell") + .screenshot({ path: path.join(artifactDir, fileName) }); +} + async function installBlockedMicrophoneFixture(page: Page) { await page.addInitScript(() => { Object.defineProperty(navigator, "mediaDevices", { @@ -185,6 +195,17 @@ describeControlUiE2e("Control UI browser Talk", () => { await expect .poll(() => page.locator('.agent-chat__voice-activity[data-status="connecting"]').count()) .toBe(1); + // The level meter renders inside the stop-voice pill button, not as a + // separate floating row above the composer. + await expect + .poll(() => + page.locator('button[aria-label="Stop voice input"] .agent-chat__voice-activity').count(), + ) + .toBe(1); + // Phone widths keep the pill wide enough for the 7-bar meter instead of + // collapsing it to the generic 44px square control size. + const pillBox = await page.getByRole("button", { name: "Stop voice input" }).boundingBox(); + expect(pillBox?.width ?? 0).toBeGreaterThanOrEqual(60); await expect .poll(() => page @@ -206,7 +227,7 @@ describeControlUiE2e("Control UI browser Talk", () => { .toBe(1); await expect.poll(() => page.locator(".agent-chat__talk-status-text").count()).toBe(0); await expect - .poll(() => page.locator('[role="status"] .agent-chat__sr-only').textContent()) + .poll(() => page.locator('[role="status"].agent-chat__voice-status').textContent()) .toBe("Listening..."); const reducedMotionTransform = await page .locator(".agent-chat__voice-activity-bar") @@ -281,6 +302,126 @@ describeControlUiE2e("Control UI browser Talk", () => { } }); + it("keeps stop-voice and stop-run controls visually distinct while both are active", async () => { + const browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + const context = await browser.newContext({ permissions: ["microphone"] }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["chat.send"], + methodResponses: { + "talk.client.create": { + provider: "google", + transport: "provider-websocket", + protocol: "google-live-bidi", + // Fake harness token, assembled so secret scanners do not flag it. + clientSecret: ["auth_tokens", "browser-talk-e2e"].join("/"), + websocketUrl: + "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained", + audio: { + inputEncoding: "pcm16", + inputSampleRateHz: 16_000, + outputEncoding: "pcm16", + outputSampleRateHz: 24_000, + }, + }, + }, + }); + await installTalkBrowserFixtures(page); + + try { + await page.goto(`${server.baseUrl}chat`); + await page.setViewportSize({ width: 1366, height: 900 }); + + await page.getByRole("button", { name: "Start voice input" }).click(); + await gateway.waitForRequest("talk.client.create"); + await gateway.deliverLatest({ setupComplete: {} }); + const stopVoice = page.getByRole("button", { name: "Stop voice input" }); + await expect.poll(() => stopVoice.isVisible()).toBe(true); + await page.evaluate(() => { + const state = ( + window as Window & { + openclawTalkE2eState?: { + inputProcessor?: { + onaudioprocess?: (event: { + inputBuffer: { getChannelData: () => Float32Array }; + }) => void; + }; + meterLevel?: number; + }; + } + ).openclawTalkE2eState; + if (state) { + state.meterLevel = 0.25; + } + state?.inputProcessor?.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(4096).fill(0.25) }, + }); + }); + await expect + .poll(async () => + Number(await page.locator(".agent-chat__voice-activity").getAttribute("data-level")), + ) + .toBeGreaterThan(0); + await captureComposerProof(page, "01-voice-live-listening.png"); + + // Enter-sends while voice is active; the deferred chat.send keeps the + // run abortable so both stop controls render side by side. + const textarea = page.locator(".agent-chat__input textarea"); + await textarea.fill("Keep working on the report"); + await textarea.press("Enter"); + const sendRequest = await gateway.waitForRequest("chat.send"); + const runId = + typeof sendRequest.params === "object" && + sendRequest.params !== null && + "idempotencyKey" in sendRequest.params + ? String(sendRequest.params.idempotencyKey) + : ""; + await gateway.resolveDeferred("chat.send", { runId, status: "started" }); + await gateway.emitGatewayEvent("chat", { + deltaText: "Working on it.", + message: { + content: [{ text: "Working on it.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + const stopRun = page.getByRole("button", { name: "Stop generating" }); + await expect.poll(() => stopRun.isVisible()).toBe(true); + await expect.poll(() => stopVoice.isVisible()).toBe(true); + + expect( + await stopVoice.evaluate((node) => node.classList.contains("chat-send-btn--voice-live")), + ).toBe(true); + expect( + await stopVoice.evaluate((node) => node.classList.contains("chat-send-btn--stop")), + ).toBe(false); + expect(await stopVoice.locator(".agent-chat__voice-activity").count()).toBe(1); + expect(await page.locator(".chat-send-btn--stop").count()).toBe(1); + await captureComposerProof(page, "02-voice-plus-run-stop.png"); + + await page.emulateMedia({ colorScheme: "dark" }); + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.themeMode)) + .toBe("dark"); + await captureComposerProof(page, "03-voice-plus-run-stop-dark.png"); + + await stopVoice.hover(); + await captureComposerProof(page, "04-voice-live-hover-stop-glyph.png"); + + // Stopping voice must leave the run (and its stop control) untouched. + await stopVoice.click(); + await expect.poll(() => stopVoice.count()).toBe(0); + await expect.poll(() => stopRun.isVisible()).toBe(true); + expect(await gateway.getRequests("chat.abort")).toHaveLength(0); + } finally { + await context.close(); + await browser.close(); + } + }); + it("renders streamed relay assistant transcript deltas as readable text", async () => { const browser = await chromium.launch({ executablePath: chromiumExecutablePath }); const context = await browser.newContext({ permissions: ["microphone"] }); diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 1ff14867197..a492e5f954f 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -98,7 +98,8 @@ describe("chat run controls", () => { container, ); const stopVoiceButton = getButton(container, 'button[aria-label="Stop voice input"]'); - expect(stopVoiceButton.classList.contains("chat-send-btn--stop")).toBe(true); + expect(stopVoiceButton.classList.contains("chat-send-btn--voice-live")).toBe(true); + expect(stopVoiceButton.querySelector(".agent-chat__voice-activity")).not.toBeNull(); stopVoiceButton.click(); expect(onToggleVoice).toHaveBeenCalledTimes(2); }); @@ -145,6 +146,13 @@ describe("chat run controls", () => { `button[aria-label="${t("chat.runControls.stopGenerating")}"]`, ); + // Redesign guard: the two controls must stay visually distinct — a live + // waveform pill for voice and a single danger stop square for the run. + expect(stopVoiceButton.classList.contains("chat-send-btn--voice-live")).toBe(true); + expect(stopVoiceButton.classList.contains("chat-send-btn--stop")).toBe(false); + expect(stopGenerationButton.classList.contains("chat-send-btn--stop")).toBe(true); + expect(container.querySelectorAll(".chat-send-btn--stop")).toHaveLength(1); + stopVoiceButton.click(); stopGenerationButton.click(); expect(onToggleVoice).toHaveBeenCalledTimes(1); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 5b66c64c0b1..61d89ff218a 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -1784,19 +1784,41 @@ describe("chat voice controls", () => { realtimeTalkInputLevel: inputLevel, }); - const statusRegion = container.querySelector('[role="status"].agent-chat__talk-status'); - const visualizer = statusRegion?.querySelector( + const stopVoiceButton = container.querySelector('button[aria-label="Stop voice input"]'); + const visualizer = stopVoiceButton?.querySelector( `.agent-chat__voice-activity[data-status="${status}"]`, ); expect(visualizer?.getAttribute("data-level")).toBe("0.64"); expect(visualizer?.getAttribute("data-source")).toBe("microphone"); - expect(visualizer?.getAttribute("role")).toBe("img"); - expect(visualizer?.getAttribute("aria-label")).toBe(t("chat.composer.microphoneInput")); + expect(visualizer?.getAttribute("aria-hidden")).toBe("true"); expect(visualizer?.querySelectorAll(".agent-chat__voice-activity-bar")).toHaveLength(7); + const statusRegion = container.querySelector('[role="status"].agent-chat__voice-status'); expect(statusRegion?.getAttribute("aria-live")).toBe("polite"); expect(statusRegion?.getAttribute("aria-atomic")).toBe("true"); - expect(statusRegion?.querySelector(".agent-chat__sr-only")?.textContent?.trim()).toBe(label); - expect(statusRegion?.querySelector(".agent-chat__talk-status-text")).toBeNull(); + expect(statusRegion?.textContent?.trim()).toBe(label); + expect(container.querySelector(".agent-chat__talk-status")).toBeNull(); + }); + + it("keeps the stop control without a live meter when a running session errors", () => { + const container = renderChatView({ + realtimeTalkActive: true, + realtimeTalkStatus: "error", + realtimeTalkDetail: "Microphone unavailable", + }); + + const stopVoiceButton = requireElement( + container, + '[aria-label="Stop voice input"]', + "stop voice input button", + ); + expect(stopVoiceButton.classList.contains("chat-send-btn--voice-error")).toBe(true); + expect(stopVoiceButton.querySelector(".agent-chat__voice-activity")).toBeNull(); + expect(container.querySelector(".agent-chat__voice-status")).toBeNull(); + expect( + container + .querySelector('[role="alert"].agent-chat__talk-status .agent-chat__talk-status-text') + ?.textContent?.trim(), + ).toBe("Microphone unavailable"); }); it("clamps the rendered microphone level", () => { diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index d28149c50c5..b6e64a51caa 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -45,7 +45,11 @@ import type { RealtimeTalkLevelSignal } from "../realtime-talk-level.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 { renderChatVoiceActivity } from "./chat-voice-activity.ts"; +import { + renderChatVoiceError, + renderMicrophoneActivity, + voiceStatusLabel, +} from "./chat-voice-activity.ts"; const COMPACTION_TOAST_DURATION_MS = 5000; const FALLBACK_TOAST_DURATION_MS = 8000; @@ -1738,6 +1742,9 @@ export type ChatRunControlsProps = { isBusy: boolean; sending: boolean; voiceActive?: boolean; + voiceStatus?: RealtimeTalkStatus; + voiceDetail?: string | null; + voiceInputLevel?: RealtimeTalkLevelSignal; onAbort?: () => void; onExport: () => void; onNewSession: () => void; @@ -1771,19 +1778,42 @@ function renderChatPrimaryActions(props: ChatRunControlsProps) { ` : nothing; + // Transports keep the session active while reporting status "error"; the + // alert row above the composer owns the error message, so the control keeps + // only its stop affordance instead of a fake listening meter plus a + // duplicate announcement. + const voiceErrored = props.voiceStatus === "error"; return html` ${props.voiceActive && props.onToggleVoice ? html` + ${voiceErrored + ? nothing + : html` + ${voiceStatusLabel(props.voiceStatus, props.voiceDetail)} + `} ${abortAction} ` : props.canAbort @@ -2169,6 +2199,9 @@ export function renderChatComposer(props: ChatComposerProps) { isBusy, sending: props.sending, voiceActive: props.realtimeTalkActive, + voiceStatus: props.realtimeTalkStatus, + voiceDetail: props.realtimeTalkDetail, + voiceInputLevel: props.realtimeTalkInputLevel, onAbort: props.onAbort, onExport: () => exportMarkdown(props), onNewSession: props.onNewSession, @@ -2292,11 +2325,9 @@ export function renderChatComposer(props: ChatComposerProps) { }} /> - ${renderChatVoiceActivity({ - active: props.realtimeTalkActive, + ${renderChatVoiceError({ status: props.realtimeTalkStatus, detail: props.realtimeTalkDetail, - inputLevel: props.realtimeTalkInputLevel, onDismissError: props.onDismissRealtimeTalkError, })} diff --git a/ui/src/pages/chat/components/chat-voice-activity.ts b/ui/src/pages/chat/components/chat-voice-activity.ts index 0bdbc7415ce..679b38769e7 100644 --- a/ui/src/pages/chat/components/chat-voice-activity.ts +++ b/ui/src/pages/chat/components/chat-voice-activity.ts @@ -69,21 +69,16 @@ if (!customElements.get(MICROPHONE_ACTIVITY_TAG)) { customElements.define(MICROPHONE_ACTIVITY_TAG, MicrophoneActivityElement); } -type ChatVoiceActivityProps = { - active?: boolean; - status?: RealtimeTalkStatus; - detail?: string | null; - inputLevel?: RealtimeTalkLevelSignal; - onDismissError?: () => void; -}; - function activeStatus( status: RealtimeTalkStatus | undefined, ): "connecting" | "listening" | "thinking" { return status === "connecting" || status === "thinking" ? status : "listening"; } -function statusLabel(status: RealtimeTalkStatus | undefined, detail: string | null | undefined) { +export function voiceStatusLabel( + status: RealtimeTalkStatus | undefined, + detail: string | null | undefined, +) { const explicitDetail = detail?.trim(); if (explicitDetail) { return explicitDetail; @@ -97,52 +92,54 @@ function statusLabel(status: RealtimeTalkStatus | undefined, detail: string | nu return "Listening..."; } -export function renderChatVoiceActivity( - props: ChatVoiceActivityProps, -): TemplateResult | typeof nothing { - if (props.status === "error" && props.detail) { - return html` - - `; - } - if (!props.active) { +type MicrophoneActivityProps = { + status?: RealtimeTalkStatus; + inputLevel?: RealtimeTalkLevelSignal; +}; + +// Class names and data attributes are asserted by the talk e2e suite; the +// element is decorative inside the labeled stop-voice button, so it stays +// aria-hidden while `data-status` keeps driving the bar animations. +export function renderMicrophoneActivity(props: MicrophoneActivityProps): TemplateResult { + return html` + + `; +} + +type ChatVoiceErrorProps = { + status?: RealtimeTalkStatus; + detail?: string | null; + onDismissError?: () => void; +}; + +export function renderChatVoiceError(props: ChatVoiceErrorProps): TemplateResult | typeof nothing { + if (props.status !== "error" || !props.detail) { return nothing; } - - const status = activeStatus(props.status); return html` -
- - - ${statusLabel(props.status, props.detail)} + `; } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index a64a908f274..6a92a57890f 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1521,49 +1521,24 @@ openclaw-chat-page { color: var(--text); } -.agent-chat__talk-status--active { - justify-content: center; - min-height: 30px; - padding-top: 8px; -} - .agent-chat__talk-status-text { min-width: 0; flex: 1; } +/* Bars-only meter; the stop-voice pill button supplies the chrome around it. */ .agent-chat__voice-activity { - position: relative; display: inline-flex; align-items: center; justify-content: center; - gap: 4px; - width: 64px; - height: 24px; - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); - border-radius: 999px; - background: color-mix(in srgb, var(--accent) 7%, var(--card)); - box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 12%, transparent); -} - -.agent-chat__voice-activity::before { - position: absolute; - inset: 0; - border-radius: inherit; - background: radial-gradient( - circle at 50% 50%, - color-mix(in srgb, var(--accent) 12%, transparent), - transparent 72% - ); - content: ""; - pointer-events: none; + gap: 3px; + height: 20px; } .agent-chat__voice-activity-bar { position: relative; width: 3px; - height: 16px; + height: 18px; flex: 0 0 3px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 88%, var(--text)); @@ -1634,6 +1609,16 @@ openclaw-chat-page { .agent-chat__voice-activity[data-status="thinking"] .agent-chat__voice-activity-bar:nth-child(5) { transform: scaleY(0.72); } + + .chat-send-btn--voice-live::after { + animation: none !important; + opacity: 0.45; + } + + .chat-send-btn--voice-live .agent-chat__voice-activity, + .chat-send-btn__voice-stop-glyph { + transition: none !important; + } } .agent-chat__voice-turns { @@ -2196,15 +2181,107 @@ openclaw-chat-page { border-color: color-mix(in srgb, var(--accent) 70%, transparent); } +/* Live voice input: the level meter itself is the control. Keeping it accent + (vs the danger stop square) is what disambiguates the two controls when a + run is abortable while recording; the stop glyph only crossfades in on + hover/focus so the resting state stays a pure waveform. */ +.chat-send-btn--voice-live { + position: relative; + width: auto; + min-width: 64px; + padding: 0 14px; + border-radius: var(--radius-full); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.chat-send-btn--voice-live::after { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 30%, transparent); + opacity: 0.5; + animation: chat-voice-live-breathe 2.8s ease-in-out infinite; + pointer-events: none; +} + +.chat-send-btn--voice-live:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 75%, transparent); + background: color-mix(in srgb, var(--accent) 16%, transparent); +} + +.chat-send-btn--voice-live .agent-chat__voice-activity { + transition: opacity var(--duration-fast) ease; +} + +.chat-send-btn--voice-live:hover:not(:disabled) .agent-chat__voice-activity, +.chat-send-btn--voice-live:focus-visible .agent-chat__voice-activity { + opacity: 0.25; +} + +.chat-send-btn__voice-stop-glyph { + position: absolute; + inset: 0; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity var(--duration-fast) ease; +} + +.chat-send-btn--voice-live .chat-send-btn__voice-stop-glyph svg { + width: 12px; + height: 12px; + fill: currentColor; + stroke: none; +} + +.chat-send-btn--voice-live:hover:not(:disabled) .chat-send-btn__voice-stop-glyph, +.chat-send-btn--voice-live:focus-visible .chat-send-btn__voice-stop-glyph { + opacity: 1; +} + +@keyframes chat-voice-live-breathe { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.8; + } +} + +/* Errored Talk session: the meter is gone, so the stop glyph stays visible + and the glow calms down; the alert row above owns the error message. */ +.chat-send-btn--voice-error .chat-send-btn__voice-stop-glyph { + opacity: 1; +} + +.chat-send-btn--voice-error::after { + animation: none; + opacity: 0.25; +} + +/* Filled square + soft danger fill so Stop generating reads as the one + destructive control; the outline-only square was ambiguous next to other + 36px icon buttons. */ .chat-send-btn--stop { - border-color: var(--danger); - background: transparent; + border-color: color-mix(in srgb, var(--danger) 45%, transparent); + background: color-mix(in srgb, var(--danger) 10%, transparent); color: var(--danger); } +.chat-send-btn--stop svg { + width: 18px; + height: 18px; + fill: currentColor; + stroke: none; +} + .chat-send-btn--stop:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--danger) 82%, #fff); - background: color-mix(in srgb, var(--danger) 11%, transparent); + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 18%, transparent); color: color-mix(in srgb, var(--danger) 82%, #fff); } @@ -3584,6 +3661,13 @@ openclaw-chat-page { height: 44px; } + /* Keep the live voice pill wide enough for its 7-bar meter; the generic + 44px square above would crush it at phone widths. */ + .chat-send-btn--voice-live { + width: auto; + min-width: 64px; + } + .agent-chat__composer-shell { width: calc(100% - 8px); margin: 6px 4px calc(14px + var(--safe-area-bottom));