From f4454d0751c5adc2c95ba45b38be5a490ee71925 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 1 Oct 2026 09:40:28 +0200 Subject: [PATCH 01/11] Keep the stock assistant identical to main and split the stock and Brunch presentations Restore main's stock transcript, tool rows, tabs, scrolling and composer behind the default presentation, and give the stock and Brunch transcripts, tool lists, composer and status row their own files. Stopped in-progress stock tools show Cancelled instead of a spinner. Co-authored-by: Cursor --- .../Editor/panels/ai-assistant-panel.test.tsx | 8 +- .../ai-assistant-contents.test.tsx | 2329 ++++++----------- .../ai-assistant-contents.tsx | 956 +------ .../brunch-response-status.tsx | 66 + .../brunch-transcript.tsx | 435 +++ .../brunch-work-fold.test.tsx | 0 .../brunch-work-fold.tsx | 4 +- .../voice-input-provenance.test.tsx | 80 + .../voice-input-provenance.tsx | 2 +- .../ai-assistant-contents/chat-tab-mark.tsx | 16 + .../ai-assistant-contents/composer.tsx | 308 +++ .../get-message-render-items.test.ts | 66 +- .../get-message-render-items.ts | 103 + .../ai-assistant-contents/reasoning.tsx | 199 +- .../shared/error-notification.ts | 6 + .../shared/sent-using-voice-mark.tsx | 19 + .../shared/transcript-props.ts | 29 + .../stock-transcript.tsx | 185 ++ .../ai-assistant-contents/tool-list.tsx | 518 +--- .../tool-list/brunch-tool-list.tsx | 354 +++ .../shared/interactive-tool-item.tsx | 96 + .../tool-list/shared/tool-row-styles.ts | 44 + .../tool-list/stock-tool-list.tsx | 312 +++ .../ai-assistant-contents/voice-dock.test.tsx | 696 ++++- .../audio-popover/settings.test.tsx | 113 + 25 files changed, 4041 insertions(+), 2903 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-response-status.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript.tsx rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/{ => brunch-transcript}/brunch-work-fold.test.tsx (100%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/{ => brunch-transcript}/brunch-work-fold.tsx (96%) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/brunch-transcript/voice-input-provenance.test.tsx rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/{ => brunch-transcript}/voice-input-provenance.tsx (98%) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/chat-tab-mark.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/error-notification.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/sent-using-voice-mark.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/transcript-props.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/stock-transcript.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list/brunch-tool-list.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list/shared/interactive-tool-item.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list/shared/tool-row-styles.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list/stock-tool-list.tsx 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..4229af55026 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,708 @@ 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("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..8a63cdbb278 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,21 @@ 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 +932,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 +1041,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(); - } - }} - > -
-