diff --git a/.changeset/petrinaut-plugin-assistants.md b/.changeset/petrinaut-plugin-assistants.md new file mode 100644 index 00000000000..3cc9525ba0e --- /dev/null +++ b/.changeset/petrinaut-plugin-assistants.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Lets plugins provide the AI assistant, with a selector in User settings when several are installed, and removes the deprecated `slots` prop. diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 6478b7fa124..e406e11bf63 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -48,11 +48,33 @@ updates start from the latest persisted value so another tab's documents survive ## Choosing the assistant -Petrinaut's stock assistant is the AI panel fallback. Under **User settings → Labs**, **Use Brunch** selects the Brunch assistant and **Enable Voice** controls whether Voice mode is offered. The assistant choice is stored under `petrinaut-website:assistant`; the separate Voice choice is stored under `petrinaut-website:voice-enabled` and is off when the value is missing or invalid. Both preferences belong to the website host, not Petrinaut. - -A Brunch-focused deployment or test launch may set `VITE_PETRINAUT_DEFAULT_ASSISTANT=brunch`; explicit browser-local assistant choices remain authoritative, so changing the launch fallback does not migrate existing users. With `VITE_BRUNCH_CHAT_ENDPOINT` configured, the command palette (⌘K) continues to offer **Use Brunch** and, once switched, **Use the stock Petrinaut assistant**. With the stock assistant selected, the panel talks to `/api/chat` with the stock tool surface, keeps its messages in the local store, and creates no Flue client, mounts no Brunch tools and shows no Workpiece pane or Voice; Brunch's conversation lives in Flue history and is untouched. Switching back restores it. Without a configured endpoint, the Labs control remains visible but disabled, the stock assistant is the only one, and no command is offered. - -Voice is available only when Brunch is selected, the browser-local Voice preference is enabled, and the existing server capability check reports Voice available. Enabling the preference does not start microphone capture or a provider session. +The stock assistant and Brunch are Petrinaut assistant plugins. With +`VITE_BRUNCH_CHAT_ENDPOINT` configured both are installed, and Petrinaut's +**User settings → General → AI assistant** selector chooses between them; the +command palette (⌘K) offers **Use the Brunch assistant** or **Use the Petrinaut +assistant**. Petrinaut stores the choice with its other user settings. Without +an endpoint the stock assistant is the only one and there is nothing to choose. +A worked-model document (`/?bundle=`) installs Brunch alone. + +Until the user chooses, the first installed assistant is active. This site +orders them by its earlier browser-local choice under +`petrinaut-website:assistant`, which it reads but no longer writes, and +otherwise by `VITE_PETRINAUT_DEFAULT_ASSISTANT` (`stock` by default, or +`brunch` for a Brunch-focused deployment). + +Only the active assistant's plugin component is mounted. With the stock +assistant active, the panel talks to `/api/chat` with the stock tool surface +and keeps its messages in the local store; no Flue client is created, no +Brunch tools are mounted, and no Ledger pane or Voice is shown. Brunch's +conversation lives in Flue history and is untouched; switching back restores +it. + +**User settings → Labs → Brunch → Enable Voice** controls whether Voice mode +is offered, stored under `petrinaut-website:voice-enabled` and off when the +value is missing or invalid. Voice is available only while Brunch is active, +the preference is enabled, and the server capability check reports Voice +available. Enabling the preference does not start microphone capture or a +provider session. ## Worked-model documents diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/active-handle.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/active-handle.ts new file mode 100644 index 00000000000..342569bc09f --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/active-handle.ts @@ -0,0 +1,18 @@ +import type { DocumentRecord } from "./documents/document-repository"; +import type { + DocumentRevisionId, + PetrinautDocHandle, +} from "@hashintel/petrinaut-core"; + +/** The live handle of the open document, with the record it was opened from. */ +export type ActiveHandle = { + handle: PetrinautDocHandle; + document: DocumentRecord; + /** + * Every revision this handle has produced (plus the one it opened at). A + * repository revision outside this set was written by someone else — another + * tab, typically — and the handle must be recreated from it rather than keep + * chaining edits from a predecessor the repository no longer holds. + */ + emittedRevisionIds: Set; +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx deleted file mode 100644 index 0939fbbef76..00000000000 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import { afterEach, beforeEach, expect, test, vi } from "vitest"; - -import { AssistantLabsSettings } from "./assistant-labs-settings"; - -import type { ComponentProps } from "react"; - -const voiceConfig = { - available: true, - connectionTimeoutMs: 10_000, -} as const; - -const defaultProps = { - assistantReady: true, - brunchConfigured: true, - brunchSelected: false, - forceBrunch: false, - openAIVoiceConfig: voiceConfig, - selectAssistant: vi.fn(), - setVoiceEnabled: vi.fn(), - voiceEnabled: false, - voicePreferenceReady: true, -} satisfies ComponentProps; - -beforeEach(() => { - vi.stubGlobal("PointerEvent", MouseEvent); -}); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); - vi.unstubAllGlobals(); -}); - -test("selects Brunch, enables Voice, and explains when Voice is unavailable", async () => { - const selectAssistant = vi.fn(); - const setVoiceEnabled = vi.fn(); - const props = { ...defaultProps, selectAssistant, setVoiceEnabled }; - const view = render(); - - const brunchToggle = screen.getByRole("checkbox", { name: "Use Brunch" }); - const voiceToggle = screen.getByRole("checkbox", { name: "Enable Voice" }); - expect(brunchToggle).toBeDefined(); - expect(voiceToggle).toBeDefined(); - - fireEvent.click(brunchToggle); - expect((brunchToggle as HTMLInputElement).checked).toBe(true); - await waitFor(() => expect(selectAssistant).toHaveBeenCalledWith("brunch")); - - view.rerender(); - fireEvent.click(screen.getByRole("checkbox", { name: "Enable Voice" })); - await waitFor(() => expect(setVoiceEnabled).toHaveBeenCalledWith(true)); - - view.rerender(); - expect( - ( - screen.getByRole("checkbox", { - name: "Enable Voice", - }) as HTMLInputElement - ).disabled, - ).toBe(true); - - view.rerender( - , - ); - expect( - screen.getByText("Voice is unavailable in this deployment."), - ).toBeDefined(); -}); - -test.each([ - { - name: "assistant preference loading", - props: { assistantReady: false }, - control: "Use Brunch", - checked: false, - description: "Loading your assistant preference…", - }, - { - name: "missing Brunch configuration", - props: { brunchConfigured: false }, - control: "Use Brunch", - checked: false, - description: - "Brunch is unavailable because this site has no Brunch endpoint configured.", - }, - { - name: "forced Brunch", - props: { brunchSelected: true, forceBrunch: true }, - control: "Use Brunch", - checked: true, - description: "This document requires Brunch.", - }, - { - name: "Voice preference loading", - props: { brunchSelected: true, voicePreferenceReady: false }, - control: "Enable Voice", - checked: false, - description: "Loading your Voice preference…", - }, - { - name: "Voice capability loading", - props: { brunchSelected: true, openAIVoiceConfig: undefined }, - control: "Enable Voice", - checked: false, - description: "Checking whether Voice is available…", - }, -])( - "disables controls during $name", - ({ props, control, checked, description }) => { - render(); - - const toggle = screen.getByRole("checkbox", { name: control }); - expect(toggle).toHaveProperty("checked", checked); - expect(toggle).toHaveProperty("disabled", true); - expect(screen.getByText(description)).toBeDefined(); - }, -); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx deleted file mode 100644 index 69713b1d52c..00000000000 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useId } from "react"; - -import { Toggle } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - -import type { OpenAIVoiceConfig } from "../voice-interview/voice-interview-control"; -import type { AssistantSelection } from "./assistant-selection"; - -const sectionStyle = css({ - border: "[1px solid {colors.neutral.s40}]", - borderRadius: "xl", - overflow: "hidden", - marginTop: "3", -}); - -const sectionTitleStyle = css({ - margin: "0", - paddingX: "3", - paddingY: "1.5", - background: "neutral.s10", - borderBottom: "[1px solid {colors.neutral.s40}]", - fontSize: "xs", - fontWeight: "medium", - color: "neutral.fg.subtle", -}); - -const rowStyle = css({ - display: "grid", - gridTemplateColumns: "[minmax(0, 1fr) auto]", - alignItems: "center", - gap: "4", - paddingX: "3", - paddingY: "2.5", - "& + &": { borderTop: "[1px solid {colors.neutral.s30}]" }, -}); - -const labelStyle = css({ - display: "block", - marginBottom: "0.5", - fontSize: "sm", - fontWeight: "medium", - lineHeight: "[1.4]", - color: "neutral.fg.heading", -}); - -const descriptionStyle = css({ - margin: "0", - fontSize: "xs", - lineHeight: "[1.6]", - color: "neutral.fg.body", -}); - -const AssistantSetting = ({ - description, - disabled, - label, - onChange, - value, -}: { - readonly description: string; - readonly disabled: boolean; - readonly label: string; - readonly onChange: (enabled: boolean) => void; - readonly value: boolean; -}) => { - const id = useId(); - - return ( -
-
- - {label} - -

- {description} -

