Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3b216b6
Unify the Brunch conversation UI for chat and voice
kostandinang Sep 24, 2026
d7ab86c
Align Brunch conversation details with the prototype
kostandinang Sep 24, 2026
dc0930f
Use neutral activity labels and stock Petrinaut styling
kostandinang Sep 24, 2026
47c8888
Restore the prototype’s compact conversation disclosures
kostandinang Sep 24, 2026
f868c1c
Align voice brief spacing and controls with the conversation design
kostandinang Sep 25, 2026
2fb9107
Align Voice controls and preparation feedback with the conversation d…
kostandinang Sep 25, 2026
6ff051f
Complete the conversation prototype controls and feedback
kostandinang Sep 28, 2026
0d8e45d
Remove candidate progress to keep core unchanged
kostandinang Sep 28, 2026
ce96ad5
Restore the single Voice and Send composer action
kostandinang Sep 28, 2026
4cb6d2c
Restore the compact inline chat composer
kostandinang Sep 28, 2026
1af85e8
Stabilize Monaco setup in voice history integration test
kostandinang Sep 28, 2026
e3b364f
Separate conversation presentation from host behavior
kostandinang Sep 28, 2026
f60122d
Align voice consent and floating minimization with the prototype
kostandinang Sep 28, 2026
2efe131
Animate interrupted Voice and simplify recovery copy
kostandinang Sep 28, 2026
44bb564
Hide empty Chat activity folds and follow growing Voice lines
kostandinang Sep 29, 2026
0c15c33
Keep preserved composer drafts when text answers an interactive tool
kostandinang Sep 29, 2026
a9f8441
Keep stock transcript labels and working status outside Brunch Chat
kostandinang Sep 29, 2026
9db1341
Reserve Brunch response status and keep disclosure choices while stre…
kostandinang Sep 29, 2026
1086aa7
Keep following a streaming transcript while its smooth scroll catches up
kostandinang Sep 29, 2026
0fad30e
Keep card-placed tools out of the work fold while they stream or fail
kostandinang Sep 29, 2026
2c61bfa
Keep the Activity timer counting across approval pauses
kostandinang Sep 29, 2026
2158fee
Place Voice control tests beside their sources
kostandinang Sep 30, 2026
cc5396d
Remove assertions on the retired voice provenance marker
kostandinang Sep 30, 2026
29252ea
Remove the unused hostExperimentRunning prop
kostandinang Sep 30, 2026
14485c8
Correct the assistant guide's voice icon, status colour and stock wor…
kostandinang Sep 30, 2026
824ed1c
Select the Brunch presentation with an explicit assistant option
kostandinang Sep 30, 2026
eb51be4
Label Voice attention and conversation stories with the Brunch presen…
kostandinang Sep 30, 2026
022ffec
Restore stock assistant styling and share conversation tab behavior
kostandinang Sep 30, 2026
5f9461c
Merge main to pick up Live utterance filtering
kostandinang Sep 30, 2026
a4ddacd
Render the Brunch presentation in the workpiece refusal integration test
kostandinang Sep 30, 2026
2fcd04d
Keep stopped tool states and Stop styling within their presentation
kostandinang Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brunch-conversation-turns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Unify Chat and Voice turns with collapsible work, answer cards, voice briefs, and produced-card placement. Preserve drafts, reading position, and Brunch disclosure choices during streaming; stabilize waiting feedback and improve composer, audio settings, and recovery feedback.
5 changes: 5 additions & 0 deletions .changeset/explicit-brunch-presentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add an explicit `aiAssistant.presentation` option so hosts can select the Brunch conversation presentation without relying on the transcript label, while preserving the stock assistant presentation by default.
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ export const createBrunchDraftExperimentInteractiveTool = ({
DraftPetrinautExperimentOutput
>({
toolName: brunchTools.draftPetrinautExperiment,
placement: "card",
inputSchema: draftPetrinautExperimentInputSchema,
outputSchema: draftPetrinautExperimentOutputSchema,
component: (props) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ export const LocalStorageDemoApp = ({
...(brunchSelected
? {
primaryLabel: "Chat",
presentation: "brunch" as const,
resolveToolPresentation: resolveBrunchToolPresentation,
workingLabel: "Brunch is working",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
within,
} from "@testing-library/react";
import { useEffect, useState } from "react";
import { afterEach, expect, test, vi } from "vitest";
import { afterEach, beforeAll, expect, test, vi } from "vitest";

import { createJsonDocHandle } from "@hashintel/petrinaut-core";
import {
Expand Down Expand Up @@ -40,8 +40,49 @@ vi.hoisted(() => {
removeEventListener() {},
dispatchEvent: () => true,
});
// Monaco probes legacy clipboard support at import time; jsdom has no
// implementation. This fixture does not exercise clipboard operations.
Object.defineProperty(document, "queryCommandSupported", {
configurable: true,
value: () => false,
});
class ClipboardItem {
constructor(readonly items: Record<string, Blob | Promise<Blob>>) {}
}
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
// Monaco's macOS gesture handler supplies deferred data. Adopt it so
// cancellation propagates to the handler rather than becoming unhandled.
write: (items: ClipboardItem[]) =>
Promise.all(
items.flatMap((item) =>
Object.values(item.items).map((value) => Promise.resolve(value)),
),
).then(() => undefined),
},
});
Object.defineProperty(window, "ClipboardItem", {
configurable: true,
value: ClipboardItem,
});
// Monaco's theme service escapes icon class names when it initializes.
Object.defineProperty(window, "CSS", {
configurable: true,
value: {
...window.CSS,
escape: (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"),
},
});
});

beforeAll(async () => {
// Settle the real editor's import and theme setup before mounting so errors
// cannot race the history assertions or escape after the test finishes.
const monaco = await import("monaco-editor");
monaco.editor.setTheme("vs");
}, 30_000);

const conversationId = "voice-continuity";
const voiceAnswerToolName = "answerQuestion";
const voiceClientToolNames = new Set([voiceAnswerToolName]);
Expand Down Expand Up @@ -343,7 +384,7 @@ test("projects typed, in-band tool, and stopped fixture history after remount",
await waitFor(() => expect(requestStop).toHaveBeenCalledOnce());
firstMount.unmount();

const secondMount = render(
render(
<ContinuityPanel
clientPromise={observation.clientPromise}
endVoice={endVoice}
Expand All @@ -358,14 +399,7 @@ test("projects typed, in-band tool, and stopped fixture history after remount",
await act(async () => fireEvent.click(showSecondPanel));
await screen.findByText("Typed planning note");
expect(observation.observe).toHaveBeenCalledTimes(2);
expect(
within(
screen.getByText("Typed planning note").closest("[data-role]")!,
).queryByTestId("voice-input-provenance"),
).toBeNull();
expect(
secondMount.container.querySelector('[data-tool-call-id="voice-tool-1"]'),
).not.toBeNull();
expect(screen.getByText("voice-tool-1: The supervisor")).not.toBeNull();
expect(screen.getByText("Durably interrupted response")).not.toBeNull();
expect(screen.getByText("Response stopped")).not.toBeNull();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { getToolName, isToolUIPart, readUIMessageStream } from "ai";
Expand Down Expand Up @@ -70,7 +71,10 @@ afterAll(() => {
});
afterEach(cleanup);

const renderAssistant = (messages: readonly PetrinautAiMessage[]) =>
const renderAssistant = (
messages: readonly PetrinautAiMessage[],
status: "ready" | "streaming" = "ready",
) =>
render(
<AiAssistantContents
input=""
Expand All @@ -79,11 +83,23 @@ const renderAssistant = (messages: readonly PetrinautAiMessage[]) =>
onInputChange={noop}
onStop={noop}
onSubmit={noop}
presentation="brunch"
resolveToolPresentation={resolveBrunchToolPresentation}
status="ready"
status={status}
/>,
);

const expandSettledTools = async () => {
const activity = screen.getByRole("button", { name: /^Activity/u });
fireEvent.click(activity);
await waitFor(() =>
expect(activity.getAttribute("aria-expanded")).toBe("true"),
);
const tools = screen.getByRole("button", { name: /^Used \d+ tools?$/u });
fireEvent.click(tools);
await waitFor(() => expect(tools.getAttribute("aria-expanded")).toBe("true"));
};

test("renders pending gold, applied green, typed refusal compact, and thrown red across reopen", async () => {
delete process.env.BRUNCH_CHAT_MODEL;
delete process.env.BRUNCH_CHAT_THINKING;
Expand Down Expand Up @@ -218,18 +234,19 @@ test("renders pending gold, applied green, typed refusal compact, and thrown red
const pending = pendingById.get(toolCallId);
expect(pending, `pending row missing for ${toolCallId}`).toBeDefined();
cleanup();
renderAssistant([pending!]);
renderAssistant([pending!], "streaming");
const pendingRow = screen
.getAllByRole("button")
.find((row) => row.getAttribute("aria-busy") === "true");
expect(pendingRow?.getAttribute("data-tone")).toBe("pending");
expect(
pendingRow?.querySelector("[data-tool-progress-spinner]"),
pendingRow?.querySelector('[data-tool-status="pending"]'),
).not.toBeNull();
}

cleanup();
renderAssistant([terminalMessage]);
await expandSettledTools();
const appliedRows = screen.getAllByRole("button", {
name: /Updated ledger/u,
});
Expand All @@ -239,21 +256,20 @@ test("renders pending gold, applied green, typed refusal compact, and thrown red
).toBe(true);
expect(
appliedRows.every(
(row) =>
row.querySelector('[data-tool-result-icon="complete"]') !== null,
(row) => row.querySelector('[data-tool-status="ok"]') !== null,
),
).toBe(true);

const refusedRow = screen.getByRole("button", {
name: /Ledger update needs correction/u,
});
expect(refusedRow.getAttribute("data-tone")).toBe("neutral");
expect(
refusedRow.querySelector('[data-tool-result-icon="not-applied"]'),
).not.toBeNull();
expect(refusedRow.querySelector('[data-tool-status="ok"]')).not.toBeNull();
expect(within(refusedRow).queryByTestId("tool-detail")).toBeNull();
fireEvent.click(refusedRow);
expect(screen.getByText(/Nothing was written/u)).not.toBeNull();
expect(screen.getAllByText(/Nothing was written/u).length).toBeGreaterThan(
0,
);

const thrownRow = screen.getByRole("button", {
name: /Could not update ledger/u,
Expand Down Expand Up @@ -297,6 +313,7 @@ test("renders pending gold, applied green, typed refusal compact, and thrown red
}) as PetrinautAiMessage[];
cleanup();
renderAssistant(reopened);
await expandSettledTools();
expect(
screen.getAllByRole("button", { name: /Updated ledger/u }),
).toHaveLength(2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,34 @@ const config = {
provider: "live" as const,
connectionTimeoutMs: 15_000,
};
test("checks the Live microphone locally, releases tracks and never starts a session", async () => {
const stopTrack = vi.fn();
const getUserMedia = vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
}));
const previous = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia },
});
try {
render(<VoiceInterviewControl {...context()} config={config} />);
fireEvent.click(
await screen.findByRole("button", { name: "Test microphone" }),
);
await screen.findByText("Microphone ready. No audio was sent.");
expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
expect(stopTrack).toHaveBeenCalledOnce();
expect(createLiveConversation).not.toHaveBeenCalled();
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).not.toBe(
"true",
);
} finally {
if (previous) Object.defineProperty(navigator, "mediaDevices", previous);
else Reflect.deleteProperty(navigator, "mediaDevices");
}
});

const start = async () => {
fireEvent.click(screen.getByRole("checkbox"));
await waitFor(() =>
Expand Down Expand Up @@ -146,6 +174,13 @@ test("starts acknowledged Live after the previous session finishes stopping", ()

rerender(<VoiceInterviewControl {...props} config={config} />);

expect(screen.getByText("Stopping voice…")).toBeTruthy();
expect(screen.queryByText("Voice disconnected")).toBeNull();
expect(
screen
.getByRole("button", { name: "Retry voice" })
.hasAttribute("disabled"),
).toBe(true);
expect(createLiveConversation).toHaveBeenCalledOnce();
act(() =>
onState({
Expand Down Expand Up @@ -188,6 +223,15 @@ test("retries an acknowledged Live failure without requesting consent again", as
).toBeNull();
expect(screen.getByRole("region", { name: "Voice mode retry" })).toBeTruthy();
expect(screen.queryByRole("checkbox")).toBeNull();
expect(screen.getByText("Voice disconnected")).toBeTruthy();
expect(screen.getByText("Try again, or continue in chat.")).toBeTruthy();
const details = screen
.getByText("Live media connection ended.")
.closest("details");
expect(details).not.toBeNull();
expect(details?.open).toBe(false);
expect(screen.getByText("Technical details")).toBeTruthy();
expect(screen.getByRole("button", { name: "Back to chat" })).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: "Retry voice" }));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export const LiveConversationControl = ({
audioSettingsStore.getSnapshot,
);
const [consented, setConsented] = useState(false);
const [checkingMicrophone, setCheckingMicrophone] = useState(false);
const [microphoneCheck, setMicrophoneCheck] = useState("");
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const [microphoneMuted, setMicrophoneMutedState] = useState(false);
const [speakerMuted, setSpeakerMutedState] = useState(false);
Expand Down Expand Up @@ -263,7 +265,6 @@ export const LiveConversationControl = ({
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 = tryStartLiveConversation();
}, [
disclosureAcknowledged,
Expand Down Expand Up @@ -425,10 +426,36 @@ export const LiveConversationControl = ({
experimental
consented={consented}
onConsentChange={setConsented}
startDisabled={phase === "stopping"}
microphoneCheck={phase === "error" ? (state.message ?? "") : ""}
checkingMicrophone={checkingMicrophone}
startDisabled={phase === "stopping" || checkingMicrophone}
microphoneCheck={
phase === "error" ? (state.message ?? "") : microphoneCheck
}
onCheckMicrophone={() => {
if (checkingMicrophone) return;
setCheckingMicrophone(true);
setMicrophoneCheck("");
void (async () => {
try {
const microphoneId = audioSettings.devices.microphoneId;
const stream = await navigator.mediaDevices.getUserMedia({
audio: microphoneId
? { deviceId: { exact: microphoneId } }
: true,
});
for (const track of stream.getTracks()) track.stop();
setMicrophoneCheck("Microphone ready. No audio was sent.");
} catch {
setMicrophoneCheck(
"Microphone access was not available. Check your browser permissions and try again.",
);
} finally {
setCheckingMicrophone(false);
}
})();
}}
onStart={() => {
if (!consented) return;
if (!consented || checkingMicrophone) return;
acknowledgeDisclosure();
setDisclosureAcknowledged(true);
tryStartLiveConversation();
Expand Down
Loading
Loading