diff --git a/.changeset/tidy-voice-dock.md b/.changeset/tidy-voice-dock.md index 66b86c2b692..39eb66d0091 100644 --- a/.changeset/tidy-voice-dock.md +++ b/.changeset/tidy-voice-dock.md @@ -2,4 +2,6 @@ "@hashintel/petrinaut": patch --- -Allow host-owned Voice sessions to report a neutral connected state and omit unsupported microphone, resume, reconnect, and playback controls from the dock. Keep the viewport controls above a collapsed Voice dock instead of reserving full-height chat panel space. +Allow host-owned Voice sessions to report a neutral connected state and omit unsupported controls, keeping viewport controls above the collapsed dock. Contain Voice failures and explicit recovery warnings behind a warning icon with hover previews and a scrollable details popover instead of global notifications, while temporary session notices remain in the dock status. + +Keep voice permission content above the compact dock without moving the controls, and show Voice setup until a connection actually starts. Keep the error icon in the same dock position when expanding or collapsing a Voice session. diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 3f79e314dbb..fcc789c0f7e 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -56,7 +56,9 @@ action appears in the first-run prompt and the assistant panel; if voice is unav composer retains a disabled **Send** action. Starting Voice mode keeps the transcript in place and opens the existing one-time disclosure. Voice selected from the first-run prompt starts compact: the disclosure and microphone check appear in a card immediately above a **Voice setup** dock, while the -AI header, transcript, and composer stay hidden. Select **Expand voice setup** to restore the full +AI header, transcript, and composer stay hidden. The card opens without shifting the dock or viewport +controls, and scrolls within the available screen height. Setup does not show **Connecting** before +you start. Select **Expand voice setup** to restore the full panel. Voice started from the composer keeps that full panel visible. Review that OpenAI processes live audio and speaks the interviewer's words while Petrinaut keeps finalized answers in the conversation rather than the audio. You can check your microphone before confirming that you @@ -87,6 +89,15 @@ flicker above the line. While the assistant speaks the ribbon takes on a restrai motion instead, colour crossfading as the turn changes hands, so which side holds it is readable at a glance. It flattens to near a line whenever nobody holds the turn. +Voice failures and recovery warnings, including unconfirmed submissions and input that was not +retained, join the Voice warning indicator rather than appearing as global notifications. Hover to +preview or click to read the complete details, including while the panel is collapsed. Distinct +issues share one icon with a count; long details wrap and scroll in the popover, where they can be +copied or dismissed with **Dismiss Voice issues**. Issues stay in the list until dismissed or the +chat is cleared; dismissing them does not trigger a retry or mean that unsent input was retained. +Temporary status notices, such as a pending handoff, replace the short dock state only while they +apply and do not become Voice issues. + Spoken turns appear in the conversation as soon as their finalized text arrives, so the transcript stays current while the session runs and tools that edit the net remain visible. Select **Collapse voice session** to reduce the panel to the Voice dock alone; this hides the AI header, transcript, and @@ -141,9 +152,9 @@ reopen the panel. The same mounted session stays paused; choose **Resume voice m ready. **Clear AI chat** is unavailable while a Voice session is active. -If voice cannot continue, the status reads **Voice interrupted** and the actionable error arrives as -a persistent toast that names the microphone, connection, or Voice failure in one sentence, followed -by any diagnostic reference in parentheses. **Reconnect voice mode** replaces the microphone action until +If voice cannot continue, the status reads **Voice interrupted** and the warning icon contains +the microphone, connection, or Voice failure, followed by any diagnostic reference in parentheses. +**Reconnect voice mode** replaces the microphone action until the session recovers. For microphone permission or device errors, allow access or connect/select a microphone before reconnecting. For an interrupted request, network error, or timeout, check the connection and reconnect. If the preview is unavailable, continue with the text composer. An invalid diff --git a/libs/@hashintel/petrinaut/src/react/voice-session/types.ts b/libs/@hashintel/petrinaut/src/react/voice-session/types.ts index bb885456aef..be5e1b5fce5 100644 --- a/libs/@hashintel/petrinaut/src/react/voice-session/types.ts +++ b/libs/@hashintel/petrinaut/src/react/voice-session/types.ts @@ -32,7 +32,9 @@ export type PetrinautAiVoiceSessionState = { microphoneMuted: boolean; /** Normalized 0–1 input level driving the listening indicator. */ microphoneLevel: number; - /** Recoverable feedback about an utterance which was not submitted. */ + /** Temporary operational status shown in place of the current phase. */ notice?: string | null; phase: PetrinautAiVoiceSessionPhase; + /** Recoverable issue retained behind the Voice warning indicator. */ + warningMessage?: string | null; }; diff --git a/libs/@hashintel/petrinaut/src/react/voice-session/use-voice-session.ts b/libs/@hashintel/petrinaut/src/react/voice-session/use-voice-session.ts index 5de03d283e8..3fa16ee72c7 100644 --- a/libs/@hashintel/petrinaut/src/react/voice-session/use-voice-session.ts +++ b/libs/@hashintel/petrinaut/src/react/voice-session/use-voice-session.ts @@ -113,6 +113,16 @@ export const useVoiceSessionNotice = (): string | null => { ); }; +export const useVoiceSessionWarningMessage = (): string | null => { + const store = use(VoiceSessionContext); + + return useSyncExternalStore( + store.subscribe, + () => store.getSnapshot().state?.warningMessage ?? null, + () => null, + ); +}; + export const useVoiceSessionInterruptionBySpeaking = (): boolean => { const store = use(VoiceSessionContext); return useSyncExternalStore( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx index 66168b6b3ef..3401c2e4f8a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx @@ -1837,7 +1837,9 @@ describe("AiAssistantPanel composer submissions", () => { expect(textarea.value).toBe("Keep this draft"); expect(textarea.disabled).toBe(false); expect(latestVoiceContext?.inputMode).toBe("voice"); - expect(screen.getByText("Voice invalidation failed.")).not.toBeNull(); + expect( + await screen.findByText("Voice invalidation failed."), + ).not.toBeNull(); }); test("restores a typed draft when its post-Voice message submission rejects", async () => { @@ -3329,7 +3331,7 @@ describe("AiAssistantPanel composer submissions", () => { await act(async () => latest?.stop()); expect(requestStop).toHaveBeenCalledOnce(); expect( - screen.getAllByText(/Durable stop failed/u).length, + (await screen.findAllByText(/Durable stop failed/u)).length, ).toBeGreaterThan(0); await act(async () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx index c6e9c98b7b3..22947e76be2 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx @@ -1,5 +1,5 @@ import { type ReactNode, useState } from "react"; -import { userEvent, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; @@ -348,6 +348,24 @@ export const VoiceModeAwaitingConsentCompact: Story = { voiceModeAvailable /> ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const consent = canvas.getByTestId("ai-voice-mode"); + const dock = canvas.getByRole("region", { name: "Voice setup" }); + const shell = dock.closest("aside")!; + const initialShellHeight = shell.getBoundingClientRect().height; + const initialDockTop = dock.getBoundingClientRect().top; + + // Host content can grow or disappear; neither should move the controls + // whose position is derived from the compact shell's reported height. + consent.style.minHeight = "320px"; + await expect(shell.getBoundingClientRect().height).toBe(initialShellHeight); + await expect(dock.getBoundingClientRect().top).toBe(initialDockTop); + consent.style.display = "none"; + await expect(shell.getBoundingClientRect().height).toBe(initialShellHeight); + consent.style.removeProperty("min-height"); + consent.style.removeProperty("display"); + }, }; export const VoiceSessionListening: Story = { @@ -363,6 +381,38 @@ export const VoiceSessionListening: Story = { ), }; +export const VoiceSessionLongWarning: Story = { + render: () => ( + + ), +}; + +export const VoiceSessionInputNotRetained: Story = { + render: () => ( + + ), +}; + export const VoiceSessionCollapsed: Story = { render: () => ( , }; +export const MultipleVoiceIssues: Story = { + render: () => ( + + ), +}; + +export const CollapsedVoiceIssues: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const indicator = canvas.getByRole("button", { + name: "Show 2 Voice issues", + }); + const { x, y, width, height } = indicator.getBoundingClientRect(); + await userEvent.click( + canvas.getByRole("button", { name: "Expand voice session" }), + ); + await expect( + canvas.getByRole("button", { name: "Show 2 Voice issues" }), + ).toBe(indicator); + await expect(indicator.getBoundingClientRect()).toMatchObject({ + x, + y, + width, + height, + }); + await userEvent.click( + canvas.getByRole("button", { name: "Collapse voice session" }), + ); + await expect(indicator.getBoundingClientRect()).toMatchObject({ + x, + y, + width, + height, + }); + }, +}; + export const StoppedResponse: Story = { render: () => ( } microphoneMuted={false} - notice={null} onCollapsedToggle={collapse} phase="connected" />, @@ -374,20 +373,16 @@ describe("AiAssistantContents", () => { expect(screen.getAllByText("Response stopped")).toHaveLength(1); }); - test("shows assistant errors as toasts instead of transcript messages", async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { writeText }, - }); + test("keeps non-Voice assistant errors in global notifications", () => { + const message = + 'Elicitor failed.\nCaused by: {"field":"answer","reason":"Required"}'; + const addNotification = vi.fn(() => "notification-id"); render( - + { onSubmit={noop} status="error" /> - , + , ); - const toast = await waitFor(() => { - const element = document.querySelector( - '[data-scope="toast"][data-part="root"]', - ); - expect(element).not.toBeNull(); - return element!; + expect(addNotification).toHaveBeenCalledOnce(); + expect(addNotification).toHaveBeenCalledWith({ + detail: message, + message: "AI assistant error", + tone: "error", }); expect( - toast.querySelector('[data-scope="toast"][data-part="title"]') - ?.textContent, - ).toBe("AI assistant error"); - expect( - toast.querySelector('[data-scope="toast"][data-part="description"]') - ?.textContent, - ).toBe( - 'Elicitor failed.\nCaused by: {"field":"answer","reason":"Required"}', - ); - fireEvent.click( - within(toast).getByRole("button", { name: "Copy details" }), - ); - expect(writeText).toHaveBeenCalledWith( - 'Elicitor failed.\nCaused by: {"field":"answer","reason":"Required"}', - ); + screen.queryByRole("button", { name: /Show .*Voice issue/ }), + ).toBeNull(); expect( - within(screen.getByTestId("ai-transcript")).queryByText( - "AI assistant error", - ), + within(screen.getByTestId("ai-transcript")).queryByText(message), ).toBeNull(); - fireEvent.click( - within(toast).getByRole("button", { name: "Close notification" }), - ); - await waitFor(() => - expect(toast.getAttribute("data-state")).toBe("closed"), - ); }); test("keeps one Voice mode slot mounted across tab switches and panel closure", () => { @@ -657,6 +630,8 @@ describe("AiAssistantContents", () => { name: "Voice mode consent", }); const setupDock = screen.getByRole("region", { name: "Voice setup" }); + expect(within(setupDock).queryByText(/connecting/i)).toBeNull(); + expect(within(setupDock).getByText("Voice setup")).not.toBeNull(); expect(permission.parentElement?.nextElementSibling).toBe( setupDock.parentElement, ); @@ -859,12 +834,97 @@ describe("AiAssistantContents", () => { }); }); expect( - within(dock).getAllByText("We didn't catch that. Please try again."), - ).not.toHaveLength(0); - expect(dock.getAttribute("data-voice-notice")).toBe("visible"); + within(dock).getByRole("status", { name: "Voice status" }).textContent, + ).toBe("Voice status: We didn't catch that. Please try again."); + expect( + within(dock).getByText("We didn't catch that. Please try again."), + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: /Show .*Voice issue/ }), + ).toBeNull(); + act(() => { + store.setState({ + errorMessage: null, + microphoneLevel: 0, + microphoneMuted: false, + notice: null, + phase: "listening", + }); + }); + expect(within(dock).getByText("Listening")).toBeTruthy(); }); - test("shows a voice recovery failure as a toast", async () => { + test.each([ + "Voice admission could not be confirmed. Check canonical history before sending again; no automatic retry was made.", + "That utterance was not retained. Wait for the pending input, then use the composer to send it.", + ])( + "contains a session warning in the warning popover until dismissed: %s", + async (warningMessage) => { + const store = createVoiceSessionStore(); + const state = { + errorMessage: null, + microphoneLevel: 0, + microphoneMuted: false, + phase: "connected" as const, + warningMessage, + }; + store.setState(state); + const end = vi.fn(); + store.setActions({ end, pause: noop }); + render( + + + + + , + ); + const dock = screen.getByTestId("ai-voice-dock"); + expect(within(dock).getByText("Connected")).toBeTruthy(); + expect(screen.queryByText(warningMessage)).toBeNull(); + expect(within(dock).getByRole("status").textContent).toBe( + "Voice status: Connected", + ); + fireEvent.click( + within(dock).getByRole("button", { name: "Show 1 Voice issue" }), + ); + expect(await screen.findByText(warningMessage)).toBeTruthy(); + expect(within(dock).queryByText(warningMessage)).toBeNull(); + expect( + screen.getByText(warningMessage).closest('[data-scope="toast"]'), + ).toBeNull(); + act(() => { + store.setState({ ...state, phase: "thinking", microphoneLevel: 0.5 }); + }); + expect(screen.getAllByText(warningMessage)).toHaveLength(1); + act(() => { + store.setState({ ...state, warningMessage: null }); + }); + expect(screen.getByText(warningMessage)).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: "Dismiss Voice issues" }), + ); + expect(screen.queryByText(warningMessage)).toBeNull(); + expect( + screen.queryByRole("button", { name: /Show .*Voice issue/ }), + ).toBeNull(); + fireEvent.click( + within(dock).getByRole("button", { name: "End voice mode" }), + ); + expect(end).toHaveBeenCalledOnce(); + }, + ); + + test("contains voice failures in the collapsed dock without a toast", async () => { const store = createVoiceSessionStore(); store.setState({ errorMessage: "Microphone unavailable. Check your browser permissions.", @@ -883,25 +943,27 @@ describe("AiAssistantContents", () => { onStop={noop} onSubmit={noop} status="ready" + voiceDockCollapsed /> , ); - const toast = await waitFor(() => { - const element = document.querySelector( - '[data-scope="toast"][data-part="root"]', - ); - expect(element).not.toBeNull(); - return element!; - }); + const dock = screen.getByTestId("ai-voice-dock"); + fireEvent.click( + within(dock).getByRole("button", { name: "Show 1 Voice issue" }), + ); + expect( + await screen.findByText( + "Microphone unavailable. Check your browser permissions.", + ), + ).toBeTruthy(); expect( - toast.querySelector('[data-scope="toast"][data-part="title"]') - ?.textContent, - ).toBe("Microphone unavailable. Check your browser permissions."); + document.querySelector('[data-scope="toast"][data-part="root"]'), + ).toBeNull(); }); - test("does not repeat a voice error toast until the session recovers", () => { + test("deduplicates voice failures locally and never calls the shared notifier", () => { const store = createVoiceSessionStore(); const errorState = { errorMessage: "Microphone unavailable. Check your browser permissions.", @@ -932,7 +994,10 @@ describe("AiAssistantContents", () => { ); const { rerender } = render(renderWithNotifier(firstAddNotification)); - expect(firstAddNotification).toHaveBeenCalledOnce(); + expect(firstAddNotification).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Show 1 Voice issue" }), + ).toBeTruthy(); rerender(renderWithNotifier(secondAddNotification)); expect(secondAddNotification).not.toHaveBeenCalled(); @@ -947,7 +1012,10 @@ describe("AiAssistantContents", () => { act(() => { store.setState(errorState); }); - expect(secondAddNotification).toHaveBeenCalledOnce(); + expect(secondAddNotification).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Show 1 Voice issue" }), + ).toBeTruthy(); }); test("isolates microphone-level updates from completed transcript messages", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx index fa06911a913..6f7290110f6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx @@ -23,12 +23,12 @@ import { EditorContext } from "../../../../../react/state/editor-context"; import { useVoiceSessionErrorMessage, useVoiceSessionPhase, + useVoiceSessionWarningMessage, } from "../../../../../react/voice-session/use-voice-session"; import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; import { HorizontalTabsHeader } from "../../../../components/sub-view/horizontal/horizontal-tabs-container"; import { ResizeHandle } from "../../../../resize/resize-handle"; import { AiVoiceModeIcon } from "../../components/ai-voice-mode-button"; -import { voiceSetupLabels } from "../../components/voice-session-labels"; import { aiFooterMinHeight } from "./ai-assistant-contents/footer-height"; import { getMessageRenderItems } from "./ai-assistant-contents/get-message-render-items"; import { @@ -41,6 +41,7 @@ import { AiAssistantToolList, type OnInteractiveToolSubmit, } from "./ai-assistant-contents/tool-list"; +import { VoiceAlerts } from "./ai-assistant-contents/voice-alerts"; import { LiveVoiceDock, VoiceDock } from "./ai-assistant-contents/voice-dock"; import { VoiceInputProvenance } from "./ai-assistant-contents/voice-input-provenance"; @@ -161,6 +162,9 @@ const cardStyle = cva({ flexDirection: "column", }, variants: { + setupOverlay: { + true: {}, + }, open: { true: { height: "full", @@ -178,6 +182,9 @@ const cardStyle = cva({ }, }, }, + compoundVariants: [ + { open: true, setupOverlay: true, css: { overflow: "visible" } }, + ], }); const panelContentStyle = cva({ @@ -188,12 +195,26 @@ const panelContentStyle = cva({ }, }); -const voiceModeStyle = css({ - position: "relative", - zIndex: "[2]", - flexShrink: "0", - overflow: "visible", - pointerEvents: "auto", +const voiceModeStyle = cva({ + base: { + position: "relative", + zIndex: "[2]", + flexShrink: "0", + overflow: "visible", + pointerEvents: "auto", + }, + variants: { + setupOverlay: { + true: { + position: "absolute", + bottom: "[calc(100% + 8px)]", + width: "full", + maxHeight: "[calc(100dvh - 120px)]", + overflowY: "auto", + borderRadius: "[12px]", + }, + }, + }, }); const headerStyle = css({ @@ -545,6 +566,7 @@ export const AiAssistantContents = ({ const { addNotification } = use(NotificationsContext); const voiceSessionPhase = useVoiceSessionPhase(); const voiceSessionErrorMessage = useVoiceSessionErrorMessage(); + const voiceSessionWarningMessage = useVoiceSessionWarningMessage(); const isVoiceSessionLive = voiceSessionPhase !== null; const isBusy = status === "submitted" || status === "streaming"; const hasInput = input.trim().length > 0; @@ -613,6 +635,7 @@ export const AiAssistantContents = ({ } = use(EditorContext); const shellRef = useRef(null); + const voiceDockRef = useRef(null); const reportDockHeight = useEffectEvent((height: number | null) => { setAiAssistantDockHeight(height); }); @@ -634,6 +657,12 @@ export const AiAssistantContents = ({ const [chipsDismissed, setChipsDismissed] = useState(false); + const [voiceAlerts, setVoiceAlerts] = useState([]); + const recordVoiceAlert = useEffectEvent((message: string) => { + setVoiceAlerts((previous) => + previous.includes(message) ? previous : [...previous, message], + ); + }); const notifiedErrorRef = useRef(undefined); useEffect(() => { if (!error) { @@ -647,9 +676,8 @@ export const AiAssistantContents = ({ addNotification(errorNotification("AI assistant error", error.message)); }, [addNotification, error]); - // Voice failures (microphone denied, connection dropped) are reported by the - // host rather than thrown, and get the same treatment: a toast, with the - // recovery action left on the session's own controls. + // Keep host-reported Voice failures and recovery notices on the session + // controls; unrelated Petrinaut notifications are untouched. const notifiedVoiceErrorRef = useRef(null); useEffect(() => { if (voiceSessionPhase !== "error") { @@ -664,8 +692,26 @@ export const AiAssistantContents = ({ } notifiedVoiceErrorRef.current = voiceSessionErrorMessage; - addNotification(errorNotification(voiceSessionErrorMessage)); - }, [addNotification, voiceSessionErrorMessage, voiceSessionPhase]); + recordVoiceAlert(voiceSessionErrorMessage); + }, [voiceSessionErrorMessage, voiceSessionPhase]); + + // Host-designated recoverable issues need the same on-demand details even + // while Voice remains connected or listening. + useEffect(() => { + if (voiceSessionWarningMessage) { + recordVoiceAlert(voiceSessionWarningMessage); + } + }, [voiceSessionWarningMessage]); + + const voiceAlertIndicator = + isOpen && voiceAlerts.length > 0 ? ( + setVoiceAlerts([])} + docked={isVoiceDockCollapsed || isVoiceSessionLive} + dockRef={voiceDockRef} + /> + ) : null; const inputRef = useRef(null); const messagesEndRef = useRef(null); @@ -790,7 +836,13 @@ export const AiAssistantContents = ({ label="Resize AI assistant" /> -
+
AI
)}
+ {!isVoiceDockCollapsed && !isVoiceSessionLive && voiceAlertIndicator} + {open && ( + setOpen(false)} + > + + + +
    + {alerts.map((message) => ( +
  • {message}
  • + ))} +
+
+ + + +
+
+ )} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock.tsx index 18970f70dfc..96696cd6a1c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock.tsx @@ -47,19 +47,30 @@ const dockStyle = css({ }); // Equal flexible sides keep the ribbon on the panel's centre line however wide -// the phase label or the action cluster turn out to be. -const sideStyle = css({ - display: "flex", - flex: "1", - minWidth: "[0]", - alignItems: "center", +// the phase label or the action cluster turn out to be. With an error control, +// reserve the controls' width and let the status shrink instead of overlapping. +const sideStyle = cva({ + base: { + display: "flex", + flex: "1", + minWidth: "[0]", + alignItems: "center", + }, + variants: { + withError: { true: { flex: "[0 0 auto]" } }, + }, }); -const centerStyle = css({ - display: "flex", - minWidth: "[0]", - alignItems: "center", - gap: "2", +const centerStyle = cva({ + base: { + display: "flex", + minWidth: "[0]", + alignItems: "center", + gap: "2", + }, + variants: { + withError: { true: { flex: "1", justifyContent: "center" } }, + }, }); const statusStyle = cva({ @@ -113,11 +124,12 @@ export type VoiceDockProps = { canRetryPlayback?: boolean; canTakeTurn: boolean; collapsed: boolean; + errorIndicator?: ReactNode; /** Rendered instead of the live indicator when the caller supplies one. */ indicator?: ReactNode; interruptionBySpeaking?: boolean; microphoneMuted: boolean; - notice: string | null; + notice?: string | null; onCollapsedEnd?: () => void; onCollapsedToggle: () => void; phase: PetrinautAiVoiceSessionPhase; @@ -135,6 +147,7 @@ export const VoiceDock = ({ canRetryPlayback = false, canTakeTurn, collapsed, + errorIndicator, indicator, interruptionBySpeaking = false, microphoneMuted, @@ -155,6 +168,10 @@ export const VoiceDock = ({ const microphoneLabel = microphoneMuted ? voiceSessionActionLabels.unmute : voiceSessionActionLabels.mute; + const statusLabel = + purpose === "setup" + ? voiceSetupLabels.status + : (notice ?? voiceSessionStatusLabel(phase)); return (
- +
); @@ -295,10 +312,12 @@ export const VoiceDock = ({ /** Reads the session straight from the store so the panel re-renders less. */ export const LiveVoiceDock = ({ collapsed, + errorIndicator, onCollapsedEnd, onCollapsedToggle, }: { collapsed: boolean; + errorIndicator?: ReactNode; onCollapsedEnd?: () => void; onCollapsedToggle: () => void; }) => { @@ -324,6 +343,7 @@ export const LiveVoiceDock = ({ canRetryPlayback={canRetryPlayback} canTakeTurn={canTakeTurn} collapsed={collapsed} + errorIndicator={errorIndicator} interruptionBySpeaking={interruptionBySpeaking} microphoneMuted={microphoneMuted} notice={notice}