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/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.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx index dfbbb80baee..56a30c1b9ed 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({ @@ -1521,6 +651,7 @@ describe("AiAssistantContents", () => { { expect(contentMounted).toHaveBeenCalledOnce(); }); - test("keeps tab names stable and one tab live region mounted across announcements", () => { - 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: "brunch" as const, - status: "ready" as const, - }; - const { rerender } = render( - , - ); + 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", - ); + 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", - ); - }); + 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 = { @@ -1758,46 +892,9 @@ describe("AiAssistantContents", () => { 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 }) => { + test.each(["output-available", "output-error"] as const)( + "preserves %s applied/error presentation", + async (state) => { render( { onInputChange={noop} onStop={noop} onSubmit={noop} - presentation="brunch" messages={[ { - id: "assistant-unapplied", - role: "assistant", - parts: [ - { - type: "dynamic-tool", - toolName: "updateArcWeight", - toolCallId: "unapplied", - state: "output-available", - input: { - transitionId: "transition", - placeId: "place", - arcDirection: "input", - weight: 4, - }, - output, - }, - ], - }, - ]} - />, - ); - 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(); - }, - ); - - test.each(["output-available", "output-error"] as const)( - "preserves %s applied/error presentation", - async (state) => { - render( - { { onInputChange: noop, onStop: noop, onSubmit: noop, + presentation: "brunch" as const, status: "ready" as const, }; const message = (text: string): PetrinautAiMessage[] => [ @@ -3964,15 +3014,14 @@ describe("AiAssistantContents", () => { status: "ready" as const, }; const { rerender } = render(); + const tablist = () => screen.getByRole("tablist"); - expect(screen.getByRole("tablist").getAttribute("data-style-variant")).toBe( - "stock", - ); + expect(tablist().getAttribute("data-style-variant")).toBe("stock"); + expect(tablist().querySelector("[data-mark]")).toBeNull(); 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); }); test("keeps the stock AI transcript label in both input modes", () => { @@ -4265,61 +3314,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 +3406,7 @@ describe("AiAssistantContents", () => { onInputChange: noop, onStop: noop, onSubmit: noop, + presentation: "brunch" as const, status: "streaming" as const, }; const view = render(); @@ -4491,7 +3552,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 +3675,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,369 +3742,56 @@ describe("AiAssistantContents", () => { ).toBe("true"); }); - test("uses the host presentation resolver at every lifecycle site", async () => { - const messages = [ + test("hides configured tool rows without removing their message parts", () => { + const hiddenPart = { + type: "dynamic-tool" as const, + toolName: "layout_petrinaut_net", + toolCallId: "hidden-layout", + state: "input-available" as const, + input: {}, + }; + const messages: PetrinautAiMessage[] = [ { - id: "assistant-labels", + id: "assistant-hidden-tool", role: "assistant", parts: [ + hiddenPart, { type: "dynamic-tool", - toolName: "one", - toolCallId: "one", - state: "input-streaming", - }, - { - type: "dynamic-tool", - toolName: "two", - toolCallId: "two", + toolName: "read_petrinaut_diagnostics", + toolCallId: "visible-diagnostics", 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( ({ + title: `Rendered ${toolName}`, + })} status="streaming" - resolveToolPresentation={({ error, output, state, toolName }) => { - 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.queryByText("Rendered layout_petrinaut_net")).toBeNull(); expect( - screen - .getByRole("button", { name: /Correctable five/u }) - .querySelector('[data-tool-status="ok"]'), + screen.getByText("Rendered read_petrinaut_diagnostics"), ).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."); + expect(messages[0]?.parts[0]).toBe(hiddenPart); }); - 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, - toolName: "layout_petrinaut_net", - toolCallId: "hidden-layout", - state: "input-available" as const, - input: {}, - }; - const messages: PetrinautAiMessage[] = [ - { - id: "assistant-hidden-tool", - role: "assistant", - parts: [ - hiddenPart, - { - type: "dynamic-tool", - toolName: "read_petrinaut_diagnostics", - toolCallId: "visible-diagnostics", - state: "input-available", - input: {}, - }, - ], - }, - ]; - - render( - ({ - title: `Rendered ${toolName}`, - })} - status="streaming" - />, - ); - - expect(screen.queryByText("Rendered layout_petrinaut_net")).toBeNull(); - expect( - screen.getByText("Rendered read_petrinaut_diagnostics"), - ).not.toBeNull(); - expect(messages[0]?.parts[0]).toBe(hiddenPart); - }); - - test("keeps completed changes as individual rows", async () => { - const messages: PetrinautAiMessage[] = [ + test("keeps completed changes as individual rows", async () => { + const messages: PetrinautAiMessage[] = [ { id: "assistant-1", role: "assistant", @@ -5413,3 +4079,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..0e05290c991 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 && ( -
-
- )} -
- ); - }, -); -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,24 @@ 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 && ( -
+ )} {!isBrunchChat && isBusy && workingLabel && ( @@ -1727,112 +1042,28 @@ export const AiAssistantContents = ({ setChipsDismissed(true)} onSelect={(prompt) => onSendPrompt(prompt)} + presentation={presentation} /> )} -
{ - event.preventDefault(); - const submitter = (event.nativeEvent as SubmitEvent) - .submitter; - if ( - canSubmit && - submitter?.hasAttribute("data-ai-assistant-submit") - ) { - onSubmit(); - } - }} - > -
-