From 00867b474bb04a8d5c4857d0d6202acd793c4cc6 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 15 Sep 2026 18:24:02 +0200 Subject: [PATCH 1/3] Remember Petrinaut Live voice disclosure acknowledgement Co-authored-by: Cursor --- .../live-conversation-control.test.tsx | 46 ++++- .../live-conversation-control.tsx | 161 ++++++++++-------- .../voice-interview-control.tsx | 2 + .../@hashintel/petrinaut/docs/ai-assistant.md | 8 +- 4 files changed, 141 insertions(+), 76 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx index 5e9792f167b..67f0c289df9 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx @@ -7,7 +7,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import { afterEach, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { BrunchPanelConversationTracker, @@ -16,6 +16,7 @@ import { import { createLiveConversation } from "./live-conversation"; import { loadOpenAIVoiceConfig, + VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, VoiceInterviewControl, } from "./voice-interview-control"; @@ -41,9 +42,26 @@ vi.mock("./live-conversation", () => ({ setSpeakerVolume: liveConversationMocks.setSpeakerVolume, })), })); +beforeEach(() => { + const values = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + } satisfies Storage, + }); +}); afterEach(() => { cleanup(); vi.clearAllMocks(); + window.localStorage.clear(); }); const context = (): PetrinautAiVoiceModeContext => ({ @@ -79,6 +97,32 @@ const start = async () => { fireEvent.click(screen.getByRole("button", { name: "Start voice" })); }; +test("starts Live directly after the voice disclosure is acknowledged", () => { + window.localStorage.setItem( + VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, + "acknowledged", + ); + + render(); + + expect( + screen.queryByRole("region", { name: "Voice mode consent" }), + ).toBeNull(); + expect(createLiveConversation).toHaveBeenCalledOnce(); +}); + +test("records the voice disclosure acknowledgement when Live starts", async () => { + render(); + + expect( + window.localStorage.getItem(VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY), + ).toBeNull(); + await start(); + expect( + window.localStorage.getItem(VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY), + ).toBe("acknowledged"); +}); + test("starts only one Live session when Start is activated twice", async () => { render(); fireEvent.click(screen.getByRole("checkbox")); diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx index e4f39ba8d4d..9396ad2d28f 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx @@ -30,15 +30,19 @@ type LiveControlsContext = PetrinautAiVoiceModeContext & | "subscribeToResponseMessageCompleted" | "subscribeToStopRequested" > & { + readonly acknowledgeDisclosure: () => void; readonly submit: ConstructorParameters< typeof LiveBrunchBridge >[0]["submit"]; readonly connectionTimeoutMs: number; + readonly isDisclosureAcknowledged: () => boolean; }; export const LiveConversationControl = ({ + acknowledgeDisclosure, inputMode, isAiAssistantOpen, + isDisclosureAcknowledged, registerVoiceModeSessionControls, reportVoiceSessionState, setVoiceActive, @@ -70,6 +74,7 @@ export const LiveConversationControl = ({ null, ); const sessionActive = useRef(false); + const handledVoiceSelection = useRef(false); const bridge = useRef(null); const latest = useRef({ submit, @@ -134,6 +139,85 @@ export const LiveConversationControl = ({ setConsented(false); await closing; }, [setVoiceActive]); + const start = useCallback(() => { + if (phase === "stopping" || sessionActive.current) return; + sessionActive.current = true; + setConsented(false); + setMicrophoneMutedState(false); + setSpeakerMutedState(false); + setSpeakerVolumeState(1); + setWarningMessage(null); + setState({ phase: "connecting", message: null }); + const next = createLiveConversation( + (nextState) => { + if (session.current !== next) return; + if ( + nextState.phase === "stopping" || + nextState.phase === "ended" || + nextState.phase === "error" + ) { + sessionActive.current = false; + } + if ( + nextState.phase === "error" || + nextState.phase === "ended" || + nextState.phase === "stopping" + ) + bridge.current?.stop(); + setState(nextState); + setVoiceActive( + nextState.phase === "connecting" || nextState.phase === "connected", + ); + }, + connectionTimeoutMs, + (input) => { + if (session.current === next) void bridge.current?.accept(input); + }, + (delegationId) => { + if (session.current === next) + bridge.current?.acceptDelegation(delegationId); + }, + (result) => { + if (session.current !== next) return; + // Every successful local send starts as unknown. Neither waiting + // for acceptance nor acceptance itself is an error or resolves a + // failure from another append. + if (result.status === "unknown" || result.status === "accepted") return; + const label = + result.kind === "commentary" ? "answer" : "continuation instruction"; + const outcome = + result.status === "local-failure" + ? "could not be sent to Live locally" + : "was rejected by Live"; + setWarningMessage( + `The ${label} ${outcome}. Check the conversation; no automatic retry or replay was made. Acceptance does not confirm playback.`, + ); + }, + ); + next.setMicrophoneMuted(false); + next.setSpeakerMuted(false); + next.setSpeakerVolume(1); + bridge.current = new LiveBrunchBridge({ + submit: (input) => latest.current.submit(input), + appendCommentary: next.appendCommentary, + appendInstructions: next.appendInstructions, + notice: setWarningMessage, + }); + bridge.current.update(latest.current.chat); + session.current = next; + setVoiceActive(true); + void next.start(); + }, [connectionTimeoutMs, phase, setVoiceActive]); + useLayoutEffect(() => { + if (inputMode !== "voice" || !isAiAssistantOpen) { + handledVoiceSelection.current = false; + return; + } + if (handledVoiceSelection.current) return; + handledVoiceSelection.current = true; + // eslint-disable-next-line react-hooks-js/set-state-in-effect -- input mode synchronizes persisted disclosure state with the Live session + if (isDisclosureAcknowledged()) start(); + }, [inputMode, isAiAssistantOpen, isDisclosureAcknowledged, start]); const setMicrophoneMuted = useCallback((muted: boolean) => { if (!sessionActive.current || !session.current) return; session.current.setMicrophoneMuted(muted); @@ -169,7 +253,8 @@ export const LiveConversationControl = ({ () => registerVoiceModeSessionControls({ end, - // Closing the panel ends Live. Reopening requires consent and a new session. + // Closing the panel ends Live. Reopening starts a new session after + // the first disclosure has been acknowledged. pause: () => { void end(); }, @@ -266,77 +351,9 @@ export const LiveConversationControl = ({ startDisabled={phase === "stopping"} microphoneCheck={phase === "error" ? (state.message ?? "") : ""} onStart={() => { - if (!consented || phase === "stopping" || sessionActive.current) return; - sessionActive.current = true; - setConsented(false); - setMicrophoneMutedState(false); - setSpeakerMutedState(false); - setSpeakerVolumeState(1); - setWarningMessage(null); - setState({ phase: "connecting", message: null }); - const next = createLiveConversation( - (nextState) => { - if (session.current !== next) return; - if ( - nextState.phase === "stopping" || - nextState.phase === "ended" || - nextState.phase === "error" - ) { - sessionActive.current = false; - } - if ( - nextState.phase === "error" || - nextState.phase === "ended" || - nextState.phase === "stopping" - ) - bridge.current?.stop(); - setState(nextState); - setVoiceActive( - nextState.phase === "connecting" || - nextState.phase === "connected", - ); - }, - connectionTimeoutMs, - (input) => { - if (session.current === next) void bridge.current?.accept(input); - }, - (delegationId) => { - if (session.current === next) - bridge.current?.acceptDelegation(delegationId); - }, - (result) => { - if (session.current !== next) return; - // Every successful local send starts as unknown. Neither waiting - // for acceptance nor acceptance itself is an error or resolves a - // failure from another append. - if (result.status === "unknown" || result.status === "accepted") - return; - const label = - result.kind === "commentary" - ? "answer" - : "continuation instruction"; - const outcome = - result.status === "local-failure" - ? "could not be sent to Live locally" - : "was rejected by Live"; - setWarningMessage( - `The ${label} ${outcome}. Check the conversation; no automatic retry or replay was made. Acceptance does not confirm playback.`, - ); - }, - ); - next.setMicrophoneMuted(false); - next.setSpeakerMuted(false); - next.setSpeakerVolume(1); - bridge.current = new LiveBrunchBridge({ - submit: (input) => latest.current.submit(input), - appendCommentary: next.appendCommentary, - appendInstructions: next.appendInstructions, - notice: setWarningMessage, - }); - bridge.current.update(latest.current.chat); - session.current = next; - setVoiceActive(true); - void next.start(); + if (!consented) return; + acknowledgeDisclosure(); + start(); }} onExit={() => { void end(); diff --git a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx index 4439256ce37..a87f61bd6ab 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx @@ -600,7 +600,9 @@ const PinnedVoiceInterviewControl = ({ return ( Date: Tue, 15 Sep 2026 18:44:42 +0200 Subject: [PATCH 2/3] Retry Live auto-start after session teardown Co-authored-by: Cursor --- .../live-conversation-control.test.tsx | 28 +++++++++++++++++++ .../live-conversation-control.tsx | 6 ++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx index 67f0c289df9..fc99e7a8ee4 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx @@ -111,6 +111,34 @@ test("starts Live directly after the voice disclosure is acknowledged", () => { expect(createLiveConversation).toHaveBeenCalledOnce(); }); +test("starts acknowledged Live after the previous session finishes stopping", () => { + window.localStorage.setItem( + VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, + "acknowledged", + ); + const props = context(); + const { rerender } = render( + , + ); + const onState = vi.mocked(createLiveConversation).mock.calls[0]![0]; + act(() => onState({ phase: "connected", message: null })); + rerender( + , + ); + act(() => onState({ phase: "stopping", message: null })); + + rerender(); + + expect(createLiveConversation).toHaveBeenCalledOnce(); + act(() => + onState({ + phase: "ended", + message: "Microphone and playback stopped.", + }), + ); + expect(createLiveConversation).toHaveBeenCalledTimes(2); +}); + test("records the voice disclosure acknowledgement when Live starts", async () => { render(); diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx index 9396ad2d28f..9de860badf7 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx @@ -140,7 +140,7 @@ export const LiveConversationControl = ({ await closing; }, [setVoiceActive]); const start = useCallback(() => { - if (phase === "stopping" || sessionActive.current) return; + if (phase === "stopping" || sessionActive.current) return false; sessionActive.current = true; setConsented(false); setMicrophoneMutedState(false); @@ -207,6 +207,7 @@ export const LiveConversationControl = ({ session.current = next; setVoiceActive(true); void next.start(); + return true; }, [connectionTimeoutMs, phase, setVoiceActive]); useLayoutEffect(() => { if (inputMode !== "voice" || !isAiAssistantOpen) { @@ -214,9 +215,8 @@ export const LiveConversationControl = ({ return; } if (handledVoiceSelection.current) return; - handledVoiceSelection.current = true; // eslint-disable-next-line react-hooks-js/set-state-in-effect -- input mode synchronizes persisted disclosure state with the Live session - if (isDisclosureAcknowledged()) start(); + handledVoiceSelection.current = isDisclosureAcknowledged() ? start() : true; }, [inputMode, isAiAssistantOpen, isDisclosureAcknowledged, start]); const setMicrophoneMuted = useCallback((muted: boolean) => { if (!sessionActive.current || !session.current) return; From c11023368fe8037c0cb6994215fd0ec7d40b465b Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 15 Sep 2026 19:39:45 +0200 Subject: [PATCH 3/3] Separate Live voice consent and retry states Co-authored-by: Cursor --- .../live-conversation-control.test.tsx | 62 ++++++++++++++++--- .../live-conversation-control.tsx | 51 ++++++++++++--- .../voice-interview-control.tsx | 43 ++++++++++--- .../voice-interview-disclosure.tsx | 56 +++++++++++++++++ .../@hashintel/petrinaut/docs/ai-assistant.md | 10 +-- 5 files changed, 190 insertions(+), 32 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx index fc99e7a8ee4..1899ea021cd 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.test.tsx @@ -15,6 +15,7 @@ import { } from "../local-storage-demo/brunch-panel-transport"; import { createLiveConversation } from "./live-conversation"; import { + LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, loadOpenAIVoiceConfig, VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, VoiceInterviewControl, @@ -99,7 +100,7 @@ const start = async () => { test("starts Live directly after the voice disclosure is acknowledged", () => { window.localStorage.setItem( - VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, + LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, "acknowledged", ); @@ -111,15 +112,30 @@ test("starts Live directly after the voice disclosure is acknowledged", () => { expect(createLiveConversation).toHaveBeenCalledOnce(); }); -test("starts acknowledged Live after the previous session finishes stopping", () => { +test("does not reuse the Realtime voice disclosure acknowledgement", () => { window.localStorage.setItem( VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, "acknowledged", ); + + render(); + + expect( + screen.getByRole("region", { name: "Voice mode consent" }), + ).toBeTruthy(); + expect(createLiveConversation).not.toHaveBeenCalled(); +}); + +test("starts acknowledged Live after the previous session finishes stopping", () => { + window.localStorage.setItem( + LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, + "acknowledged", + ); const props = context(); const { rerender } = render( , ); + expect(createLiveConversation).toHaveBeenCalledOnce(); const onState = vi.mocked(createLiveConversation).mock.calls[0]![0]; act(() => onState({ phase: "connected", message: null })); rerender( @@ -147,8 +163,34 @@ test("records the voice disclosure acknowledgement when Live starts", async () = ).toBeNull(); await start(); expect( - window.localStorage.getItem(VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY), + window.localStorage.getItem(LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY), ).toBe("acknowledged"); + expect( + window.localStorage.getItem(VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY), + ).toBeNull(); +}); + +test("retries an acknowledged Live failure without requesting consent again", async () => { + render(); + await start(); + const onState = vi.mocked(createLiveConversation).mock.calls[0]![0]; + + act(() => + onState({ + phase: "error", + message: "Live media connection ended.", + }), + ); + + expect( + screen.queryByRole("region", { name: "Voice mode consent" }), + ).toBeNull(); + expect(screen.getByRole("region", { name: "Voice mode retry" })).toBeTruthy(); + expect(screen.queryByRole("checkbox")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Retry voice" })); + + expect(createLiveConversation).toHaveBeenCalledTimes(2); }); test("starts only one Live session when Start is activated twice", async () => { @@ -387,9 +429,10 @@ test("reuses setup and reports failure to the host dock and notification surface expect(screen.getByText(connectionError)).toBeTruthy(); expect( screen - .getByRole("button", { name: "Start voice" }) + .getByRole("button", { name: "Retry voice" }) .hasAttribute("disabled"), - ).toBe(true); + ).toBe(false); + expect(screen.queryByRole("checkbox")).toBeNull(); expect(createLiveConversation).toHaveBeenCalledOnce(); }); @@ -609,7 +652,7 @@ test("resets and applies audio defaults when a Live session restarts", async () liveConversationMocks.setSpeakerMuted.mockClear(); liveConversationMocks.setSpeakerVolume.mockClear(); - await start(); + fireEvent.click(screen.getByRole("button", { name: "Retry voice" })); expect(createLiveConversation).toHaveBeenCalledTimes(2); expect( @@ -633,7 +676,7 @@ test("resets and applies audio defaults when a Live session restarts", async () ); }); -test("does not show a successful prior session close on the next consent card", async () => { +test("offers restart without showing consent after a Live session closes", async () => { render(); await start(); const onState = vi.mocked(createLiveConversation).mock.calls[0]![0]; @@ -643,8 +686,9 @@ test("does not show a successful prior session close on the next consent card", act(() => onState({ phase: "ended", message: closureMessage })); expect( - screen.getByRole("region", { name: "Voice mode consent" }), - ).toBeTruthy(); + screen.queryByRole("region", { name: "Voice mode consent" }), + ).toBeNull(); + expect(screen.getByRole("region", { name: "Voice mode retry" })).toBeTruthy(); expect(screen.queryByText(closureMessage)).toBeNull(); }); diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx index 9de860badf7..cf8499d8d7f 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation-control.tsx @@ -12,7 +12,10 @@ import { createLiveConversation, type LiveConversationState, } from "./live-conversation"; -import { VoiceInterviewDisclosure } from "./voice-interview-disclosure"; +import { + VoiceInterviewDisclosure, + VoiceInterviewRetry, +} from "./voice-interview-disclosure"; import type { VoiceInterviewControl } from "./voice-interview-control"; import type { PetrinautAiVoiceModeContext } from "@hashintel/petrinaut/ui"; @@ -65,6 +68,9 @@ export const LiveConversationControl = ({ const [microphoneMuted, setMicrophoneMutedState] = useState(false); const [speakerMuted, setSpeakerMutedState] = useState(false); const [speakerVolume, setSpeakerVolumeState] = useState(1); + const [disclosureAcknowledged, setDisclosureAcknowledged] = useState( + isDisclosureAcknowledged, + ); const [state, setState] = useState({ phase: "idle", message: null, @@ -139,7 +145,7 @@ export const LiveConversationControl = ({ setConsented(false); await closing; }, [setVoiceActive]); - const start = useCallback(() => { + const tryStartLiveConversation = useCallback(() => { if (phase === "stopping" || sessionActive.current) return false; sessionActive.current = true; setConsented(false); @@ -215,9 +221,18 @@ export const LiveConversationControl = ({ return; } if (handledVoiceSelection.current) return; + if (!disclosureAcknowledged) { + handledVoiceSelection.current = true; + return; + } // eslint-disable-next-line react-hooks-js/set-state-in-effect -- input mode synchronizes persisted disclosure state with the Live session - handledVoiceSelection.current = isDisclosureAcknowledged() ? start() : true; - }, [inputMode, isAiAssistantOpen, isDisclosureAcknowledged, start]); + handledVoiceSelection.current = tryStartLiveConversation(); + }, [ + disclosureAcknowledged, + inputMode, + isAiAssistantOpen, + tryStartLiveConversation, + ]); const setMicrophoneMuted = useCallback((muted: boolean) => { if (!sessionActive.current || !session.current) return; session.current.setMicrophoneMuted(muted); @@ -343,6 +358,26 @@ export const LiveConversationControl = ({ if (inputMode !== "voice" || phase === "connecting" || phase === "connected") return null; + const exitVoiceMode = () => { + void end(); + setInputMode("text"); + }; + if (disclosureAcknowledged) { + const retryMessage = + phase === "error" + ? (state.message ?? "The voice connection was unavailable.") + : phase === "stopping" + ? "Finishing the previous voice session." + : "Start a new Live voice session."; + return ( + + ); + } return ( { if (!consented) return; acknowledgeDisclosure(); - start(); - }} - onExit={() => { - void end(); - setInputMode("text"); + setDisclosureAcknowledged(true); + tryStartLiveConversation(); }} + onExit={exitVoiceMode} /> ); }; diff --git a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx index a87f61bd6ab..7e2edabf7b3 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx +++ b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.tsx @@ -137,6 +137,8 @@ export interface OpenAIVoiceConfig { export const VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY = "petrinaut:voice-interview-disclosure:v1"; +export const LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY = + "petrinaut:live-voice-interview-disclosure:v1"; const VOICE_INTERVIEW_DISCLOSURE_ACKNOWLEDGED = "acknowledged"; const getVoiceInterviewDisclosureStorage = (): Storage | null => { @@ -180,7 +182,8 @@ export const saveInterruptionBySpeakingPreference = ( } }; -export const isVoiceInterviewDisclosureAcknowledged = ( +const isDisclosureAcknowledged = ( + storageKey: string, storage: Pick< Storage, "getItem" @@ -188,30 +191,50 @@ export const isVoiceInterviewDisclosureAcknowledged = ( ): boolean => { try { return ( - storage?.getItem(VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY) === - VOICE_INTERVIEW_DISCLOSURE_ACKNOWLEDGED + storage?.getItem(storageKey) === VOICE_INTERVIEW_DISCLOSURE_ACKNOWLEDGED ); } catch { return false; } }; -export const acknowledgeVoiceInterviewDisclosure = ( +export const isVoiceInterviewDisclosureAcknowledged = ( + storage?: Pick | null, +): boolean => + isDisclosureAcknowledged(VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, storage); + +const isLiveVoiceInterviewDisclosureAcknowledged = ( + storage?: Pick | null, +): boolean => + isDisclosureAcknowledged( + LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, + storage, + ); + +const acknowledgeDisclosure = ( + storageKey: string, storage: Pick< Storage, "setItem" > | null = getVoiceInterviewDisclosureStorage(), ): void => { try { - storage?.setItem( - VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, - VOICE_INTERVIEW_DISCLOSURE_ACKNOWLEDGED, - ); + storage?.setItem(storageKey, VOICE_INTERVIEW_DISCLOSURE_ACKNOWLEDGED); } catch { // Storage is optional; the disclosure will appear again next time. } }; +export const acknowledgeVoiceInterviewDisclosure = ( + storage?: Pick | null, +): void => + acknowledgeDisclosure(VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, storage); + +const acknowledgeLiveVoiceInterviewDisclosure = ( + storage?: Pick | null, +): void => + acknowledgeDisclosure(LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, storage); + const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; @@ -600,9 +623,9 @@ const PinnedVoiceInterviewControl = ({ return ( ); }; + +export const VoiceInterviewRetry = ({ + message, + onExit, + onRetry, + retryDisabled = false, +}: { + readonly message: string; + readonly onExit: () => void; + readonly onRetry: () => void; + readonly retryDisabled?: boolean; +}) => { + const retryRef = useRef(null); + useEffect(() => { + retryRef.current?.focus(); + }, []); + + return ( +
+
+
+ + + +
+ + Restart voice conversation + +
+
+

+ {message} +

+
+ + +
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 39708057d5d..0a4ba87d7d2 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -78,14 +78,16 @@ finalized user messages. Brunch remains responsible for domain answers, chat history, and changes to the net; settled Brunch prose is supplied to Live for best-effort spoken delivery. Before the first Live session, the permission panel explains both OpenAI audio streams and text retention, with a permission -checkbox, **Start voice**, and **Cancel**. Petrinaut remembers the same -versioned acknowledgement in browser storage, so later Live sessions start -directly. Browser microphone permission remains separate. +checkbox, **Start voice**, and **Cancel**. Petrinaut remembers this +Live-specific versioned acknowledgement in browser storage, so later Live +sessions start directly. A failed or ended acknowledged session offers +**Retry voice** without showing the consent prompt again. Browser microphone +permission remains separate. **Cancel** returns to text without starting one. If the browser blocks remote playback, the dock keeps the warning visible and offers **Play voice audio**; selecting it retries playback from that user gesture. Closing the panel or selecting **End voice mode** ends Live audio, transcription, microphone -capture, and playback. A connection error returns to setup without retrying. +capture, and playback. A connection error never retries automatically. While Voice runs, the composer is replaced by a compact dock at the foot of the panel. It shows one short state -- **Connecting**, **Listening**, **Muted**,