}
microphoneMuted={false}
- notice={voiceSetupLabels.status}
onCollapsedToggle={() => onVoiceDockCollapsedChange?.(false)}
phase="connecting"
purpose="setup"
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-alerts.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-alerts.tsx
new file mode 100644
index 00000000000..2a5498c7ddf
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-alerts.tsx
@@ -0,0 +1,105 @@
+import { type RefObject, useRef, useState } from "react";
+
+import { Button, Icon, Popover } from "@hashintel/ds-components";
+import { css } from "@hashintel/ds-helpers/css";
+
+/** Voice failures and recovery notices, disclosed on demand in the Voice dock. */
+export const VoiceAlerts = ({
+ alerts,
+ onDismiss,
+ docked,
+ dockRef,
+}: {
+ alerts: readonly string[];
+ onDismiss: () => void;
+ docked: boolean;
+ dockRef: RefObject
;
+}) => {
+ const triggerRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const countLabel = `${alerts.length} Voice ${alerts.length === 1 ? "issue" : "issues"}`;
+ const latestAlert = alerts.at(-1) ?? "";
+ const preview =
+ latestAlert.length > 180
+ ? `${latestAlert.slice(0, 180)}… Click for details.`
+ : latestAlert;
+
+ return (
+ <>
+
+ }
+ tooltip={preview}
+ onClick={() => setOpen((wasOpen) => !wasOpen)}
+ >
+ {alerts.length > 1 ? alerts.length : null}
+
+ {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 (
-
+
+ {errorIndicator}
{actions !== null &&
(actions.readFullResponse ||
actions.repeatQuestion ||
@@ -189,14 +206,14 @@ export const VoiceDock = ({
)}
-
+
{indicator ?? }
-
- {notice ?? voiceSessionStatusLabel(phase)}
-
+ {statusLabel}
-
+
{actions !== null && (
<>
{canRetryPlayback && actions.retryPlayback && (
@@ -286,7 +303,7 @@ export const VoiceDock = ({
className={visuallyHiddenStyle}
role="status"
>
- {notice ?? `Voice status: ${voiceSessionStatusLabel(phase)}`}
+ Voice status: {statusLabel}
);
@@ -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}