From c33b35f6eb403e87310ef26106bb4f5630680006 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 14 Sep 2026 00:28:06 +0200 Subject: [PATCH 01/14] Dock and float the Petrinaut AI assistant --- .changeset/quiet-boats-dock.md | 5 + .../src/main/app/command-palette.tsx | 15 +- .../local-storage-demo-app.test.tsx | 16 + .../@hashintel/petrinaut/docs/ai-assistant.md | 21 +- .../petrinaut/docs/drawing-a-net.md | 1 + .../commands/command-palette.stories.tsx | 15 +- .../hooks/use-petrinaut-commands.test.tsx | 2 + .../hooks/use-petrinaut-mutations.test.tsx | 2 + .../src/react/state/editor-context.ts | 8 + .../src/react/state/editor-provider.test.tsx | 74 +- .../src/react/state/editor-provider.tsx | 34 +- .../src/react/state/panel-defaults.ts | 2 +- .../src/ui/hooks/use-canvas-insets.test.ts | 55 +- .../src/ui/hooks/use-canvas-insets.ts | 25 +- .../petrinaut/src/ui/petrinaut.stories.tsx | 22 + .../components/BottomBar/bottom-bar.tsx | 2 +- .../BottomBar/use-bottom-bar-layout.test.tsx | 67 ++ .../BottomBar/use-bottom-bar-layout.ts | 21 +- .../src/ui/views/Editor/editor-view.tsx | 45 +- .../experiments-story-fixtures.tsx | 2 + .../Editor/panels/ai-assistant-panel.test.tsx | 49 +- .../Editor/panels/ai-assistant-panel.tsx | 29 +- .../ai-assistant-contents.stories.tsx | 101 ++- .../ai-assistant-contents.test.tsx | 182 ++++- .../ai-assistant-contents.tsx | 767 ++++++++++-------- .../use-floating-position.ts | 121 +++ .../views/Editor/use-editor-commands.test.tsx | 104 +++ .../ui/views/Editor/use-editor-commands.ts | 51 +- .../react-flow/react-flow-canvas/mini-map.tsx | 21 +- 29 files changed, 1391 insertions(+), 468 deletions(-) create mode 100644 .changeset/quiet-boats-dock.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/use-floating-position.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/use-editor-commands.test.tsx diff --git a/.changeset/quiet-boats-dock.md b/.changeset/quiet-boats-dock.md new file mode 100644 index 00000000000..befffca76d8 --- /dev/null +++ b/.changeset/quiet-boats-dock.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Dock the AI assistant on the right, detach it into a movable floating panel, and toggle it with Cmd/Ctrl+Shift+K across editor views while preserving the conversation and keeping the toolbar centered. diff --git a/apps/petrinaut-website/src/main/app/command-palette.tsx b/apps/petrinaut-website/src/main/app/command-palette.tsx index 7a0cae0e095..903b2fc51f8 100644 --- a/apps/petrinaut-website/src/main/app/command-palette.tsx +++ b/apps/petrinaut-website/src/main/app/command-palette.tsx @@ -110,7 +110,12 @@ export const CommandPalette = () => { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + if ( + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "k" + ) { event.preventDefault(); setOpen((open) => !open); setQuery(""); @@ -158,6 +163,14 @@ export const CommandPalette = () => { aria-label="Command palette" style={paletteStyle} onPointerDown={(event) => event.stopPropagation()} + onBlur={(event) => { + if ( + event.relatedTarget && + !event.currentTarget.contains(event.relatedTarget) + ) { + setOpen(false); + } + }} > element?.focus()} 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 e2ec3ee7939..a9394db61f5 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 @@ -1241,6 +1241,22 @@ describe("local storage demo prepared fixture", () => { brunchPreviewConfig.isBrunchConfigured = true; }); + test.each(["metaKey", "ctrlKey"])( + "reserves %s + Shift + K for the assistant and keeps plain K for the palette", + (modifier) => { + seedStoredNet(); + render( {}} search={{}} />); + fireEvent.keyDown(window, { key: "K", [modifier]: true, shiftKey: true }); + expect( + screen.queryByRole("dialog", { name: "Command palette" }), + ).toBeNull(); + fireEvent.keyDown(window, { key: "k", [modifier]: true }); + expect( + screen.getByRole("dialog", { name: "Command palette" }), + ).not.toBeNull(); + }, + ); + test("shows the fixture selector only while Brunch demo mode is on", () => { seedStoredNet(); render( {}} search={{}} />); diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 0a4ba87d7d2..3cab8a4bed3 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -4,19 +4,24 @@ Petrinaut has an in-app AI assistant that can build a net from a natural-languag ## Opening the panel -There are two entry points: +Open the assistant in any of these ways: 1. **AI button** in the bottom toolbar (Edit mode only). Click it to open the panel; click again to close. The tooltip is "Show AI assistant" / "Hide AI assistant". 2. **File → New → Build with Brunch**. When the host enables its experimental Brunch demo mode, shows net-management controls, and provides an assistant, this creates a fresh empty net, opens the assistant, and offers two chips before the first message: **Interview first** (elicit before inventing missing detail or drawing the net) or **Quick preview** (fill reasonable gaps, mark them as provisional, and wait for assent). **Start blank** creates the same empty net without opening the assistant or showing the first-run prompt. Without that host flag, **New** retains its ordinary direct blank-net behavior. 3. **First-run prompt**. When you load Petrinaut against an empty net, a centred prompt appears. Type a description and its trailing action becomes **Send**; select it to open the panel with your message already in flight. When the host provides Voice mode, the empty prompt instead shows a waveform action titled **Start voice mode**. It opens the same assistant without creating an empty text message. Dismiss the prompt with the **X**, by clicking outside it, or by pressing **Escape**; it is hidden for the rest of the session once dismissed. +4. **Command palette**. Choose **Toggle AI assistant**, or press **Cmd/Ctrl+Shift+K** directly. This opens the assistant and focuses the message field, or closes it when already visible, including compact Voice mode. Reopening preserves the conversation and docked or floating layout. The command keeps you in your current view. This command is available when the host provides an AI assistant. -The assistant panel stays open when you switch to **Simulate** or **Notebook** -mode, so you can inspect an experiment while its chat result arrives. The panel -resizes by dragging its left edge. Text and voice share the **AI** transcript. -Some hosts add a second tab, such as **Workpiece**, for a saved document. -Select a tab to switch views, or use the left/right arrow keys while a tab is -focused. Switching does not end a response, clear your draft or interrupt Voice; -the composer and active controls remain available. +The assistant stays available across **Edit**, **Simulate**, **Actual**, and **Notebook** modes. Switching views preserves your conversation, draft, and active response. The panel resizes by dragging its left edge. Text and voice share the **AI** transcript. Some hosts add a second tab, such as **Workpiece**, for a saved document. Select a tab to switch views, or use the left/right arrow keys while a tab is focused. Switching does not end a response, clear your draft or interrupt Voice; the composer and active controls remain available. + +### Docking and floating + +The assistant opens in a sidebar at the far right of the editor. It sits flush against the viewport, beside the canvas and its properties panel. The sidebar slides in at its full width while the canvas makes room. Closing it returns that space to the canvas. + +The bottom toolbar stays centered on the editor when the docked assistant opens, moving only as far as needed to avoid overlapping the panels. + +Choose **Float AI assistant** in the header to detach it into a rounded panel over the canvas. The canvas expands smoothly to reclaim the sidebar's space, and the floating panel reserves no space at the right edge. Drag its **AI** header to move it, or focus **Move AI assistant** and use the arrow keys. Hold **Shift** with an arrow key to move farther. The floating panel stays within the editor when the window changes size. + +Choose **Dock AI assistant** to return it to the right sidebar. Switching between these layouts keeps your draft, conversation, and active response. Both layouts resize from the left edge. Closing and reopening the assistant keeps your layout choice for the editor session. ## The conversation diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 81ac58dc6be..16c2b08b5b2 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -221,6 +221,7 @@ The recent history is displayed in the top-right corner. Click on a history entr | Cmd+Z | Undo | | Cmd+Shift+Z | Redo | | Cmd+F | Search | +| Cmd+Shift+K | Toggle the AI assistant | | Delete / Backspace | Delete selection | On Windows/Linux, use Ctrl instead of Cmd. diff --git a/libs/@hashintel/petrinaut/src/react/commands/command-palette.stories.tsx b/libs/@hashintel/petrinaut/src/react/commands/command-palette.stories.tsx index f454920ea4d..fe6f5116edd 100644 --- a/libs/@hashintel/petrinaut/src/react/commands/command-palette.stories.tsx +++ b/libs/@hashintel/petrinaut/src/react/commands/command-palette.stories.tsx @@ -149,7 +149,12 @@ const HostCommandPalette: React.FC = () => { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + if ( + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "k" + ) { event.preventDefault(); setOpen((open) => !open); setQuery(""); @@ -192,6 +197,14 @@ const HostCommandPalette: React.FC = () => { aria-label="Command palette" className={paletteStyle} onPointerDown={(event) => event.stopPropagation()} + onBlur={(event) => { + if ( + event.relatedTarget && + !event.currentTarget.contains(event.relatedTarget) + ) { + setOpen(false); + } + }} > element?.focus()} diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx index e16c46824e0..9e2ca5d5bf7 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -48,6 +48,8 @@ const editorContextValue = ( setPropertiesPanelWidth: () => {}, setAiAssistantWidth: () => {}, setAiAssistantDockHeight: () => {}, + setAiAssistantPlacement: () => {}, + setAiAssistantCollapsed: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx index 21800a6d140..03edb94f52d 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -51,6 +51,8 @@ const editorContextValue = ( setPropertiesPanelWidth: () => {}, setAiAssistantWidth: () => {}, setAiAssistantDockHeight: () => {}, + setAiAssistantPlacement: () => {}, + setAiAssistantCollapsed: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 44e661beb46..e2348528477 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -84,6 +84,8 @@ export type EditorState = { aiAssistantWidth: number; /** Rendered compact dock height; null when expanded or closed. */ aiAssistantDockHeight: number | null; + aiAssistantPlacement: "docked" | "floating"; + isAiAssistantCollapsed: boolean; activeBottomPanelTab: BottomPanelTab; componentSubnetId: string | null; selection: SelectionMap; @@ -143,6 +145,8 @@ export type EditorActions = { setPropertiesPanelWidth: (width: number) => void; setAiAssistantWidth: (width: number) => void; setAiAssistantDockHeight: (height: number | null) => void; + setAiAssistantPlacement: (placement: "docked" | "floating") => void; + setAiAssistantCollapsed: (collapsed: boolean) => void; setBottomPanelOpen: (isOpen: boolean) => void; toggleBottomPanel: () => void; setBottomPanelHeight: (height: number) => void; @@ -199,6 +203,8 @@ export const initialEditorState: EditorState = { bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, aiAssistantWidth: DEFAULT_AI_ASSISTANT_WIDTH, aiAssistantDockHeight: null, + aiAssistantPlacement: "docked", + isAiAssistantCollapsed: false, activeBottomPanelTab: "diagnostics", componentSubnetId: null, selection: new Map(), @@ -228,6 +234,8 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { setPropertiesPanelWidth: () => {}, setAiAssistantWidth: () => {}, setAiAssistantDockHeight: () => {}, + setAiAssistantPlacement: () => {}, + setAiAssistantCollapsed: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx index 9ae98454fb8..6d99845f420 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx @@ -3,7 +3,7 @@ */ import { act, render } from "@testing-library/react"; import { use, useState } from "react"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_PETRINAUT_EXTENSIONS, @@ -20,6 +20,10 @@ import { import { EditorContext, type EditorContextValue } from "./editor-context"; import { EditorProvider } from "./editor-provider"; import { SDCPNContext, type SDCPNContextValue } from "./sdcpn-context"; +import { + defaultUserSettingsContextValue, + UserSettingsContext, +} from "./user-settings-context"; const emptySdcpn: SDCPN = { places: [], @@ -95,6 +99,74 @@ const TestHost = ({ const selectionOf = (...ids: string[]): SelectionMap => new Map(ids.map((id) => [id, { type: "place" as const, id }])); +describe("EditorProvider assistant animation", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it.each([true, false])( + "coordinates assistant layout changes with animations enabled: %s", + (showAnimations) => { + let editor: EditorContextValue; + const { unmount } = render( + + "place")}> + + + { + editor = value; + }} + /> + + + + , + ); + + const transitions = [ + { + run: () => editor.setAiAssistantOpen(true), + state: { isAiAssistantOpen: true }, + }, + { + run: () => editor.setAiAssistantPlacement("floating"), + state: { aiAssistantPlacement: "floating" }, + }, + { + run: () => editor.setAiAssistantPlacement("docked"), + state: { aiAssistantPlacement: "docked" }, + }, + { + run: () => editor.setAiAssistantOpen(false), + state: { isAiAssistantOpen: false }, + }, + { + run: () => editor.toggleAiAssistant(), + state: { isAiAssistantOpen: true }, + }, + { + run: () => editor.toggleAiAssistant(), + state: { isAiAssistantOpen: false }, + }, + ]; + for (const transition of transitions) { + act(transition.run); + expect(editor!).toMatchObject({ + ...transition.state, + isPanelAnimating: showAnimations, + }); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(editor!.isPanelAnimating).toBe(false); + } + unmount(); + }, + ); +}); + describe("EditorProvider selection gestures", () => { let editor: EditorContextValue; let recorded: RecordedNavigation[]; diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index c6b90d5b134..17a15aeeb52 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -112,6 +112,7 @@ export const EditorProvider: React.FC = ({ children }) => { const animationTimerRef = useRef | undefined>( undefined, ); + useEffect(() => () => clearTimeout(animationTimerRef.current), []); const selectionGestureRef = useRef({ active: false, hasNavigated: false }); const selectionNavigationMountedRef = useRef(true); const pendingSelectionNavigationRef = useRef<{ @@ -394,6 +395,22 @@ export const EditorProvider: React.FC = ({ children }) => { ? prev : { ...prev, aiAssistantDockHeight: height }, ), + setAiAssistantCollapsed: (collapsed) => { + scheduleAnimationEnd(); + setState((prev) => ({ + ...prev, + ...animationPatch(), + isAiAssistantCollapsed: collapsed, + })); + }, + setAiAssistantPlacement: (placement) => { + scheduleAnimationEnd(); + setState((prev) => ({ + ...prev, + ...animationPatch(), + aiAssistantPlacement: placement, + })); + }, setBottomPanelOpen: (isOpen) => { scheduleAnimationEnd(); setState((prev) => ({ @@ -508,13 +525,22 @@ export const EditorProvider: React.FC = ({ children }) => { }; }); }, - setAiAssistantOpen: (isOpen) => - setState((prev) => ({ ...prev, isAiAssistantOpen: isOpen })), - toggleAiAssistant: () => + setAiAssistantOpen: (isOpen) => { + scheduleAnimationEnd(); setState((prev) => ({ ...prev, + ...animationPatch(), + isAiAssistantOpen: isOpen, + })); + }, + toggleAiAssistant: () => { + scheduleAnimationEnd(); + setState((prev) => ({ + ...prev, + ...animationPatch(), isAiAssistantOpen: !prev.isAiAssistantOpen, - })), + })); + }, triggerPanelAnimation: () => { scheduleAnimationEnd(); setState((prev) => ({ ...prev, ...animationPatch() })); diff --git a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts index a544999cf82..f4e7be9b1ce 100644 --- a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts +++ b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts @@ -12,4 +12,4 @@ export const DEFAULT_PROPERTIES_PANEL_WIDTH = 450; export const DEFAULT_BOTTOM_PANEL_HEIGHT = 180; -export const DEFAULT_AI_ASSISTANT_WIDTH = 500; +export const DEFAULT_AI_ASSISTANT_WIDTH = 420; diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts index 25b1c55f975..f4d76c81e19 100644 --- a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts @@ -11,6 +11,8 @@ const closed: PanelLayoutState = { isAiAssistantOpen: false, aiAssistantWidth: 500, aiAssistantDockHeight: null, + aiAssistantPlacement: "docked", + isAiAssistantCollapsed: false, isBottomPanelOpen: false, bottomPanelHeight: 180, }; @@ -32,9 +34,9 @@ describe("getCanvasInsets", () => { expect(getCanvasInsets({ ...closed, hasSelection: true }).right).toBe(450); }); - it("stacks the assistant on the properties panel, which it docks beside", () => { + it("leaves the docked assistant's separate column out of the canvas insets", () => { expect(getCanvasInsets({ ...closed, isAiAssistantOpen: true }).right).toBe( - 500, + 0, ); expect( getCanvasInsets({ @@ -42,7 +44,45 @@ describe("getCanvasInsets", () => { hasSelection: true, isAiAssistantOpen: true, }).right, - ).toBe(950); + ).toBe(450); + }); + + it("reserves no canvas edge for the movable floating assistant", () => { + const floating = { + ...closed, + aiAssistantPlacement: "floating" as const, + isAiAssistantOpen: true, + hasSelection: false, + }; + expect(getCanvasInsets(floating).right).toBe(0); + expect(getCanvasInsets({ ...floating, hasSelection: true }).right).toBe( + 450, + ); + expect( + getCanvasInsets({ ...floating, isAiAssistantOpen: false }).right, + ).toBe(0); + }); + + it("leaves room for a compact voice dock until it expands or closes", () => { + const compact = { + ...closed, + isAiAssistantOpen: true, + isAiAssistantCollapsed: true, + }; + expect(getCanvasInsets(compact).right).toBe(512); + expect( + getCanvasInsets({ ...compact, isAiAssistantCollapsed: false }).right, + ).toBe(0); + expect( + getCanvasInsets({ ...compact, isAiAssistantOpen: false }).right, + ).toBe(0); + expect( + getCanvasInsets({ + ...compact, + hasSelection: true, + propertiesPanelWidth: 600, + }).right, + ).toBe(600); }); it("counts the bottom panel's height, not its open state alone", () => { @@ -59,17 +99,18 @@ describe("getCanvasInsets", () => { const compact = { ...closed, isAiAssistantOpen: true, + isAiAssistantCollapsed: true, aiAssistantDockHeight: 84, }; expect(getCanvasInsets(compact)).toEqual({ left: 0, - right: 500, + right: 512, bottom: 0, }); expect(getCanvasInsets(compact, { aboveCollapsedDock: true })).toEqual({ left: 0, right: 0, - bottom: 84, + bottom: 96, }); expect( getCanvasInsets( @@ -82,7 +123,7 @@ describe("getCanvasInsets", () => { { ...compact, aiAssistantDockHeight: 320, isBottomPanelOpen: true }, { aboveCollapsedDock: true }, ), - ).toEqual({ left: 0, right: 0, bottom: 320 }); + ).toEqual({ left: 0, right: 0, bottom: 332 }); }); it("keeps expanded positioning and ignores a closing dock's last measurement", () => { @@ -91,7 +132,7 @@ describe("getCanvasInsets", () => { { ...closed, isAiAssistantOpen: true }, { aboveCollapsedDock: true }, ), - ).toEqual({ left: 0, right: 500, bottom: 0 }); + ).toEqual({ left: 0, right: 0, bottom: 0 }); expect( getCanvasInsets( { ...closed, aiAssistantDockHeight: 84 }, 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 fa502949dba..fe79c1bc8c8 100644 --- a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts +++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts @@ -21,6 +21,8 @@ export interface PanelLayoutState { readonly isAiAssistantOpen: boolean; readonly aiAssistantWidth: number; readonly aiAssistantDockHeight: number | null; + readonly aiAssistantPlacement: "docked" | "floating"; + readonly isAiAssistantCollapsed: boolean; readonly isBottomPanelOpen: boolean; readonly bottomPanelHeight: number; } @@ -33,15 +35,17 @@ interface CanvasInsetOptions { /** * Each edge's rule is the one the panel on it renders by: search opens the * left sidebar without the toggle, a selection opens the properties panel, and - * the assistant docks beside the properties panel rather than over it, so an - * open pair covers the sum of the two. + * a compact Voice dock stays at the right edge. The expanded, docked assistant + * has its own column outside the canvas. A movable assistant reserves no edge. */ export const getCanvasInsets = ( state: PanelLayoutState, { aboveCollapsedDock = false }: CanvasInsetOptions = {}, ): CanvasInsets => { const dockHeight = - aboveCollapsedDock && state.isAiAssistantOpen + aboveCollapsedDock && + state.isAiAssistantOpen && + state.isAiAssistantCollapsed ? state.aiAssistantDockHeight : null; return { @@ -49,14 +53,17 @@ export const getCanvasInsets = ( state.isLeftSidebarOpen || state.isSearchOpen ? state.leftSidebarWidth + PANEL_MARGIN : 0, - right: - (state.hasSelection ? state.propertiesPanelWidth + PANEL_MARGIN : 0) + - (state.isAiAssistantOpen && dockHeight === null - ? state.aiAssistantWidth - : 0), + right: Math.max( + state.hasSelection ? state.propertiesPanelWidth + PANEL_MARGIN : 0, + state.isAiAssistantOpen && + state.isAiAssistantCollapsed && + dockHeight === null + ? state.aiAssistantWidth + 12 + : 0, + ), bottom: Math.max( state.isBottomPanelOpen ? state.bottomPanelHeight + PANEL_MARGIN : 0, - dockHeight ?? 0, + dockHeight === null ? 0 : dockHeight + 12, ), }; }; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx index 165bb42bba1..e4e3660b9a2 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx @@ -532,6 +532,28 @@ export const WithAiAssistant: Story = { ), }; +export const WithVoiceSetup: Story = { + render: () => ( +
+ ( +
+

Voice setup preview. No microphone or AI service is used.

+ +
+ ), + }} + initialTitle="Voice setup preview" + initialDefinition={emptySDCPN} + /> +
+ ), +}; + export const WithHostInteractiveAiTool: Story = { parameters: { docs: { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index c594ef13d90..c6467f57b28 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -48,7 +48,7 @@ const toolbarContainerStyle = css({ gap: "1", }); -// Spans the canvas so the bar centres on the canvas rather than on the space +// Spans the editor so the bar centres on the viewport rather than on the space // between the panels, and lets clicks through everywhere the bar itself is not. const bottomBarLaneStyle = css({ position: "absolute", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx new file mode 100644 index 00000000000..7bcc8265c82 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx @@ -0,0 +1,67 @@ +/** @vitest-environment jsdom */ +import { cleanup, renderHook } from "@testing-library/react"; +import { createRef, use, type ReactNode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; + +import { EditorContext } from "../../../../../react/state/editor-context"; +import { useBottomBarLayout } from "./use-bottom-bar-layout"; + +const laneRef = createRef(); +const barRef = createRef(); + +vi.mock("../../../../../react/hooks/use-element-size", () => ({ + useElementSize: (ref: unknown) => ({ width: ref === laneRef ? 2000 : 600 }), +})); + +afterEach(cleanup); + +it("centers on the full editor and moves only to clear occupied space", () => { + const defaults = renderHook(() => use(EditorContext)).result.current; + let editor = { + ...defaults, + isLeftSidebarOpen: false, + isSearchOpen: false, + hasSelection: false, + isAiAssistantOpen: false, + isAiAssistantCollapsed: false, + aiAssistantPlacement: "docked" as "docked" | "floating", + aiAssistantWidth: 420, + propertiesPanelWidth: 450, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result, rerender } = renderHook( + () => + useBottomBarLayout(laneRef, barRef, { + hasViewportControls: false, + isAnimating: true, + }), + { wrapper }, + ); + expect(result.current.offsetX).toBe(0); + editor = { ...editor, isAiAssistantOpen: true }; + rerender(); + expect(result.current.offsetX).toBe(0); + expect(result.current.isCollapsed).toBe(false); + + editor = { ...editor, hasSelection: true }; + rerender(); + expect(result.current.offsetX).toBe(-182); + + editor = { ...editor, aiAssistantPlacement: "floating" }; + rerender(); + expect(result.current.offsetX).toBe(0); + + editor = { + ...editor, + aiAssistantPlacement: "docked", + aiAssistantWidth: 1000, + }; + rerender(); + expect(result.current.isCollapsed).toBe(true); + editor = { ...editor, isAiAssistantOpen: false }; + rerender(); + expect(result.current.offsetX).toBe(0); + expect(result.current.isCollapsed).toBe(false); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts index 7444756dd96..ae4eaac3cd5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts @@ -1,6 +1,7 @@ -import { useCallback, useState } from "react"; +import { use, useCallback, useState } from "react"; import { useElementSize } from "../../../../../react/hooks/use-element-size"; +import { EditorContext } from "../../../../../react/state/editor-context"; import { VIEWPORT_CONTROLS_CLEARANCE } from "../../../../constants/ui"; import { useCanvasInsets } from "../../../../hooks/use-canvas-insets"; import { fitsWithinBounds, getBottomBarOffset } from "./bottom-bar-placement"; @@ -36,7 +37,7 @@ export interface BottomBarLayout { * the way mirrored state does. */ export const useBottomBarLayout = ( - /** Spans the canvas; the bar is centred in it and measured against it. */ + /** Spans the editor; the bar is centred in it and measured against it. */ laneRef: React.RefObject, barRef: React.RefObject, { @@ -84,6 +85,18 @@ export const useBottomBarLayout = ( ); const insets = useCanvasInsets(); + const { + isAiAssistantOpen, + aiAssistantPlacement, + isAiAssistantCollapsed, + aiAssistantWidth, + } = use(EditorContext); + const dockedAssistantWidth = + isAiAssistantOpen && + aiAssistantPlacement === "docked" && + !isAiAssistantCollapsed + ? Math.min(aiAssistantWidth, containerWidth) + : 0; const bounds = { containerWidth, leftInset: insets.left, @@ -91,7 +104,9 @@ export const useBottomBarLayout = ( // so they bound it the same way a panel does. They are absent in actual // mode, where `SDCPNCanvas` does not render them. rightInset: - insets.right + (hasViewportControls ? VIEWPORT_CONTROLS_CLEARANCE : 0), + dockedAssistantWidth + + insets.right + + (hasViewportControls ? VIEWPORT_CONTROLS_CLEARANCE : 0), margin: BOTTOM_BAR_MARGIN, }; 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 3e0c671ce61..13e7a65cbf6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -102,15 +102,17 @@ const formatRelativeTime = (isoTimestamp: string): string => { // TopBar out of view. const rowContainerStyle = css({ position: "relative", + containerType: "inline-size", flex: "[1]", + minWidth: "[0]", minHeight: "[0]", userSelect: "none", }); const canvasContainerStyle = css({ - width: "full", + minWidth: "[0]", position: "relative", - flexGrow: 1, + flex: "[1]", }); // `white-space` inherits down to the item text, whose `overflow: hidden; @@ -178,6 +180,7 @@ export const EditorView = ({ setCursorMode, clearSelection, setAiAssistantOpen, + setAiAssistantCollapsed, isBottomPanelOpen, bottomPanelHeight, } = use(EditorContext); @@ -190,6 +193,7 @@ export const EditorView = ({ useState(null); const [isAiCtaDismissed, setIsAiCtaDismissed] = useState(false); const [offerStartPosture, setOfferStartPosture] = useState(false); + const [aiAssistantFocusRequest, setAiAssistantFocusRequest] = useState(0); const { brunchDemoMode, @@ -209,6 +213,16 @@ export const EditorView = ({ // rules never disagree. const effectiveMode = useEffectiveGlobalMode(); + const toggleAiAssistant = () => { + if (isAiAssistantOpen) { + setAiAssistantOpen(false); + return; + } + setAiAssistantCollapsed(false); + setAiAssistantOpen(true); + setAiAssistantFocusRequest((request) => request + 1); + }; + // Live open state for the walkthrough. Seeded once from the persisted // "show on init" preference, so toggling that preference only takes effect // on the next init rather than reopening the walkthrough mid-session. @@ -508,7 +522,9 @@ export const EditorView = ({ enabled={enableExperimentalIconPack} motion={showAnimations ? "auto" : "none"} > - + - - )} - + {(effectiveMode === "edit" || effectiveMode === "actual") && ( + + )} {aiAssistant && ( {}, setAiAssistantWidth: () => {}, setAiAssistantDockHeight: () => {}, + setAiAssistantPlacement: () => {}, + setAiAssistantCollapsed: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx index b799f733597..9c5e66be7bd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx @@ -10,7 +10,7 @@ import { within, waitFor, } from "@testing-library/react"; -import { StrictMode, useEffect } from "react"; +import { StrictMode, useEffect, useState } from "react"; import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { @@ -47,6 +47,7 @@ import { SDCPNContext, type SDCPNContextValue, } from "../../../../react/state/sdcpn-context"; +import { useCanvasInsets } from "../../../hooks/use-canvas-insets"; import { definePetrinautAiInteractiveTool } from "../../../types/ai-interactive-tool"; import { addMappedToolOutput, @@ -66,6 +67,7 @@ import type { PetrinautAiTransport, } from "./ai-assistant-panel/types"; import type { UIMessageChunk } from "ai"; +import type { ReactNode } from "react"; let voiceModeMounts = 0; let voiceModeUnmounts = 0; @@ -120,6 +122,8 @@ const editorContextValue: EditorContextValue = { setPropertiesPanelWidth: () => {}, setAiAssistantWidth: () => {}, setAiAssistantDockHeight: () => {}, + setAiAssistantPlacement: () => {}, + setAiAssistantCollapsed: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, @@ -226,6 +230,33 @@ const SubmitForSecondConversation = ({ const testInstances: ReturnType[] = []; +const CanvasInsetProbe = () => { + const canvasInsets = useCanvasInsets(); + return {canvasInsets.right}; +}; + +const EditorTestProvider = ({ + children, + value, +}: { + children: ReactNode; + value: EditorContextValue; +}) => { + const [collapsed, setCollapsed] = useState(value.isAiAssistantCollapsed); + return ( + + {children} + + + ); +}; + const renderTestPanel = ({ aiAssistant, editorContext = editorContextValue, @@ -292,7 +323,7 @@ const renderTestPanel = ({ } > - + - + @@ -1676,8 +1707,12 @@ describe("AiAssistantPanel composer submissions", () => { const rendered = renderTestPanel({ aiAssistant, initialInteractionMode: "voice", + strictMode: true, }); await screen.findByText("Voice mode voice"); + expect(screen.getByTestId("canvas-right-inset").textContent).toBe( + String(initialEditorState.aiAssistantWidth + 12), + ); rendered.rerenderPanelWithInitialRequest( "text", @@ -2667,11 +2702,15 @@ describe("AiAssistantPanel composer submissions", () => { expect(composerWrap?.className).toContain("d_none"); const setupDock = screen.getByRole("region", { name: "Voice setup" }); + expect(screen.getByTestId("canvas-right-inset").textContent).toBe( + String(initialEditorState.aiAssistantWidth + 12), + ); fireEvent.click( within(setupDock).getByRole("button", { name: "Expand voice setup" }), ); expect(screen.queryByRole("region", { name: "Voice setup" })).toBeNull(); + expect(screen.getByTestId("canvas-right-inset").textContent).toBe("0"); expect(composerWrap?.className).not.toContain("d_none"); expect(screen.getByRole("textbox", { name: "Message AI assistant" })).toBe( composer, @@ -5094,14 +5133,14 @@ describe("AiAssistantPanel host interactive tools", () => { try { render( - + - + , ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index ad17c65c424..17301d12b87 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -50,7 +50,6 @@ import { useReadOnlyReason, } from "../../../../react/state/use-read-only-reason"; import { VoiceSessionContext } from "../../../../react/voice-session/context"; -import { PANEL_MARGIN } from "../../../constants/ui"; import { AiAssistantContents } from "./ai-assistant-panel/ai-assistant-contents"; import { selectPromptChips } from "./ai-assistant-panel/ai-assistant-contents/select-prompt-chips"; import { applyPetrinautAiMutation } from "./ai-assistant-panel/apply-petrinaut-ai-mutation"; @@ -483,6 +482,7 @@ const applyPetrinautAiCommand = async ({ interface AiAssistantPanelProps { aiAssistant: PetrinautAiAssistant; + focusRequest?: number; initialInteractionMode?: PetrinautAiInputMode | null; initialMessage?: string | null; offerStartPosture?: boolean; @@ -492,6 +492,7 @@ interface AiAssistantPanelProps { const ConversationAiAssistantPanel = ({ aiAssistant, + focusRequest = 0, initialInteractionMode, initialMessage, offerStartPosture = false, @@ -537,12 +538,11 @@ const ConversationAiAssistantPanel = ({ }, []); const { - hasSelection, - globalMode, isAiAssistantOpen, + isAiAssistantCollapsed: voiceDockCollapsed, navigateTo, - propertiesPanelWidth, selectItem, + setAiAssistantCollapsed: setVoiceDockCollapsed, setAiAssistantOpen, } = use(EditorContext); @@ -576,7 +576,17 @@ const ConversationAiAssistantPanel = ({ const [composerFocusRequest, setComposerFocusRequest] = useState(0); const [interactionMode, setInteractionMode] = useState("text"); - const [voiceDockCollapsed, setVoiceDockCollapsed] = useState(false); + const initializedLayoutRef = useRef(false); + useLayoutEffect(() => { + // A new conversation starts expanded. Effect replay preserves its voice setup. + if (initializedLayoutRef.current) { + return; + } + initializedLayoutRef.current = true; + if (voiceDockCollapsed) { + setVoiceDockCollapsed(false); + } + }, [setVoiceDockCollapsed, voiceDockCollapsed]); const interactionModeRef = useRef("text"); const selectInteractionMode = useCallback( ( @@ -593,7 +603,7 @@ const ConversationAiAssistantPanel = ({ setComposerFocusRequest((request) => request + 1); } }, - [], + [setVoiceDockCollapsed], ); const setVoiceActive = useCallback((active: boolean) => { voiceActiveRef.current = active; @@ -2096,7 +2106,7 @@ const ConversationAiAssistantPanel = ({ clearMessagesDisabled={ voiceActive || aiAssistant.canClearMessages === false } - composerFocusRequest={composerFocusRequest} + composerFocusRequest={composerFocusRequest + focusRequest} composerControl={composerControl} error={streamError ?? error} experimentStates={experimentStates} @@ -2223,11 +2233,6 @@ const ConversationAiAssistantPanel = ({ onSubmit={submitComposerInput} onVoiceDockCollapsedChange={setVoiceDockCollapsed} promptChips={promptChips} - rightOffset={ - globalMode === "edit" && hasSelection - ? propertiesPanelWidth + PANEL_MARGIN - : 0 - } status={status} stopped={stopped} voiceHandoffPending={voiceHandoffPending} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx index b86816136ef..2fb63873141 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx @@ -1,10 +1,11 @@ -import { type ReactNode, useState } from "react"; -import { expect, userEvent, waitFor, within } from "storybook/test"; +import { type ComponentProps, type ReactNode, use, useState } from "react"; +import { expect, userEvent, within } from "storybook/test"; import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { NotificationsProvider } from "../../../../../react/notifications/provider"; +import { EditorContext } from "../../../../../react/state/editor-context"; import { VoiceSessionContext } from "../../../../../react/voice-session/context"; import { createVoiceSessionStore, @@ -230,12 +231,14 @@ const hostSlotTitleStyle = css({ }); const frameStyle = css({ + containerType: "inline-size", height: "[720px]", position: "relative", width: "full", }); const narrowFrameStyle = css({ + containerType: "inline-size", height: "[720px]", maxWidth: "full", position: "relative", @@ -320,8 +323,10 @@ const createStoryVoiceSessionStore = ( }; const Frame = ({ + additionalTab, error, fixedNarrowWidth = false, + initialPlacement = "docked", initialVoiceDockCollapsed = false, inputMode = "text", messages, @@ -332,8 +337,10 @@ const Frame = ({ voiceProvider = "live", voiceSession, }: { + additionalTab?: ComponentProps["additionalTab"]; error?: Error; fixedNarrowWidth?: boolean; + initialPlacement?: "docked" | "floating"; initialVoiceDockCollapsed?: boolean; inputMode?: "text" | "voice"; messages: PetrinautAiMessage[]; @@ -344,6 +351,10 @@ const Frame = ({ voiceProvider?: VoiceProvider; voiceSession?: PetrinautAiVoiceSessionState; }) => { + const editor = use(EditorContext); + const [placement, setPlacement] = useState(initialPlacement); + const [width, setWidth] = useState(editor.aiAssistantWidth); + const [isOpen, setOpen] = useState(true); const [input, setInput] = useState(""); const [voiceDockCollapsed, setVoiceDockCollapsed] = useState( initialVoiceDockCollapsed, @@ -355,30 +366,42 @@ const Frame = ({ ); return ( - -
- {}} - onInputChange={setInput} - onInputModeChange={() => {}} - onStop={() => {}} - onSubmit={() => setInput("")} - onVoiceDockCollapsedChange={setVoiceDockCollapsed} - status={status} - stopped={stopped} - voiceDockCollapsed={voiceDockCollapsed} - voiceMode={voiceMode} - voiceModeAvailable={voiceModeAvailable} - /> -
-
+ + +
+ setOpen(false)} + onInputChange={setInput} + onInputModeChange={() => {}} + onStop={() => {}} + onSubmit={() => setInput("")} + onVoiceDockCollapsedChange={setVoiceDockCollapsed} + status={status} + stopped={stopped} + voiceDockCollapsed={voiceDockCollapsed} + voiceMode={voiceMode} + voiceModeAvailable={voiceModeAvailable} + /> +
+
+
); }; @@ -396,6 +419,32 @@ export const Empty: Story = { render: () => , }; +export const Floating: Story = { + render: () => ( + + ), +}; + +export const WithWorkpieceTab: Story = { + render: () => ( + +

Model account

+

A saved description of the process being modeled.

+ + ), + }} + messages={[userMessage, assistantMarkdownMessage]} + /> + ), +}; + export const EmptyWithVoiceAvailable: Story = { render: () => , }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx index 4bd075485ab..61a8bcfed97 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx @@ -10,7 +10,7 @@ import { within, waitFor, } from "@testing-library/react"; -import { createElement, useEffect, useState } from "react"; +import { createElement, use, useEffect, useState } from "react"; import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { DEFAULT_PETRINAUT_EXTENSIONS } from "@hashintel/petrinaut-core"; @@ -20,6 +20,7 @@ import { type NotificationsContextValue, } from "../../../../../react/notifications/context"; import { NotificationsProvider } from "../../../../../react/notifications/provider"; +import { EditorContext } from "../../../../../react/state/editor-context"; import { VoiceSessionContext } from "../../../../../react/voice-session/context"; import { createVoiceSessionStore } from "../../../../../react/voice-session/store"; import { definePetrinautAiInteractiveTool } from "../../../../types/ai-interactive-tool"; @@ -80,6 +81,75 @@ const HostContent = ({ onMount }: { onMount: () => void }) => { useEffect(onMount, [onMount]); return

Saved account

; }; +const HostControl = ({ + onMount, + onUnmount, +}: { + onMount: () => void; + onUnmount: () => void; +}) => { + useEffect(() => { + onMount(); + return onUnmount; + }, [onMount, onUnmount]); + return Host control; +}; +const DockingHarness = ({ + onMount, + onUnmount, + onStop, +}: { + onMount: () => void; + onUnmount: () => void; + onStop: () => void; +}) => { + const editor = use(EditorContext); + const [placement, setPlacement] = useState<"docked" | "floating">("docked"); + const [isOpen, setOpen] = useState(true); + const [input, setInput] = useState(""); + return ( + + + Saved model account

, + }} + composerControl={ + + } + input={input} + isOpen={isOpen} + messages={[ + { + id: "streaming-reply", + role: "assistant", + parts: [ + { + type: "text", + text: "The infection rate", + state: "streaming", + }, + ], + }, + ]} + onClose={() => setOpen(false)} + onInputChange={setInput} + onStop={onStop} + onSubmit={noop} + status="streaming" + /> +
+ ); +}; test("live-capability dock keeps microphone direct and Realtime controls absent", async () => { const end = vi.fn(); @@ -605,6 +675,101 @@ describe("AiAssistantContents", () => { expect(screen.queryByText("Not applied")).toBeNull(); }, ); + test("refocuses an open assistant on request and focuses the panel while Voice is compact", () => { + const props = { + input: "Keep this draft", + messages: [], + onClose: noop, + onInputChange: noop, + onStop: noop, + onSubmit: noop, + status: "ready" as const, + }; + const { rerender } = render( + <> + + + , + ); + const input = screen.getByRole("textbox", { name: "Message AI assistant" }); + expect(document.activeElement).toBe(input); + screen.getByRole("textbox", { name: "Other input" }).focus(); + rerender( + <> + + + , + ); + expect(document.activeElement).toBe(input); + expect((input as HTMLTextAreaElement).value).toBe("Keep this draft"); + rerender( + <> + + Voice setup} + /> + , + ); + expect(document.activeElement).toBe( + screen.getByRole("complementary", { name: "AI assistant" }), + ); + }); + + test("keeps the draft, transcript, and host controls mounted through docking and closing", () => { + const mount = vi.fn(); + const unmount = vi.fn(); + const stop = vi.fn(); + render( + + + , + ); + const panel = screen.getByRole("complementary", { name: "AI assistant" }); + const textarea = screen.getByRole("textbox", { + name: "Message AI assistant", + }); + const transcript = screen.getByTestId("ai-transcript"); + fireEvent.change(textarea, { target: { value: "Keep this draft" } }); + fireEvent.click(screen.getByRole("tab", { name: "Workpiece" })); + const workpiece = screen.getByRole("tabpanel", { name: "Workpiece" }); + expect(panel.getAttribute("data-placement")).toBe("docked"); + fireEvent.click(screen.getByRole("button", { name: "Float AI assistant" })); + expect(panel.getAttribute("data-placement")).toBe("floating"); + fireEvent.click(screen.getByRole("button", { name: "Close AI assistant" })); + fireEvent.click(screen.getByRole("button", { name: "Reopen assistant" })); + expect(panel.getAttribute("data-placement")).toBe("floating"); + fireEvent.click(screen.getByRole("button", { name: "Dock AI assistant" })); + expect(panel.getAttribute("data-placement")).toBe("docked"); + const panelWidth = panel.style.width; + fireEvent.click(screen.getByRole("button", { name: "Close AI assistant" })); + expect(panel.style.width).toBe(panelWidth); + expect(panel.hasAttribute("inert")).toBe(true); + expect( + screen.queryByRole("textbox", { name: "Message AI assistant" }), + ).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Reopen assistant" })); + expect(panel.hasAttribute("inert")).toBe(false); + expect(screen.getByRole("tabpanel", { name: "Workpiece" })).toBe(workpiece); + expect(workpiece.textContent).toContain("Saved model account"); + expect(transcript.hidden).toBe(true); + fireEvent.click(screen.getByRole("tab", { name: "AI" })); + expect(screen.getByRole("textbox", { name: "Message AI assistant" })).toBe( + textarea, + ); + expect((textarea as HTMLTextAreaElement).value).toBe("Keep this draft"); + expect(screen.getByTestId("ai-transcript")).toBe(transcript); + expect(screen.getByText("The infection rate")).not.toBeNull(); + expect(mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Stop AI response" })); + expect(stop).toHaveBeenCalledOnce(); + }); + test("labels stopped history after a later completed reply without global Stop state", () => { render( @@ -2239,16 +2404,16 @@ describe("AiAssistantContents", () => { }); test("scrolls to the latest chat content", async () => { - // jsdom does not implement `scrollIntoView`, so we install a stub on the + // jsdom does not implement `scrollTo`, so we install a stub on the // prototype and restore it afterwards. The `unbound-method` lint warning // is a false positive — we never invoke the saved reference, we only // assign it back. // eslint-disable-next-line @typescript-eslint/unbound-method - const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; + const originalScrollTo = window.HTMLElement.prototype.scrollTo; const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; - const scrollIntoView = vi.fn(); - window.HTMLElement.prototype.scrollIntoView = scrollIntoView; + const scrollTo = vi.fn(); + window.HTMLElement.prototype.scrollTo = scrollTo; // Make rAF synchronous so the scroll effect runs before the assertion. window.requestAnimationFrame = (callback) => { callback(0); @@ -2278,8 +2443,11 @@ describe("AiAssistantContents", () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); }); - expect(scrollIntoView).toHaveBeenCalled(); - window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + expect(scrollTo).toHaveBeenCalled(); + expect(scrollTo.mock.instances).toContain( + screen.getByTestId("ai-transcript"), + ); + window.HTMLElement.prototype.scrollTo = originalScrollTo; window.requestAnimationFrame = originalRequestAnimationFrame; window.cancelAnimationFrame = originalCancelAnimationFrame; }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx index fcc53026553..9f68c7c0c05 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx @@ -45,6 +45,7 @@ import { AiAssistantToolList, type OnInteractiveToolSubmit, } from "./ai-assistant-contents/tool-list"; +import { useFloatingPosition } from "./ai-assistant-contents/use-floating-position"; import { VoiceAlerts } from "./ai-assistant-contents/voice-alerts"; import { LiveVoiceDock, VoiceDock } from "./ai-assistant-contents/voice-dock"; import { VoiceInputProvenance } from "./ai-assistant-contents/voice-input-provenance"; @@ -89,7 +90,6 @@ export type AiAssistantContentsProps = { onSubmit: () => void; onVoiceDockCollapsedChange?: (collapsed: boolean) => void; promptChips?: PromptChip[]; - rightOffset?: number; status: AiAssistantStatus; stopped?: boolean; voiceHandoffPending?: boolean; @@ -98,66 +98,81 @@ export type AiAssistantContentsProps = { voiceModeAvailable?: boolean; }; +const dockSpaceStyle = css({ + flexShrink: 0, + minWidth: "[0]", + maxWidth: "[100%]", + pointerEvents: "none", + '&[data-animating="true"]': { + transition: "[width 150ms ease-in-out]", + "@media (prefers-reduced-motion: reduce)": { transition: "[none]" }, + }, + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, +}); + const shellStyle = cva({ base: { position: "absolute", - right: "0", + top: "[0]", + right: "[0]", + height: "full", + maxHeight: "full", + maxWidth: "full", + transform: "[translateX(0)]", + visibility: "visible", zIndex: "[calc(var(--z-index-sticky) + 2)]", pointerEvents: "auto", - transition: "[right 150ms ease-in-out]", + '&[data-animating="true"]': { + transition: + "[top 150ms ease-in-out, right 150ms ease-in-out, height 150ms ease-in-out, max-height 150ms ease-in-out, transform 150ms ease-in-out, visibility 0s]", + "@media (prefers-reduced-motion: reduce)": { transition: "[none]" }, + }, "@media (prefers-reduced-motion: reduce)": { transition: "[none]", }, }, variants: { - collapsed: { - true: {}, + setupOverlay: { + true: { overflow: "visible" }, }, - open: { + floating: { true: { - top: "0", - bottom: "0", - maxWidth: "[calc(100vw - 32px)]", - padding: "2", - _before: { - content: '""', - position: "absolute", - inset: "2", - borderRadius: "[14px]", - background: - "[radial-gradient(circle at 78% 28%, rgba(52,160,250,0.22), rgba(190,230,255,0.04) 54%, transparent 80%)]", - filter: "[blur(4px)]", - pointerEvents: "none", - }, + top: "[12px]", + right: "[12px]", + height: "[calc(100% - 24px)]", + maxHeight: "[640px]", + maxWidth: "[calc(100% - 24px)]", }, + }, + open: { false: { - bottom: "0", - width: "[0px]", - height: "[0px]", - overflow: "visible", + transform: "[translateX(100%)]", + visibility: "hidden", pointerEvents: "none", + '&[data-animating="true"]': { + transitionDelay: "[0s, 0s, 0s, 0s, 0s, 150ms]", + }, }, }, - }, - compoundVariants: [ - { - collapsed: true, - open: true, - css: { + collapsed: { + true: { top: "[auto]", + bottom: "[12px]", + right: "[12px]", height: "auto", + maxWidth: "[calc(100% - 24px)]", }, }, - ], + }, }); -// Tracks the card's inset within the padded shell, so the resize handle -// straddles the card's visible left border rather than the shell edge. const resizeAnchorStyle = css({ position: "absolute", - top: "2", - bottom: "2", - left: "2", + top: "[0]", + bottom: "[0]", + left: "[0]", width: "[0]", }); @@ -166,31 +181,30 @@ const cardStyle = cva({ position: "relative", display: "flex", flexDirection: "column", + height: "full", + overflow: "hidden", + backgroundColor: "neutral.s00", + borderLeft: "[1px solid {colors.neutral.bd.subtle}]", + borderRadius: "[0]", + '&[data-animating="true"]': { + transition: + "[border-radius 150ms ease-in-out, box-shadow 150ms ease-in-out]", + "@media (prefers-reduced-motion: reduce)": { transition: "[none]" }, + }, + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, }, variants: { - setupOverlay: { - true: {}, - }, - open: { + floating: { true: { - height: "full", - overflow: "hidden", - backgroundColor: "neutral.s10", - borderRadius: "[12px]", + borderLeftColor: "[transparent]", + borderRadius: "xl", boxShadow: - "[0px 0px 0px 1px rgba(0,0,0,0.06), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 12px 12px -6px rgba(0,0,0,0.02), 0px 4px 4px -12px rgba(0,0,0,0.02)]", - }, - false: { - width: "[0px]", - height: "[0px]", - overflow: "visible", - pointerEvents: "none", + "[0 0 0 1px rgba(0,0,0,0.08), 0 4px 8px -4px rgba(0,0,0,0.12), 0 12px 32px -12px rgba(0,0,0,0.16)]", }, }, }, - compoundVariants: [ - { open: true, setupOverlay: true, css: { overflow: "visible" } }, - ], }); const panelContentStyle = cva({ @@ -226,30 +240,34 @@ const voiceModeStyle = cva({ const headerStyle = css({ display: "flex", alignItems: "center", - gap: "[1px]", - paddingX: "1", - paddingTop: "[6px]", - borderBottom: "[1px solid rgba(0,0,0,0.08)]", + gap: "1", + height: "[40px]", + paddingLeft: "3", + paddingRight: "2", + borderBottom: "[1px solid {colors.neutral.bd.subtle}]", flexShrink: 0, }); const headerLabelStyle = css({ display: "flex", alignItems: "center", - height: "[28px]", - maxWidth: "[112px]", - paddingX: "3", - borderTopLeftRadius: "lg", - borderTopRightRadius: "lg", - backgroundColor: "neutral.s00", - boxShadow: "[0px 0px 0px 1px rgba(0,0,0,0.08)]", - color: "neutral.s100", - fontSize: "xs", + gap: "2", + flex: "[1]", + minWidth: "[0]", + color: "neutral.fg.heading", + fontSize: "sm", fontWeight: "medium", - lineHeight: "[12px]", - overflow: "hidden", whiteSpace: "nowrap", - textOverflow: "ellipsis", + border: "none", + padding: "[0]", + backgroundColor: "[transparent]", + textAlign: "left", + _enabled: { cursor: "grab", touchAction: "none" }, + _active: { cursor: "grabbing" }, + _focusVisible: { + outline: "[2px solid {colors.blue.s50}]", + outlineOffset: "[4px]", + }, }); const headerButtonStyle = css({ @@ -266,7 +284,8 @@ const messagesStyle = css({ flex: "[1]", minHeight: "[0]", overflowY: "auto", - padding: "2", + padding: "3", + overscrollBehavior: "contain", }); const emptyStyle = css({ @@ -290,27 +309,24 @@ const messageStyle = cva({ display: "flex", flexDirection: "column", gap: "2", - borderRadius: "xl", padding: "[10px]", fontSize: "sm", fontWeight: "medium", lineHeight: "[1.5]", color: "neutral.s100", userSelect: "text", - boxShadow: - "[0px 0px 0px 1px rgba(0,0,0,0.07), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 8px 8px -6px rgba(0,0,0,0.04)]", }, variants: { role: { assistant: { alignSelf: "stretch", - backgroundColor: "white.a95", + paddingX: "[0]", }, user: { alignSelf: "flex-end", maxWidth: "[92%]", - backgroundColor: "neutral.s20", - textAlign: "right", + backgroundColor: "neutral.bg.subtle", + borderRadius: "lg", }, }, }, @@ -337,8 +353,9 @@ const composerWrapStyle = css({ flexDirection: "column", justifyContent: "center", gap: "2", - padding: "2", - backgroundColor: "neutral.bg.subtle", + padding: "3", + borderTop: "[1px solid {colors.neutral.bd.subtle}]", + backgroundColor: "neutral.s00", flexShrink: 0, boxSizing: "border-box", minHeight: `[${aiFooterMinHeight}px]`, @@ -366,9 +383,16 @@ const composerStyle = css({ gap: "1", borderRadius: "lg", backgroundColor: "neutral.s10", - boxShadow: - "[0px 0px 0px 1px rgba(0,0,0,0.06), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 12px 12px -6px rgba(0,0,0,0.02), 0px 4px 4px -12px rgba(0,0,0,0.02)]", + border: "[1px solid {colors.neutral.bd.subtle}]", padding: "1", + transition: "[border-color 150ms ease, box-shadow 150ms ease]", + _focusWithin: { + borderColor: "blue.s50", + boxShadow: "[0 0 0 2px {colors.blue.a10}]", + }, + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, }); // Caps how tall the composer can auto-grow before it starts scrolling @@ -571,7 +595,6 @@ export const AiAssistantContents = ({ onSubmit, onVoiceDockCollapsedChange, promptChips, - rightOffset = 0, status, stopped = false, voiceHandoffPending = false, @@ -647,21 +670,34 @@ export const AiAssistantContents = ({ const isVoiceDockCollapsed = voiceDockCollapsed && (isVoiceSessionLive || inputMode === "voice"); - // Held in editor state, not here: the bottom toolbar and the viewport - // controls have to keep clear of this panel, and cannot read a local value. const { + aiAssistantPlacement, aiAssistantWidth: assistantWidth, + isPanelAnimating, + setAiAssistantPlacement, setAiAssistantWidth: setAssistantWidth, setAiAssistantDockHeight, } = use(EditorContext); - const shellRef = useRef(null); + const isFloating = aiAssistantPlacement === "floating"; const voiceDockRef = useRef(null); + const { + panelRef, + anchorForResize, + isDragging, + handleProps, + style: floatingPositionStyle, + } = useFloatingPosition(assistantWidth); + const panelWidth = `min(${assistantWidth}px, 100cqw)`; + const placementLabel = isFloating + ? "Dock AI assistant" + : "Float AI assistant"; + const reportDockHeight = useEffectEvent((height: number | null) => { setAiAssistantDockHeight(height); }); useLayoutEffect(() => { - const shell = shellRef.current; + const shell = panelRef.current; if (!isOpen || !isVoiceDockCollapsed || !shell) { return; } @@ -674,7 +710,7 @@ export const AiAssistantContents = ({ observer.disconnect(); reportDockHeight(null); }; - }, [isOpen, isVoiceDockCollapsed]); + }, [isOpen, isVoiceDockCollapsed, panelRef]); const [chipsDismissed, setChipsDismissed] = useState(false); @@ -735,7 +771,6 @@ export const AiAssistantContents = ({ ) : null; const inputRef = useRef(null); - const messagesEndRef = useRef(null); const messagesRef = useRef(null); const messagesScrollKey = getMessagesScrollKey(messages); @@ -781,9 +816,14 @@ export const AiAssistantContents = ({ useEffect(() => { if (isOpen) { - inputRef.current?.focus(); + const target = inputRef.current; + if (target && !target.disabled && !isVoiceDockCollapsed) { + target.focus({ preventScroll: true }); + } else { + panelRef.current?.focus({ preventScroll: true }); + } } - }, [composerFocusRequest, isOpen]); + }, [composerFocusRequest, isOpen, isVoiceDockCollapsed, panelRef]); // Auto-grow the composer to fit its content (up to `composerMaxHeight`, // after which it scrolls internally). Resetting to `auto` before measuring @@ -811,12 +851,10 @@ export const AiAssistantContents = ({ const isFirstScroll = !hasScrolledOnceRef.current; hasScrolledOnceRef.current = true; const scrollToEnd = () => { - // The inner optional chain (`scrollIntoView?.`) is intentional — jsdom - // omits `Element.prototype.scrollIntoView`, so unit tests need the - // graceful no-op. The lint rule can't see that. + // Scroll only the transcript: the panel may be sliding outside the editor. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - messagesEndRef.current?.scrollIntoView?.({ - block: "end", + messagesRef.current?.scrollTo?.({ + top: messagesRef.current.scrollHeight, behavior: isFirstScroll ? "instant" : "smooth", }); }; @@ -826,281 +864,326 @@ export const AiAssistantContents = ({ }, [messagesScrollKey, status]); return ( -