diff --git a/.changeset/activity-without-nested-scroll.md b/.changeset/activity-without-nested-scroll.md
new file mode 100644
index 00000000000..499de9f104e
--- /dev/null
+++ b/.changeset/activity-without-nested-scroll.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Show every reasoning step and tool in the Brunch Activity fold instead of clipping them in a nested scroll area, so the visible list always matches the tool count.
diff --git a/.changeset/brunch-activity-labels.md b/.changeset/brunch-activity-labels.md
new file mode 100644
index 00000000000..f84c1cbabed
--- /dev/null
+++ b/.changeset/brunch-activity-labels.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Show activity wording instead of naming Brunch during a turn. The waiting row and the default working status read "Working…", and the Voice brief reads "Preparing request", "Sending request", then "Request sent".
diff --git a/.changeset/brunch-activity-stays-put.md b/.changeset/brunch-activity-stays-put.md
new file mode 100644
index 00000000000..0cf0f681962
--- /dev/null
+++ b/.changeset/brunch-activity-stays-put.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Keep the Brunch Activity row in place through a turn. A spoken Live reply no longer takes over the Working… row, and the row's label no longer shifts when the spinner gives way to the Activity icon.
diff --git a/.changeset/brunch-tab-fade.md b/.changeset/brunch-tab-fade.md
new file mode 100644
index 00000000000..6989d8924cf
--- /dev/null
+++ b/.changeset/brunch-tab-fade.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Stop Brunch assistant tabs inheriting the stock tab fade, so the Brunch selected and hover treatment applies on its own.
diff --git a/.changeset/brunch-waiting-in-activity.md b/.changeset/brunch-waiting-in-activity.md
new file mode 100644
index 00000000000..400593f6f83
--- /dev/null
+++ b/.changeset/brunch-waiting-in-activity.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Show Brunch's waiting state in the transcript, where the turn's Activity will appear, and keep the spinner in the Activity header while Brunch works. The status row above the composer now appears only while the host tab is shown.
diff --git a/.changeset/voice-settings-copy.md b/.changeset/voice-settings-copy.md
new file mode 100644
index 00000000000..475c9486b21
--- /dev/null
+++ b/.changeset/voice-settings-copy.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Shorten Voice audio settings help: the voice note reads "Applies next session. To preview, mute your mic while the agent is idle." and disabled controls read "Connect Voice to use audio controls."
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx
index 092a87b8064..8c7d7c33213 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx
@@ -127,8 +127,7 @@ test.each([
props: { brunchConfigured: false },
control: "Use Brunch",
checked: false,
- description:
- "Brunch is unavailable because this site has no Brunch endpoint configured.",
+ description: "Brunch isn’t set up on this site.",
},
{
name: "Voice preference loading",
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx
index e8f232fa63a..68bcf4ff04d 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx
@@ -119,8 +119,8 @@ export const AssistantLabsSettings = ({
const brunchDescription = !assistantReady
? "Loading your assistant preference…"
: !brunchConfigured
- ? "Brunch is unavailable because this site has no Brunch endpoint configured."
- : "Use Brunch instead of the stock Petrinaut assistant.";
+ ? "Brunch isn’t set up on this site."
+ : "Use Brunch instead of the default assistant.";
const voiceDescription = !voicePreferenceReady
? "Loading your Voice preference…"
: !brunchSelected
@@ -129,7 +129,7 @@ export const AssistantLabsSettings = ({
? "Checking whether Voice is available…"
: openAIVoiceConfig === null
? "Voice is unavailable in this deployment."
- : "Make Voice mode available for Brunch conversations.";
+ : "Talk to Brunch by voice.";
return (
@@ -155,7 +155,7 @@ export const AssistantLabsSettings = ({
/>
{brunchSelected && voicePreferenceReady && voiceEnabled && (
{
await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
expect(heading()).toEqual(["Preparing draft"]);
expect(screen.getByText(/being prepared/u)).toBeTruthy();
- expect(screen.queryByText(/before this editor was loaded/u)).toBeNull();
+ expect(screen.queryByText(/is from an earlier session/u)).toBeNull();
expect(screen.queryByRole("button", { name: "Run" })).toBeNull();
await act(async () => submission.resolve());
@@ -423,7 +423,7 @@ describe("BrunchDraftExperimentWidget", () => {
name: "Run",
});
expect(run.disabled).toBe(true);
- expect(screen.getByRole("alert").textContent).toContain("Run is blocked");
+ expect(screen.getByRole("alert").textContent).toContain("Can’t run");
fireEvent.click(run);
expect(runExperiment).not.toHaveBeenCalled();
},
@@ -534,7 +534,7 @@ describe("BrunchDraftExperimentWidget", () => {
await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
expect(submit.mock.calls[0]?.[0]).toMatchObject({ status: "invalid" });
expect(submit.mock.calls[0]?.[0].diagnostics[0]).toMatch(
- /changed since the canonical read/u,
+ /changed since the AI assistant read it/u,
);
expect(heading()).toEqual(["Could not be prepared"]);
expect(screen.queryByRole("button", { name: "Run" })).toBeNull();
@@ -1441,9 +1441,7 @@ describe("BrunchDraftExperimentWidget", () => {
expect(submit).not.toHaveBeenCalled();
expect(heading()).toEqual(["Not retained in this editor"]);
- expect(
- screen.getByText(/prepared before this editor was loaded/u),
- ).toBeTruthy();
+ expect(screen.getByText(/draft is from an earlier session/u)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Run" })).toBeNull();
});
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-draft-experiment-interactive-tool.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-draft-experiment-interactive-tool.tsx
index c94d3851d36..bc353f281af 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-draft-experiment-interactive-tool.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-draft-experiment-interactive-tool.tsx
@@ -183,7 +183,7 @@ export const resolveDraftAuthorityFromHistory = async (
latest.state !== "output-available"
)
throw new Error(
- "Draft requires the latest settled canonical net read after changes.",
+ "The AI assistant needs to read the latest model before drafting.",
);
const envelope = latest.output;
const metadata =
@@ -192,7 +192,9 @@ export const resolveDraftAuthorityFromHistory = async (
: undefined;
const revision = metadata?.documentRevision.before;
if (revision === undefined)
- throw new Error("The latest canonical read has no document revision.");
+ throw new Error(
+ "The AI assistant’s latest model read has no document revision.",
+ );
return revision;
};
@@ -320,7 +322,7 @@ export const BrunchDraftExperimentWidget = ({
: {
prepared: null,
error:
- "The model changed since the canonical read. Ask Brunch to read the current model and draft again.",
+ "The model changed since the AI assistant read it. Ask it to draft again.",
};
} catch (caught) {
outcome = {
@@ -606,7 +608,7 @@ export const BrunchDraftExperimentWidget = ({
? preparationFailure.kind === "prepare"
? `The experiment proposal could not be prepared: ${preparationFailure.message}`
: `The prepared proposal could not be submitted: ${preparationFailure.message}`
- : "This draft was prepared before this editor was loaded. Ask Brunch to draft it again to run it.")}
+ : "This draft is from an earlier session. Ask the AI assistant to draft it again to run it.")}
- Run is blocked by an unsupported restriction. Ask Brunch to revise the
- proposal; a reporting-only exploration needs your explicit acceptance.
+ Can’t run: a restriction isn’t supported. Ask the AI assistant to
+ revise the proposal, or tell it you accept a run that only reports on
+ it.
The model changed since this was drafted. Review the changes below
- before running, or ask Brunch to draft it again.
+ before running, or ask the AI assistant to draft it again.
Review model changes
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-experiment-follow-up.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-experiment-follow-up.tsx
index 6bd477b346c..b2dde64a1eb 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-experiment-follow-up.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-experiment-follow-up.tsx
@@ -89,7 +89,7 @@ export const BrunchExperimentFollowUp = ({
context.status === "submitted" ||
context.status === "streaming"
}
- tooltip="The experiment finished, but its results could not be sent to Brunch."
+ tooltip="The experiment finished, but its results could not be sent to the AI assistant."
onClick={() => sendResults(drafts, context.submitText, failed)}
>
Retry result summary
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts
index 3a4a3f7c526..49a6f0d896d 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts
@@ -266,8 +266,8 @@ const summarizeSweepOutput = (
switch (sweep.status) {
case "no-settled-range":
return {
- title: "No settled range to sweep",
- detail: "The conversation has no settled user entries.",
+ title: "Nothing confirmed to sweep yet",
+ detail: "None of your messages in this conversation are confirmed yet.",
};
case "refused":
return {
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.test.ts
index 5fd3fa06bbe..9ac131c3f39 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.test.ts
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.test.ts
@@ -55,15 +55,15 @@ describe("Brunch tool presentation", () => {
test.each([
[
{ includeContent: false, sourceIds: ["m1"], locateTexts: ["claim"] },
- "Read settled passages and conversation sources",
+ "Read confirmed passages and conversation sources",
],
[
{ includeContent: false, locateTexts: ["claim"] },
- "Read settled passages",
+ "Read confirmed passages",
],
[{ includeContent: false, sourceIds: ["m1"] }, "Read conversation sources"],
- [{ includeContent: false, sourceIds: [] }, "Checked Ledger revision"],
- [{ includeContent: false }, "Checked Ledger revision"],
+ [{ includeContent: false, sourceIds: [] }, "Checked for Ledger updates"],
+ [{ includeContent: false }, "Checked for Ledger updates"],
[{ includeContent: true, sourceIds: ["m1"] }, "Read conversation sources"],
[{}, "Read ledger"],
[undefined, "Read ledger"],
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.ts
index 3f1d5cd241f..05f2c6c281a 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.ts
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-tool-presentation.ts
@@ -60,9 +60,9 @@ const lifecycleTitles = {
error: "Could not check recorded basis",
},
[brunchTools.ping]: {
- pending: "Checking Brunch connection",
- success: "Checked Brunch connection",
- error: "Could not reach Brunch",
+ pending: "Checking connection to the AI assistant",
+ success: "Checked connection to the AI assistant",
+ error: "Could not reach the AI assistant",
},
} satisfies Record;
@@ -117,16 +117,16 @@ const readWorkpiecePurpose = (input: unknown): LifecycleTitles => {
if (hasPassages && hasSources) {
return {
- pending: "Reading settled passages and conversation sources",
- success: "Read settled passages and conversation sources",
- error: "Could not read settled passages and conversation sources",
+ pending: "Reading confirmed passages and conversation sources",
+ success: "Read confirmed passages and conversation sources",
+ error: "Could not read confirmed passages and conversation sources",
};
}
if (hasPassages) {
return {
- pending: "Reading settled passages",
- success: "Read settled passages",
- error: "Could not read settled passages",
+ pending: "Reading confirmed passages",
+ success: "Read confirmed passages",
+ error: "Could not read confirmed passages",
};
}
if (hasSources) {
@@ -138,9 +138,9 @@ const readWorkpiecePurpose = (input: unknown): LifecycleTitles => {
}
if (record.includeContent === false) {
return {
- pending: "Checking Ledger revision",
- success: "Checked Ledger revision",
- error: "Could not check Ledger revision",
+ pending: "Checking for Ledger updates",
+ success: "Checked for Ledger updates",
+ error: "Could not check for Ledger updates",
};
}
return {
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-workpiece-pane.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-workpiece-pane.test.tsx
index 3201b11d642..9646d2b1b62 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-workpiece-pane.test.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-workpiece-pane.test.tsx
@@ -122,7 +122,7 @@ test("does not display the input of a typed refused settlement or mark it newer"
);
expect(html).toContain("
Settled account
");
expect(html).not.toContain("Refused silent-shrink account");
- expect(html).not.toContain("A newer Ledger revision exists");
+ expect(html).not.toContain("This view is out of date");
});
test("does not display the input of a failed settlement", () => {
@@ -150,7 +150,7 @@ test("does not display the input of a failed settlement", () => {
);
expect(html).toContain("
Settled account
");
expect(html).not.toContain("Refused stale-base account");
- expect(html).not.toContain("A newer Ledger revision exists");
+ expect(html).not.toContain("This view is out of date");
});
test("a later settlement replaces the displayed query while retaining the recorded why", () => {
@@ -164,9 +164,7 @@ test("a later settlement replaces the displayed query while retaining the record
/>,
);
expect(html).toContain("
Later account
");
- expect(html).toContain(
- "recorded explanation predates a newer Ledger revision",
- );
+ expect(html).toContain("This explanation is out of date");
});
test("an explicit later query replaces a recorded settlement", () => {
@@ -195,7 +193,7 @@ test("a later settlement whose output is not bound to its call marks the display
/>,
);
expect(html).toContain("
- A newer Ledger revision exists. Ask Brunch to refresh this view before
- relying on it.
+ This view is out of date. Ask the AI assistant to refresh it.
)}
{whyPredatesSettlement && (
- The recorded explanation predates a newer Ledger revision. Ask why
- again to assess the latest account.
+ This explanation is out of date. Ask why again for the latest.
)}
{workpiece && typeof workpiece.markdown === "string" && (
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx
index f0517efce1e..f39cc73b480 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx
@@ -427,7 +427,7 @@ describe("local storage demo Brunch voice integration", () => {
]),
);
expect(aiAssistant.resolveToolPresentation).toBeTypeOf("function");
- expect(aiAssistant.workingLabel).toBe("Brunch is working");
+ expect(aiAssistant.workingLabel).toBe("Working…");
expect(
aiAssistant.resolveToolPresentation?.({
toolName: "layout_petrinaut_net",
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx
index 2f13d2c03c5..b6c08f4025a 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx
@@ -969,7 +969,7 @@ export const LocalStorageDemoApp = ({
presentation: "brunch" as const,
mapMessagesForDisplay: mapVoiceMessages,
resolveToolPresentation: resolveBrunchToolPresentation,
- workingLabel: "Brunch is working",
+ workingLabel: "Working…",
renderComposerControl: (
context: PetrinautAiComposerControlContext,
) => ,
diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.test.ts b/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.test.ts
index 8f7c45aeef4..4984f3d9d11 100644
--- a/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.test.ts
+++ b/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.test.ts
@@ -358,7 +358,7 @@ test("keeps a dropped utterance's words visible so they can be sent from the com
await fixture.bridge.accept(speech("second", "Also check the queue"));
expect(fixture.notice).toHaveBeenCalledWith(
- expect.stringContaining("not retained"),
+ expect.stringContaining("Those words weren’t sent"),
);
expect(
history.project([]).find((message) => message.id === "second")?.parts,
@@ -472,7 +472,7 @@ test("drops a superseded transcript quietly when another turn holds the composer
await current;
});
-test("a turn keeps its annotations in the history it began in when the conversation switches", async () => {
+test("does not admit a turn prepared in a conversation that was switched away from", async () => {
const brief = Promise.withResolvers>();
const original = new VoiceMediationHistory("original");
const switched = new VoiceMediationHistory("switched");
@@ -485,25 +485,66 @@ test("a turn keeps its annotations in the history it began in when the conversat
summarize: vi.fn(),
offered: vi.fn(),
});
+ fixture.bridge.acceptDelegation("delegation");
const turn = fixture.bridge.accept(
speech("one", "Compare two to eight agents"),
);
current = switched;
- const fields = { decide: "two to eight agents" };
- brief.resolve(fields);
+ brief.resolve({ decide: "two to eight agents" });
await turn;
- const admitted = [
+
+ expect(fixture.submit).not.toHaveBeenCalled();
+ expect(original.project([])).toEqual([]);
+ expect(switched.project([])).toEqual([
{
id: "one",
- role: "user" as const,
- parts: [{ type: "text" as const, text: "Brief" }],
+ role: "user",
+ metadata: { source: "voice" },
+ parts: [{ type: "text", text: "Compare two to eight agents" }],
},
- ];
- expect(original.project(admitted)[0]?.parts[1]).toEqual({
- type: "data-brief",
- data: { fields, state: "done" },
+ ]);
+ expect(fixture.notice).toHaveBeenLastCalledWith(
+ expect.stringContaining("The conversation changed"),
+ );
+ expect(fixture.appendInstructions).toHaveBeenCalledWith(
+ expect.stringContaining("not submitted because the conversation changed"),
+ "delegation",
+ );
+
+ await fixture.bridge.accept(speech("two", "Then check the queue"));
+ expect(fixture.submit).toHaveBeenCalledOnce();
+});
+
+test("stops waiting on an admitted turn once its conversation is switched away from", async () => {
+ const original = new VoiceMediationHistory("original");
+ const switched = new VoiceMediationHistory("switched");
+ let current = original;
+ const fixture = setup({
+ get history() {
+ return current;
+ },
+ prepare: async () => ({}),
+ summarize: vi.fn(async (text: string) => text),
+ offered: vi.fn(),
+ });
+ fixture.bridge.acceptDelegation("delegation");
+ await fixture.bridge.accept(speech("one", "Compare two to eight"));
+ current = switched;
+ fixture.update({ messages: [] });
+
+ expect(fixture.appendInstructions).toHaveBeenCalledWith(
+ expect.stringContaining("answer stays in the original conversation"),
+ "delegation",
+ );
+ fixture.bridge.responseStarted(started);
+ fixture.bridge.responseCompleted({
+ ...started,
+ position: { batch: 2, index: 0 },
});
- expect(switched.project(admitted)[0]?.parts).toHaveLength(1);
+ fixture.update({ segments: [segment()], settlements: completed });
+ await Promise.resolve();
+ expect(fixture.mediation.summarize).not.toHaveBeenCalled();
+ expect(fixture.appendCommentary).not.toHaveBeenCalled();
});
test("speech cancels preparation and stale asynchronous wrap-ups without cancelling admitted Brunch work", async () => {
@@ -571,7 +612,7 @@ test("speech that cancels unsent words says they were not sent", async () => {
expect(fixture.submit).not.toHaveBeenCalled();
expect(fixture.notice).toHaveBeenCalledExactlyOnceWith(
- expect.stringContaining("was not sent"),
+ expect.stringContaining("You started speaking again"),
);
});
@@ -740,7 +781,7 @@ test("speech keeps a submitted turn's composer slot until Brunch admits it", asy
expect(prepare).toHaveBeenCalledOnce();
expect(fixture.submit).toHaveBeenCalledOnce();
expect(fixture.notice).toHaveBeenLastCalledWith(
- expect.stringContaining("Wait for the pending input"),
+ expect.stringContaining("when the assistant is ready"),
);
});
@@ -922,7 +963,7 @@ test("a finalized snapshot confirms an observed response is textless", async ()
});
expect(fixture.notice).toHaveBeenLastCalledWith(
- "Brunch settled without a spoken answer. Check the conversation.",
+ "The assistant finished without a reply to speak. Check the conversation.",
);
expect(fixture.appendCommentary).not.toHaveBeenCalled();
});
@@ -1065,7 +1106,7 @@ test("duplicates, empty input and one waiting composer submission never create a
await fixture.bridge.accept(speech("two", "Follow-up"));
expect(fixture.submit).toHaveBeenCalledOnce();
expect(fixture.notice).toHaveBeenLastCalledWith(
- expect.stringContaining("not retained"),
+ expect.stringContaining("Those words weren’t sent"),
);
release();
await pending;
@@ -1506,7 +1547,7 @@ test("uncertain admission is visible and never automatically replayed", async ()
await fixture.bridge.accept(speech("one", "First"));
expect(fixture.submit).toHaveBeenCalledOnce();
expect(fixture.notice).toHaveBeenLastCalledWith(
- expect.stringContaining("Check canonical history"),
+ expect.stringContaining("Check the conversation before sending it again"),
);
});
@@ -1521,7 +1562,7 @@ test("a response failure after confirmed admission does not report uncertain adm
expect(fixture.submit).toHaveBeenCalledOnce();
expect(fixture.appendCommentary).not.toHaveBeenCalled();
expect(fixture.notice).toHaveBeenLastCalledWith(
- "Your message was admitted, but its response could not be confirmed. Check canonical history; no automatic retry was made.",
+ "Couldn’t confirm the answer. Your message was sent; check the conversation before sending it again.",
);
});
@@ -1555,7 +1596,7 @@ test("admission frees the existing waiting-input slot, but finishing an earlier
await fixture.bridge.accept(speech("three", "Another pending input"));
expect(fixture.submit).toHaveBeenCalledTimes(2);
expect(fixture.notice).toHaveBeenLastCalledWith(
- expect.stringContaining("not retained"),
+ expect.stringContaining("Those words weren’t sent"),
);
admitCorrection();
@@ -1824,7 +1865,7 @@ test("a finalized textless unobserved answer cannot fall through to an observed
});
expect(fixture.notice).toHaveBeenLastCalledWith(
- "Brunch settled without a spoken answer. Check the conversation.",
+ "The assistant finished without a reply to speak. Check the conversation.",
);
expect(fixture.appendInstructions).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining("finished without a spoken answer"),
diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.ts b/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.ts
index 04fa37b2ec8..36903d5cfa4 100644
--- a/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.ts
+++ b/apps/petrinaut-website/src/main/app/voice-interview/live-brunch-bridge.ts
@@ -175,7 +175,7 @@ export class LiveBrunchBridge {
this.#turns.clear();
if (withdrew)
this.#dependencies.notice(
- "Your earlier utterance was not sent because you started speaking again. Use the composer to send it.",
+ "Your earlier words weren’t sent. You started speaking again, so they’re in the composer if you still want them.",
);
for (const turn of this.#summarizing) {
if (turn.delegationId !== null && !this.#abort.signal.aborted)
@@ -201,6 +201,56 @@ export class LiveBrunchBridge {
this.#waitingForComposer = undefined;
}
+ /**
+ * Submission follows the conversation shown now, so words prepared in
+ * another one are not admitted here. Like words cancelled by speech, they
+ * stay sendable in the conversation shown now.
+ */
+ #withdrawSwitched(turn: Turn): void {
+ this.#turns.delete(turn);
+ this.#preparations.delete(turn.preparation);
+ turn.history.failed(turn.inputId);
+ this.#dependencies.mediation.history.unsent(
+ turn.inputId,
+ turn.inputText,
+ this.#chat.messages?.at(-1)?.id,
+ );
+ logLiveDiagnostic("brunch.conversation-switched", {
+ inputId: turn.inputId,
+ delegationId: turn.delegationId,
+ submitted: false,
+ });
+ this.#dependencies.notice(
+ "Your earlier words weren’t sent. The conversation changed, so they’re in the composer if you still want them.",
+ );
+ this.#unserved(
+ turn.delegationId,
+ "The request was not submitted because the conversation changed.",
+ );
+ }
+
+ /** Settlement follows the conversation shown now, where a turn submitted elsewhere never settles. */
+ #releaseSwitchedTurns(): void {
+ const history = this.#dependencies.mediation.history;
+ for (const turn of this.#turns) {
+ if (!turn.submitted || turn.history === history) continue;
+ this.#turns.delete(turn);
+ this.#preparations.delete(turn.preparation);
+ if (this.#waitingForComposer === turn)
+ this.#waitingForComposer = undefined;
+ logLiveDiagnostic("brunch.conversation-switched", {
+ inputId: turn.inputId,
+ submissionId: turn.submissionId,
+ delegationId: turn.delegationId,
+ submitted: true,
+ });
+ this.#unserved(
+ turn.delegationId,
+ "The conversation was switched before Brunch finished; its answer stays in the original conversation.",
+ );
+ }
+ }
+
/** Frees the composer from one stale turn; other turns and delegations stay. */
#evict(turn: Turn): void {
turn.preparation.abort();
@@ -345,7 +395,7 @@ export class LiveBrunchBridge {
// Newer speech already replaced these words; resending them is not asked for.
if (input.superseded) return;
this.#dependencies.notice(
- "That utterance was not retained. Wait for the pending input, then use the composer to send it.",
+ "Those words weren’t sent. They’re in the composer to send when the assistant is ready.",
);
const history = this.#dependencies.mediation.history;
const waiting = this.#waitingForComposer;
@@ -394,6 +444,10 @@ export class LiveBrunchBridge {
logLiveDiagnostic("brief.unavailable", { inputId: input.id });
turn.history.preparationFailed(input.id);
}
+ if (turn.history !== this.#dependencies.mediation.history) {
+ this.#withdrawSwitched(turn);
+ return;
+ }
logLiveDiagnostic("brunch.submit", { inputId: input.id, delegationId });
turn.submitted = true;
const result = await this.#dependencies.submit({
@@ -437,8 +491,8 @@ export class LiveBrunchBridge {
});
this.#dependencies.notice(
turn.submissionId
- ? "Your message was admitted, but its response could not be confirmed. Check canonical history; no automatic retry was made."
- : "Voice admission could not be confirmed. Check canonical history before sending again; no automatic retry was made.",
+ ? "Couldn’t confirm the answer. Your message was sent; check the conversation before sending it again."
+ : "Couldn’t confirm your message was sent. Check the conversation before sending it again.",
);
this.#unconfirmed(turn);
}
@@ -499,6 +553,7 @@ export class LiveBrunchBridge {
const enteredError =
chat.status === "error" && this.#chat.status !== "error";
this.#chat = chat;
+ this.#releaseSwitchedTurns();
if (stopped || enteredError) {
this.#interruptTurns(stopped ? "stopped" : "error");
return;
@@ -657,7 +712,7 @@ export class LiveBrunchBridge {
delegationId: turn.delegationId,
});
this.#dependencies.notice(
- "Brunch did not complete this turn. Check the conversation; no result was offered to Live.",
+ "The assistant didn’t finish. Nothing will be spoken; check the conversation.",
);
this.#preparations.delete(turn.preparation);
if (settlements.some((settlement) => settlement?.outcome === "aborted"))
@@ -825,7 +880,7 @@ export class LiveBrunchBridge {
delegationId: turn.delegationId,
});
this.#dependencies.notice(
- "Brunch settled without a spoken answer. Check the conversation.",
+ "The assistant finished without a reply to speak. Check the conversation.",
);
this.#unserved(
turn.delegationId,
@@ -898,7 +953,7 @@ export class LiveBrunchBridge {
} catch {
if (!turn.preparation.signal.aborted && !this.#abort.signal.aborted) {
this.#dependencies.notice(
- "Brunch finished, but its spoken summary could not be prepared. Read the written answer; no automatic retry was made.",
+ "Couldn’t prepare a spoken summary. The written answer is in the conversation.",
);
this.#unserved(
turn.delegationId,
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 86a52270401..eae3afa69ab 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
@@ -452,7 +452,7 @@ test("requires renewed consent to save finalized spoken words beyond the browser
).toBeTruthy();
expect(
screen.getByText(
- /Brunch saves your finalized spoken words, the brief and its answer/,
+ /Your transcribed words, the request sent to the assistant and its answer are saved/,
),
).toBeTruthy();
expect(
@@ -482,13 +482,13 @@ test("starts acknowledged Live after the previous session finishes stopping", ()
rerender();
- expect(screen.getByText("Stopping voice…")).toBeTruthy();
- expect(screen.queryByText("Voice disconnected")).toBeNull();
+ expect(screen.queryByRole("region", { name: "Voice mode retry" })).toBeNull();
expect(
- screen
- .getByRole("button", { name: "Retry voice" })
- .hasAttribute("disabled"),
- ).toBe(true);
+ screen.queryByRole("region", { name: "Voice mode consent" }),
+ ).toBeNull();
+ expect(props.reportVoiceSessionState).toHaveBeenLastCalledWith(
+ expect.objectContaining({ phase: "connecting" }),
+ );
expect(createLiveConversation).toHaveBeenCalledOnce();
act(() =>
onState({
@@ -659,7 +659,9 @@ test("reuses setup and reports failure to the host dock and notification surface
screen.getByRole("region", { name: "Voice mode consent" }),
).toBeTruthy();
expect(screen.getByText("Start a voice conversation")).toBeTruthy();
- expect(screen.getByText(/OpenAI processes microphone audio/)).toBeTruthy();
+ expect(
+ screen.getByText(/OpenAI processes your microphone audio/),
+ ).toBeTruthy();
expect(screen.queryByText(/experimental|best-effort/i)).toBeNull();
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
expect(
@@ -733,8 +735,7 @@ test("reuses setup and reports failure to the host dock and notification surface
microphoneLevel: 0.12,
}),
);
- const playbackNotice =
- "Audio playback is blocked. Select Play voice audio to hear Live.";
+ const playbackNotice = "Audio blocked. Select Play to listen.";
act(() =>
onState({
phase: "connected",
@@ -773,7 +774,7 @@ test("reuses setup and reports failure to the host dock and notification surface
.value as ReturnType;
expect(liveSession.retryPlayback).toHaveBeenCalledOnce();
const connectionError =
- "live session request failed (HTTP 502, provider HTTP 401). No automatic retry was made.";
+ "live session request failed (HTTP 502, provider HTTP 401).";
act(() =>
onState({
phase: "error",
@@ -1104,7 +1105,7 @@ test("pins provider, ends through host controls, and never submits or stops cano
expect(createLiveConversation).toHaveBeenCalledOnce();
expect(
screen.getByText(
- "OpenAI processes live audio and speaks the interviewer’s words. Petrinaut saves finalized answers—not audio.",
+ "OpenAI processes your audio and speaks the interviewer’s questions. Your transcribed answers are saved; audio isn’t.",
),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Start voice" })).toBeTruthy();
@@ -1752,10 +1753,11 @@ test.each(["commentary", "instructions"] as const)(
);
}
}
- for (const [status, text] of [
- ["local-failure", "could not be sent to Live locally"],
- ["rejected", "was rejected by Live"],
- ] as const) {
+ const text =
+ kind === "commentary"
+ ? "Couldn’t speak the answer."
+ : "Voice may be out of sync.";
+ for (const status of ["local-failure", "rejected"] as const) {
act(() => call[4]({ ...result, eventId: status, status }));
expect(props.reportVoiceSessionState).toHaveBeenLastCalledWith(
expect.objectContaining({
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 5e97070e152..f9c4af77091 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
@@ -122,6 +122,7 @@ export const LiveConversationControl = ({
);
const sessionActive = useRef(false);
const handledVoiceSelection = useRef(false);
+ const [startAwaitingStop, setStartAwaitingStop] = useState(false);
const bridge = useRef(null);
const latest = useRef({
submit,
@@ -303,14 +304,10 @@ export const LiveConversationControl = ({
if (result.status === "unknown" || result.status === "accepted") return;
// Quiet interruption context is best effort, not an audible answer.
if (result.kind === "thinking") 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.`,
+ result.kind === "commentary"
+ ? "Couldn’t speak the answer. The written answer is in the conversation."
+ : "Voice may be out of sync. Check the conversation before relying on what it says.",
);
},
audioSettingsStore,
@@ -367,6 +364,7 @@ export const LiveConversationControl = ({
useLayoutEffect(() => {
if (inputMode !== "voice" || !isAiAssistantOpen) {
handledVoiceSelection.current = false;
+ setStartAwaitingStop(false);
return;
}
if (handledVoiceSelection.current) return;
@@ -375,12 +373,17 @@ export const LiveConversationControl = ({
return;
}
handledVoiceSelection.current = tryStartLiveConversation();
+ setStartAwaitingStop(!handledVoiceSelection.current);
}, [
disclosureAcknowledged,
inputMode,
isAiAssistantOpen,
tryStartLiveConversation,
]);
+ // Reopening Voice while the previous session is still closing starts the
+ // next one as soon as it ends, so that wait is part of connecting.
+ const sessionPhase =
+ startAwaitingStop && phase === "stopping" ? "connecting" : phase;
const setMicrophoneMuted = useCallback((muted: boolean) => {
if (!sessionActive.current || !session.current) return;
session.current.setMicrophoneMuted(muted);
@@ -443,13 +446,15 @@ export const LiveConversationControl = ({
reportVoiceSessionState(
inputMode === "voice" &&
isAiAssistantOpen &&
- (phase === "connecting" || phase === "connected" || phase === "error")
+ (sessionPhase === "connecting" ||
+ sessionPhase === "connected" ||
+ sessionPhase === "error")
? {
audioSettings,
phase:
- phase === "error"
+ sessionPhase === "error"
? "error"
- : phase === "connecting"
+ : sessionPhase === "connecting"
? "connecting"
: activity?.outputActive
? "speaking"
@@ -478,6 +483,7 @@ export const LiveConversationControl = ({
inputMode,
isAiAssistantOpen,
phase,
+ sessionPhase,
message,
playbackBlocked,
activity,
@@ -508,7 +514,11 @@ export const LiveConversationControl = ({
};
}, [reportVoiceSessionState, setVoiceActive]);
- if (inputMode !== "voice" || phase === "connecting" || phase === "connected")
+ if (
+ inputMode !== "voice" ||
+ sessionPhase === "connecting" ||
+ sessionPhase === "connected"
+ )
return null;
const exitVoiceMode = () => {
void end();
@@ -556,7 +566,7 @@ export const LiveConversationControl = ({
setMicrophoneCheck("Microphone ready. No audio was sent.");
} catch {
setMicrophoneCheck(
- "Microphone access was not available. Check your browser permissions and try again.",
+ "Couldn’t access the microphone. Check your browser permissions.",
);
} finally {
setCheckingMicrophone(false);
diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.test.ts b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.test.ts
index 34f60e89d22..c33f6b042d7 100644
--- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.test.ts
+++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.test.ts
@@ -1238,16 +1238,14 @@ test.each(["rejects", "throws"] as const)(
expect(fixture.onState).toHaveBeenLastCalledWith({
phase: "connected",
- message:
- "Audio playback is blocked. Select Play voice audio to hear Live.",
+ message: "Audio blocked. Select Play to listen.",
playbackBlocked: true,
activity: { microphoneLevel: 0.42, outputActive: false },
});
await vi.advanceTimersByTimeAsync(100);
expect(fixture.onState.mock.lastCall?.[0]).toMatchObject({
phase: "connected",
- message:
- "Audio playback is blocked. Select Play voice audio to hear Live.",
+ message: "Audio blocked. Select Play to listen.",
playbackBlocked: true,
});
expect(fixture.input.stop).not.toHaveBeenCalled();
@@ -1391,7 +1389,7 @@ test("reports the failed endpoint and HTTP statuses without reflecting response
expect(fixture.onState.mock.lastCall?.[0].message).not.toContain("sensitive");
expect(warning).toHaveBeenCalledExactlyOnceWith(
"[Petrinaut Live]",
- "live session request failed (HTTP 502, provider HTTP 401). No automatic retry was made.",
+ "live session request failed (HTTP 502, provider HTTP 401).",
);
expect(fixture.input.stop).toHaveBeenCalledOnce();
expect(
diff --git a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.ts b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.ts
index d500bb50229..61e198f2417 100644
--- a/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.ts
+++ b/apps/petrinaut-website/src/main/app/voice-interview/live-conversation.ts
@@ -134,9 +134,7 @@ export const createLiveConversation = (
activity?: LiveConversationState["activity"],
): LiveConversationState => ({
phase,
- message: playbackBlocked
- ? "Audio playback is blocked. Select Play voice audio to hear Live."
- : null,
+ message: playbackBlocked ? "Audio blocked. Select Play to listen." : null,
...(playbackBlocked ? { playbackBlocked: true } : {}),
...(activity ? { activity } : {}),
});
@@ -268,10 +266,7 @@ export const createLiveConversation = (
recoveryTimers.set(
kind,
setTimeout(
- () =>
- fail(
- `${label} media connection did not recover. No automatic retry was made.`,
- ),
+ () => fail(`${label} media connection did not recover.`),
connectionTimeoutMs,
),
);
@@ -370,9 +365,7 @@ export const createLiveConversation = (
([, previous]) => previous === null,
);
if (roots.length > 1) {
- fail(
- "Transcription item ordering conflicted. No automatic retry was made.",
- );
+ fail("Transcription item ordering conflicted.");
return;
}
let itemId = roots[0]?.[0];
@@ -488,9 +481,7 @@ export const createLiveConversation = (
conflictingSuccessor ||
(ancestor !== null && ancestor !== undefined)
) {
- fail(
- "Transcription item ordering conflicted. No automatic retry was made.",
- );
+ fail("Transcription item ordering conflicted.");
return;
}
committedPrevious.set(data.item_id, data.previous_item_id);
@@ -515,7 +506,7 @@ export const createLiveConversation = (
existing &&
(existing.id !== input.id || existing.text !== input.text)
) {
- fail("Transcription identity conflicted. No automatic retry was made.");
+ fail("Transcription identity conflicted.");
return;
}
if (!existing) {
@@ -530,7 +521,7 @@ export const createLiveConversation = (
data.type === "error" ||
data.type === "session.error"
)
- fail("Transcription failed. No fallback or automatic retry was made.");
+ fail("Transcription failed.");
};
const parseEvent = (kind: ConnectionKind, event: MessageEvent) => {
@@ -726,7 +717,7 @@ export const createLiveConversation = (
reportAppendResult({ ...pending, status: "rejected" });
return;
}
- fail("Live reported an error. No automatic retry was made.");
+ fail("Live reported an error.");
}
};
@@ -847,7 +838,7 @@ export const createLiveConversation = (
? `, provider HTTP ${upstreamStatus}`
: "";
fail(
- `${kind} session request failed (HTTP ${response.status}${providerStatus}). No automatic retry was made.`,
+ `${kind} session request failed (HTTP ${response.status}${providerStatus}).`,
);
throw new Error("Session creation failed");
}
@@ -879,10 +870,7 @@ export const createLiveConversation = (
logLiveDiagnostic("session.starting", { sessionId });
onState(activeState("connecting"));
connectionTimer = setTimeout(
- () =>
- fail(
- `Voice connections timed out (${connectionProgress()}). No automatic retry was made.`,
- ),
+ () => fail(`Voice connections timed out (${connectionProgress()}).`),
connectionTimeoutMs,
);
try {
@@ -919,9 +907,7 @@ export const createLiveConversation = (
createConnection("transcription", stream),
]);
} catch {
- fail(
- `Voice could not connect (${connectionProgress()}). No automatic retry was made.`,
- );
+ fail(`Voice could not connect (${connectionProgress()}).`);
}
};
diff --git a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.test.tsx b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.test.tsx
index f42de9c9837..96927df4b25 100644
--- a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.test.tsx
+++ b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-control.test.tsx
@@ -388,7 +388,7 @@ describe("voice interview control", () => {
).not.toBeNull();
expect(
within(disclosure).getByText(
- "OpenAI processes live audio and speaks the interviewer’s words. Petrinaut saves finalized answers—not audio.",
+ "OpenAI processes your audio and speaks the interviewer’s questions. Your transcribed answers are saved; audio isn’t.",
),
).not.toBeNull();
diff --git a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-disclosure.tsx b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-disclosure.tsx
index 440d8fdfc22..35663a443ad 100644
--- a/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-disclosure.tsx
+++ b/apps/petrinaut-website/src/main/app/voice-interview/voice-interview-disclosure.tsx
@@ -98,8 +98,8 @@ export const VoiceInterviewDisclosure = ({
})}
>
{experimental
- ? "OpenAI processes microphone audio for voice and transcription, and text to prepare briefs and summaries. Brunch saves your finalized spoken words, the brief and its answer. Spoken captions stay in this browser; Petrinaut does not save audio."
- : "OpenAI processes live audio and speaks the interviewer’s words. Petrinaut saves finalized answers—not audio."}
+ ? "OpenAI processes your microphone audio and the conversation text. Your transcribed words, the request sent to the assistant and its answer are saved. Audio isn’t saved, and captions stay in this browser."
+ : "OpenAI processes your audio and speaks the interviewer’s questions. Your transcribed answers are saved; audio isn’t."}
{
test("describes recoverable transcript rejections", () => {
expect(mapSnapshot({ inputNotice: "not-heard" })?.notice).toBe(
- "We didn't catch that. Please try again.",
+ "We didn't catch that. Try again.",
);
expect(mapSnapshot({ inputNotice: "too-long" })?.notice).toBe(
- "That answer is too long. Please try a shorter response.",
+ "That answer is too long. Try a shorter one.",
);
});
diff --git a/apps/petrinaut-website/src/main/app/voice-interview/voice-session-state.ts b/apps/petrinaut-website/src/main/app/voice-interview/voice-session-state.ts
index 01eb070c11a..7cdcb830574 100644
--- a/apps/petrinaut-website/src/main/app/voice-interview/voice-session-state.ts
+++ b/apps/petrinaut-website/src/main/app/voice-interview/voice-session-state.ts
@@ -107,13 +107,13 @@ export const toVoiceSessionState = ({
microphoneLevel: snapshot.microphoneLevel,
notice:
snapshot.inputNotice === "answer-pending"
- ? "Answer captured. Waiting for Brunch."
+ ? "Answer captured. Working on it."
: snapshot.inputNotice === "answer-already-pending"
- ? "Previous answer waiting. Please try again after it is sent."
+ ? "Still sending your last answer. Try again in a moment."
: snapshot.inputNotice === "not-heard"
- ? "We didn't catch that. Please try again."
+ ? "We didn't catch that. Try again."
: snapshot.inputNotice === "too-long"
- ? "That answer is too long. Please try a shorter response."
+ ? "That answer is too long. Try a shorter one."
: null,
phase: phaseOf(snapshot),
speakerMuted: snapshot.speakerMuted,
diff --git a/apps/petrinaut-website/src/server/voice/openai-realtime-call.test.ts b/apps/petrinaut-website/src/server/voice/openai-realtime-call.test.ts
index 69e67c00490..1091e7ad42d 100644
--- a/apps/petrinaut-website/src/server/voice/openai-realtime-call.test.ts
+++ b/apps/petrinaut-website/src/server/voice/openai-realtime-call.test.ts
@@ -263,7 +263,7 @@ describe("OpenAI Realtime call handler", () => {
expect(response.status).toBe(502);
const responseBody = await response.text();
expect(responseBody).toBe(
- "The voice connection returned an invalid response. Try again; if it continues, give the diagnostic reference to an operator.",
+ "The voice connection returned an invalid response. Try again. If it keeps happening, share the diagnostic reference with support.",
);
expect(responseBody).not.toContain("secret");
expect(response.headers.get(VOICE_ERROR_CODE_HEADER)).toBe(
@@ -288,7 +288,7 @@ describe("OpenAI Realtime call handler", () => {
expect(response.status).toBe(502);
expect(await response.text()).toBe(
- "The voice connection returned an invalid response. Try again; if it continues, give the diagnostic reference to an operator.",
+ "The voice connection returned an invalid response. Try again. If it keeps happening, share the diagnostic reference with support.",
);
});
@@ -307,7 +307,7 @@ describe("OpenAI Realtime call handler", () => {
expect(response.status).toBe(400);
expect(await response.text()).toBe(
- "The voice connection returned an invalid response. Try again; if it continues, give the diagnostic reference to an operator.",
+ "The voice connection returned an invalid response. Try again. If it keeps happening, share the diagnostic reference with support.",
);
expect(response.headers.get(VOICE_ERROR_CODE_HEADER)).toBe(
"invalid-response",
@@ -360,7 +360,7 @@ describe("OpenAI Realtime call handler", () => {
const response = await responsePromise;
expect(response.status).toBe(504);
expect(await response.text()).toBe(
- "The voice connection timed out. Check your connection, then reconnect voice input.",
+ "The voice connection timed out. Check your connection, then reconnect.",
);
expect(response.headers.get(VOICE_ERROR_CODE_HEADER)).toBe("timeout");
expect(response.headers.get("cache-control")).toBe("no-store");
@@ -438,7 +438,7 @@ describe("OpenAI Realtime call handler", () => {
expect(response.status).toBe(502);
expect(response.headers.get(VOICE_ERROR_CODE_HEADER)).toBe("network");
expect(await response.text()).toBe(
- "The voice connection could not be reached. Check your connection, then reconnect voice input.",
+ "The voice connection could not be reached. Check your connection, then reconnect.",
);
expect(JSON.stringify(reportDiagnostic.mock.calls)).not.toContain(
"private network diagnostics",
diff --git a/apps/petrinaut-website/src/voice-diagnostics.ts b/apps/petrinaut-website/src/voice-diagnostics.ts
index 6a64266e10d..23b6a7da24a 100644
--- a/apps/petrinaut-website/src/voice-diagnostics.ts
+++ b/apps/petrinaut-website/src/voice-diagnostics.ts
@@ -111,18 +111,16 @@ export const voiceErrorMessage = (
code: VoiceErrorCode,
): string => {
if (code === "microphone-permission") {
- return "Allow microphone access in your browser settings, then reconnect voice input.";
+ return "Microphone blocked. Allow access in your browser settings, then reconnect.";
}
if (code === "microphone-device") {
- return "No usable microphone was found. Connect or select one, then reconnect voice input.";
+ return "No microphone found. Connect or select one, then reconnect.";
}
const visibleTextFallback =
- operation === "speech" ? " Read the visible response instead." : "";
+ operation === "speech" ? " Read the written answer instead." : "";
const reconnect =
- operation === "speech"
- ? ""
- : " Check your connection, then reconnect voice input.";
+ operation === "speech" ? "" : " Check your connection, then reconnect.";
const subject =
operation === "connection" ? "voice connection" : `${operation} service`;
@@ -134,9 +132,9 @@ export const voiceErrorMessage = (
case "timeout":
return `The ${subject} timed out.${visibleTextFallback}${reconnect}`;
case "invalid-response":
- return `The ${subject} returned an invalid response.${visibleTextFallback} Try again; if it continues, give the diagnostic reference to an operator.`;
+ return `The ${subject} returned an invalid response.${visibleTextFallback} Try again. If it keeps happening, share the diagnostic reference with support.`;
case "unavailable":
- return `The ${subject} preview is unavailable or disabled.${visibleTextFallback} Continue with the text composer.`;
+ return `The ${subject} is unavailable.${visibleTextFallback} You can keep going in chat.`;
}
};
diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md
index d3e9c9df537..c957fb8d3f1 100644
--- a/libs/@hashintel/petrinaut/docs/ai-assistant.md
+++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md
@@ -37,24 +37,25 @@ In the stock assistant, an empty conversation asks you to **Describe the process
The primary tab reads **AI** unless the host names it. In Brunch it reads **Chat**, or **Voice** while Voice mode is selected. Your messages appear in right-aligned grey bubbles. Each Brunch turn groups its work in a neutral disclosure, followed by a light-blue answer and any produced cards. In Voice, the written Brunch answer is inside the work disclosure instead. The stock assistant keeps plain answers and a horizontally scrolling row of suggestion chips; Brunch's chips wrap to fit the panel.
-Before Brunch starts a response, **Waiting for Brunch** and a small blue spinner
-appear above the composer or Voice dock. This status uses reserved space, so
-appearing or clearing it does not move the transcript or input controls.
-Suggestion chips keep their space while hidden and cannot be activated during
-work. Once the response arrives, its **Working…** header takes over; there is no
-duplicate status or information button below it. The spinner stays still with
-reduced motion enabled. While viewing Ledger, the reserved row reports
-**Brunch is working** instead. The stock assistant shows its working label without this reserved Brunch status row.
+As soon as you send, **Working…** and a small blue spinner appear in the
+transcript, where that turn's activity will appear, below any spoken Voice
+reply. Once the response arrives, its **Working…** activity header takes over
+in the same place and keeps the spinner until the work ends; there is no
+duplicate status below it. Suggestion chips keep their space while hidden and
+cannot be activated during work. The spinner stays still with reduced motion
+enabled. While viewing Ledger, a status row above the composer reports
+**Working…** instead.
+The stock assistant shows its working label without these Brunch statuses.
Completed Chat answers offer **Copy** and **Retry**. The latest answer keeps these controls visible; older answers reveal them on hover or keyboard focus. Touch screens keep them visible. Copy keeps the answer's Markdown and briefly shows a check mark after copying succeeds. Retry sends that answer's original prompt as a new turn, keeping the previous answer and any unsent draft. It can lead to new tool calls, just like sending the prompt yourself. Retry is unavailable while another response or voice handoff is active.
Brunch opens activity and streaming thoughts when work starts. Activity, thoughts,
and tools keep your open/closed choices as more text arrives, tools finish, or the
-response completes. Long activity scrolls inside a bounded area instead of
-continually pushing the answer down. **Working…** stays until the response ends,
-then becomes **Activity · Ns**; history without timing says **Activity**. Previously
-completed turns start collapsed. A new **Approval required** request reveals its
-controls and removes the height limit so the decision is not hidden. Expand
+response completes. Every thought and tool stays in the transcript, so long
+activity scrolls with the conversation rather than inside its own area. **Working…**
+stays until the response ends, then becomes **Activity · Ns**; history without
+timing says **Activity**. Previously completed turns start collapsed. A new
+**Approval required** request reveals its controls. Expand
activity to inspect **Thought for Ns** and **Used N tools**. Other hosts retain
their collapsed reasoning default and automatic work/tool collapsing. Stopped
work says **Stopped after N tools** and retains a **Response stopped** note.
@@ -65,7 +66,7 @@ pending, completed, and failed tools.
Before Brunch removes model elements, an approval lists the requested removals. Associated arcs or references may also be removed. **Allow** applies that removal; **Deny** withholds that call and tells Brunch nothing was changed. Brunch's later calls in the same response wait until you answer, then continue to run. **Always allow** permits later removals only in the current mounted conversation, until you leave or reload. It does not grant permission for another conversation or browser session. Stop cancels a pending approval. Auto-layout asks separately; see `applyAutoLayout` below.
-When the host supplies them, Voice also shows a collapsed brief directly under your message, an immediate spoken-agent reply before the work, and a wrap-up after the produced cards. The brief says **Preparing for Brunch** while its fields are being prepared, **Sending to Brunch** once the fields are ready but not yet accepted, and **Sent to Brunch** after acceptance. Expand a prepared brief to see **Prepared from what you said** and its right-aligned fields. Missing excerpts display **Still open**; that placeholder is not sent as an extracted fact. If preparation fails, your original words are sent without prepared fields: the brief says **Sending without preparation**, then **Sent without preparation** after acceptance. These optional parts are absent in hosts that do not provide them. In Chat, a small neutral voice-bars icon marks user messages sent using Voice; typed messages have no icon. In Voice, those per-message icons are hidden.
+When the host supplies them, Voice also shows a collapsed brief directly under your message, an immediate spoken-agent reply before the work, and a wrap-up after the produced cards. The brief says **Preparing request** while its fields are being prepared, **Sending request** once the fields are ready but not yet accepted, and **Request sent** after acceptance. Expand a prepared brief to see **Prepared from what you said** and its right-aligned fields. Missing excerpts display **Still open**; that placeholder is not sent as an extracted fact. If preparation fails, your original words are sent without prepared fields: the brief says **Sending without preparation**, then **Sent without preparation** after acceptance. These optional parts are absent in hosts that do not provide them. In Chat, a small neutral voice-bars icon marks user messages sent using Voice; typed messages have no icon. In Voice, those per-message icons are hidden.
Hosts that provide live input captions can show your words while you speak. This partial text is display-only: it does not submit work or start preparing a brief. The finalized transcript replaces it in the same bubble before preparation starts. New spoken words and status labels fade in; reduced-motion preferences disable these effects.
@@ -130,7 +131,7 @@ history, and changes to the net. In the website's Live mode, your complete
finalized words go to Brunch alongside excerpts from a prepared brief, so short
replies, corrections and requests to use defaults are not lost during preparation.
If the brief cannot be prepared, your words are sent without excerpts.
-Brunch uses the prior conversation to interpret them. Expand **Sent to Brunch**
+Brunch uses the prior conversation to interpret them. Expand **Request sent**
to inspect the brief. **Still open** means a detail was not extracted from this
turn, not that a previous answer has been forgotten. Those placeholders are not
sent to Brunch. The brief is not editable before sending in this version.
@@ -299,7 +300,7 @@ pause, or an error makes playback unsafe.
With **Allow interruptions** enabled, start speaking while Brunch is
talking to stop its audio and give your answer. Your interrupting words are
captured; you do not need to repeat them. If Brunch is still finishing its
-previous turn, the dock shows **Answer captured. Waiting for Brunch.** and
+previous turn, the dock shows **Answer captured. Working on it.** and
sends that answer when it is ready. Wait for it to be sent before giving
another one. Disable **Allow interruptions** to use manual handover. In
manual mode, select **Your turn**, wait for cancellation to finish, then speak;
diff --git a/libs/@hashintel/petrinaut/src/ui/components/sub-view/horizontal/horizontal-tabs-container.tsx b/libs/@hashintel/petrinaut/src/ui/components/sub-view/horizontal/horizontal-tabs-container.tsx
index 8e8ae5a4d76..46b545312ef 100644
--- a/libs/@hashintel/petrinaut/src/ui/components/sub-view/horizontal/horizontal-tabs-container.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/components/sub-view/horizontal/horizontal-tabs-container.tsx
@@ -46,12 +46,24 @@ const tabButtonStyle = cva({
},
},
active: {
- true: {
+ true: {},
+ false: {},
+ },
+ },
+ compoundVariants: [
+ {
+ styleVariant: "stock",
+ active: true,
+ css: {
opacity: "[1]",
backgroundColor: "[rgba(0, 0, 0, 0.08)]",
color: "neutral.s125",
},
- false: {
+ },
+ {
+ styleVariant: "stock",
+ active: false,
+ css: {
opacity: "[0.6]",
color: "neutral.s105",
_hover: {
@@ -61,7 +73,7 @@ const tabButtonStyle = cva({
},
},
},
- },
+ ],
});
/**
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx
index 2f1d01a110e..91a967324c5 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx
@@ -268,9 +268,8 @@ const HostVoiceSlotPreview = () => (
Voice mode
- OpenAI processes live audio to speak the interviewer's questions.
- Petrinaut keeps finalized answers in the conversation rather than the
- audio.
+ OpenAI processes your audio and speaks the interviewer’s questions. Your
+ transcribed answers are saved; audio isn’t.
)}
- {isBrunchChat && (
+ {isBrunchChat && showingHostTab && (
)}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-response-status.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-response-status.tsx
index 8184d995c0c..761009fb9c9 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-response-status.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-response-status.tsx
@@ -2,30 +2,19 @@ import { LoadingSpinner } from "@hashintel/ds-components";
import { css, cx } from "@hashintel/ds-helpers/css";
/**
- * A fixed-height row below the transcript that names the wait while the chat
- * has nothing new to show: before the reply starts, or while the host tab
- * hides it. It stays mounted so the layout doesn't jump.
+ * A fixed-height row below the host tab that names Brunch's work while the
+ * host tab hides the chat. It stays mounted so the layout doesn't jump.
*/
export const BrunchResponseStatus = ({
busy,
className,
- hostTabSelected,
- replyStarted,
workingLabel,
}: {
busy: boolean;
className?: string;
- hostTabSelected: boolean;
- replyStarted: boolean;
workingLabel?: string;
}) => {
- const label = !busy
- ? undefined
- : hostTabSelected
- ? (workingLabel ?? "Brunch is working")
- : replyStarted
- ? undefined
- : "Waiting for Brunch";
+ const label = busy ? (workingLabel ?? "Working…") : undefined;
return (
{children}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.test.tsx
index 696e43d37a7..9995a0f8f0f 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.test.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.test.tsx
@@ -18,19 +18,19 @@ test.each<{
{
state: "streaming" as const,
fields: {},
- label: "Preparing for Brunch",
+ label: "Preparing request",
note: "Preparing from what you said",
},
{
state: "streaming" as const,
fields: { goal: "Compare staffing" },
- label: "Sending to Brunch",
+ label: "Sending request",
note: "Prepared from what you said",
},
{
state: "done" as const,
fields: { goal: "Compare staffing" },
- label: "Sent to Brunch",
+ label: "Request sent",
note: "Prepared from what you said",
},
{
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.tsx
index 68eb27c1f44..c9309387aed 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.tsx
@@ -52,9 +52,9 @@ export const VoiceInputProvenance = ({ brief }: { brief: VoiceBrief }) => (
: "Sent without preparation"
: brief.state === "streaming"
? Object.keys(brief.fields).length === 0
- ? "Preparing for Brunch"
- : "Sending to Brunch"
- : "Sent to Brunch"}
+ ? "Preparing request"
+ : "Sending request"
+ : "Request sent"}
collapsed={false}
indicator={}
microphoneMuted={false}
- notice="Audio playback is blocked. Select Play voice audio to hear Live."
+ notice="Audio blocked. Select Play to listen."
onCollapsedToggle={noop}
onStop={noop}
phase="speaking"
@@ -181,7 +181,7 @@ test("keeps crowded Voice actions fixed while status content can shrink", () =>
expect(status.className).toContain("tov_ellipsis");
expect(within(indicator).getByTestId("waveform")).toBeTruthy();
expect(liveStatus.textContent).toBe(
- "Voice status: Audio playback is blocked. Select Play voice audio to hear Live.",
+ "Voice status: Audio blocked. Select Play to listen.",
);
});
@@ -196,7 +196,7 @@ test("offers a user-gesture retry while session audio is blocked", () => {
collapsed: false,
indicator: ,
microphoneMuted: false,
- notice: "Audio playback is blocked. Select Play voice audio to hear Live.",
+ notice: "Audio blocked. Select Play to listen.",
onCollapsedToggle: noop,
onStop: noop,
phase: "connected" as const,
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.test.tsx
index 19760a82082..e560ebb15d0 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.test.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.test.tsx
@@ -107,7 +107,7 @@ test("keeps voice guidance in a dismissible information popover", async () => {
fireEvent.click(info);
expect(
await screen.findByText(
- "Applies next session. Mute your mic while the agent is idle to preview.",
+ "Applies next session. To preview, mute your mic while the agent is idle.",
),
).toBeTruthy();
const dialog = await screen.findByRole("dialog");
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.tsx
index f349247a1cf..273de7b181d 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-dock/audio-popover/settings.tsx
@@ -153,8 +153,8 @@ export const AudioSettings = ({
})}
>
- Applies next session. Mute your mic while the agent is
- idle to preview.
+ Applies next session. To preview, mute your mic while the
+ agent is idle.
@@ -163,10 +163,7 @@ export const AudioSettings = ({