Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-boats-dock.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion apps/petrinaut-website/src/main/app/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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);
}
}}
>
<input
ref={(element) => element?.focus()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LocalStorageDemoApp onSearchChange={() => {}} 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(<LocalStorageDemoApp onSearchChange={() => {}} search={{}} />);
Expand Down
29 changes: 20 additions & 9 deletions libs/@hashintel/petrinaut/docs/ai-assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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);
}
}}
>
<input
ref={(element) => element?.focus()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const editorContextValue = (
setPropertiesPanelWidth: () => {},
setAiAssistantWidth: () => {},
setAiAssistantDockHeight: () => {},
setAiAssistantPlacement: () => {},
setAiAssistantCollapsed: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const editorContextValue = (
setPropertiesPanelWidth: () => {},
setAiAssistantWidth: () => {},
setAiAssistantDockHeight: () => {},
setAiAssistantPlacement: () => {},
setAiAssistantCollapsed: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
Expand Down
8 changes: 8 additions & 0 deletions libs/@hashintel/petrinaut/src/react/state/editor-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -228,6 +234,8 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = {
setPropertiesPanelWidth: () => {},
setAiAssistantWidth: () => {},
setAiAssistantDockHeight: () => {},
setAiAssistantPlacement: () => {},
setAiAssistantCollapsed: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: [],
Expand Down Expand Up @@ -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(
<UserSettingsContext
value={{ ...defaultUserSettingsContextValue, showAnimations }}
>
<SDCPNContext value={makeSdcpnContextValue(() => "place")}>
<TestHost recorded={[]}>
<EditorProvider>
<EditorContextGrabber
onContextValue={(value) => {
editor = value;
}}
/>
</EditorProvider>
</TestHost>
</SDCPNContext>
</UserSettingsContext>,
);

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[];
Expand Down
34 changes: 30 additions & 4 deletions libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
const animationTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(() => () => clearTimeout(animationTimerRef.current), []);
const selectionGestureRef = useRef({ active: false, hasNavigated: false });
const selectionNavigationMountedRef = useRef(true);
const pendingSelectionNavigationRef = useRef<{
Expand Down Expand Up @@ -394,6 +395,22 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ 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) => ({
Expand Down Expand Up @@ -508,13 +525,22 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ 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() }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading