Skip to content
12 changes: 8 additions & 4 deletions .changeset/stable-composer-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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(
<BrunchAskWidget
input={{ question: "What can customers do at the ATM?" }}
state="submitted"
submit={() => {}}
submittedOutput={{ answer: "ATM withdrawal." }}
submittedOutputProvenance={<span data-testid="answer-provenance" />}
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." });
});
});
Original file line number Diff line number Diff line change
@@ -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<BrunchAskInput, BrunchAskOutput>) => {
const answerId = useId();
const [answer, setAnswer] = useState("");

const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedAnswer = answer.trim();
if (!trimmedAnswer) {
return;
}
submit({ answer: trimmedAnswer });
};

return (
<section className={containerStyle}>
<p className={questionStyle}>{input.question}</p>
{state === "submitted" ? (
<p className={answerStyle}>{submittedOutput.answer}</p>
) : (
<form className={formStyle} onSubmit={onSubmit}>
<label className={labelStyle} htmlFor={answerId}>
Your answer
</label>
<textarea
className={textareaStyle}
id={answerId}
onChange={(event) => setAnswer(event.target.value)}
placeholder="Write what you know; uncertainty is useful too."
rows={3}
value={answer}
/>
<button
className={submitButtonStyle}
disabled={answer.trim().length === 0}
type="submit"
>
Send answer
</button>
</form>
)}
</section>
);
};
import { BrunchAskWidget } from "./brunch-ask-widget";

export const brunchAskInteractiveTool = definePetrinautAiInteractiveTool({
toolName: ASK_TOOL_NAME,
inputSchema: { parse: parseBrunchAskInput },
outputSchema: { parse: parseBrunchAskOutput },
fromComposerText: brunchAskFromComposerText,
supportsSubmittedOutputProvenance: true,
component: BrunchAskWidget,
});
Loading
Loading