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/stock-assistant-parity.md b/.changeset/stock-assistant-parity.md
new file mode 100644
index 00000000000..a34b454b34c
--- /dev/null
+++ b/.changeset/stock-assistant-parity.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Keep the stock AI assistant presentation unchanged by the Brunch conversation UI: chronological messages, stock tool rows, tabs, scrolling and composer. A tool stopped mid-run now shows Cancelled instead of a spinner.
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..e452caac022 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
@@ -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 () => {
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..ca89969f732 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
@@ -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 utterance was not sent because the conversation changed. Use the composer to send it.",
+ );
+ 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();
@@ -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({
@@ -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;
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..e99d153e696 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
@@ -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({
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..4bd632fe8c5 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,
@@ -367,6 +368,7 @@ export const LiveConversationControl = ({
useLayoutEffect(() => {
if (inputMode !== "voice" || !isAiAssistantOpen) {
handledVoiceSelection.current = false;
+ setStartAwaitingStop(false);
return;
}
if (handledVoiceSelection.current) return;
@@ -375,12 +377,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 +450,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 +487,7 @@ export const LiveConversationControl = ({
inputMode,
isAiAssistantOpen,
phase,
+ sessionPhase,
message,
playbackBlocked,
activity,
@@ -508,7 +518,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();
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..1a741e8b3d8 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,7 +107,7 @@ 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."
: snapshot.inputNotice === "not-heard"
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.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx
index aea07e14582..b4b6d35ed4c 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx
@@ -582,7 +582,7 @@ describe("AiAssistantPanel composer submissions", () => {
]),
);
const ledgerTab = screen.getByRole("tab", { name: "Ledger" });
- expect(ledgerTab.querySelector("[data-attention]")).not.toBeNull();
+ expect(ledgerTab.querySelector('[aria-hidden="true"]')).not.toBeNull();
fireEvent.click(ledgerTab);
await waitFor(() => expect(screen.queryByText("9+")).toBeNull());
@@ -667,14 +667,14 @@ describe("AiAssistantPanel composer submissions", () => {
const chatTab = await screen.findByRole("tab", { name: "Chat" });
await waitFor(() =>
- expect(chatTab.querySelector("[data-attention]")).not.toBeNull(),
+ expect(chatTab.querySelector('[aria-hidden="true"]')).not.toBeNull(),
);
fireEvent.click(chatTab);
await waitFor(() =>
expect(
screen
.getByRole("tab", { name: "Chat" })
- .querySelector("[data-attention]"),
+ .querySelector('[aria-hidden="true"]'),
).toBeNull(),
);
});
@@ -705,7 +705,7 @@ describe("AiAssistantPanel composer submissions", () => {
const chatTab = await screen.findByRole("tab", { name: "Chat" });
await waitFor(() =>
- expect(chatTab.querySelector("[data-attention]")).not.toBeNull(),
+ expect(chatTab.querySelector('[aria-hidden="true"]')).not.toBeNull(),
);
});
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..03b098083ca 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
@@ -1546,7 +1546,7 @@ const BrunchWaitingPreview = () => {
messages={[userMessage]}
promptChips={REVIEW_CHIPS}
status={waiting ? "submitted" : "ready"}
- workingLabel="Brunch is working"
+ workingLabel="Working…"
inputMode={voice ? "voice" : "text"}
voiceModeAvailable
voiceSession={voice ? liveSession({ phase: "listening" }) : undefined}
@@ -1566,30 +1566,29 @@ export const BrunchWaitingForResponse: Story = {
);
}
const transcript = canvas.getByTestId("ai-transcript");
- const slot = canvas.getByTestId("brunch-response-status");
const footer = voice
? canvas.getByRole("region", { name: "Voice session" })
: canvas
.getByRole("textbox", { name: "Message AI assistant" })
.closest("form")!;
const positions = () =>
- [transcript, slot, footer].map((element) =>
+ [transcript, footer].map((element) =>
element.getBoundingClientRect().toJSON(),
);
const before = positions();
- const scrollHeight = transcript.scrollHeight;
await userEvent.click(
canvas.getByRole("button", { name: "Toggle waiting" }),
);
- await expect(canvas.getByText("Waiting for Brunch")).toBeVisible();
+ const waiting = within(transcript).getByRole("status");
+ await expect(waiting).toHaveTextContent("Working…");
+ await expect(waiting).toBeVisible();
await expect(positions()).toEqual(before);
- await expect(transcript.scrollHeight).toBe(scrollHeight);
- await expect(within(slot).queryByRole("button")).toBeNull();
+ await expect(within(waiting).queryByRole("button")).toBeNull();
await userEvent.click(
canvas.getByRole("button", { name: "Toggle waiting" }),
);
await expect(
- canvas.queryByText("Waiting for Brunch"),
+ canvasElement.querySelector('[data-work-status="pending"]'),
).not.toBeInTheDocument();
await expect(positions()).toEqual(before);
}
@@ -1649,6 +1648,23 @@ const BrunchStreamingPreview = () => {
);
};
+/** Where the Activity label sits in the transcript's content, unaffected by auto-follow scrolling. */
+const activityLabelOffset = (canvasElement: HTMLElement) => {
+ const transcript = within(canvasElement).getByTestId("ai-transcript");
+ const labels = transcript.querySelectorAll(
+ "[data-work-status] [data-label]",
+ );
+ if (labels.length !== 1) {
+ throw new Error(`Expected one Activity label, found ${labels.length}`);
+ }
+ const frame = transcript.getBoundingClientRect();
+ const label = labels[0]!.getBoundingClientRect();
+ return {
+ x: label.x - frame.x,
+ y: label.y - frame.y + transcript.scrollTop,
+ };
+};
+
export const BrunchStreamingActivity: Story = {
render: () => ,
play: async ({ canvasElement }) => {
@@ -1657,12 +1673,6 @@ export const BrunchStreamingActivity: Story = {
const details = canvasElement.querySelector(
"[data-work-details]",
)!;
- const answer = canvas.getByText("I’ll compare the three queues.");
- const composer = canvas.getByRole("textbox", {
- name: "Message AI assistant",
- });
- await waitFor(() => expect(details.clientHeight).toBe(240));
- await expect(details.scrollHeight).toBeGreaterThan(details.clientHeight);
await Promise.all(
canvasElement
.getAnimations({ subtree: true })
@@ -1671,36 +1681,166 @@ export const BrunchStreamingActivity: Story = {
)
.map((animation) => animation.finished),
);
- const positions = () =>
- [answer, composer].map((element) => element.getBoundingClientRect().y);
- const expandedPositions = positions();
- details.scrollTop = 80;
+ await expect(details.scrollHeight).toBe(details.clientHeight);
+ const labelOffset = activityLabelOffset(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Stream update 0" }),
);
await expect(activity).toHaveAttribute("aria-expanded", "true");
- await expect(positions()).toEqual(expandedPositions);
- await expect(details.scrollTop).toBe(80);
+ await expect(details.scrollHeight).toBe(details.clientHeight);
+ await expect(activityLabelOffset(canvasElement)).toEqual(labelOffset);
await userEvent.click(activity);
await waitFor(() =>
expect(
details.closest("[data-scope=collapsible][data-part=content]"),
).not.toBeVisible(),
);
- const collapsedPositions = positions();
await userEvent.click(
canvas.getByRole("button", { name: "Stream update 1" }),
);
await expect(activity).toHaveAttribute("aria-expanded", "false");
- await expect(positions()).toEqual(collapsedPositions);
await userEvent.click(
canvas.getByRole("button", { name: "Finish response" }),
);
await expect(activity).toHaveAttribute("aria-expanded", "false");
- await expect(positions()).toEqual(collapsedPositions);
+ await expect(activity).toHaveTextContent(/^Activity/u);
+ await expect(activityLabelOffset(canvasElement)).toEqual(labelOffset);
await userEvent.click(activity);
- await waitFor(() => expect(positions()).toEqual(expandedPositions));
- details.scrollTop = 0;
+ await expect(activity).toHaveAttribute("aria-expanded", "true");
+ },
+};
+
+const voiceTurnRequest: PetrinautAiMessage = {
+ ...userMessage,
+ metadata: { source: "voice" },
+};
+
+const voiceTurnReply: PetrinautAiMessage = {
+ id: "voice-reply:user-1",
+ role: "assistant",
+ metadata: { source: "voice" },
+ parts: [
+ {
+ type: "data-voiceAgentReply",
+ data: {
+ text: "Sure, I’ll ask Brunch to build that supply chain.",
+ state: "done",
+ },
+ },
+ ],
+};
+
+const voiceTurnSteps: {
+ status: "submitted" | "streaming" | "ready";
+ response?: PetrinautAiMessage["parts"];
+}[] = [
+ { status: "submitted" },
+ {
+ status: "streaming",
+ response: [{ type: "reasoning", text: "Plan stages", state: "streaming" }],
+ },
+ {
+ status: "streaming",
+ response: [
+ { type: "reasoning", text: "Plan stages", state: "done" },
+ {
+ type: "dynamic-tool",
+ toolName: "inspectModel",
+ toolCallId: "voice-turn-inspection",
+ input: {},
+ state: "input-available",
+ },
+ ],
+ },
+ {
+ status: "ready",
+ response: [
+ { type: "reasoning", text: "Plan stages", state: "done" },
+ {
+ type: "dynamic-tool",
+ toolName: "inspectModel",
+ toolCallId: "voice-turn-inspection",
+ input: {},
+ state: "output-available",
+ output: { places: 4 },
+ },
+ {
+ type: "text",
+ text: "The supply chain has four stages.",
+ state: "done",
+ },
+ ],
+ },
+];
+
+const BrunchVoiceTurnPreview = () => {
+ const [step, setStep] = useState(0);
+ const [voice, setVoice] = useState(true);
+ const { status, response } = voiceTurnSteps[step]!;
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export const BrunchVoiceTurnActivity: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ for (const voice of [true, false]) {
+ if (!voice) {
+ await userEvent.click(
+ canvas.getByRole("button", { name: "Toggle Voice preview" }),
+ );
+ }
+ await expect(
+ within(canvas.getByTestId("ai-transcript")).getByRole("status"),
+ ).toHaveTextContent("Working…");
+ const labelOffset = activityLabelOffset(canvasElement);
+ for (let i = 1; i < voiceTurnSteps.length; i++) {
+ await userEvent.click(
+ canvas.getByRole("button", { name: "Next turn step" }),
+ );
+ await expect(activityLabelOffset(canvasElement)).toEqual(labelOffset);
+ }
+ }
},
};
@@ -1878,7 +2018,7 @@ const PendingToolLifecycleHarness = () => {
presentation="brunch"
resolveToolPresentation={toolLifecycleResolver}
status={running ? "streaming" : "ready"}
- workingLabel="Brunch is working"
+ workingLabel="Working…"
/>
>
);
@@ -2072,7 +2212,7 @@ export const VoiceMediatedTurn: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const summary = canvas.getByText("Sent to Brunch").closest("summary")!;
+ const summary = canvas.getByText("Request sent").closest("summary")!;
const userTurn = summary.closest("[data-role=user]")!;
const assistantTurn = canvasElement.querySelector("[data-role=assistant]")!;
await expect(getComputedStyle(userTurn).gap).toBe("2px");
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx
index dfbbb80baee..75b0841bad4 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx
@@ -25,9 +25,6 @@ import { VoiceSessionContext } from "../../../../../react/voice-session/context"
import { createVoiceSessionStore } from "../../../../../react/voice-session/store";
import { definePetrinautAiInteractiveTool } from "../../../../types/ai-interactive-tool";
import { AiAssistantContents } from "./ai-assistant-contents";
-import { VoiceDock } from "./ai-assistant-contents/voice-dock";
-import { AudioSettings } from "./ai-assistant-contents/voice-dock/audio-popover/settings";
-import { VoiceInputProvenance } from "./ai-assistant-contents/voice-input-provenance";
import type { PetrinautAiMessage } from "./types";
@@ -171,873 +168,6 @@ const DockingHarness = ({
);
};
-test("live-capability dock keeps microphone direct and Realtime controls absent", async () => {
- const end = vi.fn();
- const collapse = vi.fn();
- const setMicrophoneMuted = vi.fn();
- const setSpeakerMuted = vi.fn();
- const setSpeakerVolume = vi.fn();
- render(
- }
- microphoneMuted={false}
- onStop={noop}
- onCollapsedToggle={collapse}
- phase="connected"
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
- expect(screen.getByText("Connected")).toBeTruthy();
- const microphone = screen.getByRole("button", { name: "Mute microphone" });
- expect(microphone).not.toBeNull();
- expect(screen.queryByRole("button", { name: "Your turn" })).toBeNull();
- fireEvent.click(screen.getByRole("button", { name: "Hide conversation" }));
- expect(collapse).toHaveBeenCalledOnce();
- fireEvent.click(microphone);
- expect(setMicrophoneMuted).toHaveBeenCalledWith(true);
-
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- expect(
- await screen.findByRole("button", { name: "Mute speaker" }),
- ).toBeTruthy();
- expect(
- screen
- .getByRole("slider", { name: "Speaker volume" })
- .getAttribute("aria-valuenow"),
- ).toBe("100");
- expect(screen.queryByRole("button", { name: "Repeat question" })).toBeNull();
- expect(screen.queryByRole("button", { name: "Read full reply" })).toBeNull();
- expect(
- screen.queryByRole("checkbox", { name: "Allow interruptions" }),
- ).toBeNull();
- expect(
- microphone.closest('[data-scope="popover"][data-part="content"]'),
- ).toBeNull();
-
- fireEvent.click(screen.getByRole("button", { name: "End voice mode" }));
- expect(end).toHaveBeenCalledOnce();
-});
-
-test("keeps crowded Voice actions fixed while status content can shrink", () => {
- render(
- }
- microphoneMuted={false}
- notice="Audio playback is blocked. Select Play voice audio to hear Live."
- onCollapsedToggle={noop}
- onStop={noop}
- phase="speaking"
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
-
- const dock = screen.getByTestId("ai-voice-dock");
- const getPart = (part: string) => {
- const element = dock.querySelector(`[data-part="${part}"]`);
- if (!element) throw new Error(`Missing Voice dock part: ${part}`);
- return element;
- };
- const leftActions = getPart("left-actions");
- const center = getPart("shrinkable-status");
- const indicator = getPart("fixed-indicator");
- const status = getPart("visible-status");
- const rightActions = getPart("right-actions");
- const liveStatus = getPart("live-status");
-
- expect(dock.className).toContain("d_grid");
- expect(dock.className).toContain("grid-tc_[auto_minmax(0,_1fr)_auto]");
- expect(leftActions.className).toContain("flex-sh_0");
- expect(rightActions.className).toContain("flex-sh_0");
- expect(center.className).toContain("min-w_[0]");
- expect(status.className).toContain("min-w_[0]");
- expect(indicator.className).toContain("flex-sh_1");
- expect(indicator.className).toContain("min-w_[32px]");
- expect(status.className).toContain("ov_hidden");
- 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.",
- );
-});
-
-test("offers a user-gesture retry while session audio is blocked", () => {
- const retryPlayback = vi.fn();
- const commonProps = {
- actions: { end: noop, pause: noop, retryPlayback },
- assistantBusy: false,
- canReadFullResponse: false,
- canRepeatQuestion: false,
- canTakeTurn: false,
- collapsed: false,
- indicator: ,
- microphoneMuted: false,
- notice: "Audio playback is blocked. Select Play voice audio to hear Live.",
- onCollapsedToggle: noop,
- onStop: noop,
- phase: "connected" as const,
- speakerMuted: false,
- speakerVolume: 1,
- };
- const rendered = render(
- ,
- );
-
- fireEvent.click(screen.getByRole("button", { name: "Play voice audio" }));
- expect(retryPlayback).toHaveBeenCalledOnce();
-
- rendered.rerender();
- expect(screen.queryByRole("button", { name: "Play voice audio" })).toBeNull();
-});
-
-test.each([
- {
- absentAction: "Resume voice mode",
- phase: "error" as const,
- recoveryAction: "Reconnect voice mode",
- },
- {
- absentAction: "Reconnect voice mode",
- phase: "paused" as const,
- recoveryAction: "Resume voice mode",
- },
-])(
- "keeps direct microphone beside $phase recovery controls",
- ({ absentAction, phase, recoveryAction }) => {
- render(
- }
- microphoneMuted={false}
- onCollapsedToggle={noop}
- onStop={noop}
- phase={phase}
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
-
- expect(screen.getByRole("button", { name: recoveryAction })).toBeTruthy();
- expect(
- screen.getByRole("button", {
- name: "Mute microphone",
- }).disabled,
- ).toBe(true);
- expect(screen.queryByRole("button", { name: absentAction })).toBeNull();
- },
-);
-
-test.each(["connecting", "error", "paused"] as const)(
- "disables the direct microphone action while Voice is %s",
- (phase) => {
- const setMicrophoneMuted = vi.fn();
- render(
- }
- microphoneMuted={false}
- onCollapsedToggle={noop}
- onStop={noop}
- phase={phase}
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
-
- const microphone = screen.getByRole("button", {
- name: "Mute microphone",
- });
- expect(microphone.disabled).toBe(true);
- fireEvent.click(microphone);
- expect(setMicrophoneMuted).not.toHaveBeenCalled();
- },
-);
-
-test.each(["listening", "thinking", "speaking"] as const)(
- "keeps the direct microphone action functional while Voice is %s",
- (phase) => {
- const setMicrophoneMuted = vi.fn();
- render(
- }
- microphoneMuted={false}
- onCollapsedToggle={noop}
- onStop={noop}
- phase={phase}
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
-
- const microphone = screen.getByRole("button", {
- name: "Mute microphone",
- });
- expect(microphone.disabled).toBe(false);
- fireEvent.click(microphone);
- expect(setMicrophoneMuted).toHaveBeenCalledExactlyOnceWith(true);
- },
-);
-
-test.each(["connecting", "error"] as const)(
- "disables only speaker controls in Audio options while Voice is %s",
- async (phase) => {
- const setInterruptionBySpeaking = vi.fn();
- const setSpeakerMuted = vi.fn();
- const setSpeakerVolume = vi.fn();
- render(
- }
- interruptionBySpeaking
- microphoneMuted={false}
- onCollapsedToggle={noop}
- onStop={noop}
- phase={phase}
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
-
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- const speakerMute = await screen.findByRole("button", {
- name: "Mute speaker",
- });
- const volume = screen.getByRole("slider", { name: "Speaker volume" });
- expect(speakerMute.disabled).toBe(true);
- expect(volume.getAttribute("aria-disabled")).toBe("true");
- fireEvent.click(speakerMute);
- volume.focus();
- fireEvent.keyDown(volume, { key: "ArrowLeft" });
- expect(setSpeakerMuted).not.toHaveBeenCalled();
- expect(setSpeakerVolume).not.toHaveBeenCalled();
-
- const interruption = screen.getByRole("checkbox", {
- name: "Allow interruptions",
- });
- expect(interruption.disabled).toBe(false);
- fireEvent.click(interruption);
- await waitFor(() =>
- expect(setInterruptionBySpeaking).toHaveBeenCalledExactlyOnceWith(false),
- );
- expect(
- screen.getByRole("button", {
- name: "Repeat question",
- }).disabled,
- ).toBe(true);
- expect(
- screen.getByRole("button", {
- name: "Read full reply",
- }).disabled,
- ).toBe(true);
- },
-);
-
-test.each(["listening", "thinking", "speaking", "paused"] as const)(
- "keeps speaker controls callable while Voice is %s",
- async (phase) => {
- const setSpeakerMuted = vi.fn();
- const setSpeakerVolume = vi.fn();
- render(
- }
- microphoneMuted={false}
- onCollapsedToggle={noop}
- onStop={noop}
- phase={phase}
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
-
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- const speakerMute = await screen.findByRole("button", {
- name: "Mute speaker",
- });
- const volume = screen.getByRole("slider", { name: "Speaker volume" });
- expect(speakerMute.disabled).toBe(false);
- expect(volume.getAttribute("aria-disabled")).not.toBe("true");
- fireEvent.click(speakerMute);
- volume.focus();
- fireEvent.keyDown(volume, { key: "ArrowLeft" });
- expect(setSpeakerMuted).toHaveBeenCalledExactlyOnceWith(true);
- await waitFor(() =>
- expect(setSpeakerVolume).toHaveBeenCalledExactlyOnceWith(0.95),
- );
- },
-);
-
-test("shows the speed slider immediately with fine keyboard steps", async () => {
- const setSpeed = vi.fn();
- render(
- }
- microphoneMuted={false}
- onCollapsedToggle={noop}
- onStop={noop}
- phase="connected"
- speakerMuted={false}
- speakerVolume={1}
- />,
- );
-
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- const speed = await screen.findByRole("slider", { name: "Speed" });
- expect(speed.getAttribute("aria-valuemin")).toBe("0.25");
- expect(speed.getAttribute("aria-valuemax")).toBe("1.5");
- expect(speed.getAttribute("aria-valuenow")).toBe("1");
- expect(screen.getByText("1.00×")).not.toBeNull();
- expect(screen.queryByText("Next reply")).toBeNull();
-
- speed.focus();
- fireEvent.keyDown(speed, { key: "ArrowRight" });
- await waitFor(() => expect(setSpeed).toHaveBeenCalledExactlyOnceWith(1.05));
-});
-
-test("keeps voice visible while toggling devices and refreshes devices when opened", async () => {
- const refreshDevices = vi.fn();
- const stopVoicePreview = vi.fn();
- const setVoice = vi.fn();
- render(
- }
- microphoneMuted={true}
- onCollapsedToggle={noop}
- onStop={noop}
- phase="connected"
- speakerMuted={false}
- speakerVolume={0.65}
- />,
- );
-
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- expect(
- (
- await screen.findByRole("slider", { name: "Speaker volume" })
- ).getAttribute("aria-valuenow"),
- ).toBe("65");
- expect(screen.getByRole("combobox", { name: "Voice" })).not.toBeNull();
- expect(screen.getByText("Voice preview playing").getAttribute("role")).toBe(
- "status",
- );
- expect(
- screen.queryByRole("button", { name: /play preview|pause preview/i }),
- ).toBeNull();
- const nativeVoice = document.querySelector("select");
- if (!nativeVoice) throw new Error("Missing native voice field");
- fireEvent.change(nativeVoice, { target: { value: "alloy" } });
- await waitFor(() =>
- expect(setVoice).toHaveBeenCalledExactlyOnceWith("alloy"),
- );
- expect(screen.queryByRole("button", { name: /^Voice/ })).toBeNull();
- const devicesToggle = screen.getByRole("button", { name: "Devices" });
- expect(devicesToggle.getAttribute("aria-expanded")).toBe("false");
- expect(screen.queryByRole("combobox", { name: "Microphone" })).toBeNull();
- expect(screen.queryByRole("combobox", { name: "Speaker" })).toBeNull();
- expect(screen.queryByText(/Applies next session/)).toBeNull();
- expect(
- screen.getByRole("button", { name: "About voice selection" }),
- ).toBeTruthy();
- expect(screen.getByRole("combobox", { name: "Voice" })).not.toBeNull();
- expect(
- screen.queryByText(
- "Speaking speed is not available with this voice provider.",
- ),
- ).toBeNull();
- expect(screen.queryByRole("button", { name: "Real-time" })).toBeNull();
- expect(screen.queryByRole("slider", { name: "Speed" })).toBeNull();
-
- expect(refreshDevices).toHaveBeenCalledOnce();
- fireEvent.click(devicesToggle);
- expect(devicesToggle.getAttribute("aria-expanded")).toBe("true");
- expect(screen.getByRole("combobox", { name: "Voice" })).not.toBeNull();
- expect(screen.getByRole("combobox", { name: "Microphone" }).textContent).toBe(
- "System default",
- );
- expect(screen.getByRole("combobox", { name: "Speaker" }).textContent).toBe(
- "System default",
- );
- expect(
- screen.getByRole("combobox", { name: "Speaker" })
- .disabled,
- ).toBe(true);
- expect(
- screen.getByText("System default — change output in your system settings."),
- ).not.toBeNull();
- expect(
- screen
- .getByText("Allow microphone access to list devices.")
- .getAttribute("role"),
- ).toBe("status");
- expect(screen.queryByRole("button", { name: "Refresh devices" })).toBeNull();
- expect(
- screen.queryByText("Disconnected devices switch to system default."),
- ).toBeNull();
- fireEvent.click(devicesToggle);
- expect(devicesToggle.getAttribute("aria-expanded")).toBe("false");
- expect(screen.queryByRole("combobox", { name: "Microphone" })).toBeNull();
- expect(screen.queryByRole("combobox", { name: "Speaker" })).toBeNull();
- expect(screen.getByRole("combobox", { name: "Voice" })).not.toBeNull();
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- expect(refreshDevices).toHaveBeenCalledOnce();
- expect(stopVoicePreview).toHaveBeenCalledOnce();
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- expect(refreshDevices).toHaveBeenCalledTimes(2);
-});
-
-test("summarizes selected audio devices without exposing device IDs", () => {
- const actions = {
- refreshDevices: noop,
- requestSpeaker: noop,
- setMicrophoneDevice: noop,
- setSpeakerDevice: noop,
- setVoice: noop,
- };
- const settings = {
- activeVoice: "alloy",
- voice: "alloy",
- voices: [{ value: "alloy", text: "Alloy" }],
- devices: {
- microphones: [{ value: "mic-1", text: "Desk microphone" }],
- speakers: [{ value: "speaker-1", text: "Headphones" }],
- microphoneId: "mic-1",
- speakerId: "speaker-1",
- canSelectSpeaker: true,
- canRequestSpeaker: false,
- busy: false,
- message: null,
- },
- };
- const { rerender } = render(
- ,
- );
- expect(screen.getByRole("button", { name: "Devices" }).textContent).toContain(
- "Desk microphone · Headphones",
- );
- expect(screen.queryByRole("combobox", { name: "Microphone" })).toBeNull();
- rerender(
- ,
- );
- expect(screen.getByRole("button", { name: "Devices" }).textContent).toContain(
- "System default · Unavailable speaker",
- );
- expect(screen.queryByText(/disconnected-private-id/u)).toBeNull();
-});
-
-test.each<{
- state: "streaming" | "done";
- preparationFailed?: boolean;
- fields: Record;
- label: string;
- note: string;
-}>([
- {
- state: "streaming" as const,
- fields: {},
- label: "Preparing for Brunch",
- note: "Preparing from what you said",
- },
- {
- state: "streaming" as const,
- fields: { goal: "Compare staffing" },
- label: "Sending to Brunch",
- note: "Prepared from what you said",
- },
- {
- state: "done" as const,
- fields: { goal: "Compare staffing" },
- label: "Sent to Brunch",
- note: "Prepared from what you said",
- },
- {
- state: "streaming" as const,
- preparationFailed: true,
- fields: {},
- label: "Sending without preparation",
- note: "Preparation failed; sending your original words",
- },
- {
- state: "done" as const,
- preparationFailed: true,
- fields: {},
- label: "Sent without preparation",
- note: "Preparation failed; your original words were sent",
- },
-])(
- "labels a brief truthfully: $label",
- ({ state, fields, preparationFailed, label, note }) => {
- render(
- ,
- );
- const disclosure = screen.getByText(label).closest("details");
- expect(disclosure?.getAttribute("aria-busy")).toBe(
- String(state === "streaming"),
- );
- expect(screen.getByText(note)).not.toBeNull();
- },
-);
-
-test("renders absent brief fields as placeholders without confusing verbatim Still open", () => {
- render(
- ,
- );
-
- expect(screen.getByText("runs").nextSibling?.textContent).toBe("Still open");
- expect(screen.getByText("constraints").nextSibling?.textContent).toBe(
- "Still open",
- );
- expect(screen.getByText("ask").nextSibling?.textContent).toBe("Still open");
- expect(screen.queryByText("still Open")).toBeNull();
-});
-
-test("stops a voice preview only when audio settings unmount", () => {
- const initialStopVoicePreview = vi.fn();
- const latestStopVoicePreview = vi.fn();
- const actions = {
- refreshDevices: vi.fn(),
- requestSpeaker: vi.fn(),
- setMicrophoneDevice: vi.fn(),
- setSpeakerDevice: vi.fn(),
- setVoice: vi.fn(),
- };
- const settings = {
- activeVoice: "alloy",
- voice: "alloy",
- voices: [{ value: "alloy", text: "Alloy" }],
- devices: {
- microphones: [],
- speakers: [],
- microphoneId: "",
- speakerId: "",
- canSelectSpeaker: false,
- canRequestSpeaker: false,
- busy: false,
- message: null,
- },
- };
- const { rerender, unmount } = render(
- ,
- );
-
- rerender(
- ,
- );
-
- expect(initialStopVoicePreview).not.toHaveBeenCalled();
- expect(latestStopVoicePreview).not.toHaveBeenCalled();
-
- unmount();
- expect(initialStopVoicePreview).not.toHaveBeenCalled();
- expect(latestStopVoicePreview).toHaveBeenCalledOnce();
-});
-
-test("gates voice previews and hides only ordinary status text", async () => {
- const stopVoicePreview = vi.fn();
- const setMicrophoneMuted = vi.fn();
- const setVoice = vi.fn();
- const dock = (
- phase: "listening" | "speaking" | "muted" | "connecting",
- microphoneMuted: boolean,
- ) => (
- }
- microphoneMuted={microphoneMuted}
- onCollapsedToggle={noop}
- onStop={noop}
- phase={phase}
- speakerMuted={false}
- speakerVolume={1}
- />
- );
- const { rerender } = render(dock("listening", false));
- fireEvent.click(screen.getByRole("button", { name: "Audio options" }));
- const voice = await screen.findByRole("combobox", {
- name: "Voice",
- });
- expect(voice.disabled).toBe(false);
- const nativeVoice = document.querySelector("select");
- if (!nativeVoice) throw new Error("Missing native voice field");
- fireEvent.change(nativeVoice, { target: { value: "verse" } });
- await waitFor(() =>
- expect(setVoice).toHaveBeenCalledExactlyOnceWith("verse"),
- );
- expect(screen.queryByText("Mute your mic to preview.")).toBeNull();
- expect(voice.getAttribute("aria-description")).toContain(
- "Mute your mic to preview.",
- );
- const toggle = screen.getByRole("checkbox", { name: "Show status text" });
- expect((toggle as HTMLInputElement).checked).toBe(true);
- fireEvent.click(screen.getByText("Show status text"));
- await waitFor(() =>
- expect(screen.queryByText("Listening", { exact: true })).toBeNull(),
- );
- expect(screen.getByRole("status", { name: "Voice status" }).textContent).toBe(
- "Voice status: Listening",
- );
- expect(screen.getByTestId("waveform")).not.toBeNull();
-
- rerender(dock("speaking", true));
- expect(voice.disabled).toBe(false);
- expect(screen.queryByText("Wait for the agent to finish.")).toBeNull();
- expect(voice.getAttribute("aria-description")).toContain(
- "Wait for the agent to finish.",
- );
- expect(screen.queryByText("Speaking", { exact: true })).toBeNull();
- rerender(dock("muted", true));
- expect(voice.disabled).toBe(false);
- expect(screen.getByText("Muted", { exact: true })).not.toBeNull();
- stopVoicePreview.mockClear();
- fireEvent.click(screen.getByRole("button", { name: "Unmute microphone" }));
- expect(stopVoicePreview).toHaveBeenCalledOnce();
- expect(setMicrophoneMuted).toHaveBeenCalledExactlyOnceWith(false);
- expect(stopVoicePreview.mock.invocationCallOrder[0]).toBeLessThan(
- setMicrophoneMuted.mock.invocationCallOrder[0]!,
- );
-
- rerender(dock("connecting", true));
- expect(voice.disabled).toBe(false);
- setVoice.mockClear();
- fireEvent.change(nativeVoice, { target: { value: "verse" } });
- await waitFor(() =>
- expect(setVoice).toHaveBeenCalledExactlyOnceWith("verse"),
- );
- expect(screen.getByText("Connecting", { exact: true })).not.toBeNull();
-});
-
describe("AiAssistantContents", () => {
test("orders optional voice slots around work and produced cards", async () => {
const card = definePetrinautAiInteractiveTool({
@@ -1096,7 +226,7 @@ describe("AiAssistantContents", () => {
/>,
);
expect(container.textContent).toMatch(
- /Sent to Brunch[\s\S]*I’ll ask Brunch\.[\s\S]*Activity[\s\S]*Written answer[\s\S]*Experiment draft[\s\S]*Your draft is ready\./u,
+ /Request sent[\s\S]*I’ll ask Brunch\.[\s\S]*Activity[\s\S]*Written answer[\s\S]*Experiment draft[\s\S]*Your draft is ready\./u,
);
expect(
screen
@@ -1108,9 +238,9 @@ describe("AiAssistantContents", () => {
.getByRole("region", { name: "Drafted experiment" })
.closest("[data-work-status]"),
).toBeNull();
- const brief = screen.getByText("Sent to Brunch").closest("details");
+ const brief = screen.getByText("Request sent").closest("details");
expect(brief?.open).toBe(false);
- fireEvent.click(screen.getByText("Sent to Brunch"));
+ fireEvent.click(screen.getByText("Request sent"));
expect(screen.getByText("Arrival rate")).not.toBeNull();
rerender(
{
]}
/>,
);
- expect(screen.queryByText("Sent to Brunch")).toBeNull();
+ expect(screen.queryByText("Request sent")).toBeNull();
expect(screen.queryByText("I’ll ask Brunch.")).toBeNull();
await expandWork();
expect(screen.getByText("Plain reply")).not.toBeNull();
@@ -1206,6 +336,50 @@ describe("AiAssistantContents", () => {
expect(screen.getByText(/"revision": 7/u)).not.toBeNull();
});
+ test("shows every counted tool without scrolling inside Activity", async () => {
+ const toolNames = ["one", "two", "three", "four", "five"];
+ const { container } = render(
+ ({
+ type: "dynamic-tool" as const,
+ toolName,
+ toolCallId: toolName,
+ state: "output-available" as const,
+ input: {},
+ output: {},
+ })),
+ { type: "text" as const, text: "Done" },
+ ],
+ },
+ ]}
+ />,
+ );
+ await expandWork();
+
+ expect(screen.getByRole("button", { name: "Used 5 tools" })).not.toBeNull();
+ for (const toolName of toolNames)
+ expect(
+ screen.getByRole("button", {
+ name: new RegExp(`\\b${toolName}\\b`, "u"),
+ }),
+ ).not.toBeNull();
+ const details = container.querySelector("[data-work-details]");
+ expect(details?.className).not.toContain("max-h_");
+ expect(details?.className).not.toContain("ov-y_auto");
+ });
+
test("marks unfinished stock tools cancelled after a stop", () => {
render(
{
{
.getByText("Compare three agents")
.closest("[data-user-bubble]");
expect(bubble).not.toBeNull();
- expect(bubble?.contains(screen.getByText("Sent to Brunch"))).toBe(false);
+ expect(bubble?.contains(screen.getByText("Request sent"))).toBe(false);
});
test("copies an answer and retries its own user prompt rather than the latest prompt", async () => {
@@ -1692,157 +867,74 @@ describe("AiAssistantContents", () => {
expect(contentMounted).toHaveBeenCalledOnce();
});
- test("keeps tab names stable and one tab live region mounted across announcements", () => {
+ test.each(["stock", "brunch"] as const)(
+ "keeps tab names stable and one tab live region mounted across announcements in the %s presentation",
+ (presentation) => {
+ const props = {
+ additionalTab: { label: "Ledger", content: Saved account
},
+ hostAttentionCount: 2,
+ input: "",
+ messages: [],
+ onClose: noop,
+ onInputChange: noop,
+ onStop: noop,
+ onSubmit: noop,
+ primaryAttention: true,
+ primaryLabel: "Chat",
+ presentation,
+ status: "ready" as const,
+ };
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByRole("tab", { name: "Chat" })).not.toBeNull();
+ expect(screen.getByRole("tab", { name: "Ledger" })).not.toBeNull();
+ const tabHeader = within(screen.getByRole("tablist").parentElement!);
+ expect(tabHeader.getAllByRole("status")).toHaveLength(1);
+ expect(tabHeader.getByRole("status").textContent).toBe(
+ "2 unseen Ledger updates",
+ );
+
+ rerender();
+ expect(tabHeader.getAllByRole("status")).toHaveLength(1);
+ expect(tabHeader.getByRole("status").textContent).toBe("");
+ rerender(
+ ,
+ );
+ expect(tabHeader.getByRole("status").textContent).toBe(
+ "2 unseen Ledger updates",
+ );
+ },
+ );
+
+ test("returns to chat when the host withdraws its additional tab", () => {
const props = {
- additionalTab: { label: "Ledger", content: Saved account
},
- hostAttentionCount: 2,
input: "",
+ status: "ready" as const,
messages: [],
onClose: noop,
onInputChange: noop,
onStop: noop,
onSubmit: noop,
- primaryAttention: true,
- primaryLabel: "Chat",
- presentation: "brunch" as const,
- status: "ready" as const,
};
const { rerender } = render(
Host notes
}}
/>,
);
-
- expect(screen.getByRole("tab", { name: "Chat" })).not.toBeNull();
- expect(screen.getByRole("tab", { name: "Ledger" })).not.toBeNull();
- const tabHeader = within(screen.getByRole("tablist").parentElement!);
- expect(tabHeader.getAllByRole("status")).toHaveLength(1);
- expect(tabHeader.getByRole("status").textContent).toBe(
- "2 unseen Ledger updates",
- );
-
- rerender();
- expect(tabHeader.getAllByRole("status")).toHaveLength(1);
- expect(tabHeader.getByRole("status").textContent).toBe("");
- rerender(
- ,
- );
- expect(tabHeader.getByRole("status").textContent).toBe(
- "2 unseen Ledger updates",
- );
- });
-
- test("returns to chat when the host withdraws its additional tab", () => {
- const props = {
- input: "",
- status: "ready" as const,
- messages: [],
- onClose: noop,
- onInputChange: noop,
- onStop: noop,
- onSubmit: noop,
- };
- const { rerender } = render(
- Host notes }}
- />,
- );
- fireEvent.click(screen.getByRole("tab", { name: "Notes" }));
- rerender();
- expect(screen.queryByRole("tab", { name: "Notes" })).toBeNull();
- expect(screen.getByTestId("ai-transcript").hidden).toBe(false);
- });
-
- test.each([
- {
- label: "blocked",
- output: {
- applied: false,
- blocked: "readonly",
- reason: "Read-only document.",
- },
- },
- {
- label: "declined",
- output: { applied: false, reason: "User declined auto-layout." },
- },
- {
- label: "no-op",
- output: {
- applied: false,
- reason: "The mutation left the document unchanged.",
- },
- },
- {
- label: "stale host",
- output: {
- applied: false,
- reason:
- "The requested base does not match the independently observed document.",
- },
- },
- {
- label: "contradictory supplied summary",
- output: {
- applied: false,
- reason: "Not applied by the host.",
- title: "Updated arc weight",
- detail: "Requested value: 4",
- },
- },
- ])(
- "renders an explicit $label result as not applied, never requested-value success",
- async ({ output }) => {
- render(
- ,
- );
- await expandWork();
- const row = screen.getByRole("button", { name: /Not applied/u });
- expect(row.getAttribute("data-tone")).toBe("neutral");
- expect(within(row).getByText(output.reason)).not.toBeNull();
- expect(
- within(row).queryByText("Updated arc weight", { exact: true }),
- ).toBeNull();
- expect(row.querySelector('[data-tool-status="ok"]')).not.toBeNull();
- expect(
- row.querySelector('[data-tool-result-icon="complete"]'),
- ).toBeNull();
- },
- );
+ fireEvent.click(screen.getByRole("tab", { name: "Notes" }));
+ rerender();
+ expect(screen.queryByRole("tab", { name: "Notes" })).toBeNull();
+ expect(screen.getByTestId("ai-transcript").hidden).toBe(false);
+ });
test.each(["output-available", "output-error"] as const)(
"preserves %s applied/error presentation",
@@ -3025,6 +2117,7 @@ describe("AiAssistantContents", () => {
{
onInputChange: noop,
onStop: noop,
onSubmit: noop,
+ presentation: "brunch" as const,
status: "ready" as const,
};
const message = (text: string): PetrinautAiMessage[] => [
@@ -3735,7 +2829,7 @@ describe("AiAssistantContents", () => {
expect(screen.getByText(/Ask AI to create a Petri net/u)).not.toBeNull();
});
- test("reserves a Brunch-only response status without adding transcript work", () => {
+ test("holds the Activity row's place while waiting for Brunch", () => {
const props = {
input: "",
messages: [
@@ -3755,21 +2849,17 @@ describe("AiAssistantContents", () => {
const { rerender } = render(
,
);
- const slot = screen.getByTestId("brunch-response-status");
- expect(slot.textContent).toBe("");
+ const transcript = screen.getByTestId("ai-transcript");
+ expect(screen.queryByTestId("brunch-response-status")).toBeNull();
+ expect(within(transcript).queryByRole("status")).toBeNull();
rerender();
- expect(screen.getByTestId("brunch-response-status")).toBe(slot);
- expect(within(slot).getByRole("status").textContent).toBe(
- "Waiting for Brunch",
- );
- expect(within(slot).queryByRole("button")).toBeNull();
+ const waiting = within(transcript).getByRole("status");
+ expect(waiting.textContent).toBe("Working…");
+ expect(waiting.closest('[data-work-status="pending"]')).not.toBeNull();
+ expect(within(waiting).queryByRole("button")).toBeNull();
expect(screen.queryByRole("button", { name: "Working…" })).toBeNull();
- expect(
- within(screen.getByTestId("ai-transcript")).queryByText(
- /Waiting for Brunch/,
- ),
- ).toBeNull();
+ expect(screen.queryByTestId("brunch-response-status")).toBeNull();
rerender(
{
]}
/>,
);
- expect(screen.getByRole("button", { name: "Working…" })).toBeTruthy();
- expect(within(slot).getByRole("status").textContent).toBe("");
+ const working = screen.getByRole("button", { name: "Working…" });
+ expect(working.querySelector('[data-work-status="pending"]')).toBeNull();
+ expect(document.querySelector('[data-work-status="pending"]')).toBeNull();
rerender();
- expect(screen.getByTestId("brunch-response-status")).toBe(slot);
- expect(slot.textContent).toBe("");
+ expect(within(transcript).queryByRole("status")).toBeNull();
rerender(
{
expect(screen.queryByText(/Brunch/u)).toBeNull();
});
+ test.each(["voice", "text"] as const)(
+ "keeps Activity off Live's spoken reply message (%s)",
+ (inputMode) => {
+ const request: PetrinautAiMessage = {
+ id: "request",
+ role: "user",
+ metadata: { source: "voice" },
+ parts: [{ type: "text", text: "Review this model" }],
+ };
+ const spokenReply: PetrinautAiMessage = {
+ id: "voice-reply:request",
+ role: "assistant",
+ metadata: { source: "voice" },
+ parts: [
+ {
+ type: "data-voiceAgentReply",
+ data: { text: "I hear you.", state: "done" },
+ },
+ ],
+ };
+ const props = {
+ input: "",
+ inputMode,
+ onClose: noop,
+ onInputChange: noop,
+ onStop: noop,
+ onSubmit: noop,
+ primaryLabel: "Chat",
+ presentation: "brunch" as const,
+ };
+ const replyTurn = () =>
+ screen
+ .getByText("I hear you.")
+ .closest('[data-role="assistant"]')!;
+ const { rerender } = render(
+ ,
+ );
+
+ expect(replyTurn().querySelector("[data-work-status]")).toBeNull();
+ const waiting = within(screen.getByTestId("ai-transcript")).getByRole(
+ "status",
+ );
+ expect(waiting.textContent).toBe("Working…");
+ expect(replyTurn().nextElementSibling?.contains(waiting)).toBe(true);
+
+ rerender(
+ ,
+ );
+
+ expect(replyTurn().querySelector("[data-work-status]")).toBeNull();
+ expect(screen.getAllByRole("button", { name: "Working…" })).toHaveLength(
+ 1,
+ );
+ expect(document.querySelector('[data-work-status="pending"]')).toBeNull();
+
+ rerender(
+ ,
+ );
+
+ expect(replyTurn().textContent).not.toContain("Response stopped");
+ expect(screen.getByText("Response stopped")).not.toBeNull();
+ },
+ );
+
test("keeps Brunch prompt chips mounted but inaccessible while waiting", () => {
const props = {
input: "",
@@ -3865,6 +3042,38 @@ describe("AiAssistantContents", () => {
expect(screen.queryByText("Brunch is working")).toBeNull();
});
+ test("keeps the Brunch working status visible while the host tab is selected", () => {
+ render(
+ Saved account }}
+ hostTabSelected
+ input=""
+ messages={[
+ {
+ id: "request",
+ role: "user",
+ parts: [{ type: "text", text: "Review this model" }],
+ },
+ ]}
+ onClose={noop}
+ onInputChange={noop}
+ onStop={noop}
+ onSubmit={noop}
+ presentation="brunch"
+ status="submitted"
+ />,
+ );
+
+ const status = screen.getByTestId("brunch-response-status");
+ expect(status.textContent).toBe("Working…");
+ expect(status.closest("[hidden]")).toBeNull();
+ expect(
+ document
+ .querySelector('[data-work-status="pending"]')
+ ?.closest("[hidden]"),
+ ).not.toBeNull();
+ });
+
test("keeps the working label visible while the host tab is selected", () => {
render(
{
status: "ready" as const,
};
const { rerender } = render();
+ const tablist = () => screen.getByRole("tablist");
+ const inactiveTab = () => screen.getByRole("tab", { name: "Ledger" });
- expect(screen.getByRole("tablist").getAttribute("data-style-variant")).toBe(
- "stock",
- );
+ expect(tablist().getAttribute("data-style-variant")).toBe("stock");
+ expect(tablist().querySelector("[data-mark]")).toBeNull();
+ expect(inactiveTab().className).toContain("op_[0.6]");
rerender();
- expect(screen.getByRole("tablist").getAttribute("data-style-variant")).toBe(
- "brunch",
- );
+ expect(tablist().getAttribute("data-style-variant")).toBe("brunch");
+ expect(tablist().querySelectorAll("[data-mark]")).toHaveLength(2);
+ expect(inactiveTab().className).not.toContain("op_[0.6]");
+ });
+
+ test("keeps the stock transcript chrome for a stock assistant", () => {
+ const props = {
+ input: "",
+ messages: [
+ {
+ id: "user-1",
+ role: "user" as const,
+ parts: [{ type: "text" as const, text: "A stock prompt" }],
+ },
+ {
+ id: "assistant-1",
+ role: "assistant" as const,
+ parts: [{ type: "text" as const, text: "A stock answer" }],
+ },
+ ],
+ onClose: noop,
+ onInputChange: noop,
+ onStop: noop,
+ onSubmit: noop,
+ status: "ready" as const,
+ };
+ const { container, rerender } = render();
+ const userMessage = () => container.querySelector('[data-role="user"]');
+ const userBubble = () => container.querySelector("[data-user-bubble]");
+ const assistantMessage = () =>
+ container.querySelector('[data-role="assistant"]');
+
+ expect(userMessage()?.className).toContain("bg-c_neutral.bg.subtle");
+ expect(userBubble()).toBeNull();
+ expect(assistantMessage()?.className).not.toContain("p_[6px_0]");
+
+ rerender();
+ expect(userMessage()?.className).not.toContain("bg-c_neutral.bg.subtle");
+ expect(userBubble()?.className).toContain("bg-c_neutral.a20");
+ expect(assistantMessage()?.className).toContain("p_[6px_0]");
});
test("keeps the stock AI transcript label in both input modes", () => {
@@ -4265,61 +3513,72 @@ describe("AiAssistantContents", () => {
expect(screen.queryByRole("button", { name: /^Activity/u })).toBeNull();
});
- test("does not auto-follow new content after the reader scrolls more than 96px from the end", () => {
- // eslint-disable-next-line @typescript-eslint/unbound-method -- Saved only for restoration.
- const originalScrollTo = window.HTMLElement.prototype.scrollTo;
- const originalRequestAnimationFrame = window.requestAnimationFrame;
- const scrollTo = vi.fn();
- window.HTMLElement.prototype.scrollTo = scrollTo;
- window.requestAnimationFrame = (callback) => {
- callback(0);
- return 0;
- };
- const props = {
- input: "",
- messages: [
- {
- id: "assistant-1",
- role: "assistant" as const,
- parts: [
- { type: "text" as const, state: "streaming" as const, text: "One" },
- ],
- },
- ],
- onClose: noop,
- onInputChange: noop,
- onStop: noop,
- onSubmit: noop,
- status: "streaming" as const,
- };
- const view = render();
- const transcript = screen.getByTestId("ai-transcript");
- Object.defineProperties(transcript, {
- clientHeight: { configurable: true, value: 400 },
- scrollHeight: { configurable: true, value: 1000 },
- scrollTop: { configurable: true, writable: true, value: 600 },
- });
- fireEvent.scroll(transcript);
- transcript.scrollTop = 400;
- fireEvent.scroll(transcript);
- scrollTo.mockClear();
-
- view.rerender(
- {
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- Saved only for restoration.
+ const originalScrollTo = window.HTMLElement.prototype.scrollTo;
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
+ const scrollTo = vi.fn();
+ window.HTMLElement.prototype.scrollTo = scrollTo;
+ window.requestAnimationFrame = (callback) => {
+ callback(0);
+ return 0;
+ };
+ const props = {
+ input: "",
+ messages: [
{
- ...props.messages[0]!,
- parts: [{ type: "text", state: "streaming", text: "One two" }],
+ id: "assistant-1",
+ role: "assistant" as const,
+ parts: [
+ {
+ type: "text" as const,
+ state: "streaming" as const,
+ text: "One",
+ },
+ ],
},
- ]}
- />,
- );
+ ],
+ onClose: noop,
+ onInputChange: noop,
+ onStop: noop,
+ onSubmit: noop,
+ presentation,
+ status: "streaming" as const,
+ };
+ const view = render();
+ const transcript = screen.getByTestId("ai-transcript");
+ Object.defineProperties(transcript, {
+ clientHeight: { configurable: true, value: 400 },
+ scrollHeight: { configurable: true, value: 1000 },
+ scrollTop: { configurable: true, writable: true, value: 600 },
+ });
+ fireEvent.scroll(transcript);
+ transcript.scrollTop = 400;
+ fireEvent.scroll(transcript);
+ scrollTo.mockClear();
- expect(scrollTo).not.toHaveBeenCalled();
- window.HTMLElement.prototype.scrollTo = originalScrollTo;
- window.requestAnimationFrame = originalRequestAnimationFrame;
- });
+ view.rerender(
+ ,
+ );
+
+ expect(scrollTo.mock.calls.length > 0).toBe(follows);
+ window.HTMLElement.prototype.scrollTo = originalScrollTo;
+ window.requestAnimationFrame = originalRequestAnimationFrame;
+ },
+ );
test("keeps following while its smooth scroll trails content that grew mid-animation", () => {
// eslint-disable-next-line @typescript-eslint/unbound-method -- Saved only for restoration.
@@ -4346,6 +3605,7 @@ describe("AiAssistantContents", () => {
onInputChange: noop,
onStop: noop,
onSubmit: noop,
+ presentation: "brunch" as const,
status: "streaming" as const,
};
const view = render();
@@ -4491,7 +3751,7 @@ describe("AiAssistantContents", () => {
);
expect(container.textContent).toMatch(
- /Thought[\s\S]*I found the current places\./u,
+ /Thinking[\s\S]*I found the current places\./u,
);
});
@@ -4614,88 +3874,6 @@ describe("AiAssistantContents", () => {
});
});
- test("shows known noninteractive tool progress and replaces it with the terminal result", async () => {
- const createMessages = (
- state: "input-streaming" | "input-available" | "output-available",
- ) =>
- [
- {
- id: "assistant-1",
- role: "assistant",
- parts: [
- {
- type: "tool-addPlace",
- state,
- toolCallId: "tool-1",
- input: {
- id: "place__buffer",
- name: "Buffer",
- colorId: null,
- dynamicsEnabled: false,
- differentialEquationId: null,
- x: 0,
- y: 0,
- },
- output:
- state === "output-available"
- ? { applied: true, title: "Added place Buffer" }
- : undefined,
- },
- ],
- },
- ] as PetrinautAiMessage[];
- const props = {
- input: "",
- onClose: noop,
- onInputChange: noop,
- onStop: noop,
- onSubmit: noop,
- status: "streaming" as const,
- };
- const rendered = render(
- ,
- );
-
- expect(screen.getByText("Preparing…")).not.toBeNull();
- await expandWork();
- const pendingRow = screen.getByRole("button", { name: /Preparing/u });
- expect(within(pendingRow).queryByText(/Buffer/u)).toBeNull();
- expect(pendingRow.getAttribute("aria-busy")).toBe("true");
- expect(pendingRow.getAttribute("data-tone")).toBe("success");
- expect(
- pendingRow.querySelector('[data-tool-status="pending"]'),
- ).not.toBeNull();
-
- rendered.rerender(
- ,
- );
-
- expect(screen.queryByText("Preparing…")).toBeNull();
- expect(screen.getByText("Running…")).not.toBeNull();
-
- rendered.rerender(
- ,
- );
-
- expect(screen.queryByText("Running…")).toBeNull();
- const completedRow = screen.getByRole("button", {
- name: /Added place Buffer/u,
- });
- expect(completedRow.hasAttribute("aria-busy")).toBe(false);
- expect(
- completedRow.querySelector('[data-tool-status="ok"]'),
- ).not.toBeNull();
- });
-
test("renders individual tool rows with tones and no operations control", async () => {
const messages: PetrinautAiMessage[] = [
{
@@ -4763,319 +3941,6 @@ describe("AiAssistantContents", () => {
).toBe("true");
});
- test("uses the host presentation resolver at every lifecycle site", async () => {
- const messages = [
- {
- id: "assistant-labels",
- role: "assistant",
- parts: [
- {
- type: "dynamic-tool",
- toolName: "one",
- toolCallId: "one",
- state: "input-streaming",
- },
- {
- type: "dynamic-tool",
- toolName: "two",
- toolCallId: "two",
- state: "input-available",
- input: {},
- },
- {
- type: "dynamic-tool",
- toolName: "three",
- toolCallId: "three",
- state: "output-available",
- output: { title: "Stable result title" },
- },
- {
- type: "dynamic-tool",
- toolName: "four",
- toolCallId: "four",
- state: "output-error",
- errorText: "Host tool failed",
- },
- {
- type: "dynamic-tool",
- toolName: "unknown-tool",
- toolCallId: "unknown",
- state: "output-available",
- output: { title: "Unknown result title" },
- },
- {
- type: "dynamic-tool",
- toolName: "five",
- toolCallId: "not-applied",
- state: "output-available",
- output: { applied: false, reason: "Nothing changed" },
- },
- {
- type: "dynamic-tool",
- toolName: "six",
- toolCallId: "preserved-detail",
- state: "output-available",
- output: {
- title: "Default result title",
- detail: "Viewport frame: framed.",
- },
- },
- ],
- },
- ] as PetrinautAiMessage[];
- render(
- {
- if (toolName === "unknown-tool") return undefined;
- if (toolName === "five") {
- return {
- title: "Correctable five",
- tone: "neutral",
- items: ["Nothing changed"],
- };
- }
- if (toolName === "six") return { title: "Completed six" };
- const verb =
- state === "pending"
- ? toolName === "one"
- ? "Preparing"
- : "Running"
- : state === "success"
- ? "Completed"
- : "Could not complete";
- return {
- title: `${verb} ${toolName}`,
- detail:
- error ??
- (typeof output === "object" &&
- output !== null &&
- "title" in output &&
- typeof output.title === "string"
- ? output.title
- : undefined),
- };
- }}
- />,
- );
-
- await expandWork();
- const preparingOne = screen.getByText("Preparing one").closest("button");
- const runningTwo = screen.getByText("Running two").closest("button");
- expect(preparingOne?.getAttribute("aria-busy")).toBe("true");
- expect(runningTwo?.getAttribute("aria-busy")).toBe("true");
- expect(screen.queryByText(/operations/u)).toBeNull();
- expect(screen.getByText("Completed three")).not.toBeNull();
- expect(screen.getByText("Could not complete four")).not.toBeNull();
- expect(
- within(
- screen.getByText("Completed three").closest("button")!,
- ).getByTestId("tool-detail").textContent,
- ).toBe("Stable result title");
- expect(
- within(
- screen.getByText("Could not complete four").closest("button")!,
- ).getByTestId("tool-detail").textContent,
- ).toBe("Host tool failed");
- expect(screen.getByText("Unknown result title")).not.toBeNull();
- expect(screen.getByText("Correctable five")).not.toBeNull();
- expect(screen.queryByText("Not applied")).toBeNull();
- expect(screen.queryByText("Completed five")).toBeNull();
- expect(
- screen
- .getByRole("button", { name: /Correctable five/u })
- .getAttribute("data-tone"),
- ).toBe("neutral");
- expect(
- screen
- .getByRole("button", { name: /Correctable five/u })
- .querySelector('[data-tool-status="ok"]'),
- ).not.toBeNull();
- fireEvent.click(screen.getByRole("button", { name: /Correctable five/u }));
- expect(screen.getByText("Nothing changed")).not.toBeNull();
- expect(
- within(screen.getByText("Completed six").closest("button")!).getByTestId(
- "tool-detail",
- ).textContent,
- ).toBe("Viewport frame: framed.");
- });
-
- test("renders host pending, applied, refused and thrown tool cues", async () => {
- const resolveToolPresentation = ({
- output,
- state,
- toolName,
- }: {
- output: unknown;
- state: "error" | "pending" | "success";
- toolName: string;
- }) => {
- if (toolName !== "mutate_workpiece") return undefined;
- if (
- typeof output === "object" &&
- output !== null &&
- "disposition" in output &&
- output.disposition === "refused" &&
- "message" in output &&
- typeof output.message === "string"
- ) {
- return {
- title: "Ledger update needs correction",
- tone: "neutral" as const,
- items: [output.message],
- };
- }
- return {
- title:
- state === "pending"
- ? "Updating ledger"
- : state === "success"
- ? "Updated ledger"
- : "Could not update ledger",
- tone:
- state === "pending"
- ? ("pending" as const)
- : state === "error"
- ? ("danger" as const)
- : ("success" as const),
- };
- };
- const renderTools = (messages: PetrinautAiMessage[]) =>
- render(
- ,
- );
-
- const pending = renderTools([
- {
- id: "assistant-pending",
- role: "assistant",
- parts: [
- {
- type: "dynamic-tool",
- toolName: "mutate_workpiece",
- toolCallId: "pending-call",
- state: "input-streaming",
- input: {},
- },
- ],
- },
- ]);
- await expandWork();
- const pendingRow = screen.getByRole("button", { name: /Updating ledger/u });
- expect(pendingRow.getAttribute("data-tone")).toBe("pending");
- expect(
- pendingRow.querySelector('[data-tool-status="pending"]'),
- ).not.toBeNull();
- pending.unmount();
-
- const applied = renderTools([
- {
- id: "assistant-applied",
- role: "assistant",
- parts: [
- {
- type: "dynamic-tool",
- toolName: "mutate_workpiece",
- toolCallId: "applied-call",
- state: "output-available",
- input: {},
- output: {
- disposition: "applied",
- applied: true,
- revisionId: "applied-call",
- sha256: "b".repeat(64),
- ordinal: 1,
- },
- },
- ],
- },
- ]);
- await expandWork();
- const appliedRow = screen.getByRole("button", { name: /Updated ledger/u });
- expect(appliedRow.getAttribute("data-tone")).toBe("success");
- expect(appliedRow.querySelector('[data-tool-status="ok"]')).not.toBeNull();
- applied.unmount();
-
- const refused = renderTools([
- {
- id: "assistant-refused",
- role: "assistant",
- parts: [
- {
- type: "dynamic-tool",
- toolName: "mutate_workpiece",
- toolCallId: "refused-call",
- state: "output-available",
- input: {},
- output: {
- disposition: "refused",
- applied: false,
- correctable: true,
- code: "silent-shrink",
- message:
- "Nothing was written; resubmit the complete settled account.",
- currentRevision: null,
- },
- },
- ],
- },
- ]);
- await expandWork();
- const refusedRow = screen.getByRole("button", {
- name: /Ledger update needs correction/u,
- });
- expect(refusedRow.getAttribute("data-tone")).toBe("neutral");
- 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; resubmit the complete settled account.",
- ),
- ).not.toBeNull();
- refused.unmount();
-
- renderTools([
- {
- id: "assistant-thrown",
- role: "assistant",
- parts: [
- {
- type: "dynamic-tool",
- toolName: "mutate_workpiece",
- toolCallId: "thrown-call",
- state: "output-error",
- input: {},
- errorText: "Current state missing",
- },
- ],
- },
- ]);
- await expandWork();
- const thrownRow = screen.getByRole("button", {
- name: /Could not update ledger/u,
- });
- expect(thrownRow.getAttribute("data-tone")).toBe("danger");
- expect(
- thrownRow.querySelector('[data-tool-status="error"]'),
- ).not.toBeNull();
- });
-
test("hides configured tool rows without removing their message parts", () => {
const hiddenPart = {
type: "dynamic-tool" as const,
@@ -5413,3 +4278,747 @@ describe("AiAssistantContents", () => {
expect(screen.getByText("parameter: old_rate")).not.toBeNull();
});
});
+
+const toolRowPresentations = [
+ {
+ presentation: "stock",
+ revealTools: () => Promise.resolve(),
+ markers: {
+ pending: "[data-tool-progress-spinner]",
+ complete: '[data-tool-result-icon="complete"]',
+ notApplied: '[data-tool-result-icon="not-applied"]',
+ error: "svg",
+ },
+ },
+ {
+ presentation: "brunch",
+ revealTools: expandWork,
+ markers: {
+ pending: '[data-tool-status="pending"]',
+ complete: '[data-tool-status="ok"]',
+ notApplied: '[data-tool-status="ok"]',
+ error: '[data-tool-status="error"]',
+ },
+ },
+] as const;
+
+describe.each(toolRowPresentations)(
+ "AiAssistantContents tool rows in the $presentation presentation",
+ ({ presentation, revealTools, markers }) => {
+ test.each([
+ {
+ label: "blocked",
+ output: {
+ applied: false,
+ blocked: "readonly",
+ reason: "Read-only document.",
+ },
+ },
+ {
+ label: "declined",
+ output: { applied: false, reason: "User declined auto-layout." },
+ },
+ {
+ label: "no-op",
+ output: {
+ applied: false,
+ reason: "The mutation left the document unchanged.",
+ },
+ },
+ {
+ label: "stale host",
+ output: {
+ applied: false,
+ reason:
+ "The requested base does not match the independently observed document.",
+ },
+ },
+ {
+ label: "contradictory supplied summary",
+ output: {
+ applied: false,
+ reason: "Not applied by the host.",
+ title: "Updated arc weight",
+ detail: "Requested value: 4",
+ },
+ },
+ ])(
+ "renders an explicit $label result as not applied, never requested-value success",
+ async ({ output }) => {
+ render(
+ ,
+ );
+ await revealTools();
+ const row = screen.getByRole("button", { name: /Not applied/u });
+ expect(row.getAttribute("data-tone")).toBe("neutral");
+ expect(within(row).getByText(output.reason)).not.toBeNull();
+ expect(
+ within(row).queryByText("Updated arc weight", { exact: true }),
+ ).toBeNull();
+ expect(row.querySelector(markers.notApplied)).not.toBeNull();
+ expect(
+ row.querySelector('[data-tool-result-icon="complete"]'),
+ ).toBeNull();
+ },
+ );
+
+ test("shows known noninteractive tool progress and replaces it with the terminal result", async () => {
+ const createMessages = (
+ state: "input-streaming" | "input-available" | "output-available",
+ ) =>
+ [
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ {
+ type: "tool-addPlace",
+ state,
+ toolCallId: "tool-1",
+ input: {
+ id: "place__buffer",
+ name: "Buffer",
+ colorId: null,
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 0,
+ y: 0,
+ },
+ output:
+ state === "output-available"
+ ? { applied: true, title: "Added place Buffer" }
+ : undefined,
+ },
+ ],
+ },
+ ] as PetrinautAiMessage[];
+ const props = {
+ input: "",
+ onClose: noop,
+ onInputChange: noop,
+ onStop: noop,
+ onSubmit: noop,
+ presentation,
+ status: "streaming" as const,
+ };
+ const rendered = render(
+ ,
+ );
+
+ await revealTools();
+ expect(screen.getByText("Preparing…")).not.toBeNull();
+ const pendingRow = screen.getByRole("button", { name: /Preparing/u });
+ expect(within(pendingRow).queryByText(/Buffer/u)).toBeNull();
+ expect(pendingRow.getAttribute("aria-busy")).toBe("true");
+ expect(pendingRow.getAttribute("data-tone")).toBe("success");
+ expect(pendingRow.querySelector(markers.pending)).not.toBeNull();
+
+ rendered.rerender(
+ ,
+ );
+
+ expect(screen.queryByText("Preparing…")).toBeNull();
+ expect(screen.getByText("Running…")).not.toBeNull();
+
+ rendered.rerender(
+ ,
+ );
+
+ expect(screen.queryByText("Running…")).toBeNull();
+ const completedRow = screen.getByRole("button", {
+ name: /Added place Buffer/u,
+ });
+ expect(completedRow.hasAttribute("aria-busy")).toBe(false);
+ expect(completedRow.querySelector(markers.complete)).not.toBeNull();
+ });
+
+ test("uses the host presentation resolver at every lifecycle site", async () => {
+ const messages = [
+ {
+ id: "assistant-labels",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "one",
+ toolCallId: "one",
+ state: "input-streaming",
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "two",
+ toolCallId: "two",
+ state: "input-available",
+ input: {},
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "three",
+ toolCallId: "three",
+ state: "output-available",
+ output: { title: "Stable result title" },
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "four",
+ toolCallId: "four",
+ state: "output-error",
+ errorText: "Host tool failed",
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "unknown-tool",
+ toolCallId: "unknown",
+ state: "output-available",
+ output: { title: "Unknown result title" },
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "five",
+ toolCallId: "not-applied",
+ state: "output-available",
+ output: { applied: false, reason: "Nothing changed" },
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "six",
+ toolCallId: "preserved-detail",
+ state: "output-available",
+ output: {
+ title: "Default result title",
+ detail: "Viewport frame: framed.",
+ },
+ },
+ ],
+ },
+ ] as PetrinautAiMessage[];
+ render(
+ {
+ if (toolName === "unknown-tool") return undefined;
+ if (toolName === "five") {
+ return {
+ title: "Correctable five",
+ tone: "neutral",
+ items: ["Nothing changed"],
+ };
+ }
+ if (toolName === "six") return { title: "Completed six" };
+ const verb =
+ state === "pending"
+ ? toolName === "one"
+ ? "Preparing"
+ : "Running"
+ : state === "success"
+ ? "Completed"
+ : "Could not complete";
+ return {
+ title: `${verb} ${toolName}`,
+ detail:
+ error ??
+ (typeof output === "object" &&
+ output !== null &&
+ "title" in output &&
+ typeof output.title === "string"
+ ? output.title
+ : undefined),
+ };
+ }}
+ />,
+ );
+
+ await revealTools();
+ const preparingOne = screen.getByText("Preparing one").closest("button");
+ const runningTwo = screen.getByText("Running two").closest("button");
+ expect(preparingOne?.getAttribute("aria-busy")).toBe("true");
+ expect(runningTwo?.getAttribute("aria-busy")).toBe("true");
+ expect(screen.queryByText(/operations/u)).toBeNull();
+ expect(screen.getByText("Completed three")).not.toBeNull();
+ expect(screen.getByText("Could not complete four")).not.toBeNull();
+ expect(
+ within(
+ screen.getByText("Completed three").closest("button")!,
+ ).getByTestId("tool-detail").textContent,
+ ).toBe("Stable result title");
+ expect(
+ within(
+ screen.getByText("Could not complete four").closest("button")!,
+ ).getByTestId("tool-detail").textContent,
+ ).toBe("Host tool failed");
+ expect(screen.getByText("Unknown result title")).not.toBeNull();
+ expect(screen.getByText("Correctable five")).not.toBeNull();
+ expect(screen.queryByText("Not applied")).toBeNull();
+ expect(screen.queryByText("Completed five")).toBeNull();
+ expect(
+ screen
+ .getByRole("button", { name: /Correctable five/u })
+ .getAttribute("data-tone"),
+ ).toBe("neutral");
+ expect(
+ screen
+ .getByRole("button", { name: /Correctable five/u })
+ .querySelector(markers.notApplied),
+ ).not.toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", { name: /Correctable five/u }),
+ );
+ expect(screen.getByText("Nothing changed")).not.toBeNull();
+ expect(
+ within(
+ screen.getByText("Completed six").closest("button")!,
+ ).getByTestId("tool-detail").textContent,
+ ).toBe("Viewport frame: framed.");
+ });
+
+ test("renders host pending, applied, refused and thrown tool cues", async () => {
+ const resolveToolPresentation = ({
+ output,
+ state,
+ toolName,
+ }: {
+ output: unknown;
+ state: "error" | "pending" | "success";
+ toolName: string;
+ }) => {
+ if (toolName !== "mutate_workpiece") return undefined;
+ if (
+ typeof output === "object" &&
+ output !== null &&
+ "disposition" in output &&
+ output.disposition === "refused" &&
+ "message" in output &&
+ typeof output.message === "string"
+ ) {
+ return {
+ title: "Ledger update needs correction",
+ tone: "neutral" as const,
+ items: [output.message],
+ };
+ }
+ return {
+ title:
+ state === "pending"
+ ? "Updating ledger"
+ : state === "success"
+ ? "Updated ledger"
+ : "Could not update ledger",
+ tone:
+ state === "pending"
+ ? ("pending" as const)
+ : state === "error"
+ ? ("danger" as const)
+ : ("success" as const),
+ };
+ };
+ const renderTools = (messages: PetrinautAiMessage[]) =>
+ render(
+ ,
+ );
+
+ const pending = renderTools([
+ {
+ id: "assistant-pending",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "mutate_workpiece",
+ toolCallId: "pending-call",
+ state: "input-streaming",
+ input: {},
+ },
+ ],
+ },
+ ]);
+ await revealTools();
+ const pendingRow = screen.getByRole("button", {
+ name: /Updating ledger/u,
+ });
+ expect(pendingRow.getAttribute("data-tone")).toBe("pending");
+ expect(pendingRow.querySelector(markers.pending)).not.toBeNull();
+ pending.unmount();
+
+ const applied = renderTools([
+ {
+ id: "assistant-applied",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "mutate_workpiece",
+ toolCallId: "applied-call",
+ state: "output-available",
+ input: {},
+ output: {
+ disposition: "applied",
+ applied: true,
+ revisionId: "applied-call",
+ sha256: "b".repeat(64),
+ ordinal: 1,
+ },
+ },
+ ],
+ },
+ ]);
+ await revealTools();
+ const appliedRow = screen.getByRole("button", {
+ name: /Updated ledger/u,
+ });
+ expect(appliedRow.getAttribute("data-tone")).toBe("success");
+ expect(appliedRow.querySelector(markers.complete)).not.toBeNull();
+ applied.unmount();
+
+ const refused = renderTools([
+ {
+ id: "assistant-refused",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "mutate_workpiece",
+ toolCallId: "refused-call",
+ state: "output-available",
+ input: {},
+ output: {
+ disposition: "refused",
+ applied: false,
+ correctable: true,
+ code: "silent-shrink",
+ message:
+ "Nothing was written; resubmit the complete settled account.",
+ currentRevision: null,
+ },
+ },
+ ],
+ },
+ ]);
+ await revealTools();
+ const refusedRow = screen.getByRole("button", {
+ name: /Ledger update needs correction/u,
+ });
+ expect(refusedRow.getAttribute("data-tone")).toBe("neutral");
+ expect(refusedRow.querySelector(markers.notApplied)).not.toBeNull();
+ expect(within(refusedRow).queryByTestId("tool-detail")).toBeNull();
+ fireEvent.click(refusedRow);
+ expect(
+ screen.getByText(
+ "Nothing was written; resubmit the complete settled account.",
+ ),
+ ).not.toBeNull();
+ refused.unmount();
+
+ renderTools([
+ {
+ id: "assistant-thrown",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "mutate_workpiece",
+ toolCallId: "thrown-call",
+ state: "output-error",
+ input: {},
+ errorText: "Current state missing",
+ },
+ ],
+ },
+ ]);
+ await revealTools();
+ const thrownRow = screen.getByRole("button", {
+ name: /Could not update ledger/u,
+ });
+ expect(thrownRow.getAttribute("data-tone")).toBe("danger");
+ expect(thrownRow.querySelector(markers.error)).not.toBeNull();
+ });
+ },
+);
+
+describe("AiAssistantContents in the stock presentation", () => {
+ test.each([
+ { status: "streaming" as const, live: true },
+ { status: "error" as const, live: false },
+ ])(
+ "keeps reasoning live only while its reply streams ($status)",
+ ({ status, live }) => {
+ render(
+ ,
+ );
+
+ expect(
+ screen
+ .getByRole("button", { name: /Thinking/u })
+ .getAttribute("aria-expanded"),
+ ).toBe(String(live));
+ },
+ );
+
+ test("renders streamed markdown and collapsed reasoning", () => {
+ const startedAt = Date.parse("2026-05-14T12:00:00Z");
+ const finishedAt = startedAt + 4_500;
+ const messages: PetrinautAiMessage[] = [
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ {
+ type: "reasoning",
+ state: "done",
+ text: "**Planning the net**\n\nUnderstanding the requested model.",
+ providerMetadata: {
+ petrinaut: { startedAt, finishedAt },
+ },
+ },
+ {
+ type: "text",
+ state: "done",
+ text: "**Created** a supply chain model.",
+ },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Created")).not.toBeNull();
+ expect(
+ screen
+ .getByRole("button", { name: /Thinking: Planning the net/u })
+ .getAttribute("aria-expanded"),
+ ).toBe("false");
+ expect(screen.getByText("Thinking: Planning the net")).not.toBeNull();
+ expect(screen.queryByTestId("reasoning-status")).toBeNull();
+ expect(screen.getByLabelText(/Reasoning time/u)).not.toBeNull();
+ });
+
+ test("selects a target from a completed tool summary without a single-item chevron", () => {
+ const onSelectToolTarget = vi.fn();
+ const messages: PetrinautAiMessage[] = [
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ {
+ type: "tool-addPlace",
+ state: "output-available",
+ toolCallId: "tool-1",
+ input: {
+ id: "place__buffer",
+ name: "Buffer",
+ colorId: null,
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 0,
+ y: 0,
+ },
+ output: {
+ applied: true,
+ title: "Added place Buffer",
+ detail: "Previous name: Queue",
+ target: {
+ kind: "selection",
+ item: { type: "place", id: "place__buffer" },
+ },
+ },
+ },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const toolButton = screen.getByRole("button", {
+ name: /Added place Buffer/u,
+ });
+
+ fireEvent.click(toolButton);
+
+ expect(screen.queryByTestId("tool-item-chevron")).toBeNull();
+ expect(toolButton.getAttribute("data-tone")).toBe("success");
+ expect(screen.getByTestId("tool-detail").textContent).toBe(
+ "Previous name: Queue",
+ );
+ expect(onSelectToolTarget).toHaveBeenCalledWith({
+ kind: "selection",
+ item: { type: "place", id: "place__buffer" },
+ });
+ });
+
+ test("prioritizes Stop and retains disabled Send without Voice mode", () => {
+ const onStop = vi.fn();
+ const props = {
+ input: "Draft",
+ messages: [] as PetrinautAiMessage[],
+ onClose: noop,
+ onInputChange: noop,
+ onStop,
+ onSubmit: vi.fn(),
+ status: "streaming" as const,
+ voiceModeAvailable: true,
+ };
+ const rendered = render();
+
+ expect(
+ screen.queryByRole("button", { name: "Start voice mode" }),
+ ).toBeNull();
+ expect(screen.queryByRole("button", { name: "Send message" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Stop AI response" }));
+ expect(onStop).toHaveBeenCalledOnce();
+
+ rendered.rerender(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", {
+ name: "Send message",
+ }).disabled,
+ ).toBe(true);
+ });
+
+ test("switches the trailing action from Voice mode to Send for trimmed input", () => {
+ const onInputModeChange = vi.fn();
+ const onSubmit = vi.fn();
+ const props = {
+ messages: [] as PetrinautAiMessage[],
+ onClose: noop,
+ onInputChange: noop,
+ onInputModeChange,
+ onStop: noop,
+ onSubmit,
+ status: "ready" as const,
+ voiceModeAvailable: true,
+ };
+ const rendered = render();
+
+ const voiceButton = screen.getByRole("button", {
+ name: "Start voice mode",
+ });
+ expect(voiceButton.querySelector("svg")).not.toBeNull();
+ expect(voiceButton.parentElement?.getAttribute("data-scope")).toBe(
+ "tooltip",
+ );
+ fireEvent.click(voiceButton);
+
+ expect(onInputModeChange).toHaveBeenCalledOnce();
+ expect(onInputModeChange).toHaveBeenCalledWith("voice");
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ rendered.rerender();
+ expect(
+ screen.getByRole("button", { name: "Start voice mode" }),
+ ).not.toBeNull();
+
+ rendered.rerender(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Start voice mode" }),
+ ).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ expect(onSubmit).toHaveBeenCalledOnce();
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx
index 35b0ca4c872..9c3e3ef427d 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx
@@ -1,7 +1,5 @@
import {
- memo,
type ReactNode,
- type RefObject,
use,
useEffect,
useEffectEvent,
@@ -10,15 +8,11 @@ import {
useRef,
useState,
} from "react";
-import ReactMarkdown from "react-markdown";
import { Button, Icon, LoadingSpinner } from "@hashintel/ds-components";
import { css, cva } from "@hashintel/ds-helpers/css";
-import {
- NotificationsContext,
- type AddNotificationInput,
-} from "../../../../../react/notifications/context";
+import { NotificationsContext } from "../../../../../react/notifications/context";
import { EditorContext } from "../../../../../react/state/editor-context";
import {
useVoiceSessionErrorMessage,
@@ -35,34 +29,26 @@ import { ResizeHandle } from "../../../../resize/resize-handle";
import { AiVoiceModeIcon } from "../../components/ai-voice-mode-button";
import { FloatingResizeHandles } from "../../shared/floating-resize-handles";
import { useFloatingPanel } from "../../shared/use-floating-panel";
-import { BrunchWorkFold } from "./ai-assistant-contents/brunch-work-fold";
-import {
- ExperimentCard,
- type AiExperimentState,
-} from "./ai-assistant-contents/experiment-card";
+import { BrunchResponseStatus } from "./ai-assistant-contents/brunch-response-status";
+import { BrunchTranscript } from "./ai-assistant-contents/brunch-transcript";
+import { ChatTabMark } from "./ai-assistant-contents/chat-tab-mark";
+import { AiAssistantComposer } from "./ai-assistant-contents/composer";
import { aiFooterMinHeight } from "./ai-assistant-contents/footer-height";
-import { getMessageRenderItems } from "./ai-assistant-contents/get-message-render-items";
import {
PromptChips,
type PromptChip,
} from "./ai-assistant-contents/prompt-chips";
-import { AiAssistantReasoning } from "./ai-assistant-contents/reasoning";
-import { markdownStyle } from "./ai-assistant-contents/shared/markdown-style";
-import {
- AiAssistantToolList,
- type OnInteractiveToolSubmit,
-} from "./ai-assistant-contents/tool-list";
+import { errorNotification } from "./ai-assistant-contents/shared/error-notification";
+import { StockTranscript } from "./ai-assistant-contents/stock-transcript";
import { VoiceAlerts } from "./ai-assistant-contents/voice-alerts";
import { LiveVoiceDock, VoiceDock } from "./ai-assistant-contents/voice-dock";
-import { VoiceInputProvenance } from "./ai-assistant-contents/voice-input-provenance";
import { getInteractiveTool } from "./interactive-tools/registry";
-import type {
- PetrinautAiAssistant,
- PetrinautAiToolPresentationResolver,
-} from "../../../../petrinaut";
+import type { PetrinautAiAssistant } from "../../../../petrinaut";
import type { PetrinautAiInputMode } from "../../../../types/ai-assistant-composer-control";
import type { PetrinautAiInteractiveTool } from "../../../../types/ai-interactive-tool";
+import type { AiExperimentState } from "./ai-assistant-contents/experiment-card";
+import type { OnInteractiveToolSubmit } from "./ai-assistant-contents/tool-list";
import type { AiToolTarget } from "./tool-summaries";
import type { PetrinautAiMessage } from "./types";
@@ -70,11 +56,6 @@ type AiAssistantStatus = "submitted" | "streaming" | "ready" | "error";
const EMPTY_INTERACTIVE_TOOLS: readonly PetrinautAiInteractiveTool[] = [];
-const errorNotification = (
- message: string,
- detail?: string,
-): AddNotificationInput => ({ detail, message, tone: "error" });
-
export type AiAssistantContentsProps = {
additionalTab?: PetrinautAiAssistant["additionalTab"];
attentionAnnouncement?: string;
@@ -325,16 +306,23 @@ const headerButtonStyle = css({
},
});
-const messagesStyle = css({
- display: "flex",
- flexDirection: "column",
- gap: "3",
- flex: "[1]",
- minHeight: "[0]",
- overflowY: "auto",
- padding: "3",
- paddingBottom: "4",
- overscrollBehavior: "contain",
+const messagesStyle = cva({
+ base: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "3",
+ flex: "[1]",
+ minHeight: "[0]",
+ overflowY: "auto",
+ padding: "3",
+ overscrollBehavior: "contain",
+ },
+ variants: {
+ presentation: {
+ stock: {},
+ brunch: { paddingBottom: "4" },
+ },
+ },
});
const emptyStyle = css({
@@ -353,93 +341,6 @@ const emptyStyle = css({
padding: "[20px]",
});
-const messageStyle = cva({
- base: {
- display: "flex",
- flexDirection: "column",
- gap: "2",
- padding: "[10px]",
- fontSize: "sm",
- fontWeight: "medium",
- lineHeight: "[1.5]",
- color: "neutral.s100",
- userSelect: "text",
- },
- variants: {
- role: {
- assistant: {
- alignSelf: "stretch",
- gap: "1",
- padding: "[6px 0]",
- '&[data-input-mode="text"]': {
- paddingY: "1",
- '& > [data-answer="brunch"]:not(:first-child)': { marginTop: "1" },
- "@media (hover: hover) and (pointer: fine)": {
- "&:not([data-latest-answer]):not(:hover):not(:focus-within) > [data-answer-actions]":
- {
- opacity: "0",
- pointerEvents: "none",
- },
- },
- },
- },
- user: {
- alignSelf: "flex-end",
- maxWidth: "[92%]",
- gap: "0.5",
- padding: "[0]",
- },
- },
- },
-});
-
-// User input isn't Markdown — rendering it as such would mangle stray
-// `*`, `_`, `#`, etc. and collapse the single newlines they typed. Render it
-// verbatim with preserved whitespace instead.
-const userTextStyle = css({
- display: "flex",
- alignItems: "flex-start",
- gap: "1.5",
- alignSelf: "flex-end",
- backgroundColor: "neutral.a20",
- borderRadius: "lg",
- padding: "[10px]",
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
-});
-
-const answerStyle = cva({
- base: {
- overflowWrap: "anywhere",
- },
- variants: {
- presentation: {
- stock: {},
- brunch: {
- alignSelf: "flex-start",
- maxWidth: "[92%]",
- backgroundColor: "blue.a20",
- borderRadius: "lg",
- padding: "[10px]",
- color: "neutral.s100",
- '&[data-streaming="true"]': {
- animation: "[petrinautComposerActionSwap 180ms ease-out]",
- },
- "@media (prefers-reduced-motion: reduce)": {
- animation: "[none]",
- },
- "[data-work-status] &": {
- alignSelf: "stretch",
- maxWidth: "full",
- backgroundColor: "neutral.s00",
- border: "[1px solid {colors.neutral.a30}]",
- },
- },
- },
- },
- defaultVariants: { presentation: "stock" },
-});
-
const workingStatusStyle = css({
display: "flex",
alignItems: "center",
@@ -449,16 +350,6 @@ const workingStatusStyle = css({
color: "neutral.s80",
fontSize: "sm",
fontWeight: "medium",
- animation: "[petrinautComposerActionSwap 180ms ease-out]",
- "@media (prefers-reduced-motion: reduce)": { animation: "[none]" },
-});
-
-const stoppedNoteStyle = css({
- alignSelf: "center",
- marginTop: "1.5",
- paddingY: "1",
- fontSize: "xs",
- color: "neutral.s80",
});
const composerWrapStyle = css({
@@ -480,116 +371,6 @@ const composerWrapStyle = css({
},
});
-const composerActionGlyphStyle = css({
- display: "inline-flex",
- animationName: "[petrinautComposerActionSwap]",
- animationDuration: "[140ms]",
- animationTimingFunction: "[cubic-bezier(0.2, 0.9, 0.3, 1)]",
- "@media (prefers-reduced-motion: reduce)": {
- animationName: "[none]",
- },
-});
-
-const composerActionButtonStyle = css({
- flexShrink: 0,
- width: "[30px]",
- height: "[30px]",
- minWidth: "[30px]",
- borderRadius: "[calc({radii.lg} - {spacing.1})]",
- "&[data-stop=true]": {
- width: "[28px]",
- height: "[28px]",
- minWidth: "[28px]",
- borderRadius: "full",
- },
-});
-
-const composerStyle = css({
- display: "flex",
- alignItems: "flex-end",
- gap: "1",
- borderRadius: "lg",
- backgroundColor: "neutral.s10",
- border: "[1px solid {colors.neutral.bd.subtle}]",
- padding: "1",
- transition: "[border-color 150ms ease, box-shadow 150ms ease]",
- _focusWithin: {
- borderColor: "blue.s50",
- boxShadow: "[0 0 0 2px {colors.blue.a10}]",
- },
- "@media (prefers-reduced-motion: reduce)": {
- transition: "[none]",
- },
-});
-
-// Caps how tall the composer can auto-grow before it starts scrolling
-// internally. Kept in sync with `maxHeight` below — the auto-grow effect
-// reads this constant directly so the two can't drift.
-const composerMaxHeight = 160;
-
-const composerTextareaStyle = css({
- flex: "[1]",
- minWidth: "[0]",
- minHeight: "[30px]",
- maxHeight: `[${composerMaxHeight}px]`,
- paddingX: "2",
- paddingY: "[5px]",
- border: "none",
- outline: "none",
- resize: "none",
- overflowY: "auto",
- backgroundColor: "[transparent]",
- color: "neutral.fg.body",
- fontFamily: "[inherit]",
- fontSize: "sm",
- fontWeight: "medium",
- lineHeight: "[1.4]",
- // Animates the height changes driven by the auto-grow effect, so adding a
- // line (Shift+Enter) or wrapping expands the box smoothly.
- transition: "[height 120ms ease]",
- "@media (prefers-reduced-motion: reduce)": {
- transition: "[none]",
- },
- _placeholder: {
- color: "neutral.s70",
- },
- _disabled: {
- cursor: "not-allowed",
- color: "neutral.s90",
- },
-});
-
-const composerHintStyle = css({
- flex: "1",
- paddingX: "2",
- paddingBottom: "1",
- color: "neutral.s80",
- fontSize: "xs",
- textAlign: "left",
-});
-
-const StreamingWords = ({
- text,
- streaming,
-}: {
- text: string;
- streaming: boolean;
-}) =>
- streaming
- ? [...text.matchAll(/\S+\s*|\s+/gu)].map((word) => (
-
- {word[0]}
-
- ))
- : text;
-
const getPartScrollSignature = (
part: PetrinautAiMessage["parts"][number],
): string => {
@@ -629,342 +410,6 @@ const getMessagesScrollKey = (messages: PetrinautAiMessage[]): string => {
return `${messages.length}:${last.id}:${last.parts.length}:${partSignature}:${dataSignature}`;
};
-type MessageHandlersRef = RefObject<{
- onInteractiveToolSubmit?: OnInteractiveToolSubmit;
- onSelectToolTarget?: (target: AiToolTarget) => void;
- onRetryMessage: (messageId: string) => void;
-}>;
-
-/**
- * Per-message renderer wrapped in `React.memo`.
- *
- * The AI SDK rebuilds the `messages` array on every reasoning/text delta but
- * uses `slice` for unchanged messages and only `structuredClone`s the active
- * one. That gives every completed message a stable reference between chunks,
- * so memoising by reference equality lets us skip re-rendering the whole transcript on
- * every chunk — only the message currently being streamed has to re-render.
- *
- * Callbacks are forwarded via a ref so identity churn from the panel's inline
- * arrow functions doesn't bust the memo.
- */
-const AiAssistantMessage = memo(
- ({
- handlersRef,
- hiddenToolNames,
- interactiveTools,
- message,
- experimentStates,
- onCancelExperiment,
- resolveToolPresentation,
- presentation,
- voice,
- expandReasoning,
- active,
- stopped,
- canRetry,
- latestAnswer,
- }: {
- handlersRef: MessageHandlersRef;
- hiddenToolNames?: ReadonlySet;
- interactiveTools: readonly PetrinautAiInteractiveTool[];
- message: PetrinautAiMessage;
- experimentStates?: Record;
- onCancelExperiment?: (toolCallId: string) => void;
- resolveToolPresentation?: PetrinautAiToolPresentationResolver;
- presentation: NonNullable;
- voice: boolean;
- expandReasoning: boolean;
- active: boolean;
- stopped: boolean;
- canRetry: boolean;
- latestAnswer: boolean;
- }) => {
- const { addNotification } = use(NotificationsContext);
- const [copied, setCopied] = useState(false);
- useEffect(() => {
- if (!copied) return;
- const timer = window.setTimeout(() => setCopied(false), 1_200);
- return () => window.clearTimeout(timer);
- }, [copied]);
- const role = message.role === "user" ? "user" : "assistant";
- const renderItems = getMessageRenderItems(
- message,
- interactiveTools,
- resolveToolPresentation,
- hiddenToolNames,
- );
- const { work, answers, cards, brief, voiceAgentReply, voiceAgentWrapUp } =
- renderItems;
- const wasStopped = stopped || message.metadata?.stopped === true;
- const awaitingApproval = work.tools.some(
- (tool) => tool.interactive && tool.state === "input-available",
- );
- const working =
- active &&
- (expandReasoning ||
- answers.length === 0 ||
- work.reasoning.some((item) => item.part.state === "streaming") ||
- work.tools.some(
- (tool) =>
- tool.state === "input-streaming" ||
- tool.state === "input-available",
- ));
- const workStatus = wasStopped
- ? "stopped"
- : awaitingApproval
- ? "approval"
- : working
- ? "streaming"
- : "settled";
- const writtenAnswer =
- answers.length > 0 ? (
-
- {answers.map((item) => (
-
- {item.part.text}
-
- ))}
-
- ) : null;
- // Voice renders the written answer inside the fold; Chat renders it below.
- const showWork =
- role === "assistant" &&
- (active ||
- wasStopped ||
- work.reasoning.length > 0 ||
- work.tools.length > 0 ||
- (voice && answers.length > 0));
-
- return (
-
- {role === "user" && (
-
- {!voice && message.metadata?.source === "voice" && (
-
-
-
- )}
-
- {answers.map((item) => (
-
-
-
- ))}
-
-
- )}
- {brief &&
}
- {voiceAgentReply && (
-
-
-
- )}
- {showWork && presentation === "brunch" && (
-
- {work.reasoning.map((item) => (
-
- ))}
-
- handlersRef.current.onInteractiveToolSubmit?.(params)
- }
- onSelectToolTarget={(target) =>
- handlersRef.current.onSelectToolTarget?.(target)
- }
- />
- {voice && writtenAnswer}
- {voice && working && !writtenAnswer && (
-
- {["92%", "74%", "46%"].map((width) => (
-
- ))}
-
- )}
-
- )}
- {showWork && presentation === "stock" && (
- <>
- {work.reasoning.map((item) => (
-
- ))}
-
- handlersRef.current.onInteractiveToolSubmit?.(params)
- }
- onSelectToolTarget={(target) =>
- handlersRef.current.onSelectToolTarget?.(target)
- }
- />
- >
- )}
- {role === "assistant" &&
- (!voice || presentation === "stock") &&
- writtenAnswer}
- {cards.map((item) =>
- item.type === "experiment" ? (
-
- ) : (
-
- handlersRef.current.onInteractiveToolSubmit?.(params)
- }
- onSelectToolTarget={(target) =>
- handlersRef.current.onSelectToolTarget?.(target)
- }
- />
- ),
- )}
- {voiceAgentWrapUp && (
-
-
-
- )}
- {role === "assistant" && wasStopped && (
- Response stopped
- )}
- {presentation === "brunch" &&
- role === "assistant" &&
- !voice &&
- writtenAnswer &&
- !active && (
-
-
- }
- onClick={() => {
- void (async () => {
- try {
- await navigator.clipboard.writeText(
- answers.map((answer) => answer.part.text).join("\n\n"),
- );
- setCopied(true);
- } catch {
- addNotification(
- errorNotification("Could not copy the answer"),
- );
- }
- })();
- }}
- />
- {canRetry && (
- }
- onClick={() => handlersRef.current.onRetryMessage(message.id)}
- />
- )}
-
- )}
-
- );
- },
-);
-AiAssistantMessage.displayName = "AiAssistantMessage";
-
export const getTranscriptLabel = (
primaryLabel: string | undefined,
inputMode: PetrinautAiInputMode,
@@ -1030,22 +475,12 @@ export const AiAssistantContents = ({
const voiceSessionWarningMessage = useVoiceSessionWarningMessage();
const isVoiceSessionLive = voiceSessionPhase !== null;
const isBusy = status === "submitted" || status === "streaming";
- const hasInput = input.trim().length > 0;
- const canSubmit = hasInput && !isBusy && !voiceHandoffPending;
const isBrunchChat = presentation === "brunch";
const transcriptLabel = getTranscriptLabel(
primaryLabel,
inputMode,
presentation,
);
- const pendingLabel =
- isBrunchChat && isBusy
- ? showingHostTab
- ? (workingLabel ?? "Brunch is working")
- : messages.at(-1)?.role !== "assistant"
- ? "Waiting for Brunch"
- : undefined
- : undefined;
const awaitingDecision =
!stopped &&
messages.some((message) =>
@@ -1069,57 +504,6 @@ export const AiAssistantContents = ({
: undefined
: undefined;
- const composerAction: {
- disabled: boolean;
- glyph: "arrowUp" | "stopFilled" | "voice";
- isSubmit: boolean;
- label: string;
- onClick?: () => void;
- tone: "brand" | "neutral";
- type: "button" | "submit";
- variant: "solid" | "subtle";
- } = isBusy
- ? {
- disabled: false,
- glyph: "stopFilled",
- isSubmit: false,
- label: "Stop AI response",
- onClick: onStop,
- tone: isBrunchChat ? "brand" : "neutral",
- type: "button",
- variant: isBrunchChat ? "solid" : "subtle",
- }
- : canSubmit
- ? {
- disabled: false,
- glyph: "arrowUp",
- isSubmit: true,
- label: "Send message",
- tone: "brand",
- type: "submit",
- variant: "solid",
- }
- : !hasInput && voiceModeAvailable && onInputModeChange
- ? {
- disabled: false,
- glyph: "voice",
- isSubmit: false,
- label: "Start voice mode",
- onClick: () => onInputModeChange("voice"),
- tone: "brand",
- type: "button",
- variant: "solid",
- }
- : {
- disabled: true,
- glyph: "arrowUp",
- isSubmit: false,
- label: "Send message",
- tone: "brand",
- type: "submit",
- variant: "solid",
- };
-
const isVoiceDockCollapsed =
voiceDockCollapsed && (isVoiceSessionLive || inputMode === "voice");
@@ -1243,6 +627,8 @@ export const AiAssistantContents = ({
}
distanceFromEndRef.current =
node.scrollHeight - node.scrollTop - node.clientHeight;
+ // The stock transcript always follows new output.
+ if (!isBrunchChat) return;
// The smooth follow scroll only moves down, and can trail an end that
// grows mid-animation; only the reader moving up stops following.
if (distanceFromEndRef.current <= 96) {
@@ -1270,22 +656,6 @@ export const AiAssistantContents = ({
const suppressChips =
isBrunchChat && (isBusy || experimentRunning || awaitingDecision);
- // Stable container for the per-render callbacks so `AiAssistantMessage`'s
- // memo comparator doesn't see identity churn from the panel's inline
- // arrow functions on every render. The ref itself is stable across renders,
- // so memoised children never re-render due to handler changes — but we
- // refresh `.current` in an effect so any new closure capture is picked up
- // by the next event.
- const firstUserIndex = messages.findIndex(
- (message) => message.role === "user",
- );
- const latestAnswerId = messages.findLast(
- (message) =>
- message.role === "assistant" &&
- message.parts.some(
- (part) => part.type === "text" && part.text.trim().length > 0,
- ),
- )?.id;
const onRetryMessage = (messageId: string) => {
if (isBusy || voiceHandoffPending) return;
const index = messages.findIndex((message) => message.id === messageId);
@@ -1297,6 +667,12 @@ export const AiAssistantContents = ({
.join("\n\n");
if (prompt) onRetryPrompt?.(prompt);
};
+ // Stable container for the per-render callbacks so the memoised transcript
+ // messages don't see identity churn from the panel's inline arrow functions
+ // on every render. The ref itself is stable across renders, so memoised
+ // children never re-render due to handler changes — but we refresh
+ // `.current` in an effect so any new closure capture is picked up by the
+ // next event.
const handlersRef = useRef({
onInteractiveToolSubmit,
onSelectToolTarget,
@@ -1309,6 +685,15 @@ export const AiAssistantContents = ({
onRetryMessage,
};
});
+ const transcriptProps = {
+ experimentStates,
+ handlersRef,
+ hiddenToolNames,
+ interactiveTools,
+ messages,
+ onCancelExperiment,
+ resolveToolPresentation,
+ };
useEffect(() => {
if (isOpen) {
@@ -1321,26 +706,6 @@ export const AiAssistantContents = ({
}
}, [composerFocusRequest, isOpen, isVoiceDockCollapsed, panelRef]);
- // Auto-grow the composer to fit its content (up to `composerMaxHeight`,
- // after which it scrolls internally). Resetting to `auto` before measuring
- // `scrollHeight` lets the box shrink again when text is removed; both writes
- // happen synchronously so the browser only paints the final height and the
- // CSS `height` transition animates the change. `scrollHeight` is a rounded
- // integer, so the height it yields can land a fraction of a pixel under the
- // real content and raise a scrollbar on a box that visibly fits; scrolling
- // is therefore only allowed once the content genuinely passes the cap.
- useEffect(() => {
- const textarea = inputRef.current;
- if (!textarea) {
- return;
- }
- textarea.style.height = "auto";
- const contentHeight = textarea.scrollHeight;
- textarea.style.height = `${Math.min(contentHeight, composerMaxHeight)}px`;
- textarea.style.overflowY =
- contentHeight > composerMaxHeight ? "auto" : "hidden";
- }, [input, isOpen]);
-
const hasScrolledOnceRef = useRef(false);
useEffect(() => {
@@ -1447,31 +812,21 @@ export const AiAssistantContents = ({
{
id: aiTabId,
title: transcriptLabel,
- mark:
+ mark: isBrunchChat ? (
inputMode === "voice" ? (
) : (
-
- ),
+
+ )
+ ) : undefined,
attention: { marker: primaryAttention },
},
{
id: hostTabId,
title: additionalTab.label,
- mark: ,
+ mark: isBrunchChat ? (
+
+ ) : undefined,
attention: { count: hostAttentionCount },
},
]}
@@ -1537,7 +892,7 @@ export const AiAssistantContents = ({
role={additionalTab ? "tabpanel" : undefined}
aria-labelledby={additionalTab ? `tab-${aiTabId}` : undefined}
hidden={showingHostTab}
- className={`${messagesStyle} ${panelContentStyle({
+ className={`${messagesStyle({ presentation })} ${panelContentStyle({
visible: !isVoiceDockCollapsed && !showingHostTab,
})}`}
data-testid="ai-transcript"
@@ -1553,37 +908,22 @@ export const AiAssistantContents = ({
)}
- {messages.map((message, index) => (
- = 0 &&
- index > firstUserIndex
+ onRetryPrompt !== undefined && !isBusy && !voiceHandoffPending
}
- active={
- isBusy &&
- index === messages.length - 1 &&
- message.role === "assistant"
- }
- stopped={stopped && index === messages.length - 1}
+ voice={inputMode === "voice"}
+ />
+ ) : (
+
- ))}
- {stopped && messages.at(-1)?.role === "user" && (
- Response stopped
)}
@@ -1593,49 +933,22 @@ export const AiAssistantContents = ({
role="tabpanel"
aria-labelledby={`tab-${hostTabId}`}
hidden={!showingHostTab}
- className={`${messagesStyle} ${panelContentStyle({
- visible: !isVoiceDockCollapsed && showingHostTab,
- })}`}
+ className={`${messagesStyle({ presentation })} ${panelContentStyle(
+ {
+ visible: !isVoiceDockCollapsed && showingHostTab,
+ },
+ )}`}
>
{additionalTab.content}
)}
- {isBrunchChat && (
-
- {pendingLabel && (
-
- )}
-
- {pendingLabel}
-
-
+ {isBrunchChat && showingHostTab && (
+
)}
{!isBrunchChat && isBusy && workingLabel && (
@@ -1727,112 +1040,28 @@ export const AiAssistantContents = ({
setChipsDismissed(true)}
onSelect={(prompt) => onSendPrompt(prompt)}
+ presentation={presentation}
/>
)}
-
+ 0}
+ hint={composerHint}
+ input={input}
+ inputRef={inputRef}
+ isOpen={isOpen}
+ onInputChange={onInputChange}
+ onInputModeChange={onInputModeChange}
+ onStop={onStop}
+ onSubmit={onSubmit}
+ presentation={presentation}
+ voiceModeAvailable={voiceModeAvailable}
+ />
>
)}
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
new file mode 100644
index 00000000000..761009fb9c9
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-response-status.tsx
@@ -0,0 +1,55 @@
+import { LoadingSpinner } from "@hashintel/ds-components";
+import { css, cx } from "@hashintel/ds-helpers/css";
+
+/**
+ * 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,
+ workingLabel,
+}: {
+ busy: boolean;
+ className?: string;
+ workingLabel?: string;
+}) => {
+ const label = busy ? (workingLabel ?? "Working…") : undefined;
+
+ return (
+
+ {label && (
+
+ )}
+
+ {label}
+
+
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript.tsx
new file mode 100644
index 00000000000..cc4aef52f90
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript.tsx
@@ -0,0 +1,467 @@
+import { memo, use, useEffect, useState } from "react";
+import ReactMarkdown from "react-markdown";
+
+import { Button } from "@hashintel/ds-components";
+import { css, cva } from "@hashintel/ds-helpers/css";
+
+import { NotificationsContext } from "../../../../../../react/notifications/context";
+import { ExperimentalIcon } from "../../../../../experimental-icons";
+import {
+ BrunchWorkFold,
+ BrunchWorkPending,
+} from "./brunch-transcript/brunch-work-fold";
+import { VoiceInputProvenance } from "./brunch-transcript/voice-input-provenance";
+import { ExperimentCard } from "./experiment-card";
+import { getMessageRenderItems } from "./get-message-render-items";
+import { AiAssistantReasoning } from "./reasoning";
+import { errorNotification } from "./shared/error-notification";
+import { markdownStyle } from "./shared/markdown-style";
+import { SentUsingVoiceMark } from "./shared/sent-using-voice-mark";
+import { AiAssistantToolList } from "./tool-list";
+
+import type { PetrinautAiMessage } from "../types";
+import type { TranscriptProps } from "./shared/transcript-props";
+
+const messageStyle = cva({
+ base: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "2",
+ padding: "[10px]",
+ fontSize: "sm",
+ fontWeight: "medium",
+ lineHeight: "[1.5]",
+ color: "neutral.s100",
+ userSelect: "text",
+ },
+ variants: {
+ role: {
+ assistant: {
+ alignSelf: "stretch",
+ gap: "1",
+ padding: "[6px 0]",
+ '&[data-input-mode="text"]': {
+ paddingY: "1",
+ '& > [data-answer="brunch"]:not(:first-child)': { marginTop: "1" },
+ "@media (hover: hover) and (pointer: fine)": {
+ "&:not([data-latest-answer]):not(:hover):not(:focus-within) > [data-answer-actions]":
+ {
+ opacity: "0",
+ pointerEvents: "none",
+ },
+ },
+ },
+ },
+ user: {
+ alignSelf: "flex-end",
+ maxWidth: "[92%]",
+ gap: "0.5",
+ padding: "[0]",
+ },
+ },
+ },
+});
+
+// User input isn't Markdown — rendering it as such would mangle stray
+// `*`, `_`, `#`, etc. and collapse the single newlines they typed. Render it
+// verbatim with preserved whitespace instead.
+const userTextStyle = css({
+ display: "flex",
+ alignItems: "flex-start",
+ gap: "1.5",
+ alignSelf: "flex-end",
+ backgroundColor: "neutral.a20",
+ borderRadius: "lg",
+ padding: "[10px]",
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+});
+
+const answerStyle = css({
+ alignSelf: "flex-start",
+ maxWidth: "[92%]",
+ backgroundColor: "blue.a20",
+ borderRadius: "lg",
+ padding: "[10px]",
+ color: "neutral.s100",
+ overflowWrap: "anywhere",
+ '&[data-streaming="true"]': {
+ animation: "[petrinautComposerActionSwap 180ms ease-out]",
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ animation: "[none]",
+ },
+ "[data-work-status] &": {
+ alignSelf: "stretch",
+ maxWidth: "full",
+ backgroundColor: "neutral.s00",
+ border: "[1px solid {colors.neutral.a30}]",
+ },
+});
+
+const stoppedNoteStyle = css({
+ alignSelf: "center",
+ marginTop: "1.5",
+ paddingY: "1",
+ fontSize: "xs",
+ color: "neutral.s80",
+});
+
+const spokenLinePartTypes = new Set([
+ "data-voiceAgentReply",
+ "data-voiceAgentWrapUp",
+]);
+
+/**
+ * A host may project Live's spoken reply or wrap-up as its own assistant
+ * message. It is never Brunch's response, so it must not take over the
+ * Activity fold or the waiting row.
+ */
+const isSpokenLine = (message: PetrinautAiMessage) =>
+ message.role === "assistant" &&
+ message.parts.length > 0 &&
+ message.parts.every((part) => spokenLinePartTypes.has(part.type));
+
+const StreamingWords = ({
+ text,
+ streaming,
+}: {
+ text: string;
+ streaming: boolean;
+}) =>
+ streaming
+ ? [...text.matchAll(/\S+\s*|\s+/gu)].map((word) => (
+
+ {word[0]}
+
+ ))
+ : text;
+
+type BrunchMessageProps = Omit & {
+ message: PetrinautAiMessage;
+ voice: boolean;
+ active: boolean;
+ canRetry: boolean;
+ latestAnswer: boolean;
+};
+
+/**
+ * One conversation turn: the user's bubble, the work fold, the written answer
+ * and any produced cards, wrapped in `React.memo`.
+ *
+ * The AI SDK rebuilds the `messages` array on every reasoning/text delta but
+ * uses `slice` for unchanged messages and only `structuredClone`s the active
+ * one. That gives every completed message a stable reference between chunks,
+ * so memoising by reference equality lets us skip re-rendering the whole
+ * transcript on every chunk — only the message currently being streamed has
+ * to re-render.
+ */
+const BrunchMessage = memo(
+ ({
+ handlersRef,
+ hiddenToolNames,
+ interactiveTools,
+ message,
+ experimentStates,
+ onCancelExperiment,
+ resolveToolPresentation,
+ voice,
+ active,
+ stopped,
+ canRetry,
+ latestAnswer,
+ }: BrunchMessageProps) => {
+ const { addNotification } = use(NotificationsContext);
+ const [copied, setCopied] = useState(false);
+ useEffect(() => {
+ if (!copied) return;
+ const timer = window.setTimeout(() => setCopied(false), 1_200);
+ return () => window.clearTimeout(timer);
+ }, [copied]);
+ const role = message.role === "user" ? "user" : "assistant";
+ const renderItems = getMessageRenderItems(
+ message,
+ interactiveTools,
+ resolveToolPresentation,
+ hiddenToolNames,
+ );
+ const { work, answers, cards, brief, voiceAgentReply, voiceAgentWrapUp } =
+ renderItems;
+ const wasStopped = stopped || message.metadata?.stopped === true;
+ const awaitingApproval = work.tools.some(
+ (tool) => tool.interactive && tool.state === "input-available",
+ );
+ const workStatus = wasStopped
+ ? "stopped"
+ : awaitingApproval
+ ? "approval"
+ : active
+ ? "streaming"
+ : "settled";
+ const writtenAnswer =
+ answers.length > 0 ? (
+
+ {answers.map((item) => (
+
+ {item.part.text}
+
+ ))}
+
+ ) : null;
+ // Voice renders the written answer inside the fold; Chat renders it below.
+ const showWork =
+ role === "assistant" &&
+ (active ||
+ wasStopped ||
+ work.reasoning.length > 0 ||
+ work.tools.length > 0 ||
+ (voice && answers.length > 0));
+
+ return (
+
+ {role === "user" && (
+
+ {!voice && message.metadata?.source === "voice" && (
+
+ )}
+
+ {answers.map((item) => (
+
+
+
+ ))}
+
+
+ )}
+ {brief &&
}
+ {voiceAgentReply && (
+
+
+
+ )}
+ {showWork && (
+
+ {work.reasoning.map((item) => (
+
+ ))}
+
+ handlersRef.current.onInteractiveToolSubmit?.(params)
+ }
+ onSelectToolTarget={(target) =>
+ handlersRef.current.onSelectToolTarget?.(target)
+ }
+ />
+ {voice && writtenAnswer}
+ {voice && active && !writtenAnswer && (
+
+ {["92%", "74%", "46%"].map((width) => (
+
+ ))}
+
+ )}
+
+ )}
+ {role === "assistant" && !voice && writtenAnswer}
+ {cards.map((item) =>
+ item.type === "experiment" ? (
+
+ ) : (
+
+ handlersRef.current.onInteractiveToolSubmit?.(params)
+ }
+ onSelectToolTarget={(target) =>
+ handlersRef.current.onSelectToolTarget?.(target)
+ }
+ />
+ ),
+ )}
+ {voiceAgentWrapUp && (
+
+
+
+ )}
+ {role === "assistant" && wasStopped && (
+ Response stopped
+ )}
+ {role === "assistant" && !voice && writtenAnswer && !active && (
+
+
+ }
+ onClick={() => {
+ void (async () => {
+ try {
+ await navigator.clipboard.writeText(
+ answers.map((answer) => answer.part.text).join("\n\n"),
+ );
+ setCopied(true);
+ } catch {
+ addNotification(
+ errorNotification("Could not copy the answer"),
+ );
+ }
+ })();
+ }}
+ />
+ {canRetry && (
+ }
+ onClick={() => handlersRef.current.onRetryMessage(message.id)}
+ />
+ )}
+
+ )}
+
+ );
+ },
+);
+BrunchMessage.displayName = "BrunchMessage";
+
+export const BrunchTranscript = ({
+ messages,
+ stopped,
+ busy,
+ canRetry,
+ voice,
+ ...messageProps
+}: TranscriptProps & {
+ busy: boolean;
+ /** Whether a finished answer may be retried at all right now. */
+ canRetry: boolean;
+ voice: boolean;
+}) => {
+ const firstUserIndex = messages.findIndex(
+ (message) => message.role === "user",
+ );
+ const latestAnswerId = messages.findLast(
+ (message) =>
+ message.role === "assistant" &&
+ message.parts.some(
+ (part) => part.type === "text" && part.text.trim().length > 0,
+ ),
+ )?.id;
+ const responseIndex = messages.findLastIndex(
+ (message) => !isSpokenLine(message),
+ );
+ const responseRole = messages[responseIndex]?.role;
+
+ return (
+ <>
+ {messages.map((message, index) => (
+ = 0 && index > firstUserIndex}
+ active={
+ busy && index === responseIndex && message.role === "assistant"
+ }
+ stopped={stopped && index === responseIndex}
+ />
+ ))}
+ {busy && responseRole !== "assistant" && (
+
+
+
+ )}
+ {stopped && responseRole === "user" && (
+ Response stopped
+ )}
+ >
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-work-fold.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/brunch-work-fold.test.tsx
similarity index 100%
rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-work-fold.test.tsx
rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/brunch-work-fold.test.tsx
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-work-fold.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/brunch-work-fold.tsx
similarity index 71%
rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-work-fold.tsx
rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/brunch-work-fold.tsx
index 15038c45e37..942173b666a 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-work-fold.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/brunch-work-fold.tsx
@@ -1,11 +1,11 @@
import { Collapsible } from "@ark-ui/react/collapsible";
import { type ReactNode, useState } from "react";
-import { Icon } from "@hashintel/ds-components";
+import { Icon, LoadingSpinner } from "@hashintel/ds-components";
import { css } from "@hashintel/ds-helpers/css";
-import { collapsibleContentStyle } from "./shared/collapsible-content-style";
-import { useElapsedTime } from "./shared/use-elapsed-time";
+import { collapsibleContentStyle } from "../shared/collapsible-content-style";
+import { useElapsedTime } from "../shared/use-elapsed-time";
export type BrunchWorkStatus = "streaming" | "settled" | "approval" | "stopped";
@@ -44,7 +44,45 @@ const triggerStyle = css({
animation: "[shimmer 2.4s linear infinite]",
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
},
+ "&[data-pending]": {
+ cursor: "default",
+ _hover: { backgroundColor: "[transparent]" },
+ },
+});
+const workIconSlotStyle = css({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: "[16px]",
+ height: "[16px]",
+ flexShrink: 0,
});
+const spinnerStyle = css({
+ color: "blue.s90",
+ "@media (prefers-reduced-motion: reduce)": {
+ animation: "[none !important]",
+ },
+});
+
+const WorkIcon = ({ working }: { working: boolean }) => (
+
+ {working ? (
+
+ ) : (
+
+ )}
+
+);
+
+/** Stands where the Activity fold will appear until Brunch's first part arrives. */
+export const BrunchWorkPending = ({ label }: { label: string }) => (
+
+
+
+ {label}
+
+
+);
export const BrunchWorkFold = ({
status,
@@ -90,14 +128,13 @@ export const BrunchWorkFold = ({
className={triggerStyle}
data-working={status === "streaming"}
>
-
+
{label}
{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
new file mode 100644
index 00000000000..9995a0f8f0f
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, test } from "vitest";
+
+import { VoiceInputProvenance } from "./voice-input-provenance";
+
+afterEach(cleanup);
+
+test.each<{
+ state: "streaming" | "done";
+ preparationFailed?: boolean;
+ fields: Record
;
+ label: string;
+ note: string;
+}>([
+ {
+ state: "streaming" as const,
+ fields: {},
+ label: "Preparing request",
+ note: "Preparing from what you said",
+ },
+ {
+ state: "streaming" as const,
+ fields: { goal: "Compare staffing" },
+ label: "Sending request",
+ note: "Prepared from what you said",
+ },
+ {
+ state: "done" as const,
+ fields: { goal: "Compare staffing" },
+ label: "Request sent",
+ note: "Prepared from what you said",
+ },
+ {
+ state: "streaming" as const,
+ preparationFailed: true,
+ fields: {},
+ label: "Sending without preparation",
+ note: "Preparation failed; sending your original words",
+ },
+ {
+ state: "done" as const,
+ preparationFailed: true,
+ fields: {},
+ label: "Sent without preparation",
+ note: "Preparation failed; your original words were sent",
+ },
+])(
+ "labels a brief truthfully: $label",
+ ({ state, fields, preparationFailed, label, note }) => {
+ render(
+ ,
+ );
+ const disclosure = screen.getByText(label).closest("details");
+ expect(disclosure?.getAttribute("aria-busy")).toBe(
+ String(state === "streaming"),
+ );
+ expect(screen.getByText(note)).not.toBeNull();
+ },
+);
+
+test("renders absent brief fields as placeholders without confusing verbatim Still open", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("runs").nextSibling?.textContent).toBe("Still open");
+ expect(screen.getByText("constraints").nextSibling?.textContent).toBe(
+ "Still open",
+ );
+ expect(screen.getByText("ask").nextSibling?.textContent).toBe("Still open");
+ expect(screen.queryByText("still Open")).toBeNull();
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/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
similarity index 95%
rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/voice-input-provenance.tsx
rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.tsx
index ed5d0c78f4f..c9309387aed 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/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
@@ -1,7 +1,7 @@
import { Icon } from "@hashintel/ds-components";
import { css } from "@hashintel/ds-helpers/css";
-import type { VoiceBrief } from "./get-message-render-items";
+import type { VoiceBrief } from "../get-message-render-items";
/** A quiet, right-aligned disclosure beneath the original spoken words. */
export const VoiceInputProvenance = ({ brief }: { brief: VoiceBrief }) => (
@@ -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"}
(
+
+);
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx
new file mode 100644
index 00000000000..fb46c3fadef
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx
@@ -0,0 +1,308 @@
+import { type ReactNode, type RefObject, useEffect } from "react";
+
+import { Button, Icon } from "@hashintel/ds-components";
+import { css } from "@hashintel/ds-helpers/css";
+
+import { AiVoiceModeIcon } from "../../../components/ai-voice-mode-button";
+
+import type { PetrinautAiAssistant } from "../../../../../petrinaut";
+import type { PetrinautAiInputMode } from "../../../../../types/ai-assistant-composer-control";
+
+const composerActionGlyphStyle = css({
+ display: "inline-flex",
+ animationName: "[petrinautComposerActionSwap]",
+ animationDuration: "[140ms]",
+ animationTimingFunction: "[cubic-bezier(0.2, 0.9, 0.3, 1)]",
+ "@media (prefers-reduced-motion: reduce)": {
+ animationName: "[none]",
+ },
+});
+
+const composerActionButtonStyle = css({
+ flexShrink: 0,
+ width: "[30px]",
+ height: "[30px]",
+ minWidth: "[30px]",
+ borderRadius: "[calc({radii.lg} - {spacing.1})]",
+ "&[data-stop=true]": {
+ width: "[28px]",
+ height: "[28px]",
+ minWidth: "[28px]",
+ borderRadius: "full",
+ },
+});
+
+const composerStyle = css({
+ display: "flex",
+ alignItems: "flex-end",
+ gap: "1",
+ borderRadius: "lg",
+ backgroundColor: "neutral.s10",
+ border: "[1px solid {colors.neutral.bd.subtle}]",
+ padding: "1",
+ transition: "[border-color 150ms ease, box-shadow 150ms ease]",
+ _focusWithin: {
+ borderColor: "blue.s50",
+ boxShadow: "[0 0 0 2px {colors.blue.a10}]",
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ transition: "[none]",
+ },
+});
+
+// Caps how tall the composer can auto-grow before it starts scrolling
+// internally. Kept in sync with `maxHeight` below — the auto-grow effect
+// reads this constant directly so the two can't drift.
+const composerMaxHeight = 160;
+
+const composerTextareaStyle = css({
+ flex: "[1]",
+ minWidth: "[0]",
+ minHeight: "[30px]",
+ maxHeight: `[${composerMaxHeight}px]`,
+ paddingX: "2",
+ paddingY: "[5px]",
+ border: "none",
+ outline: "none",
+ resize: "none",
+ overflowY: "auto",
+ backgroundColor: "[transparent]",
+ color: "neutral.fg.body",
+ fontFamily: "[inherit]",
+ fontSize: "sm",
+ fontWeight: "medium",
+ lineHeight: "[1.4]",
+ // Animates the height changes driven by the auto-grow effect, so adding a
+ // line (Shift+Enter) or wrapping expands the box smoothly.
+ transition: "[height 120ms ease]",
+ "@media (prefers-reduced-motion: reduce)": {
+ transition: "[none]",
+ },
+ _placeholder: {
+ color: "neutral.s70",
+ },
+ _disabled: {
+ cursor: "not-allowed",
+ color: "neutral.s90",
+ },
+});
+
+const composerHintStyle = css({
+ flex: "1",
+ paddingX: "2",
+ paddingBottom: "1",
+ color: "neutral.s80",
+ fontSize: "xs",
+ textAlign: "left",
+});
+
+type ComposerAction = {
+ disabled: boolean;
+ glyph: "arrowUp" | "stopFilled" | "voice";
+ isSubmit: boolean;
+ label: string;
+ onClick?: () => void;
+ tone: "brand" | "neutral";
+ type: "button" | "submit";
+ variant: "solid" | "subtle";
+};
+
+/**
+ * The message form: an auto-growing textarea, an optional host control, and
+ * one trailing action that is Stop while busy, Send once there is text, and
+ * Voice mode otherwise.
+ */
+export const AiAssistantComposer = ({
+ busy,
+ control,
+ disabled,
+ hint,
+ input,
+ inputRef,
+ isOpen,
+ onInputChange,
+ onInputModeChange,
+ onStop,
+ onSubmit,
+ hasHistory,
+ presentation,
+ voiceModeAvailable,
+}: {
+ busy: boolean;
+ control?: ReactNode;
+ disabled: boolean;
+ /** Brunch only; a status shown beside the actions. */
+ hint?: string;
+ input: string;
+ inputRef: RefObject
;
+ isOpen: boolean;
+ onInputChange: (value: string) => void;
+ onInputModeChange?: (mode: PetrinautAiInputMode) => void;
+ onStop: () => void;
+ onSubmit: () => void;
+ /** Whether the transcript already has messages to continue from. */
+ hasHistory: boolean;
+ presentation: NonNullable;
+ voiceModeAvailable: boolean;
+}) => {
+ const isBrunch = presentation === "brunch";
+ const hasInput = input.trim().length > 0;
+ const canSubmit = hasInput && !busy && !disabled;
+
+ const action: ComposerAction = busy
+ ? {
+ disabled: false,
+ glyph: "stopFilled",
+ isSubmit: false,
+ label: "Stop AI response",
+ onClick: onStop,
+ tone: isBrunch ? "brand" : "neutral",
+ type: "button",
+ variant: isBrunch ? "solid" : "subtle",
+ }
+ : canSubmit
+ ? {
+ disabled: false,
+ glyph: "arrowUp",
+ isSubmit: true,
+ label: "Send message",
+ tone: "brand",
+ type: "submit",
+ variant: "solid",
+ }
+ : !hasInput && voiceModeAvailable && onInputModeChange
+ ? {
+ disabled: false,
+ glyph: "voice",
+ isSubmit: false,
+ label: "Start voice mode",
+ onClick: () => onInputModeChange("voice"),
+ tone: "brand",
+ type: "button",
+ variant: "solid",
+ }
+ : {
+ disabled: true,
+ glyph: "arrowUp",
+ isSubmit: false,
+ label: "Send message",
+ tone: "brand",
+ type: "submit",
+ variant: "solid",
+ };
+
+ // Auto-grow the composer to fit its content (up to `composerMaxHeight`,
+ // after which it scrolls internally). Resetting to `auto` before measuring
+ // `scrollHeight` lets the box shrink again when text is removed; both writes
+ // happen synchronously so the browser only paints the final height and the
+ // CSS `height` transition animates the change. `scrollHeight` is a rounded
+ // integer, so the height it yields can land a fraction of a pixel under the
+ // real content and raise a scrollbar on a box that visibly fits; scrolling
+ // is therefore only allowed once the content genuinely passes the cap.
+ useEffect(() => {
+ const textarea = inputRef.current;
+ if (!textarea) {
+ return;
+ }
+ textarea.style.height = "auto";
+ const contentHeight = textarea.scrollHeight;
+ textarea.style.height = `${Math.min(contentHeight, composerMaxHeight)}px`;
+ textarea.style.overflowY =
+ contentHeight > composerMaxHeight ? "auto" : "hidden";
+ }, [input, inputRef, isOpen]);
+
+ const brunchStop = isBrunch && busy;
+ const actionButton = (
+