-
- -
- ); -}; - -export const AssistantLabsSettings = ({ - assistantReady, - brunchConfigured, - brunchSelected, - forceBrunch, - openAIVoiceConfig, - selectAssistant, - setVoiceEnabled, - voiceEnabled, - voicePreferenceReady, -}: { - readonly assistantReady: boolean; - readonly brunchConfigured: boolean; - readonly brunchSelected: boolean; - readonly forceBrunch: boolean; - readonly openAIVoiceConfig: OpenAIVoiceConfig | null | undefined; - readonly selectAssistant: (selection: AssistantSelection) => void; - readonly setVoiceEnabled: (enabled: boolean) => void; - readonly voiceEnabled: boolean; - readonly voicePreferenceReady: boolean; -}) => { - const brunchDescription = !assistantReady - ? "Loading your assistant preference…" - : !brunchConfigured - ? "Brunch is unavailable because this site has no Brunch endpoint configured." - : forceBrunch - ? "This document requires Brunch." - : "Use Brunch instead of the stock Petrinaut assistant."; - const voiceDescription = !voicePreferenceReady - ? "Loading your Voice preference…" - : !brunchSelected - ? "Select Brunch to enable Voice." - : openAIVoiceConfig === undefined - ? "Checking whether Voice is available…" - : openAIVoiceConfig === null - ? "Voice is unavailable in this deployment." - : "Make Voice mode available for Brunch conversations."; - - return ( -
-

AI assistant

- selectAssistant(enabled ? "brunch" : "stock")} - value={brunchSelected} - /> - -
- ); -}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-selection.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-selection.ts index 0b326aa119b..18b9e86271c 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-selection.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-selection.ts @@ -1,17 +1,17 @@ -import { readBrowserStorage, writeBrowserStorage } from "./browser-storage"; -import { usePersistedState } from "./use-persisted-state"; - /** - * Which assistant the demo's AI panel talks to. - * - * The stock assistant is Petrinaut's default; Brunch is the hidden alternate. - * The choice is the host's: it is a browser-local preference of this website, - * not a Petrinaut setting and not a server rollout. It only has - * effect when a Brunch endpoint is configured — without one there is nothing - * to select and the stock assistant is used regardless. + * Which assistant the demo offers first. Petrinaut keeps the user's choice + * in User settings once one is made; until then the first installed + * assistant is active, and this decides the order. It only matters where a + * Brunch endpoint is configured — without one the stock assistant is the + * only one. */ export type AssistantSelection = "brunch" | "stock"; +/** + * This site's earlier browser-local choice, from before the choice moved to + * Petrinaut's User settings. Read, never written: it orders the assistants + * until the user chooses again. + */ export const assistantSelectionStorageKey = "petrinaut-website:assistant"; export const resolveDefaultAssistantSelection = ( @@ -43,35 +43,3 @@ export const parseAssistantSelection = ( * talks to the stock backend and nothing else. */ export const stockChatEndpoint = "/api/chat"; - -const readAssistantSelection = (): AssistantSelection => - parseAssistantSelection( - readBrowserStorage(localStorage, assistantSelectionStorageKey), - ); - -const writeAssistantSelection = (selection: AssistantSelection): void => - writeBrowserStorage(localStorage, assistantSelectionStorageKey, selection); - -/** - * Whether Brunch is the assistant in use: configured and selected. Everything - * Brunch-specific in the host keys off this, so with the stock assistant - * selected no Flue client is created, no Brunch tools are mounted, and no - * Brunch history is read or written. - */ -export const isBrunchSelected = ( - isBrunchConfigured: boolean, - selection: AssistantSelection, -): boolean => isBrunchConfigured && selection === "brunch"; - -export const useAssistantSelection = (input?: { - readonly enabled: boolean; -}) => { - const enabled = input?.enabled ?? true; - const [selection, setSelection, ready] = usePersistedState({ - enabled, - fallback: defaultAssistantSelection, - read: readAssistantSelection, - write: writeAssistantSelection, - }); - return { ready, selection, setSelection }; -}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-assistant-plugin.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-assistant-plugin.tsx new file mode 100644 index 00000000000..42506c9d1e1 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-assistant-plugin.tsx @@ -0,0 +1,82 @@ +import { useEffect } from "react"; + +import { + definePetrinautPlugin, + usePetrinautActiveAssistantId, +} from "@hashintel/petrinaut/ui"; + +import { loadOpenAIVoiceConfig } from "../../../voice-interview/voice-interview-control"; +import { useDemoAssistantHost } from "../demo-assistant-host"; +import { BrunchAssistant, brunchAssistantId } from "./brunch-assistant"; +import { BrunchVoiceSetting } from "./brunch-voice-setting"; + +/** + * Checks whether this deployment offers Voice each time Brunch becomes the + * active assistant, and forgets the answer when another assistant takes + * over. The answer lives with the host, so opening another document, which + * remounts the editor, keeps it. + */ +const BrunchVoiceCapability = () => { + const { voice } = useDemoAssistantHost(); + const brunchActive = usePetrinautActiveAssistantId() === brunchAssistantId; + const { setConfig } = voice; + const known = voice.config !== undefined; + + useEffect(() => { + if (!brunchActive) { + setConfig(undefined); + return; + } + if (known) { + return; + } + const abortController = new AbortController(); + void loadOpenAIVoiceConfig( + globalThis.fetch.bind(globalThis), + abortController.signal, + ).then((config) => { + if (!abortController.signal.aborted) { + setConfig(config); + } + }); + return () => abortController.abort(); + }, [brunchActive, known, setConfig]); + + return null; +}; + +/** The Brunch Labs group: Voice, which only Brunch conversations offer. */ +const BrunchVoiceSettings = () => { + const { voice } = useDemoAssistantHost(); + const brunchActive = usePetrinautActiveAssistantId() === brunchAssistantId; + return ( + + ); +}; + +/** + * Brunch as a Petrinaut assistant, with its Voice preference in Labs. The + * host installs it only where a Brunch endpoint is configured. + */ +export const brunchAssistantPlugin = definePetrinautPlugin({ + id: brunchAssistantId, + name: "Brunch", + assistants: [ + { id: brunchAssistantId, label: "Brunch", component: BrunchAssistant }, + ], + component: BrunchVoiceCapability, + settingsGroups: [ + { + id: `${brunchAssistantId}.voice`, + section: "labs", + title: "Brunch", + component: BrunchVoiceSettings, + }, + ], +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-assistant.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-assistant.tsx new file mode 100644 index 00000000000..2761556c862 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-assistant.tsx @@ -0,0 +1,461 @@ +import { + createFlueClient, + type FlueConversationSettlement, + type FlueConversationState, +} from "@flue/sdk"; +import { + type RefObject, + use, + useCallback, + useEffect, + useMemo, + useSyncExternalStore, +} from "react"; + +import { batchedConstructionMode } from "@hashintel/brunch-agent-plugin-sdcpn"; +import { + agentOwnershipHeaders, + flueConversationIdWeb, +} from "@hashintel/brunch-agent-transport-aisdk"; +import { BRUNCH_DOCUMENT_REVISION_HEADER } from "@hashintel/brunch-agent-transport-aisdk/headers"; +import { ErrorTrackerContext } from "@hashintel/petrinaut/react"; +import { + type PetrinautAiAssistant, + type PetrinautAiStopResult, + type PetrinautAiVoiceMode, + type PetrinautAiVoiceModeContext, + usePetrinautAiAssistant, +} from "@hashintel/petrinaut/ui"; + +import { + type OpenAIVoiceConfig, + VoiceInterviewControl, +} from "../../../voice-interview/voice-interview-control"; +import { + batchedConstructionClientToolNames, + brunchPetrinautDynamicToolNames, +} from "../../brunch-client-tools"; +import { ordinaryConstructionConversationIdFrom } from "../../brunch-conversation-id"; +import { createBrunchDraftExperimentInteractiveTool } from "../../brunch-draft-experiment-interactive-tool"; +import { + BrunchPanelConversationTracker, + type BrunchPanelAdmissionTarget, + createBrunchPanelTransport, +} from "../../brunch-panel-transport"; +import { createBrunchPetrinautTools } from "../../brunch-petrinaut-tools"; +import { resolveBrunchToolPresentation } from "../../brunch-tool-presentation"; +import { foldBrunchWorkpieceHistory } from "../../brunch-workpiece-history"; +import { BrunchWorkpiecePane } from "../../brunch-workpiece-pane"; +import { + createJoinedBrowserMutationRecorder, + observeBrowserDefinition, +} from "../../mutation-record"; +import { useFlueChatHistory } from "../../use-flue-chat-history"; +import { useDemoAssistantHost } from "../demo-assistant-host"; +import { brunchPreviewConfig, brunchPrincipal } from "./brunch-site-config"; +import { + type FixtureProcessAgentConfiguration, + type ProcessAgentBinding, + useProcessAgentBinding, +} from "./use-process-agent-binding"; + +import type { ActiveHandle } from "../../active-handle"; + +export const brunchAssistantId = "website.brunch"; + +export const getBrunchVoiceMode = ( + config: OpenAIVoiceConfig | null | undefined, + tracker?: BrunchPanelConversationTracker, + settlements?: readonly FlueConversationSettlement[], + snapshot?: FlueConversationState, +): PetrinautAiVoiceMode | undefined => { + if (!config) return undefined; + + const resolveInputSubmission = tracker?.submissionForInput.bind(tracker); + const resolveResponseSubmission = + tracker?.submissionsForResponse.bind(tracker); + const subscribeToResponseMessageCompleted = + tracker?.subscribeToResponseMessageCompleted.bind(tracker); + const subscribeToResponseMessageStarted = + tracker?.subscribeToResponseMessageStarted.bind(tracker); + const subscribeToStopRequested = + tracker?.subscribeToStopRequested.bind(tracker); + const subscribeToAdmission = + tracker === undefined + ? undefined + : (target: BrunchPanelAdmissionTarget, listener: (id: string) => void) => + tracker.subscribeToAdmission(target, ({ admission }) => + listener(admission.submissionId), + ); + const subscribeToAdmissionFailure = + tracker?.subscribeToAdmissionFailure.bind(tracker); + + return (context: PetrinautAiVoiceModeContext) => ( + + ); +}; + +const createBrunchFlueClient = async ( + conversationId: string, + currentRevisionId: () => string | undefined, +) => { + const identity = { conversationId, principalKey: brunchPrincipal }; + const instanceId = await flueConversationIdWeb(identity); + const mountUrl = new URL( + brunchPreviewConfig.chatEndpoint, + window.location.origin, + ); + mountUrl.pathname = `${mountUrl.pathname.replace(/\/+$/u, "")}/${instanceId}`; + return createFlueClient({ + url: mountUrl.href, + headers: () => { + const revisionId = currentRevisionId(); + return { + ...agentOwnershipHeaders(identity), + ...(revisionId === undefined + ? {} + : { [BRUNCH_DOCUMENT_REVISION_HEADER]: revisionId }), + }; + }, + }); +}; + +/** + * Flue's `abort()` is conversation-wide and only reaches unsettled work, so a + * Stop pressed while `send()` is still in flight must first let that admission + * land; otherwise `aborted: false` would read as "already settled" while the + * admitted turn keeps running. + */ +export const requestFlueStop = async ( + clientPromise: Promise>, + tracker: BrunchPanelConversationTracker, +): Promise => { + tracker.recordStopRequested(); + const client = await clientPromise; + await tracker.settleInFlightSubmissions(); + const result = await client.abort(); + return result.aborted ? "stop-requested" : "already-settled"; +}; + +const useProcessAgentSession = (input: { + readonly activeHandleRef: RefObject; + readonly binding: ProcessAgentBinding | null; +}) => { + "use no memo"; // The Flue header callback deliberately reads the live handle ref after render. + + const readCurrentRevisionId = useCallback(() => { + const handle = input.activeHandleRef.current; + return input.binding !== null && + handle?.document.documentId === input.binding.documentId && + handle.document.incarnationId === input.binding.incarnationId + ? handle.handle.revisionId.get() + : undefined; + }, [input.activeHandleRef, input.binding]); + + return useMemo(() => { + const conversationTracker = new BrunchPanelConversationTracker(); + const flueClientPromise = + input.binding === null + ? null + : createBrunchFlueClient( + input.binding.conversationId, + readCurrentRevisionId, + ); + return { conversationTracker, flueClientPromise }; + }, [input.binding, readCurrentRevisionId]); +}; + +/** + * Brunch, the process-construction agent. Mounted only while Brunch is the + * active assistant, so with the stock assistant active no Flue client is + * created, no Brunch tools exist, and no Brunch history is read or written. + * It passes `null` until the conversation is bound to the open document. + */ +export const BrunchAssistant = () => { + const { + activeHandle, + activeHandleRef, + document: currentDocument, + processAgentSeed, + settleRevision, + voice, + } = useDemoAssistantHost(); + const currentNetTitle = currentDocument.title; + + const productConstructionConversationId = + ordinaryConstructionConversationIdFrom(currentDocument.incarnationId); + const fixtureProcessAgentConfiguration = + useMemo( + () => ({ conversationId: productConstructionConversationId }), + [productConstructionConversationId], + ); + const processAgentBinding = useProcessAgentBinding({ + document: currentDocument, + seed: processAgentSeed, + fixture: fixtureProcessAgentConfiguration, + }); + const conversationId = processAgentBinding?.conversationId ?? null; + const { conversationTracker, flueClientPromise } = useProcessAgentSession({ + activeHandleRef, + binding: processAgentBinding, + }); + // Failures the host contains — a stopped batch operation, an unrecordable + // transition, a lost history observation, a failed server tool — resolve + // normally for the panel and the model; this is where they become visible. + const { captureException } = use(ErrorTrackerContext); + const reportBrunchFailure = useCallback( + ( + failureSource: string, + error: unknown, + tags?: Readonly>, + ) => + captureException(error, { + source: `brunch.${failureSource}`, + ...(tags === undefined ? {} : { tags }), + }), + [captureException], + ); + const constructionBrowser = useMemo( + () => + processAgentBinding === null || + activeHandle.document.documentId !== processAgentBinding.documentId + ? undefined + : { binding: processAgentBinding }, + [activeHandle, processAgentBinding], + ); + // The handle mutates behind a stable identity. Subscribe to its real snapshot; + // a render-time read alone can be memoized by React Compiler across hand edits. + const subscribeToObservedLiveHash = useCallback( + (changed: () => void) => + constructionBrowser ? activeHandle.handle.subscribe(changed) : () => {}, + [activeHandle, constructionBrowser], + ); + const getObservedLiveHash = useCallback( + () => + constructionBrowser && activeHandle.handle.doc() + ? observeBrowserDefinition(activeHandle.handle).sha256 + : undefined, + [activeHandle, constructionBrowser], + ); + const getServerObservedLiveHash = useCallback(() => undefined, []); + const observedLiveHash = useSyncExternalStore( + subscribeToObservedLiveHash, + getObservedLiveHash, + getServerObservedLiveHash, + ); + const mutationRecorder = useMemo( + () => + constructionBrowser + ? createJoinedBrowserMutationRecorder({ + handle: activeHandle.handle, + ...constructionBrowser, + onContainedFailure: (failure) => + reportBrunchFailure("mutation-record", failure.error, { + kind: failure.kind, + toolCallId: failure.toolCallId, + }), + }) + : undefined, + [constructionBrowser, activeHandle, reportBrunchFailure], + ); + const constructionClientTools = batchedConstructionClientToolNames; + const flueHistory = useFlueChatHistory( + flueClientPromise, + conversationId ?? "", + constructionClientTools, + mutationRecorder?.mapClientToolInput, + mutationRecorder?.validatedClientToolNames, + brunchPetrinautDynamicToolNames, + ); + useEffect(() => { + if (flueHistory.error === undefined) return; + reportBrunchFailure("history", flueHistory.error, { + phase: flueHistory.phase ?? "unknown", + }); + }, [flueHistory.error, flueHistory.phase, reportBrunchFailure]); + const brunchVoiceMode = useMemo( + () => + getBrunchVoiceMode( + voice.ready && voice.enabled ? voice.config : null, + conversationTracker, + flueHistory.settlements, + flueHistory.snapshot, + ), + [ + conversationTracker, + flueHistory.settlements, + flueHistory.snapshot, + voice.config, + voice.enabled, + voice.ready, + ], + ); + const transport = useMemo( + () => + flueClientPromise === null + ? null + : createBrunchPanelTransport(flueClientPromise, conversationTracker, { + ...(constructionBrowser + ? { + initialData: { + mode: batchedConstructionMode, + construction: { binding: constructionBrowser.binding }, + }, + } + : {}), + dynamicClientToolNames: brunchPetrinautDynamicToolNames, + ...(conversationId === null + ? {} + : { + liveToolStream: { + headers: agentOwnershipHeaders({ + conversationId, + principalKey: brunchPrincipal, + }), + }, + }), + clientToolNames: constructionClientTools, + mapClientToolInput: mutationRecorder?.mapClientToolInput, + validatedClientToolNames: + mutationRecorder?.validatedClientToolNames, + clientToolResultMetadata: + mutationRecorder?.clientToolResultMetadata, + clientToolResultOutput: mutationRecorder?.clientToolResultOutput, + onAdmission: flueHistory.refresh, + onToolOutputError: (event) => + reportBrunchFailure("server-tool", new Error(event.errorText), { + submissionId: event.submissionId, + toolCallId: event.toolCallId, + toolName: event.toolName ?? "unknown", + }), + }), + [ + conversationTracker, + conversationId, + constructionClientTools, + constructionBrowser, + flueClientPromise, + flueHistory.refresh, + reportBrunchFailure, + mutationRecorder, + ], + ); + + const assistant = useMemo((): PetrinautAiAssistant | null => { + if (flueClientPromise === null || transport === null) { + return null; + } + const activityIdentities = + constructionBrowser && flueHistory.ready + ? flueHistory.phase === "absent" + ? [] + : flueHistory.snapshot === undefined + ? undefined + : foldBrunchWorkpieceHistory( + flueHistory.snapshot.messages, + constructionBrowser.binding, + ).activityIdentities + : undefined; + return { + additionalTab: constructionBrowser + ? { + label: "Ledger", + activityIdentities, + content: ( + + ), + } + : undefined, + primaryLabel: "Chat", + resolveToolPresentation: resolveBrunchToolPresentation, + workingLabel: "Brunch is working", + ...(conversationId === null ? {} : { conversationId }), + canClearMessages: false, + // Brunch's own tool names wrap canonical Petrinaut operations here, in + // the host; Petrinaut keeps its names and executes only what it is told. + automaticTools: createBrunchPetrinautTools({ + readTitle: () => currentNetTitle, + ...(constructionBrowser + ? { + mutation: { + binding: constructionBrowser.binding, + retainAttempt: mutationRecorder?.retainAttempt, + onOperationFailure: (failure) => + reportBrunchFailure("mutate-petrinet", failure.error, { + toolCallId: failure.toolCallId, + operationId: failure.operationId, + operationType: failure.operationType, + status: failure.status, + }), + }, + } + : {}), + settleDocumentRevision: (revisionId) => + settleRevision({ + documentId: currentDocument.documentId, + revisionId, + }), + }), + // The drafted-experiment card is the one Brunch tool the person answers + // in the panel: it prepares against the live model, reports "drafted", + // and waits for Run or Dismiss. Session-only; nothing is persisted. + interactiveTools: [ + createBrunchDraftExperimentInteractiveTool({ + readTitle: () => currentNetTitle, + }), + ], + transport, + ...(mutationRecorder === undefined + ? {} + : { executeMutation: mutationRecorder.executeMutation }), + requestStop: () => + requestFlueStop(flueClientPromise, conversationTracker), + followMessages: { + // This closure and `messages` below describe the same observed + // snapshot, never a later mutable settlement cache. + canReplace: () => + conversationTracker.canReplaceMessages(flueHistory.snapshot), + }, + messages: flueHistory.messages, + ...(brunchVoiceMode ? { renderVoiceMode: brunchVoiceMode } : {}), + }; + }, [ + brunchVoiceMode, + observedLiveHash, + constructionBrowser, + conversationTracker, + conversationId, + currentDocument.documentId, + currentNetTitle, + flueClientPromise, + flueHistory.messages, + flueHistory.phase, + flueHistory.ready, + flueHistory.snapshot, + transport, + reportBrunchFailure, + mutationRecorder, + settleRevision, + ]); + + usePetrinautAiAssistant(assistant); + return null; +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-site-config.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-site-config.ts new file mode 100644 index 00000000000..c56cefcbedd --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-site-config.ts @@ -0,0 +1,10 @@ +import { resolveBrunchPreviewConfig } from "../../brunch-preview-config"; +import { getOrCreateBrunchPrincipal } from "../../brunch-principal"; + +/** This site's Brunch endpoint, and whether one is configured at all. */ +export const brunchPreviewConfig = resolveBrunchPreviewConfig( + import.meta.env.VITE_BRUNCH_CHAT_ENDPOINT, +); + +/** The browser's Brunch principal, kept across visits. */ +export const brunchPrincipal = getOrCreateBrunchPrincipal(); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-voice-setting.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-voice-setting.test.tsx new file mode 100644 index 00000000000..11cfba6f2fb --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-voice-setting.test.tsx @@ -0,0 +1,77 @@ +/** + * @vitest-environment jsdom + */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { BrunchVoiceSetting } from "./brunch-voice-setting"; + +import type { ComponentProps } from "react"; + +const voiceConfig = { + available: true, + connectionTimeoutMs: 10_000, +} as const; + +const defaultProps = { + brunchActive: true, + openAIVoiceConfig: voiceConfig, + setVoiceEnabled: vi.fn(), + voiceEnabled: false, + voicePreferenceReady: true, +} satisfies ComponentProps; + +beforeEach(() => { + vi.stubGlobal("PointerEvent", MouseEvent); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +test("enables Voice while Brunch is active, and explains when it is unavailable", async () => { + const setVoiceEnabled = vi.fn(); + const props = { ...defaultProps, setVoiceEnabled }; + const view = render(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Enable Voice" })); + await waitFor(() => expect(setVoiceEnabled).toHaveBeenCalledWith(true)); + + view.rerender(); + expect( + screen.getByText("Voice is unavailable in this deployment."), + ).toBeDefined(); +}); + +test.each([ + { + name: "another assistant active", + props: { brunchActive: false }, + description: "Select Brunch to enable Voice.", + }, + { + name: "Voice preference loading", + props: { voicePreferenceReady: false }, + description: "Loading your Voice preference…", + }, + { + name: "Voice capability loading", + props: { openAIVoiceConfig: undefined }, + description: "Checking whether Voice is available…", + }, +])("disables Voice during $name", ({ props, description }) => { + render(); + + const toggle = screen.getByRole("checkbox", { name: "Enable Voice" }); + expect(toggle).toHaveProperty("checked", false); + expect(toggle).toHaveProperty("disabled", true); + expect(screen.getByText(description)).toBeDefined(); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-voice-setting.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-voice-setting.tsx new file mode 100644 index 00000000000..3106a154727 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/brunch/brunch-voice-setting.tsx @@ -0,0 +1,82 @@ +import { useId } from "react"; + +import { Toggle } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import type { OpenAIVoiceConfig } from "../../../voice-interview/voice-interview-control"; + +const rowStyle = css({ + display: "grid", + gridTemplateColumns: "[minmax(0, 1fr) auto]", + alignItems: "center", + gap: "4", + paddingX: "3", + paddingY: "2.5", +}); + +const labelStyle = css({ + display: "block", + marginBottom: "0.5", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[1.4]", + color: "neutral.fg.heading", +}); + +const descriptionStyle = css({ + margin: "0", + fontSize: "xs", + lineHeight: "[1.6]", + color: "neutral.fg.body", +}); + +export const BrunchVoiceSetting = ({ + brunchActive, + openAIVoiceConfig, + setVoiceEnabled, + voiceEnabled, + voicePreferenceReady, +}: { + readonly brunchActive: boolean; + readonly openAIVoiceConfig: OpenAIVoiceConfig | null | undefined; + readonly setVoiceEnabled: (enabled: boolean) => void; + readonly voiceEnabled: boolean; + readonly voicePreferenceReady: boolean; +}) => { + const id = useId(); + const description = !voicePreferenceReady + ? "Loading your Voice preference…" + : !brunchActive + ? "Select Brunch to enable Voice." + : openAIVoiceConfig === undefined + ? "Checking whether Voice is available…" + : openAIVoiceConfig === null + ? "Voice is unavailable in this deployment." + : "Make Voice mode available for Brunch conversations."; + + return ( +
+
+ + Enable Voice + +

+ {description} +

+
+ +
+ ); +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/demo-assistant-host.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/demo-assistant-host.ts new file mode 100644 index 00000000000..371172b9a6d --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/demo-assistant-host.ts @@ -0,0 +1,55 @@ +import { createContext, type RefObject, use } from "react"; + +import type { OpenAIVoiceConfig } from "../../voice-interview/voice-interview-control"; +import type { ActiveHandle } from "../active-handle"; +import type { + DocumentRecord, + DocumentRepository, + ProcessAgentSeed, +} from "../documents/document-repository"; +import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; + +export type StockMessagesByNetId = Record; + +/** + * What the demo host owns and its assistant plugins read: the open document + * and its live handle, the repository's durability barrier, the stock + * assistant's per-net history, and the Voice preference and availability. + * The plugins are module constants; only this value changes. + */ +export type DemoAssistantHost = { + activeHandle: ActiveHandle; + /** The same handle, for callbacks that must read it after render. */ + activeHandleRef: RefObject; + document: DocumentRecord; + processAgentSeed: ProcessAgentSeed | undefined; + settleRevision: DocumentRepository["settleRevision"]; + stockMessages: { + byNetId: StockMessagesByNetId; + setByNetId: ( + update: (previous: StockMessagesByNetId) => StockMessagesByNetId, + ) => void; + }; + voice: { + enabled: boolean; + ready: boolean; + setEnabled: (enabled: boolean) => void; + /** `undefined` while unknown, `null` where this deployment has none. */ + config: OpenAIVoiceConfig | null | undefined; + setConfig: (config: OpenAIVoiceConfig | null | undefined) => void; + }; +}; + +export const DemoAssistantHostContext = createContext( + null, +); + +export const useDemoAssistantHost = (): DemoAssistantHost => { + const host = use(DemoAssistantHostContext); + if (host === null) { + throw new Error( + "A demo assistant plugin must render inside DemoAssistantHostContext.", + ); + } + return host; +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/stock/stock-assistant-plugin.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/stock/stock-assistant-plugin.tsx new file mode 100644 index 00000000000..1d07b87e5a3 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistants/stock/stock-assistant-plugin.tsx @@ -0,0 +1,59 @@ +import { useMemo } from "react"; + +import { + DefaultChatTransport, + definePetrinautPlugin, + type PetrinautAiAssistant, + type PetrinautAiMessage, + usePetrinautAiAssistant, +} from "@hashintel/petrinaut/ui"; + +import { VOICE_REQUEST_ID_HEADER } from "../../../../../voice-diagnostics"; +import { stockChatEndpoint } from "../../assistant-selection"; +import { useDemoAssistantHost } from "../demo-assistant-host"; + +export const stockAssistantId = "website.stock-assistant"; + +// The stock assistant's transport is the same whether or not Brunch is +// configured: selecting the stock assistant must not route it through Brunch. +const stockChatTransport = new DefaultChatTransport({ + api: stockChatEndpoint, + headers: () => ({ + [VOICE_REQUEST_ID_HEADER]: crypto.randomUUID(), + }), +}); + +/** Petrinaut's own assistant, with its history kept per net in local storage. */ +const StockAssistant = () => { + const { document, stockMessages } = useDemoAssistantHost(); + const netId = document.documentId; + const { byNetId, setByNetId } = stockMessages; + const assistant = useMemo( + () => ({ + canClearMessages: true, + automaticTools: [], + interactiveTools: [], + transport: stockChatTransport, + messages: byNetId[netId], + onMessages: (messages: PetrinautAiMessage[]) => + setByNetId((previous) => ({ ...previous, [netId]: messages })), + onClearMessages: () => + setByNetId((previous) => { + const next = { ...previous }; + delete next[netId]; + return next; + }), + }), + [byNetId, netId, setByNetId], + ); + usePetrinautAiAssistant(assistant); + return null; +}; + +export const stockAssistantPlugin = definePetrinautPlugin({ + id: stockAssistantId, + name: "Stock Petrinaut assistant", + assistants: [ + { id: stockAssistantId, label: "Petrinaut", component: StockAssistant }, + ], +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx index 7f025317c65..d46808d28f0 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx @@ -37,14 +37,14 @@ import { parseAssistantSelection, resolveDefaultAssistantSelection, } from "./assistant-selection"; -import { brunchClientToolNames } from "./brunch-client-tools"; -import { ordinaryConstructionConversationIdFrom } from "./brunch-conversation-id"; -import { BrunchPanelConversationTracker } from "./brunch-panel-transport"; import { getBrunchVoiceMode, - LocalStorageDemoApp, requestFlueStop, -} from "./local-storage-demo-app"; +} from "./assistants/brunch/brunch-assistant"; +import { brunchClientToolNames } from "./brunch-client-tools"; +import { ordinaryConstructionConversationIdFrom } from "./brunch-conversation-id"; +import { BrunchPanelConversationTracker } from "./brunch-panel-transport"; +import { LocalStorageDemoApp } from "./local-storage-demo-app"; import { localStorageDemoRouteIdentity, withLocalStorageDemoIdentity, @@ -134,7 +134,6 @@ vi.mock("./brunch-preview-config", () => ({ const editorProps = vi.hoisted(() => ({ current: null as { - aiAssistant?: unknown; createNewNet?: (params: { petriNetDefinition: unknown; title: string; @@ -143,7 +142,6 @@ const editorProps = vi.hoisted(() => ({ handle?: unknown; loadPetriNet?: unknown; navigation?: unknown; - slots?: { settingsLabs?: ReactNode }; title?: string; } | null, })); @@ -216,10 +214,31 @@ vi.mock("./brunch-panel-transport", async (importOriginal) => { vi.mock("@hashintel/petrinaut/ui", async () => { const { createContext, use } = await import("react"); - type MockPlugin = { id: string; component?: ComponentType }; - // The editor mounts the installed plugins' components; the mock does the - // same, so the palette plugin renders under the mocked editor. + const { UserSettingsContext, useCommand } = + await import("@hashintel/petrinaut/react"); + type MockAssistant = { id: string; label: string; component: ComponentType }; + type MockPlugin = { + id: string; + component?: ComponentType; + assistants?: readonly MockAssistant[]; + settingsGroups?: readonly { id: string; component: ComponentType }[]; + }; + // The editor mounts the installed plugins' components, the active + // assistant's component and the settings groups; the mock does the same, + // and resolves the active assistant as the editor does: the one chosen in + // User settings, else the first installed. const PluginsContext = createContext([]); + const ActiveAssistantIdContext = createContext(undefined); + const SwitchCommand = ({ assistant }: { assistant: MockAssistant }) => { + const { setAiAssistantId } = use(UserSettingsContext); + useCommand({ + id: `petrinaut.ai-assistant.use:${assistant.id}`, + label: `Use the ${assistant.label} assistant`, + category: "Editor", + run: () => setAiAssistantId(assistant.id), + }); + return null; + }; return { DefaultChatTransport: class { public constructor(options: unknown) { @@ -228,20 +247,43 @@ vi.mock("@hashintel/petrinaut/ui", async () => { }, Petrinaut: (props: Record) => { editorProps.current = props; - renderedPetrinaut.aiAssistant = props.aiAssistant; - renderedAssistants.push(props.aiAssistant as PetrinautAiAssistant); const plugins = use(PluginsContext); + const { aiAssistantId } = use(UserSettingsContext); + const assistants = plugins.flatMap((plugin) => plugin.assistants ?? []); + const active = + assistants.find((assistant) => assistant.id === aiAssistantId) ?? + assistants[0]; + const Assistant = active?.component; return ( - <> + {plugins.map((plugin) => { const PluginComponent = plugin.component; return PluginComponent ? : null; })} - {(props.slots as { settingsLabs?: ReactNode } | undefined) - ?.settingsLabs ?? null} - + {Assistant ? : null} + {assistants.length > 1 + ? assistants + .filter((assistant) => assistant !== active) + .map((assistant) => ( + + )) + : null} + {plugins + .flatMap((plugin) => plugin.settingsGroups ?? []) + .map((group) => { + const Group = group.component; + return ; + })} + ); }, + usePetrinautAiAssistant: (assistant: PetrinautAiAssistant | null) => { + renderedPetrinaut.aiAssistant = assistant; + if (assistant !== null) { + renderedAssistants.push(assistant); + } + }, + usePetrinautActiveAssistantId: () => use(ActiveAssistantIdContext), WalkthroughProvider: ({ children }: { children: ReactNode }) => children, PetrinautPluginsProvider: ({ plugins, @@ -552,9 +594,6 @@ describe("local storage demo Brunch voice integration", () => { expect( aiAssistant.interactiveTools?.map(({ toolName }) => toolName), ).toEqual(["draft_petrinaut_experiment"]); - expect(editorProps.current?.slots).not.toHaveProperty( - "simulateModeIndicator", - ); expect(aiAssistant.resolveToolPresentation).toBeTypeOf("function"); expect(aiAssistant.workingLabel).toBe("Brunch is working"); expect( @@ -1372,9 +1411,8 @@ describe("local storage demo Brunch controls", () => { }; render( {}} search={{}} />); - await waitFor(() => expect(editorProps.current?.aiAssistant).toBeDefined()); - const aiAssistant = editorProps.current - ?.aiAssistant as PetrinautAiAssistant; + await waitFor(() => expect(renderedPetrinaut.aiAssistant).not.toBeNull()); + const aiAssistant = renderedPetrinaut.aiAssistant as PetrinautAiAssistant; const transportOptions = brunchPanelTransportOptions.current as { readonly initialData?: { readonly mode?: string; @@ -1476,9 +1514,8 @@ describe("worked-model net-projection selection", () => { await waitFor(() => expect(editorProps.current?.title).toBe("Inventory purchasing"), ); - const assistant = editorProps.current?.aiAssistant as - | PetrinautAiAssistant - | undefined; + const assistant = + renderedPetrinaut.aiAssistant as PetrinautAiAssistant | null; expect(assistant?.conversationId).toBe("bundle-conversation"); expect(assistant?.executeMutation).toBeDefined(); const handle = editorProps.current?.handle as PetrinautDocHandle; @@ -1607,7 +1644,7 @@ describe("worked-model net-projection selection", () => { fireEvent.keyDown(window, { key: "k", metaKey: true }); expect( screen.queryByRole("button", { - name: /stock Petrinaut assistant|Use Brunch/, + name: /Use the (Petrinaut|Brunch) assistant/, }), ).toBeNull(); }); @@ -1823,7 +1860,7 @@ describe("worked-model net-projection selection", () => { await waitFor(() => expect( - (editorProps.current?.aiAssistant as PetrinautAiAssistant | undefined) + (renderedPetrinaut.aiAssistant as PetrinautAiAssistant | null) ?.automaticTools, ).toEqual([]), ); @@ -1958,14 +1995,17 @@ describe("assistant selection", () => { fireEvent.click(screen.getByRole("button", { name: label })); }; const currentAssistant = () => - editorProps.current?.aiAssistant as PetrinautAiAssistant; - const currentVoiceCapability = () => { - const settingsLabs = editorProps.current?.slots?.settingsLabs; - if (!isValidElement<{ openAIVoiceConfig: unknown }>(settingsLabs)) { - throw new Error("Expected the website Labs settings to render."); - } - return settingsLabs.props.openAIVoiceConfig; - }; + renderedPetrinaut.aiAssistant as PetrinautAiAssistant; + /** What Brunch's Labs group says about Voice, which follows its capability check. */ + const voiceDescription = () => + screen.getByText("Enable Voice", { selector: "span" }).nextElementSibling + ?.textContent; + const chosenAssistantId = () => + ( + JSON.parse(localStorage.getItem("petrinaut:user-settings") ?? "{}") as { + aiAssistantId?: string | null; + } + ).aiAssistantId; afterEach(() => { cleanup(); @@ -2012,20 +2052,15 @@ describe("assistant selection", () => { const firstView = render( {}} search={{}} />, ); - const firstBrunchToggle = await screen.findByRole("checkbox", { - name: "Use Brunch", - }); - const firstVoiceToggle = screen.getByRole("checkbox", { + const firstVoiceToggle = await screen.findByRole("checkbox", { name: "Enable Voice", }); - await waitFor(() => - expect(firstBrunchToggle).toHaveProperty("disabled", false), - ); expect(firstVoiceToggle).toHaveProperty("checked", false); + expect(firstVoiceToggle).toHaveProperty("disabled", true); - fireEvent.click(firstBrunchToggle); + switchAssistant(/Use the Brunch assistant/); await waitFor(() => { - expect(localStorage.getItem(assistantSelectionStorageKey)).toBe("brunch"); + expect(chosenAssistantId()).toBe("website.brunch"); expect(firstVoiceToggle).toHaveProperty("disabled", false); }); fireEvent.click(firstVoiceToggle); @@ -2035,14 +2070,11 @@ describe("assistant selection", () => { firstView.unmount(); render( {}} search={{}} />); - const restoredBrunchToggle = await screen.findByRole("checkbox", { - name: "Use Brunch", - }); - const restoredVoiceToggle = screen.getByRole("checkbox", { + const restoredVoiceToggle = await screen.findByRole("checkbox", { name: "Enable Voice", }); await waitFor(() => { - expect(restoredBrunchToggle).toHaveProperty("checked", true); + expect(currentAssistant().executeMutation).toBeDefined(); expect(restoredVoiceToggle).toHaveProperty("checked", true); expect(restoredVoiceToggle).toHaveProperty("disabled", false); }); @@ -2060,7 +2092,7 @@ describe("assistant selection", () => { expect(localStorage.getItem(assistantSelectionStorageKey)).toBe("brunch"); const brunchTransport = currentAssistant().transport; - switchAssistant(/Use the stock Petrinaut assistant/); + switchAssistant(/Use the Petrinaut assistant/); await waitFor(() => expect(currentAssistant().executeMutation).toBeUndefined(), @@ -2080,8 +2112,10 @@ describe("assistant selection", () => { expect(stock.requestStop).toBeUndefined(); expect(stock.followMessages).toBeUndefined(); expect(stock.canClearMessages).toBe(true); - // The preference persists as the host's own key. - expect(localStorage.getItem(assistantSelectionStorageKey)).toBe("stock"); + // The choice persists in Petrinaut's User settings; the site's earlier + // key is only read. + expect(chosenAssistantId()).toBe("website.stock-assistant"); + expect(localStorage.getItem(assistantSelectionStorageKey)).toBe("brunch"); // Brunch demo affordances are gone with it. fireEvent.keyDown(window, { key: "k", metaKey: true }); expect( @@ -2103,10 +2137,9 @@ describe("assistant selection", () => { ); await waitFor(() => { expect(fetch).toHaveBeenCalledOnce(); - expect(currentVoiceCapability()).toEqual({ - available: true, - connectionTimeoutMs: 10_000, - }); + expect(voiceDescription()).toBe( + "Make Voice mode available for Brunch conversations.", + ); }); expect(currentAssistant().renderVoiceMode).toBeUndefined(); @@ -2118,7 +2151,7 @@ describe("assistant selection", () => { ); renderedAssistants.length = 0; - switchAssistant(/Use the stock Petrinaut assistant/); + switchAssistant(/Use the Petrinaut assistant/); expect(renderedAssistants.length).toBeGreaterThan(0); expect( @@ -2128,6 +2161,43 @@ describe("assistant selection", () => { ).toBe(true); }); + test("keeps the Voice answer when another net opens", async () => { + const incarnationId = "voice-across-nets-incarnation"; + seedStoredNet(incarnationId); + localStorage.setItem(assistantSelectionStorageKey, "brunch"); + localStorage.setItem(voicePreferenceStorageKey, "true"); + flueClientMock.current = flueHistoryClient(incarnationId); + const fetch = vi.fn(async () => + Response.json({ available: true, connectionTimeoutMs: 10_000 }), + ); + vi.stubGlobal("fetch", fetch); + render( {}} search={{}} />); + await waitFor(() => + expect(voiceDescription()).toBe( + "Make Voice mode available for Brunch conversations.", + ), + ); + + act(() => { + editorProps.current?.createNewNet?.({ + petriNetDefinition: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + title: "Another net", + }); + }); + + await waitFor(() => expect(editorProps.current?.title).toBe("Another net")); + expect(voiceDescription()).toBe( + "Make Voice mode available for Brunch conversations.", + ); + expect(fetch).toHaveBeenCalledOnce(); + }); + test("clears cached Voice capability during each Stock to Brunch check", async () => { const incarnationId = "delayed-voice-capability-incarnation"; seedStoredNet(incarnationId); @@ -2145,9 +2215,9 @@ describe("assistant selection", () => { expect(currentAssistant().renderVoiceMode).toBeUndefined(); renderedAssistants.length = 0; - switchAssistant(/Use Brunch/); + switchAssistant(/Use the Brunch assistant/); await waitFor(() => expect(fetch).toHaveBeenCalledOnce()); - expect(currentVoiceCapability()).toBeUndefined(); + expect(voiceDescription()).toBe("Checking whether Voice is available…"); expect(currentAssistant().renderVoiceMode).toBeUndefined(); expect(renderedAssistants.length).toBeGreaterThan(0); expect( @@ -2163,14 +2233,14 @@ describe("assistant selection", () => { expect(currentAssistant().renderVoiceMode).toBeDefined(), ); - switchAssistant(/Use the stock Petrinaut assistant/); + switchAssistant(/Use the Petrinaut assistant/); await waitFor(() => expect(currentAssistant().renderVoiceMode).toBeUndefined(), ); renderedAssistants.length = 0; - switchAssistant(/Use Brunch/); + switchAssistant(/Use the Brunch assistant/); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - expect(currentVoiceCapability()).toBeUndefined(); + expect(voiceDescription()).toBe("Checking whether Voice is available…"); expect(currentAssistant().renderVoiceMode).toBeUndefined(); expect(renderedAssistants.length).toBeGreaterThan(0); expect( @@ -2212,7 +2282,7 @@ describe("assistant selection", () => { "net-1": [stockMessage], }); - switchAssistant(/Use Brunch/); + switchAssistant(/Use the Brunch assistant/); await waitFor(() => expect(currentAssistant().executeMutation).toBeDefined(), ); @@ -2231,7 +2301,7 @@ describe("assistant selection", () => { "net-1": [stockMessage], }); - switchAssistant(/Use the stock Petrinaut assistant/); + switchAssistant(/Use the Petrinaut assistant/); await waitFor(() => expect(currentAssistant().executeMutation).toBeUndefined(), ); @@ -2249,7 +2319,7 @@ describe("assistant selection", () => { fireEvent.keyDown(window, { key: "k", metaKey: true }); expect( screen.queryByRole("button", { - name: /stock Petrinaut assistant|Use Brunch/, + name: /Use the (Petrinaut|Brunch) assistant/, }), ).toBeNull(); fireEvent.keyDown(window, { key: "Escape" }); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index fddabcccd92..c38499e7795 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -4,31 +4,16 @@ */ import { - createFlueClient, - type FlueConversationSettlement, - type FlueConversationState, -} from "@flue/sdk"; -import { - use, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, - useSyncExternalStore, - type RefObject, } from "react"; -import { batchedConstructionMode } from "@hashintel/brunch-agent-plugin-sdcpn"; -import { - agentOwnershipHeaders, - flueConversationIdWeb, -} from "@hashintel/brunch-agent-transport-aisdk"; -import { BRUNCH_DOCUMENT_REVISION_HEADER } from "@hashintel/brunch-agent-transport-aisdk/headers"; import { createJsonDocHandle, - type DocumentRevisionId, type MinimalNetMetadata, type PetrinautDocHandle, type PetrinautHandleCapabilities, @@ -36,17 +21,12 @@ import { } from "@hashintel/petrinaut-core"; import { CommandRegistryProvider, - ErrorTrackerContext, useCommand, UserSettingsProvider, } from "@hashintel/petrinaut/react"; import { - DefaultChatTransport, Petrinaut, - type PetrinautAiMessage, - type PetrinautAiStopResult, - type PetrinautAiVoiceMode, - type PetrinautAiVoiceModeContext, + type PetrinautPlugin, PetrinautPluginsProvider, WalkthroughProvider, } from "@hashintel/petrinaut/ui"; @@ -56,54 +36,32 @@ import { withClearedSharedLocation, } from "../../../examples/use-shared-search-navigation"; import { sentryFeedbackPlugin } from "../../../sentry/sentry-feedback-plugin"; -import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics"; import { commandPalettePlugin } from "../command-palette"; import { - loadOpenAIVoiceConfig, - type OpenAIVoiceConfig, - VoiceInterviewControl, -} from "../voice-interview/voice-interview-control"; -import { AssistantLabsSettings } from "./assistant-labs-settings"; -import { - isBrunchSelected, - stockChatEndpoint, type AssistantSelection, - useAssistantSelection, + assistantSelectionStorageKey, + parseAssistantSelection, } from "./assistant-selection"; +import { brunchAssistantPlugin } from "./assistants/brunch/brunch-assistant-plugin"; import { - useProcessAgentBinding, - type FixtureProcessAgentConfiguration, - type ProcessAgentBinding, -} from "./assistants/brunch/use-process-agent-binding"; + brunchPreviewConfig, + brunchPrincipal, +} from "./assistants/brunch/brunch-site-config"; import { - batchedConstructionClientToolNames, - brunchPetrinautDynamicToolNames, -} from "./brunch-client-tools"; -import { ordinaryConstructionConversationIdFrom } from "./brunch-conversation-id"; -import { createBrunchDraftExperimentInteractiveTool } from "./brunch-draft-experiment-interactive-tool"; -import { - BrunchPanelConversationTracker, - type BrunchPanelAdmissionTarget, - createBrunchPanelTransport, -} from "./brunch-panel-transport"; -import { createBrunchPetrinautTools } from "./brunch-petrinaut-tools"; -import { resolveBrunchPreviewConfig } from "./brunch-preview-config"; -import { getOrCreateBrunchPrincipal } from "./brunch-principal"; -import { resolveBrunchToolPresentation } from "./brunch-tool-presentation"; -import { foldBrunchWorkpieceHistory } from "./brunch-workpiece-history"; -import { BrunchWorkpiecePane } from "./brunch-workpiece-pane"; + type DemoAssistantHost, + DemoAssistantHostContext, +} from "./assistants/demo-assistant-host"; +import { stockAssistantPlugin } from "./assistants/stock/stock-assistant-plugin"; +import { readBrowserStorage } from "./browser-storage"; import { useDocumentController } from "./documents/use-document-controller"; import { localStorageDemoRouteIdentity } from "./local-storage-demo-search"; -import { - createJoinedBrowserMutationRecorder, - observeBrowserDefinition, -} from "./mutation-record"; -import { useFlueChatHistory } from "./use-flue-chat-history"; import { useLocalStorageAiMessages } from "./use-local-storage-ai-messages"; import { emptySDCPN } from "./use-local-storage-sdcpns"; import { useVoicePreference } from "./voice-preference"; import { walkthroughSteps } from "./walkthrough/walkthrough-steps"; +import type { OpenAIVoiceConfig } from "../voice-interview/voice-interview-control"; +import type { ActiveHandle } from "./active-handle"; import type { DocumentRecord } from "./documents/document-repository"; import type { LocalStorageDemoSearch } from "./local-storage-demo-search"; @@ -115,55 +73,23 @@ const DEMO_CAPABILITIES = { // palette with its top-bar button. The editor's built-ins stay installed. const demoPlugins = [sentryFeedbackPlugin, commandPalettePlugin]; -const brunchPreviewConfig = resolveBrunchPreviewConfig( - import.meta.env.VITE_BRUNCH_CHAT_ENDPOINT, -); - -export const getBrunchVoiceMode = ( - config: OpenAIVoiceConfig | null | undefined, - tracker?: BrunchPanelConversationTracker, - settlements?: readonly FlueConversationSettlement[], - snapshot?: FlueConversationState, -): PetrinautAiVoiceMode | undefined => { - if (!config) return undefined; - - const resolveInputSubmission = tracker?.submissionForInput.bind(tracker); - const resolveResponseSubmission = - tracker?.submissionsForResponse.bind(tracker); - const subscribeToResponseMessageCompleted = - tracker?.subscribeToResponseMessageCompleted.bind(tracker); - const subscribeToResponseMessageStarted = - tracker?.subscribeToResponseMessageStarted.bind(tracker); - const subscribeToStopRequested = - tracker?.subscribeToStopRequested.bind(tracker); - const subscribeToAdmission = - tracker === undefined - ? undefined - : (target: BrunchPanelAdmissionTarget, listener: (id: string) => void) => - tracker.subscribeToAdmission(target, ({ admission }) => - listener(admission.submissionId), - ); - const subscribeToAdmissionFailure = - tracker?.subscribeToAdmissionFailure.bind(tracker); - - return (context: PetrinautAiVoiceModeContext) => ( - +/** + * The assistants each route offers, as plugins. Stock is the default and + * Brunch the alternate where a Brunch endpoint is configured; the first is + * active until the user chooses in User settings. A remote worked-model + * document is Brunch's alone. + */ +const assistantPlugins = { + stockOnly: [stockAssistantPlugin, ...demoPlugins], + stockFirst: [stockAssistantPlugin, brunchAssistantPlugin, ...demoPlugins], + brunchFirst: [brunchAssistantPlugin, stockAssistantPlugin, ...demoPlugins], + brunchOnly: [brunchAssistantPlugin, ...demoPlugins], +} satisfies Record; + +const readLegacyAssistantChoice = (): AssistantSelection => + parseAssistantSelection( + readBrowserStorage(localStorage, assistantSelectionStorageKey), ); -}; const createHandle = (document: DocumentRecord): PetrinautDocHandle => createJsonDocHandle({ @@ -173,75 +99,6 @@ const createHandle = (document: DocumentRecord): PetrinautDocHandle => capabilities: DEMO_CAPABILITIES, }); -const brunchPrincipal = getOrCreateBrunchPrincipal(); - -// The stock assistant's transport is the same whether or not Brunch is -// configured: selecting the stock assistant must not route it through Brunch. -const stockChatTransport = new DefaultChatTransport({ - api: stockChatEndpoint, - headers: () => ({ - [VOICE_REQUEST_ID_HEADER]: crypto.randomUUID(), - }), -}); - -const createBrunchFlueClient = async ( - conversationId: string, - currentRevisionId: () => string | undefined, -) => { - const identity = { conversationId, principalKey: brunchPrincipal }; - const instanceId = await flueConversationIdWeb(identity); - const mountUrl = new URL( - brunchPreviewConfig.chatEndpoint, - window.location.origin, - ); - mountUrl.pathname = `${mountUrl.pathname.replace(/\/+$/u, "")}/${instanceId}`; - return createFlueClient({ - url: mountUrl.href, - headers: () => { - const revisionId = currentRevisionId(); - return { - ...agentOwnershipHeaders(identity), - ...(revisionId === undefined - ? {} - : { [BRUNCH_DOCUMENT_REVISION_HEADER]: revisionId }), - }; - }, - }); -}; - -/** - * Flue's `abort()` is conversation-wide and only reaches unsettled work, so a - * Stop pressed while `send()` is still in flight must first let that admission - * land; otherwise `aborted: false` would read as "already settled" while the - * admitted turn keeps running. - */ -export const requestFlueStop = async ( - clientPromise: Promise>, - tracker: BrunchPanelConversationTracker, -): Promise => { - tracker.recordStopRequested(); - const client = await clientPromise; - await tracker.settleInFlightSubmissions(); - const result = await client.abort(); - return result.aborted ? "stop-requested" : "already-settled"; -}; - -const createConversationTrackerFor = ( - _conversationId: string | null, -): BrunchPanelConversationTracker => new BrunchPanelConversationTracker(); - -type ActiveHandle = { - handle: PetrinautDocHandle; - document: DocumentRecord; - /** - * Every revision this handle has produced (plus the one it opened at). A - * repository revision outside this set was written by someone else — another - * tab, typically — and the handle must be recreated from it rather than keep - * chaining edits from a predecessor the repository no longer holds. - */ - emittedRevisionIds: Set; -}; - type PersistFailure = { /** The handle whose change was refused; it is replaced, not kept. */ handle: PetrinautDocHandle; @@ -250,37 +107,6 @@ type PersistFailure = { error: Error; }; -const useProcessAgentSession = (input: { - readonly activeHandleRef: RefObject; - readonly binding: ProcessAgentBinding | null; - readonly brunchSelected: boolean; -}) => { - "use no memo"; // The Flue header callback deliberately reads the live handle ref after render. - - const readCurrentRevisionId = useCallback(() => { - const handle = input.activeHandleRef.current; - return input.binding !== null && - handle?.document.documentId === input.binding.documentId && - handle.document.incarnationId === input.binding.incarnationId - ? handle.handle.revisionId.get() - : undefined; - }, [input.activeHandleRef, input.binding]); - - return useMemo(() => { - const conversationTracker = createConversationTrackerFor( - input.binding?.conversationId ?? null, - ); - const flueClientPromise = - input.brunchSelected && input.binding !== null - ? createBrunchFlueClient( - input.binding.conversationId, - readCurrentRevisionId, - ) - : null; - return { conversationTracker, flueClientPromise }; - }, [input.binding, input.brunchSelected, readCurrentRevisionId]); -}; - const createActiveHandle = (document: DocumentRecord): ActiveHandle => { const handle = createHandle(document); return { @@ -292,20 +118,14 @@ const createActiveHandle = (document: DocumentRecord): ActiveHandle => { /** * The demo's own palette commands, registered beside Petrinaut's: one starts - * a fresh net, one switches between Brunch and the stock assistant, one + * a fresh net, and one projects a worked-model template into a fresh net. */ const DemoCommands = ({ createNewNet, createCleanNetProjection, - brunchSelected, - canSelectAssistant, - selectAssistant, }: { createNewNet: (params: { petriNetDefinition: SDCPN; title: string }) => void; createCleanNetProjection?: () => Promise; - brunchSelected: boolean; - canSelectAssistant: boolean; - selectAssistant: (selection: "brunch" | "stock") => void; }) => { useCommand({ id: "demo.net.new", @@ -315,8 +135,6 @@ const DemoCommands = ({ run: () => createNewNet({ petriNetDefinition: emptySDCPN, title: "New Process" }), }); - // Only offered when there is a Brunch to select; without an endpoint the - // stock assistant is the only one and the choice would be a fiction. useCommand( { id: "demo.worked-model.fresh-net-projection", @@ -329,18 +147,6 @@ const DemoCommands = ({ }, { when: createCleanNetProjection !== undefined }, ); - useCommand( - { - id: "demo.assistant.switch", - label: brunchSelected - ? "Use the stock Petrinaut assistant" - : "Use Brunch", - category: "Demo", - keywords: ["assistant", "brunch", "stock", "ai"], - run: () => selectAssistant(brunchSelected ? "stock" : "brunch"), - }, - { when: brunchPreviewConfig.isBrunchConfigured && canSelectAssistant }, - ); return null; }; @@ -365,33 +171,35 @@ export const LocalStorageDemoApp = ({ const routeIdentity = localStorageDemoRouteIdentity(search); const remoteRouteSelected = routeIdentity === "worked-model-bundle" && search.bundle !== undefined; - // Stock is the default assistant; Brunch is the host-selected hidden alternate. - // Every Brunch-specific branch below keys off this, never off bare configuration, - // so selecting stock leaves no Brunch dependency behind. - const { - ready: assistantSelectionReady, - selection: assistantSelection, - setSelection: setAssistantSelection, - } = useAssistantSelection({ enabled: !remoteRouteSelected }); + // Which assistant comes first, and so is active until the user chooses in + // User settings: this site's earlier browser-local choice, else the build's + // default. Read once, the first time a local route renders. + const [defaultAssistant, setDefaultAssistant] = + useState(() => + remoteRouteSelected ? null : readLegacyAssistantChoice(), + ); + if (!remoteRouteSelected && defaultAssistant === null) { + setDefaultAssistant(readLegacyAssistantChoice()); + } + const plugins = remoteRouteSelected + ? brunchPreviewConfig.isBrunchConfigured + ? assistantPlugins.brunchOnly + : assistantPlugins.stockOnly + : !brunchPreviewConfig.isBrunchConfigured + ? assistantPlugins.stockOnly + : defaultAssistant === "brunch" + ? assistantPlugins.brunchFirst + : assistantPlugins.stockFirst; const { enabled: voiceEnabled, ready: voicePreferenceReady, setEnabled: setVoiceEnabled, } = useVoicePreference(); - const brunchSelected = remoteRouteSelected - ? brunchPreviewConfig.isBrunchConfigured - : assistantSelectionReady && - isBrunchSelected( - brunchPreviewConfig.isBrunchConfigured, - assistantSelection, - ); + // Brunch's assistant checks whether this deployment offers Voice while it is + // active; its Labs group reads the answer here. const [openAIVoiceConfig, setOpenAIVoiceConfig] = useState< OpenAIVoiceConfig | null | undefined >(undefined); - const selectAssistant = (selection: AssistantSelection) => { - setOpenAIVoiceConfig(selection === "brunch" ? undefined : null); - setAssistantSelection(selection); - }; /** * History is left to the library's default on purpose. That default already * replaces rather than pushes while an intent continues, so a drag-select @@ -421,8 +229,6 @@ export const LocalStorageDemoApp = ({ const { aiMessagesByNetId, setAiMessagesByNetId } = useLocalStorageAiMessages( { enabled: !remoteRouteSelected }, ); - const productConstructionSelected = brunchSelected; - const batchedConstructionSelected = productConstructionSelected; const selectLocalRoute = useCallback( () => onSearchChange( @@ -445,27 +251,6 @@ export const LocalStorageDemoApp = ({ }); const { source } = controller; const currentDocument = source.repository.current; - const currentNetId = currentDocument?.documentId ?? null; - const currentNetTitle = currentDocument?.title ?? ""; - - useEffect(() => { - if (!brunchSelected) { - return; - } - - const abortController = new AbortController(); - setOpenAIVoiceConfig(undefined); - void loadOpenAIVoiceConfig( - globalThis.fetch.bind(globalThis), - abortController.signal, - ).then((config) => { - if (!abortController.signal.aborted) { - setOpenAIVoiceConfig(config); - } - }); - - return () => abortController.abort(); - }, [brunchSelected]); // Live editable document handle for the selected net only. const [activeHandle, setActiveHandle] = useState(null); @@ -583,346 +368,51 @@ export const LocalStorageDemoApp = ({ documentId: currentDocument.documentId, title, }); - const productConstructionConversationId = - currentDocument === null - ? undefined - : productConstructionSelected - ? ordinaryConstructionConversationIdFrom(currentDocument.incarnationId) - : undefined; - const fixtureProcessAgentConfiguration = useMemo< - FixtureProcessAgentConfiguration | undefined - >( - () => - productConstructionConversationId === undefined - ? undefined - : { conversationId: productConstructionConversationId }, - [productConstructionConversationId], - ); - const processAgentBinding = useProcessAgentBinding({ - document: currentDocument, - seed: source.processAgentSeed, - fixture: fixtureProcessAgentConfiguration, - }); - const conversationId = processAgentBinding?.conversationId ?? null; - const processAgentSession = useProcessAgentSession({ - activeHandleRef, - binding: processAgentBinding, - brunchSelected, - }); - const { conversationTracker, flueClientPromise } = processAgentSession; - // Failures the host contains — a stopped batch operation, an unrecordable - // transition, a lost history observation, a failed server tool — resolve - // normally for the panel and the model; this is where they become visible. - const { captureException } = use(ErrorTrackerContext); - const reportBrunchFailure = useCallback( - ( - failureSource: string, - error: unknown, - tags?: Readonly>, - ) => - captureException(error, { - source: `brunch.${failureSource}`, - ...(tags === undefined ? {} : { tags }), - }), - [captureException], + const { repository } = source; + const settleRevision = useCallback( + (input) => repository.settleRevision(input), + [repository], ); - const constructionBrowser = useMemo(() => { + const processAgentSeed = source.processAgentSeed; + // What the assistant plugins read. Absent until the open document has its + // live handle, which is also when the editor renders. + const assistantHost = useMemo((): DemoAssistantHost | null => { if ( - !activeHandle || - processAgentBinding === null || - activeHandle.document.documentId !== processAgentBinding.documentId - ) - return undefined; - return productConstructionSelected - ? { binding: processAgentBinding } - : undefined; - }, [activeHandle, processAgentBinding, productConstructionSelected]); - // The handle mutates behind a stable identity. Subscribe to its real snapshot; - // a render-time read alone can be memoized by React Compiler across hand edits. - const subscribeToObservedLiveHash = useCallback( - (changed: () => void) => - constructionBrowser && activeHandle - ? activeHandle.handle.subscribe(changed) - : () => {}, - [activeHandle, constructionBrowser], - ); - const getObservedLiveHash = useCallback( - () => - constructionBrowser && activeHandle?.handle.doc() - ? observeBrowserDefinition(activeHandle.handle).sha256 - : undefined, - [activeHandle, constructionBrowser], - ); - const getServerObservedLiveHash = useCallback(() => undefined, []); - const observedLiveHash = useSyncExternalStore( - subscribeToObservedLiveHash, - getObservedLiveHash, - getServerObservedLiveHash, - ); - const mutationRecorder = useMemo( - () => - constructionBrowser && activeHandle - ? createJoinedBrowserMutationRecorder({ - handle: activeHandle.handle, - ...constructionBrowser, - onContainedFailure: (failure) => - reportBrunchFailure("mutation-record", failure.error, { - kind: failure.kind, - toolCallId: failure.toolCallId, - }), - }) - : undefined, - [constructionBrowser, activeHandle, reportBrunchFailure], - ); - const constructionClientTools = batchedConstructionSelected - ? batchedConstructionClientToolNames - : undefined; - const flueHistory = useFlueChatHistory( - flueClientPromise, - conversationId ?? "", - constructionClientTools, - mutationRecorder?.mapClientToolInput, - mutationRecorder?.validatedClientToolNames, - brunchPetrinautDynamicToolNames, - ); - useEffect(() => { - if (flueHistory.error === undefined) return; - reportBrunchFailure("history", flueHistory.error, { - phase: flueHistory.phase ?? "unknown", - }); - }, [flueHistory.error, flueHistory.phase, reportBrunchFailure]); - const brunchVoiceMode = useMemo( - () => - getBrunchVoiceMode( - brunchSelected && voicePreferenceReady && voiceEnabled - ? openAIVoiceConfig - : null, - conversationTracker, - flueHistory.settlements, - flueHistory.snapshot, - ), - [ - brunchSelected, - conversationTracker, - flueHistory.settlements, - flueHistory.snapshot, - openAIVoiceConfig, - voiceEnabled, - voicePreferenceReady, - ], - ); - const transportClientPromise = flueClientPromise; - const petrinautAiChatTransport = useMemo(() => { - if (transportClientPromise !== null) { - return createBrunchPanelTransport( - transportClientPromise, - conversationTracker, - { - ...(productConstructionSelected && constructionBrowser - ? { - initialData: { - mode: batchedConstructionMode, - construction: { binding: constructionBrowser.binding }, - }, - } - : {}), - dynamicClientToolNames: brunchPetrinautDynamicToolNames, - ...(transportClientPromise === flueClientPromise && - conversationId !== null - ? { - liveToolStream: { - headers: agentOwnershipHeaders({ - conversationId, - principalKey: brunchPrincipal, - }), - }, - } - : {}), - ...(constructionClientTools === undefined - ? {} - : { - clientToolNames: constructionClientTools, - mapClientToolInput: mutationRecorder?.mapClientToolInput, - validatedClientToolNames: - mutationRecorder?.validatedClientToolNames, - clientToolResultMetadata: - mutationRecorder?.clientToolResultMetadata, - clientToolResultOutput: - mutationRecorder?.clientToolResultOutput, - }), - onAdmission: flueHistory.refresh, - onToolOutputError: (event) => - reportBrunchFailure("server-tool", new Error(event.errorText), { - submissionId: event.submissionId, - toolCallId: event.toolCallId, - toolName: event.toolName ?? "unknown", - }), - }, - ); + currentDocument === null || + activeHandle === null || + activeHandle.document.documentId !== currentDocument.documentId + ) { + return null; } - return stockChatTransport; - }, [ - conversationTracker, - conversationId, - constructionClientTools, - productConstructionSelected, - constructionBrowser, - flueClientPromise, - flueHistory.refresh, - reportBrunchFailure, - transportClientPromise, - mutationRecorder, - ]); - - const aiAssistant = useMemo(() => { - const activityIdentities = - constructionBrowser && flueHistory.ready - ? flueHistory.phase === "absent" - ? [] - : flueHistory.snapshot === undefined - ? undefined - : foldBrunchWorkpieceHistory( - flueHistory.snapshot.messages, - constructionBrowser.binding, - ).activityIdentities - : undefined; return { - additionalTab: constructionBrowser - ? { - label: "Ledger", - activityIdentities, - content: ( - - ), - } - : undefined, - ...(brunchSelected - ? { - primaryLabel: "Chat", - resolveToolPresentation: resolveBrunchToolPresentation, - workingLabel: "Brunch is working", - } - : {}), - ...(conversationId === null ? {} : { conversationId }), - canClearMessages: flueClientPromise === null, - // Brunch's own tool names wrap canonical Petrinaut operations here, in - // the host; Petrinaut keeps its names and executes only what it is told. - automaticTools: - flueClientPromise === null - ? [] - : createBrunchPetrinautTools({ - readTitle: () => currentNetTitle, - ...(batchedConstructionSelected && constructionBrowser - ? { - mutation: { - binding: constructionBrowser.binding, - retainAttempt: mutationRecorder?.retainAttempt, - onOperationFailure: (failure) => - reportBrunchFailure("mutate-petrinet", failure.error, { - toolCallId: failure.toolCallId, - operationId: failure.operationId, - operationType: failure.operationType, - status: failure.status, - }), - }, - } - : {}), - ...(currentDocument === null - ? {} - : { - settleDocumentRevision: (revisionId) => - source.repository.settleRevision({ - documentId: currentDocument.documentId, - revisionId, - }), - }), - }), - // The drafted-experiment card is the one Brunch tool the person answers - // in the panel: it prepares against the live model, reports "drafted", - // and waits for Run or Dismiss. Session-only; nothing is persisted. - interactiveTools: - flueClientPromise === null - ? [] - : [ - createBrunchDraftExperimentInteractiveTool({ - readTitle: () => currentNetTitle, - }), - ], - transport: petrinautAiChatTransport, - ...(mutationRecorder === undefined - ? {} - : { executeMutation: mutationRecorder.executeMutation }), - ...(flueClientPromise === null - ? {} - : { - requestStop: () => - requestFlueStop(flueClientPromise, conversationTracker), - followMessages: { - // This closure and `messages` below describe the same observed - // snapshot, never a later mutable settlement cache. - canReplace: () => - conversationTracker.canReplaceMessages(flueHistory.snapshot), - }, - }), - messages: - flueClientPromise === null - ? currentNetId - ? aiMessagesByNetId[currentNetId] - : undefined - : flueHistory.messages, - onMessages: (messages: PetrinautAiMessage[]) => { - if (!currentNetId || flueClientPromise !== null) { - return; - } - - setAiMessagesByNetId((prev) => ({ - ...prev, - [currentNetId]: messages, - })); + activeHandle, + activeHandleRef, + document: currentDocument, + processAgentSeed, + settleRevision, + stockMessages: { + byNetId: aiMessagesByNetId, + setByNetId: setAiMessagesByNetId, }, - onClearMessages: () => { - if (!currentNetId || flueClientPromise !== null) { - return; - } - - setAiMessagesByNetId((prev) => { - const next = { ...prev }; - delete next[currentNetId]; - return next; - }); + voice: { + enabled: voiceEnabled, + ready: voicePreferenceReady, + setEnabled: setVoiceEnabled, + config: openAIVoiceConfig, + setConfig: setOpenAIVoiceConfig, }, - ...(brunchVoiceMode - ? { - renderVoiceMode: brunchVoiceMode, - } - : {}), }; }, [ + activeHandle, aiMessagesByNetId, - brunchSelected, - brunchVoiceMode, - batchedConstructionSelected, - observedLiveHash, - constructionBrowser, - conversationTracker, - conversationId, - currentNetId, - currentNetTitle, - flueClientPromise, - flueHistory.messages, - flueHistory.phase, - flueHistory.ready, - flueHistory.snapshot, - petrinautAiChatTransport, - reportBrunchFailure, - mutationRecorder, - setAiMessagesByNetId, currentDocument, - source.repository, + openAIVoiceConfig, + processAgentSeed, + setAiMessagesByNetId, + setVoiceEnabled, + settleRevision, + voiceEnabled, + voicePreferenceReady, ]); if (source.repository.status.state === "unavailable") { @@ -1007,47 +497,30 @@ export const LocalStorageDemoApp = ({ ) : null} {/* The settings are mounted here, above the editor, so the demo's own - command and selector read the same persisted state the editor does. */} + commands read the same persisted state the editor does. */} - - - - ), - }} - title={currentDocument.title} - /> - - + + + + + + + diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 09fe8230b94..38e1617b299 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -1,6 +1,8 @@ # AI Assistant -Petrinaut has an in-app AI assistant that can build a net from a natural-language description, review or revise an existing one, read TypeScript compilation diagnostics, run experiments, and consult its own user-guide pages to answer "how do I ..." questions. The host application controls whether the assistant and its tools are available -- it is enabled on [demo.petrinaut.org](https://demo.petrinaut.org) and in [HASH](https://hash.ai) and may or may not be enabled in other Petrinaut embeds. +Petrinaut has an in-app AI assistant that can build a net from a natural-language description, review or revise an existing one, read TypeScript compilation diagnostics, run experiments, and consult its own user-guide pages to answer "how do I ..." questions. The host application controls whether the assistant and its tools are available -- it is enabled on [demo.petrinaut.org](https://demo.petrinaut.org) and in [HASH](https://hash.ai) and may or may not be enabled in other Petrinaut embeds. Without an assistant, the AI button, the first-run prompt, and the palette command are absent. + +A host can offer more than one assistant. The first one answers until you choose another in **User Settings → General → AI assistant**, or run **Use the _name_ assistant** from the command palette. Each assistant has its own conversation, and switching never moves messages from one to the other. Whether a conversation is still there when you switch back depends on the host; the demo site keeps both. ## Opening the panel diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index b4bd8363327..2973d20c92b 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -14,7 +14,7 @@ Changes apply immediately and are saved as your preferences across nets. On host | Section | Settings | | ------------ | -------------------------------------------------------------------- | -| **General** | Animations, panel loading, and the welcome guide. | +| **General** | Animations, panel loading, the welcome guide, and the AI assistant. | | **Viewport** | Minimap, compact nodes, arc rendering, grid snapping, and selection. | | **Labs** | Experimental modeling views, code layouts, and developer tools. | @@ -36,6 +36,10 @@ When enabled, hidden panels remain loaded in the background. Switching between p Show the getting-started guide the next time you open Petrinaut. +### AI assistant + +When the host offers more than one AI assistant, choose which one answers in the AI panel. The panel switches at once, and each assistant has its own conversation. The choice is saved with your other preferences. With a single assistant there is nothing to choose, and the group is hidden. + ## Viewport ### Minimap @@ -168,10 +172,9 @@ Controls selection box behavior in [Select mode](drawing-a-net.md#pan-and-select ## Labs -Hosts can place application-specific controls after Petrinaut's built-in Labs -groups. For example, a host may put assistant provider and Voice availability -choices there. The host owns those controls, their availability, and how their -preferences are saved. +Hosts can add their own groups after Petrinaut's built-in Labs groups, for +example a Voice option for one of their assistants. The host owns those +controls, their availability, and how their preferences are saved. ### Net Components (experimental) diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 3a49b7f543b..f3f3a9796b3 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -31,8 +31,13 @@ export { reactiveModulesPlugin, } from "./ui/plugins/built-in-plugins"; export { definePetrinautPlugin } from "./ui/plugins/plugin"; +export { + usePetrinautActiveAssistantId, + usePetrinautAiAssistant, +} from "./ui/plugins/plugin-assistants"; export type { PetrinautPlugin, + PetrinautPluginAssistant, PetrinautPluginButton, PetrinautPluginButtonPlacement, PetrinautPluginEditView, @@ -49,7 +54,6 @@ export type { SubView, SubViewResizeConfig, } from "./ui/components/sub-view/types"; -export type { PetrinautSlots } from "./ui/types/petrinaut-slots"; export type { PetrinautAiComposerControl, PetrinautAiComposerControlContext, diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts index f33d76ccfad..8884e9fbad7 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -75,6 +75,11 @@ export type UserSettings = { * registers; the settings dialog has no control for it. */ brunchDemoMode: boolean; + /** + * The AI assistant chosen in User settings, by plugin assistant id. `null` + * until the user picks one; the editor then uses the first installed. + */ + aiAssistantId: string | null; subViewPanels: SubViewPanelsSettings; /** Where each document's canvas was last left, keyed by document id. */ canvasViewports: Record; @@ -103,6 +108,7 @@ export type UserSettingsActions = { setShowWalkthroughOnInit: (value: boolean) => void; setShowCompilationOutput: (value: boolean) => void; setBrunchDemoMode: (value: boolean) => void; + setAiAssistantId: (value: string | null) => void; updateSubViewSection: ( containerName: string, sectionId: string, @@ -136,6 +142,7 @@ export const defaultUserSettings: UserSettings = { showWalkthroughOnInit: true, showCompilationOutput: false, brunchDemoMode: false, + aiAssistantId: null, subViewPanels: {}, canvasViewports: {}, }; @@ -168,6 +175,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = { setShowWalkthroughOnInit: () => {}, setShowCompilationOutput: () => {}, setBrunchDemoMode: () => {}, + setAiAssistantId: () => {}, updateSubViewSection: () => {}, setCanvasViewport: () => {}, }; diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx index 9664a6ecaef..f71af83cd9d 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -151,6 +151,8 @@ const OwnedUserSettingsProvider: React.FC = ({ }, setBrunchDemoMode: (value: boolean) => setState((prev) => ({ ...prev, brunchDemoMode: value })), + setAiAssistantId: (value: string | null) => + setState((prev) => ({ ...prev, aiAssistantId: value })), updateSubViewSection: ( containerName: string, sectionId: string, diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts index fe79c1bc8c8..56c5cfb2e78 100644 --- a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts +++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts @@ -2,6 +2,7 @@ import { use } from "react"; import { EditorContext } from "../../react/state/editor-context"; import { PANEL_MARGIN } from "../constants/ui"; +import { useHasActiveAiAssistant } from "../plugins/plugin-assistants"; import { usePetrinautPresentation } from "../views/shared/presentation-context"; /** How much of the canvas each edge's panels cover, in CSS pixels. */ @@ -80,10 +81,16 @@ const NO_INSETS: CanvasInsets = { left: 0, right: 0, bottom: 0 }; export const useCanvasInsets = (options?: CanvasInsetOptions): CanvasInsets => { const presentation = usePetrinautPresentation(); const editor = use(EditorContext); + // The panel is open only while it has an assistant to show: one that fails + // or stops passing a value leaves the open flag set with nothing on screen. + const hasAssistant = useHasActiveAiAssistant(); // Where the panels sit beside the canvas rather than over it, the canvas is // already the space it occupies and there is nothing to keep clear of. return presentation.panelsOverlayCanvas - ? getCanvasInsets(editor, options) + ? getCanvasInsets( + hasAssistant ? editor : { ...editor, isAiAssistantOpen: false }, + options, + ) : NO_INSETS; }; diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index 62ebdba7e47..306382e3e25 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -249,16 +249,21 @@ export { DefaultChatTransport } from "ai"; // no-op changes outside the handle (e.g. memoising Storybook stories). export { isSDCPNEqual } from "@hashintel/petrinaut-core"; -// Plugins — how a host adds buttons, commands, panel sections and edit views -// to the editor. `` above `` installs them. +// Plugins — how a host adds buttons, commands, panel sections, edit views and +// AI assistants to the editor. `` above `` installs them. export { definitionsViewPlugin, petrinautBuiltInPlugins, reactiveModulesPlugin, } from "./plugins/built-in-plugins"; export { definePetrinautPlugin } from "./plugins/plugin"; +export { + usePetrinautActiveAssistantId, + usePetrinautAiAssistant, +} from "./plugins/plugin-assistants"; export type { PetrinautPlugin, + PetrinautPluginAssistant, PetrinautPluginButton, PetrinautPluginButtonPlacement, PetrinautPluginEditView, @@ -272,8 +277,6 @@ export type { export { PetrinautPluginsProvider } from "./plugins/plugins-provider"; export type { PetrinautPluginsProviderProps } from "./plugins/plugins-provider"; export type { SubView, SubViewResizeConfig } from "./components/sub-view/types"; -// Superseded by plugin settings groups; removed once the Labs host has moved. -export type { PetrinautSlots } from "./types/petrinaut-slots"; // Walkthrough — first-visit product tour. Exposed so embedders can drive it // programmatically (e.g. trigger from their own help menu) without relying on diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index 65dceb98f3b..a624f480937 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -22,6 +22,10 @@ import { MonacoProvider } from "./monaco/provider"; import { petrinautBuiltInPlugins } from "./plugins/built-in-plugins"; import { InstalledPluginsProvider } from "./plugins/installed-plugins"; import { resolveInstalledPlugins } from "./plugins/plugin"; +import { + HostAiAssistantContext, + hostAiAssistantPlugin, +} from "./plugins/plugin-assistants"; import { usePetrinautPluginsConfiguration } from "./plugins/plugins-provider"; import { EditorView } from "./views/Editor/editor-view"; import { @@ -147,7 +151,6 @@ export type PetrinautAiAssistant = { import type { PetrinautNavigationController } from "../react/navigation"; import type { NetManagement } from "../react/net-management-context"; -import type { PetrinautSlots } from "./types/petrinaut-slots"; export type PetrinautProps = { handle: PetrinautDocHandle; @@ -168,12 +171,12 @@ export type PetrinautProps = { existingNets?: MinimalNetMetadata[]; createNewNet?: (params: { petriNetDefinition: SDCPN; title: string }) => void; loadPetriNet?: (petriNetId: string) => void; - aiAssistant?: PetrinautAiAssistant; /** - * @deprecated Install a plugin with a Labs `settingsGroups` entry through - * `PetrinautPluginsProvider` instead. + * @deprecated Install a plugin with an `assistants` entry through + * `PetrinautPluginsProvider` instead. A host that passes this gets it as + * the first assistant. */ - slots?: PetrinautSlots; + aiAssistant?: PetrinautAiAssistant; /** * Optional simulation-worker factory. Provide this when the host bundler * needs to own worker instantiation (e.g. when consuming the published @@ -227,7 +230,6 @@ export const Petrinaut: FunctionComponent = ({ createNewNet = noop, loadPetriNet = noop, aiAssistant, - slots, simulationWorkerFactory, monteCarloWorkerFactory, lspWorkerFactory, @@ -246,7 +248,9 @@ export const Petrinaut: FunctionComponent = ({ const pluginsConfiguration = usePetrinautPluginsConfiguration(); const installedPlugins = resolveInstalledPlugins( pluginsConfiguration.builtInPlugins ?? petrinautBuiltInPlugins, - pluginsConfiguration.plugins, + aiAssistant === undefined + ? pluginsConfiguration.plugins + : [hostAiAssistantPlugin, ...pluginsConfiguration.plugins], ); const netManagement: NetManagement = { @@ -274,12 +278,12 @@ export const Petrinaut: FunctionComponent = ({ className={cx(editorRootStyle, "petrinaut-root")} ref={portalContainerRef} > - + + + diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-assistants.test.tsx b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-assistants.test.tsx new file mode 100644 index 00000000000..a936cd7eb2e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-assistants.test.tsx @@ -0,0 +1,288 @@ +/** + * @vitest-environment jsdom + */ +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; +import { type ReactNode, use, useEffect, useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createCommandRegistry } from "@hashintel/petrinaut-core"; + +import { CommandRegistryProvider } from "../../react/commands/command-registry"; +import { ErrorTrackerContext } from "../../react/error-tracker-context"; +import { EditorContext } from "../../react/state/editor-context"; +import { + defaultUserSettingsContextValue, + UserSettingsContext, +} from "../../react/state/user-settings-context"; +import { useCanvasInsets } from "../hooks/use-canvas-insets"; +import { InstalledPluginsProvider } from "./installed-plugins"; +import { definePetrinautPlugin, type PetrinautPlugin } from "./plugin"; +import { + HostAiAssistantContext, + hostAiAssistantPlugin, + PluginAssistants, + useActiveAiAssistant, + useHasActiveAiAssistant, + usePetrinautActiveAssistantId, + usePetrinautAiAssistant, +} from "./plugin-assistants"; + +import type { PetrinautAiAssistant } from "../petrinaut"; + +afterEach(cleanup); + +const assistantWith = (label: string): PetrinautAiAssistant => ({ + primaryLabel: label, + transport: { + sendMessages: () => Promise.reject(new Error("not used")), + reconnectToStream: () => Promise.resolve(null), + }, +}); + +const mounts: string[] = []; + +/** An assistant plugin whose component records its mounts and publishes. */ +const assistantPlugin = (id: string, label: string) => { + const assistant = assistantWith(label); + const Assistant = () => { + "use no memo"; // Closes over the factory's arguments, which the compiler would hoist out. + useEffect(() => { + mounts.push(id); + }, []); + usePetrinautAiAssistant(assistant); + return null; + }; + return definePetrinautPlugin({ + id, + assistants: [{ id, label, component: Assistant }], + }); +}; + +/** Stands in for the settings selector: one button per assistant id. */ +const ChooseProbe = ({ ids }: { ids: readonly string[] }) => { + const { setAiAssistantId } = use(UserSettingsContext); + return ids.map((id) => ( + + )); +}; + +/** The editor's side: what the AI panel would read, and the chosen id. */ +const PanelProbe = () => { + const active = useActiveAiAssistant(); + const activeId = usePetrinautActiveAssistantId(); + const present = useHasActiveAiAssistant(); + if (present !== (active !== null)) { + throw new Error("Presence must match the assistant the panel shows."); + } + return ( + <> + + {active === null ? "none" : `${active.id}:${active.label}`} + + {activeId ?? "none"} + + ); +}; + +const renderAssistants = ( + plugins: readonly PetrinautPlugin[], + options: { + chosenId?: string | null; + choices?: readonly string[]; + host?: PetrinautAiAssistant; + } = {}, +) => { + const registry = createCommandRegistry(); + const captureException = vi.fn(); + const Settings = ({ children }: { children: ReactNode }) => { + const [aiAssistantId, setAiAssistantId] = useState( + options.chosenId ?? null, + ); + return ( + + {children} + + ); + }; + render( + + + + + + + + + + + + + + , + ); + return { + registry, + captureException, + choose: (id: string) => + fireEvent.click(screen.getByRole("button", { name: `choose ${id}` })), + active: () => + screen.getByRole("status", { name: "active assistant" }).textContent, + activeId: () => + screen.getByRole("status", { name: "active assistant id" }).textContent, + }; +}; + +const switchCommands = (registry: ReturnType) => + registry + .list() + .filter((command) => command.id.startsWith("petrinaut.ai-assistant.use:")) + .map((command) => command.label); + +describe("PluginAssistants", () => { + afterEach(() => { + mounts.length = 0; + }); + + it("shows no assistant and offers no switch when none is installed", () => { + const { active, registry } = renderAssistants([]); + expect(active()).toBe("none"); + expect(switchCommands(registry)).toEqual([]); + }); + + it("shows the only assistant once its component has passed it", () => { + const { active, registry } = renderAssistants([ + assistantPlugin("test.stock", "Stock"), + ]); + expect(active()).toBe("test.stock:Stock"); + expect(switchCommands(registry)).toEqual([]); + }); + + it("starts with the first assistant and switches to the chosen one", () => { + const { active, choose, registry } = renderAssistants( + [ + assistantPlugin("test.stock", "Stock"), + assistantPlugin("test.brunch", "Brunch"), + ], + { choices: ["test.brunch"] }, + ); + expect(active()).toBe("test.stock:Stock"); + expect(mounts).toEqual(["test.stock"]); + expect(switchCommands(registry)).toEqual(["Use the Brunch assistant"]); + + choose("test.brunch"); + expect(active()).toBe("test.brunch:Brunch"); + expect(mounts).toEqual(["test.stock", "test.brunch"]); + expect(switchCommands(registry)).toEqual(["Use the Stock assistant"]); + }); + + it("switches from the palette", () => { + const { active, registry } = renderAssistants([ + assistantPlugin("test.stock", "Stock"), + assistantPlugin("test.brunch", "Brunch"), + ]); + act(() => { + void registry.execute("petrinaut.ai-assistant.use:test.brunch"); + }); + expect(active()).toBe("test.brunch:Brunch"); + }); + + it("falls back to the first assistant when the chosen one is gone", () => { + const { active } = renderAssistants( + [assistantPlugin("test.stock", "Stock")], + { chosenId: "test.brunch" }, + ); + expect(active()).toBe("test.stock:Stock"); + }); + + it("hides the panel while the assistant passes null, and still names it active", () => { + const NotReady = () => { + usePetrinautAiAssistant(null); + return null; + }; + const { active, activeId } = renderAssistants([ + definePetrinautPlugin({ + id: "test.pending", + assistants: [ + { id: "test.pending", label: "Pending", component: NotReady }, + ], + }), + ]); + expect(active()).toBe("none"); + expect(activeId()).toBe("test.pending"); + }); + + it("reports a failing assistant and keeps the editor rendered", () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const Broken = () => { + throw new Error("assistant failure"); + }; + const { active, captureException } = renderAssistants([ + definePetrinautPlugin({ + id: "test.broken", + assistants: [{ id: "test.broken", label: "Broken", component: Broken }], + }), + ]); + expect(active()).toBe("none"); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "assistant failure" }), + { + source: "plugin.contribution", + tags: { + pluginId: "test.broken", + contributionId: "test.broken", + place: "assistant", + }, + }, + ); + consoleError.mockRestore(); + }); + + it("serves the deprecated aiAssistant prop as the first assistant", () => { + const { active, registry } = renderAssistants( + [hostAiAssistantPlugin, assistantPlugin("test.brunch", "Brunch")], + { host: assistantWith("Host") }, + ); + expect(active()).toBe("petrinaut.host-ai-assistant:AI"); + expect(switchCommands(registry)).toEqual(["Use the Brunch assistant"]); + }); +}); + +describe("useCanvasInsets", () => { + it("leaves no room for an open panel that has no assistant to show", () => { + const editor = renderHook(() => use(EditorContext)).result.current; + const openCollapsedPanel = { + ...editor, + isAiAssistantOpen: true, + isAiAssistantCollapsed: true, + aiAssistantDockHeight: null, + aiAssistantWidth: 400, + }; + const { result } = renderHook(() => useCanvasInsets(), { + wrapper: ({ children }: { children: ReactNode }) => ( + + + {children} + + + ), + }); + expect(result.current.right).toBe(0); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-assistants.tsx b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-assistants.tsx new file mode 100644 index 00000000000..5793f1fa037 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-assistants.tsx @@ -0,0 +1,211 @@ +import { + createContext, + type ReactNode, + use, + useLayoutEffect, + useState, + useSyncExternalStore, +} from "react"; + +import { useCommand } from "../../react/commands/command-registry"; +import { + createValueStore, + type ValueStore, +} from "../../react/create-value-store"; +import { UserSettingsContext } from "../../react/state/user-settings-context"; +import { useInstalledPlugins } from "./installed-plugins"; +import { + definePetrinautPlugin, + type PetrinautPluginAssistant, + type PetrinautPluginAssistantEntry, + resolveActiveAssistant, + selectPluginAssistants, +} from "./plugin"; +import { PluginContributionBoundary } from "./plugin-boundary"; + +import type { PetrinautAiAssistant } from "../petrinaut"; + +type PublishedAssistant = { id: string; assistant: PetrinautAiAssistant }; + +type AssistantsContextValue = { + /** Holds what the active assistant's component last passed. */ + store: ValueStore; + entries: readonly PetrinautPluginAssistantEntry[]; + active: PetrinautPluginAssistant | undefined; +}; + +const AssistantsContext = createContext({ + store: createValueStore(null), + entries: [], + active: undefined, +}); + +/** What the mounted assistant component publishes into, under its own id. */ +const AssistantSlotContext = createContext<{ + store: ValueStore; + id: string; +} | null>(null); + +/** + * Passes the assistant a `PetrinautPluginAssistant.component` builds to the + * editor's AI panel. `null` means not ready yet, and hides the panel. Outside + * an assistant component it does nothing. + * + * The panel builds its chat once per assistant, document and + * `conversationId`. A new `transport` reaches only the next conversation, and + * without `followMessages` the `messages` seed the chat once. Every other + * field applies on the next render. + */ +export const usePetrinautAiAssistant = ( + assistant: PetrinautAiAssistant | null, +): void => { + const slot = use(AssistantSlotContext); + useLayoutEffect(() => { + slot?.store.set(assistant === null ? null : { id: slot.id, assistant }); + }, [slot, assistant]); + useLayoutEffect(() => { + if (slot === null) { + return; + } + return () => { + if (slot.store.getSnapshot()?.id === slot.id) { + slot.store.set(null); + } + }; + }, [slot]); +}; + +export type ActiveAiAssistant = { + id: string; + label: string; + assistant: PetrinautAiAssistant; +}; + +/** + * The assistant the AI panel shows: the active plugin assistant, once its + * component has passed a value. `null` hides the panel and every AI entry + * point. + */ +export const useActiveAiAssistant = (): ActiveAiAssistant | null => { + const { store, active } = use(AssistantsContext); + const published = useSyncExternalStore(store.subscribe, store.getSnapshot); + return active !== undefined && published?.id === active.id + ? { id: active.id, label: active.label, assistant: published.assistant } + : null; +}; + +/** + * Whether the AI panel has an assistant to show. Re-renders only when that + * changes, not on every value the assistant passes, for the canvas and the + * controls that make room for the panel. + */ +export const useHasActiveAiAssistant = (): boolean => { + const { store, active } = use(AssistantsContext); + const activeId = active?.id; + return useSyncExternalStore( + store.subscribe, + () => activeId !== undefined && store.getSnapshot()?.id === activeId, + ); +}; + +/** + * The id of the assistant the user has active, before its component has + * passed a value, or `undefined` when none is installed. For a plugin's other + * contributions, such as a settings group that applies only to its own + * assistant. + */ +export const usePetrinautActiveAssistantId = (): string | undefined => + use(AssistantsContext).active?.id; + +/** The installed assistants and the active one, for the settings selector. */ +export const useAssistantChoice = () => { + const { entries, active } = use(AssistantsContext); + const { setAiAssistantId } = use(UserSettingsContext); + return { + assistants: entries.map(({ assistant }) => assistant), + activeId: active?.id, + choose: setAiAssistantId, + }; +}; + +const AssistantSwitchCommand = ({ + assistant, +}: { + assistant: PetrinautPluginAssistant; +}) => { + const { setAiAssistantId } = use(UserSettingsContext); + useCommand({ + id: `petrinaut.ai-assistant.use:${assistant.id}`, + label: `Use the ${assistant.label} assistant`, + category: "Editor", + keywords: ["assistant", "ai", "switch", assistant.label], + run: () => setAiAssistantId(assistant.id), + }); + return null; +}; + +/** + * Resolves the active assistant for one editor and mounts its component + * beside the editor. The component publishes into a store rather than + * wrapping the editor, so switching assistants or a failing one never + * remounts the editor, and a publish re-renders only the store's readers. + */ +export const PluginAssistants = ({ children }: { children: ReactNode }) => { + const [store] = useState(() => + createValueStore(null), + ); + const entries = selectPluginAssistants(useInstalledPlugins()); + const { aiAssistantId } = use(UserSettingsContext); + const active = resolveActiveAssistant(entries, aiAssistantId); + const Assistant = active?.assistant.component; + + return ( + + {children} + {active !== undefined && Assistant !== undefined && ( + + + + + + )} + {entries.length > 1 && + entries + .filter(({ assistant }) => assistant.id !== active?.assistant.id) + .map(({ assistant }) => ( + + ))} + + ); +}; + +/** The deprecated `aiAssistant` prop, read by the plugin that stands in for it. */ +export const HostAiAssistantContext = createContext< + PetrinautAiAssistant | undefined +>(undefined); + +const HostAiAssistant = () => { + usePetrinautAiAssistant(use(HostAiAssistantContext) ?? null); + return null; +}; + +/** + * Installed while a host passes the deprecated `aiAssistant` prop, ahead of + * every plugin, so that assistant stays the default. + */ +export const hostAiAssistantPlugin = definePetrinautPlugin({ + id: "petrinaut.host-ai-assistant", + name: "AI assistant from the aiAssistant prop", + assistants: [ + { + id: "petrinaut.host-ai-assistant", + label: "AI", + component: HostAiAssistant, + }, + ], +}); diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-boundary.tsx b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-boundary.tsx index a24ceedbb86..f7869d13aa3 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-boundary.tsx +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-boundary.tsx @@ -12,6 +12,7 @@ import type { export type PluginContributionPlace = | "component" | "commands" + | "assistant" | "edit-view" | `settings-${PetrinautPluginSettingsSection}` | PetrinautPluginButtonPlacement diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin.test.ts b/libs/@hashintel/petrinaut/src/ui/plugins/plugin.test.ts index 3c73ad5f548..0171caa7df4 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/plugin.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { definePetrinautPlugin, + resolveActiveAssistant, resolveInstalledPlugins, + selectPluginAssistants, selectPluginEditViews, selectPluginSettingsGroups, selectPluginSubViews, @@ -112,6 +114,22 @@ describe("resolveInstalledPlugins", () => { expect(() => resolveInstalledPlugins([chrome], [elsewhere])).not.toThrow(); }); + it("refuses two assistants with one id", () => { + const assistantsOf = (id: string) => + definePetrinautPlugin({ + id, + assistants: [{ id: "test.assistant", label: id, component: Empty }], + }); + expect(() => + resolveInstalledPlugins( + [], + [assistantsOf("test.a"), assistantsOf("test.b")], + ), + ).toThrow( + /"test.b" contributes assistant "test.assistant", which plugin "test.a" already contributes/, + ); + }); + it("refuses an edit view that would hide the Canvas", () => { const canvas = definePetrinautPlugin({ id: "test.canvas", @@ -159,3 +177,44 @@ describe("contribution selectors", () => { ).toEqual(["test.chrome.labs"]); }); }); + +describe("resolveActiveAssistant", () => { + const stock = definePetrinautPlugin({ + id: "test.stock", + assistants: [{ id: "test.stock", label: "Stock", component: Empty }], + }); + const brunch = definePetrinautPlugin({ + id: "test.brunch", + assistants: [{ id: "test.brunch", label: "Brunch", component: Empty }], + }); + const activeId = ( + plugins: readonly (typeof stock)[], + chosenId: string | null, + ) => + resolveActiveAssistant(selectPluginAssistants(plugins), chosenId)?.assistant + .id; + + it("has no active assistant when none is installed", () => { + expect(activeId([chrome], null)).toBeUndefined(); + expect(activeId([], "test.brunch")).toBeUndefined(); + }); + + it("uses the only assistant, whatever was chosen", () => { + expect(activeId([stock], null)).toBe("test.stock"); + expect(activeId([stock], "test.brunch")).toBe("test.stock"); + }); + + it("uses the chosen assistant while it is installed, else the first", () => { + expect(activeId([stock, brunch], null)).toBe("test.stock"); + expect(activeId([stock, brunch], "test.brunch")).toBe("test.brunch"); + expect(activeId([brunch, stock], "test.gone")).toBe("test.brunch"); + }); + + it("keeps the plugin that contributed the assistant", () => { + expect( + selectPluginAssistants([chrome, brunch]).map( + ({ pluginId, assistant }) => `${pluginId}/${assistant.id}`, + ), + ).toEqual(["test.brunch/test.brunch"]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin.ts b/libs/@hashintel/petrinaut/src/ui/plugins/plugin.ts index a03508bcf28..439cc6c8fc0 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/plugin.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin.ts @@ -14,6 +14,7 @@ * | `bottom-panel` | tabs (a `SubView`) | * | edit views | workspaces beside the Canvas in Edit mode | * | settings `labs` | groups after the built-in Labs settings | + * | assistants | the AI assistant behind the editor's AI panel | * | command registry | palette commands, static or from `component` | * * `component` mounts once inside the editor's providers while the plugin is @@ -109,6 +110,25 @@ export type PetrinautPluginSettingsGroup = { component: ComponentType; }; +/** + * An AI assistant for the editor's AI panel. The editor shows the panel only + * while an assistant is active: the one chosen in User settings, or the first + * installed. With several installed, User settings offers a selector. + */ +export type PetrinautPluginAssistant = { + /** Stable, namespaced id, e.g. `website.brunch`. Stored as the choice. */ + id: string; + /** Shown in the selector and in the palette command that switches to it. */ + label: string; + /** + * Mounted beside the editor, inside its providers, while this assistant is + * active. It builds the assistant with hooks and passes it to + * `usePetrinautAiAssistant`; it renders nothing. Switching assistants + * unmounts it, and a thrown error removes the AI panel alone. + */ + component: ComponentType; +}; + export type PetrinautPlugin = { /** Stable, namespaced id, e.g. `petrinaut.definitions-view`. */ id: string; @@ -125,6 +145,7 @@ export type PetrinautPlugin = { subViews?: readonly PetrinautPluginSubView[]; editViews?: readonly PetrinautPluginEditView[]; settingsGroups?: readonly PetrinautPluginSettingsGroup[]; + assistants?: readonly PetrinautPluginAssistant[]; /** * Mounted once per editor, inside its providers, while the plugin is * installed. It remounts when the editor switches to another document. @@ -181,6 +202,9 @@ const assertUniqueContributionIds = ( for (const group of plugin.settingsGroups ?? []) { claim(`settings ${group.section}`, group.id, plugin.id); } + for (const assistant of plugin.assistants ?? []) { + claim("assistant", assistant.id, plugin.id); + } for (const view of plugin.editViews ?? []) { if (reservedEditViewIds.has(view.id)) { throw new Error( @@ -278,3 +302,30 @@ export const selectPluginSettingsGroups = ( .filter((group) => group.section === section) .map((group) => ({ pluginId: plugin.id, group })), ); + +export type PetrinautPluginAssistantEntry = { + pluginId: string; + assistant: PetrinautPluginAssistant; +}; + +export const selectPluginAssistants = ( + plugins: readonly PetrinautPlugin[], +): PetrinautPluginAssistantEntry[] => + plugins.flatMap((plugin) => + (plugin.assistants ?? []).map((assistant) => ({ + pluginId: plugin.id, + assistant, + })), + ); + +/** + * The active assistant: the chosen one while it is installed, otherwise the + * first installed, otherwise none. A stale choice falls back without being + * cleared, so it applies again once its plugin is back. + */ +export const resolveActiveAssistant = ( + assistants: readonly PetrinautPluginAssistantEntry[], + chosenId: string | null, +): PetrinautPluginAssistantEntry | undefined => + assistants.find(({ assistant }) => assistant.id === chosenId) ?? + assistants[0]; diff --git a/libs/@hashintel/petrinaut/src/ui/types/petrinaut-slots.ts b/libs/@hashintel/petrinaut/src/ui/types/petrinaut-slots.ts deleted file mode 100644 index 312702ac424..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/types/petrinaut-slots.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Host content rendered at a named location in the editor. The top-bar - * locations moved to plugins; only the Labs settings content remains, until - * its host moves to a plugin too. - * - * @deprecated Install a plugin with a `settingsGroups` entry for the Labs - * section through `PetrinautPluginsProvider` instead. - */ -export type PetrinautSlots = { - /** Rendered after Petrinaut's built-in groups in the Labs settings section. */ - settingsLabs?: React.ReactNode; -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx index f81bd761ce2..5f11fe9a458 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx @@ -11,6 +11,7 @@ import { import { petrinautBuiltInPlugins } from "../../plugins/built-in-plugins"; import { InstalledPluginsProvider } from "../../plugins/installed-plugins"; import { definePetrinautPlugin } from "../../plugins/plugin"; +import { usePetrinautAiAssistant } from "../../plugins/plugin-assistants"; import { EditorView } from "./editor-view"; import type { PetrinautAiAssistant } from "../../petrinaut"; @@ -108,6 +109,18 @@ const aiAssistant: PetrinautAiAssistant = { }, }; +const TestAssistant = () => { + usePetrinautAiAssistant(aiAssistant); + return null; +}; + +const testAssistantPlugin = definePetrinautPlugin({ + id: "test.assistant", + assistants: [ + { id: "test.assistant", label: "Test", component: TestAssistant }, + ], +}); + const EditorAtMode = ({ mode, view = "canvas", @@ -125,8 +138,10 @@ const EditorAtMode = ({ isAiAssistantOpen: true, }} > - - + + ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 92eb5aff395..892d82aceec 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -3,7 +3,7 @@ * @role Arranges the panels, toolbars and dialogs around the canvas */ -import { Activity, use, useState, type ReactNode } from "react"; +import { Activity, use, useState } from "react"; import { type MenuItem } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; @@ -48,6 +48,10 @@ import { importSDCPN } from "../../file-io/import-sdcpn"; import { KeyboardShortcut } from "../../keyboard-shortcut"; import { CodeNavigationProvider } from "../../monaco/code-navigation"; import { InstalledPlugins } from "../../plugins/installed-plugins"; +import { + PluginAssistants, + useActiveAiAssistant, +} from "../../plugins/plugin-assistants"; import { PluginContributionBoundary } from "../../plugins/plugin-boundary"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { AiCtaModal } from "./components/ai-cta-modal"; @@ -77,7 +81,6 @@ import { SimulationCreationDrawer } from "./simulation-creation-drawer"; import { useEditWorkspace } from "./use-edit-workspace"; import { autoLayoutShortcut, EditorCommands } from "./use-editor-commands"; -import type { PetrinautAiAssistant } from "../../petrinaut"; import type { PetrinautAiInputMode } from "../../types/ai-assistant-composer-control"; const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { @@ -159,18 +162,13 @@ const isEmptySDCPN = (sdcpn: SDCPN) => * It relies on sdcpn-store and editor-store for state, and uses SDCPNView for visualization. */ const EditorViewContent = ({ - aiAssistant, hideNetManagementControls, - settingsLabs, titleEditable, }: { - aiAssistant?: PetrinautAiAssistant; /** * See {@link TopBar} for the full semantics. */ hideNetManagementControls?: "all" | "except-title"; - /** The deprecated `slots.settingsLabs` content, until its host moves to a plugin. */ - settingsLabs?: ReactNode; titleEditable: boolean; }) => { const showNetManagementMenuItems = hideNetManagementControls === undefined; @@ -217,6 +215,10 @@ const EditorViewContent = ({ const { installedEditViews, activeEditView, isCanvasWorkspace } = useEditWorkspace(); + // The AI panel and its entry points show only while a plugin assistant is + // active and has passed its value. + const activeAssistant = useActiveAiAssistant(); + const aiAssistant = activeAssistant?.assistant; const pluginEditViews = installedEditViews.map(({ view }) => view); const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< @@ -568,7 +570,7 @@ const EditorViewContent = ({ applyAutoLayoutAndFrame={runAutoLayoutAndFrame} onToggleAiAssistant={aiAssistant ? toggleAiAssistant : undefined} /> - + - {aiAssistant && ( + {activeAssistant && aiAssistant && ( , ) => ( - + + + ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx index 9f7a93f6d4a..15c995b4704 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx @@ -24,11 +24,11 @@ import { definePetrinautPlugin, type PetrinautPlugin, } from "../../../plugins/plugin"; +import { PluginAssistants } from "../../../plugins/plugin-assistants"; import { UserSettings } from "./user-settings"; import type { PetrinautNavigationState } from "../../../../react/navigation"; import type { PetrinautOptimizationSource } from "../../../../react/optimization-context"; -import type { ReactNode } from "react"; beforeEach(() => { localStorage.clear(); @@ -55,7 +55,6 @@ afterEach(() => { const renderSettings = ( initialState: Partial = {}, optimization: PetrinautOptimizationSource | null = null, - settingsLabs?: ReactNode, plugins: readonly PetrinautPlugin[] = [], ) => { const registry = createCommandRegistry(); @@ -65,7 +64,9 @@ const renderSettings = ( - + + + @@ -440,44 +441,34 @@ describe("Labs settings", () => { ).toBeNull(); }); - it("renders host Labs content after the built-in groups", async () => { - const withoutHost = renderSettings({ - overlay: { type: "user-settings", section: "labs" }, - }); - await screen.findByRole("heading", { name: "Labs" }); - expect( - screen.queryByRole("region", { name: "Host AI settings" }), - ).toBeNull(); - withoutHost.unmount(); - - renderSettings( - { overlay: { type: "user-settings", section: "labs" } }, - null, -
- -
, - ); - await screen.findByRole("heading", { name: "Labs" }); - expect( - screen.getByRole("region", { name: "Host AI settings" }), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: "Use Brunch" })).toBeTruthy(); - }); - - it("walks enabled host Labs controls and crosses the existing focus flow", async () => { + it("walks enabled plugin Labs controls and crosses the existing focus flow", async () => { renderSettings( { overlay: { type: "user-settings", section: "labs" } }, null, -
- - - - -
, + [ + definePetrinautPlugin({ + id: "test.host", + settingsGroups: [ + { + id: "test.host.labs", + section: "labs", + title: "Host AI settings", + component: () => ( + <> + + + + + + ), + }, + ], + }), + ], ); await screen.findByRole("heading", { name: "Labs" }); const labs = screen.getByRole("tab", { name: "Labs" }); @@ -506,7 +497,7 @@ describe("Labs settings", () => { expect(document.activeElement).toBe(last); }); - it("renders plugin Labs groups after host content and walks them in install order", async () => { + it("renders plugin Labs groups after the built-in groups and walks them in install order", async () => { const labsGroup = (id: string, label: string) => definePetrinautPlugin({ id, @@ -522,9 +513,6 @@ describe("Labs settings", () => { renderSettings( { overlay: { type: "user-settings", section: "labs" } }, null, -
- -
, [ labsGroup("test.first", "First plugin"), labsGroup("test.second", "Second plugin"), @@ -537,12 +525,14 @@ describe("Labs settings", () => { expect( screen.getByRole("heading", { name: "Second plugin group" }), ).toBeTruthy(); - const host = screen.getByRole("button", { name: "Host control" }); + const compilation = screen.getByRole("checkbox", { + name: "Compilation output", + }); const first = screen.getByRole("button", { name: "First plugin" }); const second = screen.getByRole("button", { name: "Second plugin" }); - act(() => host.focus()); - fireEvent.keyDown(host, { key: "ArrowDown" }); + act(() => compilation.focus()); + fireEvent.keyDown(compilation, { key: "ArrowDown" }); expect(document.activeElement).toBe(first); fireEvent.keyDown(first, { key: "ArrowDown" }); expect(document.activeElement).toBe(second); @@ -554,7 +544,6 @@ describe("Labs settings", () => { renderSettings( { overlay: { type: "user-settings", section: "general" } }, null, - undefined, [ definePetrinautPlugin({ id: "test.labs-only", @@ -574,6 +563,49 @@ describe("Labs settings", () => { }); }); +describe("AI assistant selector", () => { + const assistantPlugin = (id: string, label: string) => + definePetrinautPlugin({ + id, + assistants: [{ id, label, component: () => null }], + }); + + it("offers no selector while one assistant is installed", async () => { + renderSettings({ overlay: { type: "user-settings" } }, null, [ + assistantPlugin("test.stock", "Stock"), + ]); + await screen.findByRole("heading", { name: "General" }); + expect(screen.queryByRole("region", { name: "AI assistant" })).toBeNull(); + }); + + it("chooses among several assistants and keeps the choice", async () => { + renderSettings({ overlay: { type: "user-settings" } }, null, [ + assistantPlugin("test.stock", "Stock"), + assistantPlugin("test.brunch", "Brunch"), + ]); + const select = await screen.findByRole("combobox", { name: "Assistant" }); + expect(select.textContent).toContain("Stock"); + + fireEvent.click(select); + fireEvent.click(await screen.findByRole("option", { name: "Brunch" })); + + await vi.waitFor(() => + expect( + ( + JSON.parse( + localStorage.getItem("petrinaut:user-settings") ?? "{}", + ) as { + aiAssistantId?: string; + } + ).aiAssistantId, + ).toBe("test.brunch"), + ); + expect( + screen.getByRole("combobox", { name: "Assistant" }).textContent, + ).toContain("Brunch"); + }); +}); + describe("WebGPU availability", () => { it("detects WebGPU support from the host, not a build flag", () => { // The runtime gate the control's `disabled` state is derived from. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.tsx index 81bb85f770e..efe654e0273 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.tsx @@ -1,14 +1,10 @@ -import { useEffect, useEffectEvent, type ReactNode } from "react"; +import { useEffect, useEffectEvent } from "react"; import { useCommand } from "../../../../react/commands/command-registry"; import { usePetrinautNavigation } from "../../../../react/navigation"; import { UserSettingsDialog } from "./user-settings/user-settings-dialog"; -export const UserSettings = ({ - settingsLabs, -}: { - settingsLabs?: ReactNode; -}) => { +export const UserSettings = () => { const navigation = usePetrinautNavigation(); const overlay = navigation.state.overlay; const section = @@ -59,7 +55,6 @@ export const UserSettings = ({ return section === null ? null : ( navigation.navigate( { overlay: { type: "user-settings", section: nextSection } }, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx index 9d65d1350ff..7d55a5025ac 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx @@ -13,8 +13,8 @@ import { css } from "@hashintel/ds-helpers/css"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; +import { useAssistantChoice } from "../../../../plugins/plugin-assistants"; import { PluginSettingsGroups } from "../../../../plugins/plugin-settings-groups"; -import { FocusControls } from "../../../../worksheet/focus-controls"; import { focusLands } from "../../../../worksheet/focus-flow"; import { FocusRoot, FocusStack } from "../../../../worksheet/focus-stack"; import { useFocusMember } from "../../../../worksheet/use-focus-member"; @@ -354,17 +354,17 @@ const SettingsTabs = () => { export const UserSettingsDialog = ({ section, - settingsLabs, onSectionChange, onClose, }: { section: PetrinautSettingsSection; - settingsLabs?: ReactNode; onSectionChange: (section: PetrinautSettingsSection) => void; onClose: () => void; }) => { const settings = use(UserSettingsContext); const { extensions } = use(SDCPNContext); + const assistantChoice = useAssistantChoice(); + const activeAssistantId = assistantChoice.activeId; const item = sections.find((candidate) => candidate.id === section) ?? sections[0]; const headingRef = useRef(null); @@ -482,6 +482,36 @@ export const UserSettingsDialog = ({ onChange={settings.setShowWalkthroughOnInit} /> + {assistantChoice.assistants.length > 1 && + activeAssistantId !== undefined && ( + + + {(aria) => ( +