diff --git a/.changeset/quiet-boats-dock.md b/.changeset/quiet-boats-dock.md
new file mode 100644
index 00000000000..92cbc08da4f
--- /dev/null
+++ b/.changeset/quiet-boats-dock.md
@@ -0,0 +1,6 @@
+---
+"@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.
+Space out the header tabs, animate its icons, align the send button inside the composer, resize the floating panel from any edge or corner with fading border highlights, and hide Clear AI while the conversation is empty.
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..40e6d4684d9 100644
--- a/libs/@hashintel/petrinaut/docs/ai-assistant.md
+++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md
@@ -4,22 +4,33 @@ 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. Its left divider and resize highlight span the full panel height. 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 anywhere in the header outside the tabs and action buttons 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. Resize the sidebar from its left edge. When floating, drag the left or right edge to change its width, the top or bottom edge to change its height, or any corner to change both together. The opposite edge or corner stays fixed, and the panel stays inside the editor. Resizing only one dimension preserves the saved size of the other, even when the editor is too small to display it in full. Closing and reopening the assistant keeps your layout choice and floating height for the editor session.
+
+Hovering a floating side highlights its straight border, fading out before each corner. Hovering a corner highlights its rounded border and fades along the two adjoining edges. Drag the corner to resize both dimensions.
+
+The header text is not selectable. Its icons animate on hover and click, respecting your reduced-motion preference.
## The conversation
+Type in the message field and press **Enter** or choose the **Send message** button with the upward arrow at its right edge. **Shift+Enter** adds a line. The field grows with your message while the send button stays at the bottom right.
+
While a response is streaming you can:
- Watch the model's text and reasoning appear live. The **Reasoning** block is collapsible; while it is streaming, it auto-opens, shows a shimmer effect, and (once attached timing information arrives) an elapsed timer.
@@ -181,7 +192,7 @@ Voice ends when the panel closes. If Realtime Voice is interrupted, allow
microphone access or check the connection, then select **Reconnect voice
mode**. **Clear AI chat** is unavailable while a Voice session is active.
-When no interview is active and the host permits clearing, **Clear AI chat** via the delete button in the top right of the panel wipes the local conversation, stops any in-flight stream, and tells the host app to forget the messages if it persists them. Hosts with canonical history may disable this control. The Brunch panel disables it because clearing only the browser view would not delete Flue history and the conversation would return on rehydration.
+The delete button appears in the top right of the panel once the conversation contains messages. When no interview is active and the host permits clearing, **Clear AI chat** wipes the local conversation, stops any in-flight stream, and tells the host app to forget the messages if it persists them. Hosts with canonical history may disable this control. The Brunch panel disables it because clearing only the browser view would not delete Flue history and the conversation would return on rehydration.
## What the assistant can do
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.