diff --git a/.changeset/stable-composer-controls.md b/.changeset/stable-composer-controls.md index a648d9c4a80..423e83029fb 100644 --- a/.changeset/stable-composer-controls.md +++ b/.changeset/stable-composer-controls.md @@ -30,10 +30,14 @@ per-session Show transcription in chat action write those turns into the convers instead. Keep every session control -- transcription, the microphone toggle, Resume, Reconnect, and End -- in the dock, leaving the canvas toolbar untouched. Add `setMicrophoneMuted` to the Voice mode controls and a `muted` session phase, so muting stops capture without interrupting what the assistant -is saying, unlike pausing. Surface voice recovery failures as toasts with privacy-safe diagnostic -references, and request one-time consent before the host starts the microphone. Mark persisted spoken -messages and the exact interactive-tool answer completed by Voice with an inline Voice chip ahead of -the words themselves. +is saying, unlike pausing. Add an accessible manual Your turn control that cancels pending Voice +speech and hands the live microphone turn back to the user without disconnecting the session. Surface +voice recovery failures as toasts with privacy-safe diagnostic references, and request one-time +consent before the host starts the microphone. Mark persisted spoken messages and every +interactive-tool answer completed by Voice with visible provenance. Persisted spoken messages and +Brunch answer bubbles use the same compact, icon-only waveform indicator. Add a backwards-compatible +submitted-output provenance slot to interactive-tool widgets: opted-in widgets can position Voice +provenance beside their submitted value, while existing widgets retain the trailing fallback. End Voice mode before submitting typed text exactly once through the shared composer, preserving the draft if handoff fails. Pause active media before the AI panel closes and reopen the mounted session diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.test.ts deleted file mode 100644 index c7db816a6b9..00000000000 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { brunchAskFromComposerText } from "./brunch-ask-mapping"; - -describe("Brunch ask composer mapping", () => { - test("maps finalized composer text to the pending ask answer", () => { - expect( - brunchAskFromComposerText({ - input: { question: "Who triages the incident?" }, - text: "The support lead.", - }), - ).toEqual({ answer: "The support lead." }); - }); -}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.test.tsx new file mode 100644 index 00000000000..4ec3cadb006 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.test.tsx @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@hashintel/petrinaut/ui", () => ({ + definePetrinautAiInteractiveTool: (definition: unknown) => definition, +})); + +import * as brunchAskInteractiveToolModule from "./brunch-ask-interactive-tool"; +import { brunchAskFromComposerText } from "./brunch-ask-mapping"; +import { BrunchAskWidget } from "./brunch-ask-widget"; + +afterEach(cleanup); + +describe("Brunch ask Fast Refresh boundary", () => { + test("keeps the interactive-tool module free of component exports", () => { + expect(Object.keys(brunchAskInteractiveToolModule)).toEqual([ + "brunchAskInteractiveTool", + ]); + }); +}); + +describe("Brunch ask widget", () => { + test("renders voice provenance inside the submitted answer bubble", () => { + render( + {}} + submittedOutput={{ answer: "ATM withdrawal." }} + submittedOutputProvenance={} + toolCallId="brunch-ask-1" + />, + ); + + const answerText = screen.getByText("ATM withdrawal."); + const answerBubble = answerText.closest( + '[data-role="user-answer"]', + ) as HTMLElement; + const provenance = within(answerBubble).getByTestId("answer-provenance"); + + expect(provenance.nextElementSibling).toBe(answerText); + }); +}); + +describe("Brunch ask composer mapping", () => { + test("maps finalized composer text to the pending ask answer", () => { + expect( + brunchAskFromComposerText({ + input: { question: "Who triages the incident?" }, + text: "The support lead.", + }), + ).toEqual({ answer: "The support lead." }); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx index 7c4d01b4442..01119606a48 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx @@ -1,151 +1,18 @@ -import { type FormEvent, useId, useState } from "react"; - import { ASK_TOOL_NAME, - type BrunchAskInput, - type BrunchAskOutput, parseBrunchAskInput, parseBrunchAskOutput, } from "@hashintel/brunch-agent/client-tools"; -import { css } from "@hashintel/ds-helpers/css"; -import { - definePetrinautAiInteractiveTool, - type PetrinautAiInteractiveToolWidgetProps, -} from "@hashintel/petrinaut/ui"; +import { definePetrinautAiInteractiveTool } from "@hashintel/petrinaut/ui"; import { brunchAskFromComposerText } from "./brunch-ask-mapping"; - -const containerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - padding: "3", - borderWidth: "thin", - borderStyle: "solid", - borderColor: "blue.a30", - borderRadius: "lg", - backgroundColor: "blue.a10", -}); - -const questionStyle = css({ - color: "neutral.s100", - fontSize: "sm", - fontWeight: "medium", - lineHeight: "relaxed", -}); - -const formStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", -}); - -const labelStyle = css({ - color: "neutral.s90", - fontSize: "xs", - fontWeight: "medium", -}); - -const textareaStyle = css({ - width: "full", - minHeight: "20", - padding: "2", - borderWidth: "thin", - borderStyle: "solid", - borderColor: "neutral.a30", - borderRadius: "md", - backgroundColor: "neutral.s00", - color: "neutral.s100", - fontSize: "sm", - resize: "vertical", - _focusVisible: { - borderColor: "blue.a70", - outline: "2px solid", - outlineColor: "blue.a30", - outlineOffset: "[1px]", - }, -}); - -const submitButtonStyle = css({ - alignSelf: "flex-end", - paddingX: "3", - paddingY: "2", - borderRadius: "md", - backgroundColor: "blue.a85", - color: "white", - cursor: "pointer", - fontSize: "sm", - fontWeight: "medium", - _hover: { - backgroundColor: "blue.a100", - }, - _disabled: { - cursor: "not-allowed", - opacity: 0.45, - }, -}); - -const answerStyle = css({ - padding: "2", - borderRadius: "md", - backgroundColor: "neutral.s00", - color: "neutral.s90", - fontSize: "sm", -}); - -const BrunchAskWidget = ({ - input, - state, - submit, - submittedOutput, -}: PetrinautAiInteractiveToolWidgetProps) => { - const answerId = useId(); - const [answer, setAnswer] = useState(""); - - const onSubmit = (event: FormEvent) => { - event.preventDefault(); - const trimmedAnswer = answer.trim(); - if (!trimmedAnswer) { - return; - } - submit({ answer: trimmedAnswer }); - }; - - return ( -
-

{input.question}

- {state === "submitted" ? ( -

{submittedOutput.answer}

- ) : ( -
- -