diff --git a/.changeset/dialog-content-position.md b/.changeset/dialog-content-position.md
new file mode 100644
index 00000000000..063f66f226f
--- /dev/null
+++ b/.changeset/dialog-content-position.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/ds-components": patch
+---
+
+Support content refs and inline styles on Dialog for movable, resizable layouts.
diff --git a/.changeset/petrinaut-user-settings.md b/.changeset/petrinaut-user-settings.md
new file mode 100644
index 00000000000..c0a00437fd4
--- /dev/null
+++ b/.changeset/petrinaut-user-settings.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Provide movable, resizable User Settings with keyboard-accessible sections, inline experimental badges, and scroll-edge blur.
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/examples/example-search.test.ts b/apps/petrinaut-website/src/examples/example-search.test.ts
index d231a63afbf..6d53a5045ca 100644
--- a/apps/petrinaut-website/src/examples/example-search.test.ts
+++ b/apps/petrinaut-website/src/examples/example-search.test.ts
@@ -8,6 +8,30 @@ import {
} from "./example-search";
describe("example search contract", () => {
+ it("validates settings sections only for the user settings dialog", () => {
+ expect(
+ validateSharedExampleSearch({
+ overlay: "user-settings",
+ settings: "viewport",
+ }),
+ ).toMatchObject({ overlay: "user-settings", settings: "viewport" });
+ expect(
+ validateSharedExampleSearch({
+ overlay: "user-settings",
+ settings: "unknown",
+ }).settings,
+ ).toBeUndefined();
+ expect(
+ validateSharedExampleSearch({
+ overlay: "create-experiment",
+ settings: "viewport",
+ }).settings,
+ ).toBeUndefined();
+ expect(
+ validateSharedExampleSearch({ overlay: "viewport-settings" }).overlay,
+ ).toBe("viewport-settings");
+ });
+
it("strips unsupported query values", () => {
expect(
validateSharedExampleSearch({
diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts
index e8685086546..b245df20796 100644
--- a/apps/petrinaut-website/src/examples/example-search.ts
+++ b/apps/petrinaut-website/src/examples/example-search.ts
@@ -31,12 +31,20 @@ export const sharedSimulateViews = [
] as const;
export const sharedOverlays = [
+ "user-settings",
"viewport-settings",
"create-scenario",
"create-metric",
"create-experiment",
] as const;
+export const sharedSettingsSections = [
+ "general",
+ "viewport",
+ "simulation",
+ "labs",
+] as const;
+
export type SharedMode = (typeof sharedModes)[number];
export type SharedSimulateView = (typeof sharedSimulateViews)[number];
export type SharedOverlay = (typeof sharedOverlays)[number];
@@ -58,6 +66,7 @@ export type SharedExampleSearch = {
mode?: SharedMode;
view?: SharedSimulateView;
overlay?: SharedOverlay;
+ settings?: (typeof sharedSettingsSections)[number];
};
/** The keys this contract owns. Anything else in a URL is foreign. */
@@ -69,6 +78,7 @@ const sharedSearchKeys = [
"mode",
"view",
"overlay",
+ "settings",
] as const satisfies readonly (keyof SharedExampleSearch)[];
// `.catch(undefined)` is the contract's whole validation story: anything a URL
@@ -117,6 +127,14 @@ export const validateSharedExampleSearch = (
mode: optionalMode.parse(input.mode),
view: optionalSimulateView.parse(input.view),
overlay: optionalOverlay.parse(input.overlay),
+ settings:
+ input.overlay === "user-settings"
+ ? z
+ .enum(sharedSettingsSections)
+ .optional()
+ .catch(undefined)
+ .parse(input.settings)
+ : undefined,
...selectionToSearch(selectionFromInput(input)),
});
diff --git a/apps/petrinaut-website/src/examples/navigation-search.test.ts b/apps/petrinaut-website/src/examples/navigation-search.test.ts
index 87f7546875c..b691d6e1b5b 100644
--- a/apps/petrinaut-website/src/examples/navigation-search.test.ts
+++ b/apps/petrinaut-website/src/examples/navigation-search.test.ts
@@ -8,6 +8,31 @@ import {
} from "./navigation-search";
describe("navigation state projection", () => {
+ it.each(["general", "viewport", "simulation", "labs"] as const)(
+ "round-trips the %s settings section in Simulate",
+ (settings) => {
+ const search = {
+ mode: "simulate",
+ view: "metrics",
+ overlay: "user-settings",
+ settings,
+ } as const;
+ const state = sharedSearchToNavigationState(search);
+ expect(state.overlay).toEqual({
+ type: "user-settings",
+ section: settings,
+ });
+ expect(navigationStateToSharedSearch(state)).toMatchObject(search);
+ expect(
+ applyPreviewNavigationUpdate(search, (current) => current),
+ ).toMatchObject(search);
+ expect(
+ sharedSearchToNavigationState({ mode: "simulate", view: "metrics" })
+ .overlay,
+ ).toBeNull();
+ },
+ );
+
it("round-trips scenario, subnet, and selection", () => {
const state = sharedSearchToNavigationState({
scenario: "none",
diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts
index b3ed0b90e43..7a009ee4148 100644
--- a/apps/petrinaut-website/src/examples/navigation-search.ts
+++ b/apps/petrinaut-website/src/examples/navigation-search.ts
@@ -71,7 +71,9 @@ const overlayToSearch = (
const overlayFromSearch = (
overlay: SharedOverlay,
-): PetrinautNavigationOverlay => ({ type: overlay });
+ section: SharedExampleSearch["settings"],
+): PetrinautNavigationOverlay =>
+ overlay === "user-settings" ? { type: overlay, section } : { type: overlay };
export const sharedSearchToNavigationState = (
search: SharedExampleSearch,
@@ -86,7 +88,7 @@ export const sharedSearchToNavigationState = (
overlay:
search.overlay === undefined
? baseline.overlay
- : overlayFromSearch(search.overlay),
+ : overlayFromSearch(search.overlay, search.settings),
});
export const navigationStateToSharedSearch = (
@@ -106,6 +108,10 @@ export const navigationStateToSharedSearch = (
view === simulateViewToSearch(baseline.simulateView) ? undefined : view,
overlay:
overlay === overlayToSearch(baseline.overlay) ? undefined : overlay,
+ settings:
+ state.overlay?.type === "user-settings"
+ ? state.overlay.section
+ : undefined,
...selectionToSearch(state.selection),
};
};
@@ -139,6 +145,7 @@ export const applyPreviewNavigationUpdate = (
mode: search.mode,
view: search.view,
overlay: search.overlay,
+ settings: search.settings,
...navigationStateToPreviewSearch(
update(previewSearchToNavigationState(search)),
),
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/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx
index ab1fd78bf89..095f6eeadce 100644
--- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx
+++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx
@@ -23,6 +23,8 @@ export type DialogSize = "xs" | "sm" | "md" | "lg" | "xl" | "fullScreen";
const backdropClassName = overlayPartsStyles({ component: "dialog" }).backdrop;
const DialogRoot = ({
+ ref,
+ style,
className,
size = "md",
variant = "partitionedFooter",
@@ -35,6 +37,8 @@ const DialogRoot = ({
onKeyDown,
...ariaAttributes
}: {
+ ref?: React.Ref;
+ style?: React.CSSProperties;
className?: string;
size?: DialogSize;
onKeyDown?: React.KeyboardEventHandler;
@@ -112,6 +116,8 @@ const DialogRoot = ({
{
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/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts
index 8127388cbbe..257c0f14a11 100644
--- a/libs/@hashintel/petrinaut/src/react/index.ts
+++ b/libs/@hashintel/petrinaut/src/react/index.ts
@@ -56,6 +56,7 @@ export type {
PetrinautNavigationUpdate,
PetrinautNavigationUpdater,
PetrinautSimulateResource,
+ PetrinautSettingsSection,
} from "./navigation";
// The vocabularies two navigation fields are drawn from. A host encoding the
// location into a router needs to spell them, and to fail its own build when
diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx
index 217891b0f0d..5f6129a882c 100644
--- a/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx
+++ b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx
@@ -9,6 +9,7 @@ import {
openPetrinautSimulationResource,
openPetrinautSubnet,
PetrinautNavigationProvider,
+ petrinautNavigationStatesMatch,
simulateDrawerToNavigationOverlay,
simulateDrawerToNavigationResource,
usePetrinautNavigation,
@@ -20,6 +21,21 @@ import type {
} from ".";
describe("Petrinaut navigation", () => {
+ test("distinguishes settings sections and normalizes the default section", () => {
+ const state = (
+ section?: "general" | "viewport",
+ ): PetrinautNavigationState => ({
+ ...defaultPetrinautNavigationState,
+ overlay: { type: "user-settings", section },
+ });
+ expect(petrinautNavigationStatesMatch(state(), state("general"))).toBe(
+ true,
+ );
+ expect(
+ petrinautNavigationStatesMatch(state("general"), state("viewport")),
+ ).toBe(false);
+ });
+
test("passes an updater and app-history intent to a controlled host", () => {
const onNavigate = vi.fn();
const controller: PetrinautNavigationController = {
diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx
index 82104d7352c..9757d6bb29e 100644
--- a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx
+++ b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx
@@ -29,7 +29,14 @@ export type PetrinautSimulateResource =
| { type: "metric"; id: string }
| { type: "experiment"; id: string };
+export type PetrinautSettingsSection =
+ | "general"
+ | "viewport"
+ | "simulation"
+ | "labs";
+
export type PetrinautNavigationOverlay =
+ | { type: "user-settings"; section?: PetrinautSettingsSection }
| { type: "viewport-settings" }
| { type: "create-scenario" }
| { type: "create-metric" }
@@ -170,7 +177,11 @@ export const petrinautNavigationStatesMatch = (
left.scenarioId === right.scenarioId &&
left.subnetId === right.subnetId &&
selectionsMatch(left.selection, right.selection) &&
- left.overlay?.type === right.overlay?.type;
+ left.overlay?.type === right.overlay?.type &&
+ (left.overlay?.type !== "user-settings" ||
+ right.overlay?.type !== "user-settings" ||
+ (left.overlay.section ?? "general") ===
+ (right.overlay.section ?? "general"));
const resolveNavigationUpdate = (
current: Readonly,
@@ -376,6 +387,7 @@ export const navigationResourceToSimulateDrawer = (
case "create-experiment":
return { type: overlay.type };
case "viewport-settings":
+ case "user-settings":
case undefined:
break;
}
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..d32b82ce049 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx
@@ -27,6 +27,8 @@ import {
import { usePetrinautCommands } from "../../../react";
import { ActualModeContext } from "../../../react/actual-mode-context";
+import { formatShortcutKeys } from "../../../react/commands/format-shortcut";
+import { usePetrinautNavigation } from "../../../react/navigation";
import { EditorContext } from "../../../react/state/editor-context";
import { SDCPNContext } from "../../../react/state/sdcpn-context";
import { useEffectiveGlobalMode } from "../../../react/state/use-effective-global-mode";
@@ -57,6 +59,7 @@ import {
shouldShowBrunchCreateNew,
} from "./editor-view/create-new-net-menu";
import { emptyPetriNetDefinition } from "./editor-view/empty-petri-net-definition";
+import { UserSettings } from "./editor-view/user-settings";
import { AiAssistantPanel } from "./panels/ai-assistant-panel";
import { BottomPanel } from "./panels/BottomPanel/panel";
import { LeftSideBar } from "./panels/LeftSideBar/panel";
@@ -102,15 +105,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;
@@ -152,6 +157,7 @@ export const EditorView = ({
viewportActions?: ViewportAction[];
}) => {
const showNetManagementMenuItems = hideNetManagementControls === undefined;
+ const navigation = usePetrinautNavigation();
// Auto-layout moves nodes, which a read-only net rejects, so the menu would
// otherwise offer an item that silently does nothing.
const isReadOnly = useIsReadOnly();
@@ -178,6 +184,7 @@ export const EditorView = ({
setCursorMode,
clearSelection,
setAiAssistantOpen,
+ setAiAssistantCollapsed,
isBottomPanelOpen,
bottomPanelHeight,
} = use(EditorContext);
@@ -190,6 +197,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 +217,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.
@@ -483,6 +501,16 @@ export const EditorView = ({
},
]
: []),
+ {
+ id: "user-settings",
+ text: "User settings",
+ suffix: formatShortcutKeys("mod+,").join(" "),
+ onClick: () =>
+ navigation.navigate(
+ { overlay: { type: "user-settings", section: "general" } },
+ { cause: "user", action: "overlay" },
+ ),
+ },
{
id: "docs",
text: "Docs",
@@ -508,7 +536,10 @@ export const EditorView = ({
enabled={enableExperimentalIconPack}
motion={showAnimations ? "auto" : "none"}
>
-
+
+
-
-
)}
-
+ {(effectiveMode === "edit" || effectiveMode === "actual") && (
+
+ )}
{aiAssistant && (
{
+ localStorage.clear();
+ vi.stubGlobal("PointerEvent", MouseEvent);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", {
+ configurable: true,
+ value: vi.fn(),
+ });
+});
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
+});
+
+const renderSettings = (
+ initialState: Partial = {},
+ optimization: PetrinautOptimizationSource | null = null,
+) => {
+ const registry = createCommandRegistry();
+ const result = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ return { ...result, registry };
+};
+
+describe("user settings", () => {
+ it.each(["heading", "top padding"])(
+ "drags from %s, stays within the viewport, and stops on release",
+ async (dragSource) => {
+ renderSettings({ overlay: { type: "user-settings" } });
+ const dialog = await screen.findByRole("dialog");
+ const heading = screen.getByRole("heading", { name: "General" });
+ const handle = dialog.querySelector(
+ '[data-scope="tabs"][data-part="root"]',
+ )!;
+ vi.spyOn(
+ heading.closest("header")!,
+ "getBoundingClientRect",
+ ).mockReturnValue(new DOMRect(100, 100, 400, 80));
+ handle.setPointerCapture = vi.fn();
+ const releasePointerCapture = vi.fn();
+ handle.releasePointerCapture = releasePointerCapture;
+ vi.spyOn(dialog, "getBoundingClientRect").mockReturnValue({
+ x: 100,
+ y: 100,
+ left: 100,
+ top: 100,
+ right: 500,
+ bottom: 500,
+ width: 400,
+ height: 400,
+ toJSON: () => ({}),
+ });
+ vi.spyOn(dialog.parentElement!, "getBoundingClientRect").mockReturnValue(
+ new DOMRect(0, 0, 1024, 768),
+ );
+ fireEvent.pointerDown(dragSource === "heading" ? heading : handle, {
+ button: 0,
+ clientX: 200,
+ clientY: 150,
+ });
+ fireEvent.pointerMove(handle, { clientX: 250, clientY: 170 });
+ expect(dialog.style.getPropertyValue("--floating-panel-right")).toContain(
+ "474px",
+ );
+ expect(dialog.style.getPropertyValue("--floating-panel-top")).toContain(
+ "120px",
+ );
+ fireEvent.pointerMove(handle, { clientX: -1000, clientY: -1000 });
+ expect(dialog.style.getPropertyValue("--floating-panel-right")).toContain(
+ "604px",
+ );
+ expect(dialog.style.getPropertyValue("--floating-panel-top")).toContain(
+ "20px",
+ );
+ fireEvent.pointerUp(handle);
+ fireEvent.pointerMove(handle, { clientX: 300, clientY: 300 });
+ expect(dialog.style.getPropertyValue("--floating-panel-right")).toContain(
+ "604px",
+ );
+ expect(dialog.style.getPropertyValue("--floating-panel-top")).toContain(
+ "20px",
+ );
+ expect(releasePointerCapture).toHaveBeenCalledOnce();
+ },
+ );
+
+ it("keeps tabs and scrolled settings outside the drag area", async () => {
+ renderSettings({ overlay: { type: "user-settings", section: "viewport" } });
+ const dialog = await screen.findByRole("dialog");
+ const heading = screen.getByRole("heading", { name: "Viewport" });
+ const handle = dialog.querySelector(
+ '[data-scope="tabs"][data-part="root"]',
+ )!;
+ const setPointerCapture = vi.fn();
+ handle.setPointerCapture = setPointerCapture;
+ vi.spyOn(
+ heading.closest("header")!,
+ "getBoundingClientRect",
+ ).mockReturnValue(new DOMRect(100, 100, 400, 80));
+ const initialPosition = dialog.style.cssText;
+ fireEvent.pointerDown(screen.getByRole("tab", { name: "General" }), {
+ button: 0,
+ clientX: 150,
+ clientY: 150,
+ });
+ const panel = screen.getByRole("tabpanel");
+ panel.scrollTop = 200;
+ fireEvent.scroll(panel);
+ fireEvent.pointerDown(screen.getByText("Minimap"), {
+ button: 0,
+ clientX: 300,
+ clientY: 250,
+ });
+ fireEvent.pointerMove(handle, { clientX: 320, clientY: 270 });
+ expect(setPointerCapture).not.toHaveBeenCalled();
+ expect(dialog.style.cssText).toBe(initialPosition);
+ });
+
+ it.each([
+ ["top", 760, 440, 140, 320],
+ ["right", 820, 480, 100, 260],
+ ["bottom", 760, 520, 100, 320],
+ ["left", 700, 480, 100, 320],
+ ["top-left", 700, 440, 140, 320],
+ ["top-right", 820, 440, 140, 260],
+ ["bottom-left", 700, 520, 100, 320],
+ ["bottom-right", 820, 520, 100, 260],
+ ] as const)(
+ "resizes from %s and preserves size when changing sections",
+ async (direction, width, height, top, right) => {
+ renderSettings({ overlay: { type: "user-settings" } });
+ const dialog = await screen.findByRole("dialog");
+ vi.spyOn(dialog, "getBoundingClientRect").mockReturnValue(
+ new DOMRect(200, 100, 760, 480),
+ );
+ vi.spyOn(dialog.parentElement!, "getBoundingClientRect").mockReturnValue(
+ new DOMRect(0, 0, 1280, 900),
+ );
+ const handle = screen.getByRole("button", {
+ name: `Resize User settings from ${direction}`,
+ });
+ handle.setPointerCapture = vi.fn();
+ handle.releasePointerCapture = vi.fn();
+ fireEvent.pointerDown(handle, { button: 0, clientX: 200, clientY: 100 });
+ fireEvent.pointerMove(handle, { clientX: 260, clientY: 140 });
+ fireEvent.pointerUp(handle);
+ expect(dialog.style.getPropertyValue("--floating-panel-width")).toContain(
+ `${width}px`,
+ );
+ expect(
+ dialog.style.getPropertyValue("--floating-panel-height"),
+ ).toContain(`${height}px`);
+ expect(dialog.style.getPropertyValue("--floating-panel-top")).toContain(
+ `${top}px`,
+ );
+ expect(dialog.style.getPropertyValue("--floating-panel-right")).toContain(
+ `${right}px`,
+ );
+ const sizeProperties = [
+ "--floating-panel-width",
+ "--floating-panel-height",
+ "--floating-panel-top",
+ "--floating-panel-right",
+ ];
+ const resizedStyle = sizeProperties.map((property) =>
+ dialog.style.getPropertyValue(property),
+ );
+ fireEvent.click(screen.getByRole("tab", { name: "Viewport" }));
+ await screen.findByRole("heading", { name: "Viewport" });
+ expect(
+ sizeProperties.map((property) =>
+ dialog.style.getPropertyValue(property),
+ ),
+ ).toEqual(resizedStyle);
+ },
+ );
+
+ it.each(["edit", "simulate", "actual", "notebook"] as const)(
+ "opens from the shortcut and palette in %s mode",
+ async (mode) => {
+ const { registry } = renderSettings({ mode });
+ expect(
+ registry
+ .list()
+ .find((command) => command.id === "petrinaut.settings.open")
+ ?.shortcut,
+ ).toBe("mod+,");
+ fireEvent.keyDown(window, { key: ",", ctrlKey: true });
+ expect(
+ (await screen.findByRole("tab", { name: "General" })).getAttribute(
+ "aria-selected",
+ ),
+ ).toBe("true");
+ act(() => {
+ registry.execute("petrinaut.settings.open");
+ });
+ expect(screen.getAllByRole("dialog")).toHaveLength(1);
+ },
+ );
+
+ it("opens legacy viewport links and applies changes across tabs and remounts", async () => {
+ const first = renderSettings({ overlay: { type: "viewport-settings" } });
+ expect(
+ (await screen.findByRole("tab", { name: "Viewport" })).getAttribute(
+ "aria-selected",
+ ),
+ ).toBe("true");
+ fireEvent.click(screen.getByRole("checkbox", { name: "Minimap" }));
+ fireEvent.focus(screen.getByRole("tab", { name: "General" }));
+ fireEvent.click(screen.getByRole("tab", { name: "General" }));
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole("tab", { name: "General" })
+ .getAttribute("aria-selected"),
+ ).toBe("true"),
+ );
+ fireEvent.focus(screen.getByRole("tab", { name: "Viewport" }));
+ fireEvent.click(screen.getByRole("tab", { name: "Viewport" }));
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole("tab", { name: "Viewport" })
+ .getAttribute("aria-selected"),
+ ).toBe("true"),
+ );
+ expect(
+ (screen.getByRole("checkbox", { name: "Minimap" }) as HTMLInputElement)
+ .checked,
+ ).toBe(false);
+ first.unmount();
+ renderSettings({ overlay: { type: "user-settings", section: "viewport" } });
+ await screen.findByRole("tab", { name: "Viewport" });
+ expect(
+ (screen.getByRole("checkbox", { name: "Minimap" }) as HTMLInputElement)
+ .checked,
+ ).toBe(false);
+ });
+
+ it("opens through the palette without changing the workspace mode", async () => {
+ const { registry } = renderSettings({
+ mode: "simulate",
+ simulateView: "experiments",
+ });
+ act(() => {
+ registry.execute("petrinaut.settings.open");
+ });
+ expect(
+ (await screen.findByRole("tab", { name: "General" })).getAttribute(
+ "aria-selected",
+ ),
+ ).toBe("true");
+ });
+
+ it("disables unavailable GPU compute and omits an unavailable optimizer", async () => {
+ renderSettings({
+ overlay: { type: "user-settings", section: "simulation" },
+ });
+ await screen.findByRole("tab", { name: "Simulation" });
+ expect(
+ (screen.getByRole("checkbox", { name: "WebGPU" }) as HTMLInputElement)
+ .disabled,
+ ).toBe(true);
+ expect(
+ screen.queryByRole("checkbox", { name: "In-browser optimization" }),
+ ).toBeNull();
+ const simulation = screen.getByRole("tab", { name: "Simulation" });
+ act(() => simulation.focus());
+ fireEvent.keyDown(simulation, { key: "ArrowRight" });
+ expect(document.activeElement).toBe(
+ screen.getByRole("checkbox", { name: "Parameter sweeps" }),
+ );
+ });
+
+ it("walks settings with worksheet arrows and restores focus across columns", async () => {
+ renderSettings({ overlay: { type: "user-settings" } });
+ const general = await screen.findByRole("tab", { name: "General" });
+ act(() => general.focus());
+ fireEvent.keyDown(general, { key: "ArrowRight" });
+ const animations = screen.getByRole("checkbox", { name: "Animations" });
+ expect(document.activeElement).toBe(animations);
+ fireEvent.keyDown(animations, { key: "ArrowDown" });
+ const panels = screen.getByRole("checkbox", {
+ name: "Keep panels mounted",
+ });
+ expect(document.activeElement).toBe(panels);
+ fireEvent.keyDown(panels, { key: "ArrowDown" });
+ const welcome = screen.getByRole("checkbox", {
+ name: "Show welcome guide",
+ });
+ expect(document.activeElement).toBe(welcome);
+ fireEvent.keyDown(welcome, { key: "ArrowLeft" });
+ expect(document.activeElement).toBe(general);
+ fireEvent.keyDown(general, { key: "ArrowRight" });
+ expect(document.activeElement).toBe(welcome);
+ fireEvent.keyDown(welcome, { key: "ArrowUp" });
+ expect(document.activeElement).toBe(panels);
+ });
+
+ it("keeps one content panel and lets dropdowns own their open keyboard interaction", async () => {
+ renderSettings({ overlay: { type: "user-settings" } });
+ const viewport = await screen.findByRole("tab", { name: "Viewport" });
+ screen.getByRole("tabpanel").scrollTop = 200;
+ act(() => viewport.focus());
+ fireEvent.click(viewport);
+ await waitFor(() =>
+ expect(viewport.getAttribute("aria-selected")).toBe("true"),
+ );
+ expect(screen.getAllByRole("tabpanel", { hidden: true })).toHaveLength(1);
+ expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
+ fireEvent.keyDown(viewport, { key: "ArrowRight" });
+ expect(document.activeElement).toBe(
+ screen.getByRole("checkbox", { name: "Minimap" }),
+ );
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
+ expect(document.activeElement).toBe(
+ screen.getByRole("checkbox", { name: "Petricon" }),
+ );
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
+ expect(document.activeElement).toBe(
+ screen.getByRole("checkbox", {
+ name: "Automatic arc connections",
+ }),
+ );
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
+ const arcs = screen.getByRole("combobox", { name: "Arc rendering" });
+ expect(document.activeElement).toBe(arcs);
+ fireEvent.keyDown(arcs, { key: "ArrowDown" });
+ expect(document.activeElement).toBe(
+ screen.getByRole("checkbox", { name: "Highlight on hover" }),
+ );
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" });
+ expect(document.activeElement).toBe(arcs);
+ fireEvent.keyDown(arcs, { key: "Enter" });
+ await waitFor(() =>
+ expect(arcs.getAttribute("aria-expanded")).toBe("true"),
+ );
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByRole("listbox")),
+ );
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
+ expect(screen.getByRole("listbox")).toBeTruthy();
+ expect(document.activeElement).not.toBe(
+ screen.getByRole("checkbox", { name: "Highlight on hover" }),
+ );
+ fireEvent.keyDown(document.activeElement!, { key: "Escape" });
+ await waitFor(() =>
+ expect(arcs.getAttribute("aria-expanded")).toBe("false"),
+ );
+ expect(screen.getByRole("dialog", { name: "User settings" })).toBeTruthy();
+ });
+
+ it("ignores modified shortcuts and unregisters when unmounted", async () => {
+ const { registry, unmount } = renderSettings();
+ fireEvent.keyDown(window, { key: ",", metaKey: true, shiftKey: true });
+ expect(screen.queryByRole("dialog")).toBeNull();
+ fireEvent.keyDown(window, { key: ",", metaKey: true });
+ await screen.findByRole("dialog");
+ unmount();
+ await waitFor(() => expect(registry.list()).toHaveLength(0));
+ const event = new KeyboardEvent("keydown", {
+ key: ",",
+ metaKey: true,
+ cancelable: true,
+ });
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ });
+});
+
+describe("experimental simulation settings", () => {
+ it("hides the optimization group when the host has no in-browser optimizer", async () => {
+ renderSettings({
+ overlay: { type: "user-settings", section: "simulation" },
+ });
+ await screen.findByRole("heading", { name: "Simulation" });
+ expect(screen.queryByRole("region", { name: "Optimization" })).toBeNull();
+ });
+
+ it("offers optimization settings for a connected optimizer", async () => {
+ renderSettings(
+ { overlay: { type: "user-settings", section: "simulation" } },
+ { kind: "connected", connect: vi.fn() },
+ );
+ expect(
+ await screen.findByRole("region", { name: "Optimization" }),
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("checkbox", { name: "In-browser optimization" }),
+ ).toBeTruthy();
+ });
+
+ it("keep parameter sweeps and in-browser optimization off by default", () => {
+ expect(defaultUserSettings.enableParameterSweeps).toBe(false);
+ expect(defaultUserSettings.enableInBrowserOptimization).toBe(false);
+ });
+
+ it("offer no Ad-hoc scenarios row: the scenario form is the only scenario form", async () => {
+ // The dialog body portals to document.body, which `screen` covers.
+ renderSettings({ overlay: { type: "user-settings", section: "labs" } });
+
+ await screen.findByRole("tab", { name: "Labs" });
+ expect(screen.queryByText(/Ad-hoc scenarios/)).toBeNull();
+ expect(
+ screen.queryByText(/Define initial state and parameters inline/),
+ ).toBeNull();
+ });
+});
+
+describe("WebGPU setting", () => {
+ it("is off by default", () => {
+ // The GPU path is a restricted subset engine with a different random
+ // generator, so it must never be offered — let alone used — without the user
+ // turning it on.
+ expect(defaultUserSettings.webGpuEnabled).toBe(false);
+ });
+
+ it("detects WebGPU support from the host, not a build flag", () => {
+ // The runtime gate the control's `disabled` state is derived from.
+ vi.stubGlobal("navigator", { gpu: {} });
+ try {
+ expect(isWebGpuAvailable()).toBe(true);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+
+ vi.stubGlobal("navigator", {});
+ try {
+ expect(isWebGpuAvailable()).toBe(false);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
+
+describe("combined UX settings", () => {
+ it("keeps Petricon and automatic arc settings across remounts", async () => {
+ const first = renderSettings({
+ overlay: { type: "user-settings", section: "viewport" },
+ });
+ const iconPack = await screen.findByRole("checkbox", {
+ name: "Petricon",
+ });
+ await act(async () => fireEvent.click(iconPack));
+ await act(async () =>
+ fireEvent.click(
+ screen.getByRole("checkbox", {
+ name: "Automatic arc connections",
+ }),
+ ),
+ );
+ first.unmount();
+ renderSettings({ overlay: { type: "user-settings", section: "viewport" } });
+ expect(
+ (
+ (await screen.findByRole("checkbox", {
+ name: "Petricon",
+ })) as HTMLInputElement
+ ).checked,
+ ).toBe(true);
+ expect(
+ (
+ screen.getByRole("checkbox", {
+ name: "Automatic arc connections",
+ }) as HTMLInputElement
+ ).checked,
+ ).toBe(true);
+ expect(
+ screen.queryByRole("combobox", { name: "Arc rendering" }),
+ ).toBeNull();
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.tsx
new file mode 100644
index 00000000000..efe654e0273
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useEffectEvent } from "react";
+
+import { useCommand } from "../../../../react/commands/command-registry";
+import { usePetrinautNavigation } from "../../../../react/navigation";
+import { UserSettingsDialog } from "./user-settings/user-settings-dialog";
+
+export const UserSettings = () => {
+ const navigation = usePetrinautNavigation();
+ const overlay = navigation.state.overlay;
+ const section =
+ overlay?.type === "user-settings"
+ ? (overlay.section ?? "general")
+ : overlay?.type === "viewport-settings"
+ ? "viewport"
+ : null;
+
+ const openSettings = () => {
+ if (section === null) {
+ navigation.navigate(
+ { overlay: { type: "user-settings", section: "general" } },
+ { cause: "user", action: "overlay" },
+ );
+ }
+ };
+
+ useCommand({
+ id: "petrinaut.settings.open",
+ label: "Open user settings",
+ category: "Editor",
+ keywords: ["preferences", "viewport", "animations", "experimental"],
+ shortcut: "mod+,",
+ run: openSettings,
+ });
+
+ const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
+ if (
+ !event.defaultPrevented &&
+ !event.repeat &&
+ (event.metaKey || event.ctrlKey) &&
+ !event.altKey &&
+ !event.shiftKey &&
+ event.key === ","
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ openSettings();
+ }
+ });
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
+ }, []);
+
+ return section === null ? null : (
+
+ navigation.navigate(
+ { overlay: { type: "user-settings", section: nextSection } },
+ { cause: "user", action: "overlay", phase: "continue" },
+ )
+ }
+ onClose={() =>
+ navigation.navigate(
+ { overlay: null },
+ { cause: "user", action: "overlay" },
+ )
+ }
+ />
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx
new file mode 100644
index 00000000000..2bcbcea49e1
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx
@@ -0,0 +1,643 @@
+import { Tabs } from "@ark-ui/react/tabs";
+import {
+ use,
+ useId,
+ useRef,
+ useState,
+ type AriaAttributes,
+ type ReactNode,
+} from "react";
+
+import { Chip, Dialog, Icon, Select, Toggle } from "@hashintel/ds-components";
+import { css } from "@hashintel/ds-helpers/css";
+import { isWebGpuAvailable } from "@hashintel/petrinaut-core";
+import { isConnectedOptimization } from "@hashintel/petrinaut-core/optimization";
+
+import { PetrinautOptimizationContext } from "../../../../../react/optimization-context";
+import { SDCPNContext } from "../../../../../react/state/sdcpn-context";
+import { UserSettingsContext } from "../../../../../react/state/user-settings-context";
+import { focusLands } from "../../../../worksheet/focus-flow";
+import { FocusRoot, FocusStack } from "../../../../worksheet/focus-stack";
+import { useFocusMember } from "../../../../worksheet/use-focus-member";
+import { FloatingResizeHandles } from "../../shared/floating-resize-handles";
+import { useFloatingPanel } from "../../shared/use-floating-panel";
+import { SettingsHeading } from "./user-settings-dialog/settings-heading";
+import { SettingsPanel } from "./user-settings-dialog/settings-panel";
+
+import type { PetrinautSettingsSection } from "../../../../../react/navigation";
+import type { IconName } from "@hashintel/ds-components";
+
+const settingsDialogStyles = {
+ position: "fixed",
+ userSelect: "none",
+ "&": {
+ padding: "0",
+ overflow: "visible",
+ border: "[1px solid {colors.neutral.s50}]",
+ },
+ '[data-overlay-stack-root]:has(&) > [data-part="backdrop"]': {
+ background: "[transparent]",
+ },
+} as const;
+const settingsDialogStyle = css(settingsDialogStyles);
+
+const sections = [
+ {
+ id: "general",
+ label: "General",
+ icon: "sliders",
+ description: "Make Petrinaut feel right for you.",
+ },
+ {
+ id: "viewport",
+ label: "Viewport",
+ icon: "grid",
+ description: "Choose how your net looks and responds.",
+ },
+ {
+ id: "simulation",
+ label: "Simulation",
+ icon: "play",
+ description: "Explore more ways to run and optimize your models.",
+ },
+ {
+ id: "labs",
+ label: "Labs",
+ icon: "flask",
+ description: "Try features that are still taking shape.",
+ },
+] as const satisfies readonly {
+ id: PetrinautSettingsSection;
+ label: string;
+ icon: IconName;
+ description: string;
+}[];
+
+const layoutStyle = css({
+ position: "relative",
+ _before: {
+ content: '""',
+ position: "absolute",
+ top: "0",
+ insetInline: "0",
+ height: "3",
+ cursor: "grab",
+ touchAction: "none",
+ },
+ display: "grid",
+ gridTemplateColumns: "[160px minmax(0, 1fr)]",
+ height: "full",
+ overflow: "hidden",
+ borderRadius: "[inherit]",
+ minHeight: "0",
+ "@media (max-width: 600px)": {
+ gridTemplateColumns: "[124px minmax(0, 1fr)]",
+ },
+});
+
+const sidebarStyle = css({
+ userSelect: "none",
+ gridArea: "[1 / 1]",
+ display: "flex",
+ flexDirection: "column",
+ gap: "1",
+ padding: "3",
+ background: "neutral.s10",
+ borderRight: "[1px solid {colors.neutral.s40}]",
+ overflowY: "auto",
+ "@media (max-width: 600px)": { padding: "2" },
+});
+
+const tabStyle = css({
+ userSelect: "none",
+ display: "flex",
+ alignItems: "center",
+ gap: "2.5",
+ paddingX: "3",
+ paddingY: "2",
+ borderRadius: "lg",
+ fontSize: "sm",
+ fontWeight: "medium",
+ textAlign: "left",
+ color: "neutral.fg.body",
+ cursor: "pointer",
+ position: "relative",
+ transition:
+ "[background-color 140ms ease, color 140ms ease, box-shadow 140ms ease]",
+ _hover: { background: "neutral.s20", color: "neutral.fg.heading" },
+ _selected: {
+ background: "neutral.s00",
+ color: "neutral.fg.heading",
+ boxShadow:
+ "[0 1px 3px rgba(0, 0, 0, 0.06), 0 0 0 1px {colors.neutral.s40}]",
+ },
+ _focusVisible: {
+ outline: "[2px solid {colors.blue.s70}]",
+ outlineOffset: "[2px]",
+ },
+ "@media (max-width: 600px)": { paddingX: "2", gap: "2", fontSize: "xs" },
+ "@media (prefers-reduced-motion: reduce)": { transition: "[none]" },
+});
+
+const descriptionStyle = css({
+ fontSize: "xs",
+ color: "neutral.fg.body",
+ lineHeight: "[1.6]",
+});
+const groupStyle = css({
+ border: "[1px solid {colors.neutral.s40}]",
+ borderRadius: "xl",
+ overflow: "hidden",
+ marginTop: "3",
+});
+const groupTitleStyle = css({
+ paddingX: "3",
+ paddingY: "1.5",
+ background: "neutral.s10",
+ borderBottom: "[1px solid {colors.neutral.s40}]",
+ fontSize: "xs",
+ fontWeight: "medium",
+ color: "neutral.fg.subtle",
+});
+const rowStyle = css({
+ display: "grid",
+ gridTemplateColumns: "[minmax(0, 1fr) auto]",
+ alignItems: "center",
+ gap: "4",
+ paddingX: "3",
+ paddingY: "2.5",
+ "& + &": { borderTop: "[1px solid {colors.neutral.s30}]" },
+ "@media (max-width: 600px)": {
+ padding: "3",
+ gap: "2",
+ "&[data-wide-control]": { gridTemplateColumns: "[minmax(0, 1fr)]" },
+ },
+});
+const labelStyle = css({
+ display: "flex",
+ alignItems: "center",
+ gap: "1.5",
+ fontSize: "sm",
+ fontWeight: "medium",
+ color: "neutral.fg.heading",
+ marginBottom: "0.5",
+ lineHeight: "[1.4]",
+});
+
+const SettingsGroup = ({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) => (
+
+);
+
+const SettingRow = ({
+ label,
+ description,
+ experimental,
+ wideControl,
+ children,
+}: {
+ label: string;
+ description: string;
+ experimental?: boolean;
+ wideControl?: boolean;
+ children: (aria: AriaAttributes) => ReactNode;
+}) => {
+ const id = useId();
+ const elementRef = useRef(null);
+ const member = useFocusMember(() =>
+ focusLands(
+ elementRef.current?.querySelector(
+ 'input[type="checkbox"]:not(:disabled), button[role="combobox"]:not(:disabled)',
+ ),
+ ),
+ );
+ return (
+ {
+ elementRef.current = element;
+ member.attach(element);
+ }}
+ className={rowStyle}
+ data-wide-control={wideControl || undefined}
+ onKeyDownCapture={(event) => {
+ const target = event.target;
+ if (
+ event.defaultPrevented ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey ||
+ !(target instanceof HTMLElement) ||
+ !event.currentTarget.contains(target) ||
+ target.getAttribute("aria-expanded") === "true"
+ ) {
+ return;
+ }
+ const direction =
+ event.key === "ArrowUp"
+ ? "up"
+ : event.key === "ArrowDown"
+ ? "down"
+ : event.key === "ArrowLeft"
+ ? "left"
+ : event.key === "ArrowRight"
+ ? "right"
+ : null;
+ if (direction) {
+ event.preventDefault();
+ event.stopPropagation();
+ member.moveFrom(direction);
+ }
+ }}
+ >
+
+
+
{label}
+ {experimental && (
+
+
+ Experimental
+
+
+ )}
+
+
+ {description}
+
+
+ {children({
+ "aria-labelledby": `${id}-label`,
+ "aria-describedby": experimental
+ ? `${id}-experimental ${id}-description`
+ : `${id}-description`,
+ })}
+
+ );
+};
+
+const SettingToggle = ({
+ label,
+ description,
+ experimental,
+ value,
+ onChange,
+ disabled,
+}: {
+ label: string;
+ description: string;
+ experimental?: boolean;
+ value: boolean;
+ onChange: (value: boolean) => void;
+ disabled?: boolean;
+}) => (
+
+ {(aria) => (
+
+ )}
+
+);
+
+const SettingsTabs = () => {
+ const elementRef = useRef(null);
+ const member = useFocusMember(() =>
+ focusLands(
+ elementRef.current?.querySelector('[aria-selected="true"]'),
+ ),
+ );
+
+ return (
+ {
+ elementRef.current = element;
+ member.attach(element);
+ }}
+ aria-label="Settings sections"
+ className={sidebarStyle}
+ onKeyDown={(event) => {
+ if (
+ !event.defaultPrevented &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey &&
+ (event.key === "ArrowRight" || event.key === "ArrowLeft")
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ member.moveFrom(event.key === "ArrowRight" ? "right" : "left");
+ }
+ }}
+ >
+ {sections.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+ );
+};
+
+export const UserSettingsDialog = ({
+ section,
+ onSectionChange,
+ onClose,
+}: {
+ section: PetrinautSettingsSection;
+ onSectionChange: (section: PetrinautSettingsSection) => void;
+ onClose: () => void;
+}) => {
+ const settings = use(UserSettingsContext);
+ const { extensions } = use(SDCPNContext);
+ const optimization = use(PetrinautOptimizationContext);
+ const inBrowserOptimizationOffered =
+ optimization !== null && isConnectedOptimization(optimization);
+ const webGpuAvailable = isWebGpuAvailable();
+ const item =
+ sections.find((candidate) => candidate.id === section) ?? sections[0];
+ const headingRef = useRef(null);
+ const [width, setWidth] = useState(760);
+ const { panelRef, handleProps, getResizeHandleProps, style } =
+ useFloatingPanel({
+ width,
+ onWidthChange: setWidth,
+ initialHeight: 480,
+ initialPosition: "center",
+ limits: { minWidth: 520, maxWidth: Infinity, gap: 20 },
+ });
+
+ return (
+
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-heading.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-heading.test.tsx
new file mode 100644
index 00000000000..db7d79b544e
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-heading.test.tsx
@@ -0,0 +1,109 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { SettingsHeading } from "./settings-heading";
+
+const general = {
+ id: "general",
+ label: "General",
+ description: "General settings",
+} as const;
+const viewport = {
+ id: "viewport",
+ label: "Viewport",
+ description: "Viewport settings",
+} as const;
+const labs = {
+ id: "labs",
+ label: "Labs",
+ description: "Experimental settings",
+} as const;
+const cancelAnimation = vi.fn();
+const animate = vi.fn(() => ({ cancel: cancelAnimation }));
+
+beforeEach(() => {
+ vi.stubGlobal("matchMedia", () => ({ matches: false }));
+ Object.defineProperty(HTMLElement.prototype, "animate", {
+ configurable: true,
+ value: animate,
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ Reflect.deleteProperty(HTMLElement.prototype, "animate");
+});
+
+describe("settings heading transitions", () => {
+ it("animates the title row from below in either direction, cancelling interrupted motion", () => {
+ const { rerender, unmount } = render(
+ ,
+ );
+ expect(animate).not.toHaveBeenCalled();
+ rerender();
+ expect(screen.getByRole("heading", { name: "Viewport" })).toBeTruthy();
+ expect(animate.mock.contexts.at(-1)).toBe(
+ screen.getByRole("heading").parentElement,
+ );
+ expect(animate).toHaveBeenLastCalledWith(
+ [
+ { opacity: 0, filter: "blur(2px)", transform: "translateY(3px)" },
+ { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" },
+ ],
+ expect.objectContaining({ duration: 220 }),
+ );
+ rerender();
+ expect(cancelAnimation).toHaveBeenCalledTimes(1);
+ expect(animate.mock.contexts.at(-1)).toBe(
+ screen.getByRole("heading").parentElement,
+ );
+ expect(animate).toHaveBeenLastCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ transform: "translateY(3px)" }),
+ ]),
+ expect.any(Object),
+ );
+ unmount();
+ expect(cancelAnimation).toHaveBeenCalledTimes(2);
+ });
+
+ it.each(["preference", "system"])(
+ "respects the %s motion setting",
+ (source) => {
+ const animated = source !== "preference";
+ if (source === "system")
+ vi.stubGlobal("matchMedia", () => ({ matches: true }));
+ const { rerender } = render(
+ ,
+ );
+ rerender();
+ expect(screen.getByRole("heading", { name: "Viewport" })).toBeTruthy();
+ expect(animate).not.toHaveBeenCalled();
+ },
+ );
+
+ it("renders only the latest heading during rapid direction changes", () => {
+ const { rerender } = render();
+ for (const section of [viewport, labs, general]) {
+ rerender();
+ expect(screen.getAllByRole("heading")).toHaveLength(1);
+ expect(screen.getByRole("heading").textContent).toBe(section.label);
+ expect(screen.getByText(section.description)).toBeTruthy();
+ const animatedRow = animate.mock.contexts.at(-1) as HTMLElement;
+ expect(animatedRow.contains(screen.getByText(section.description))).toBe(
+ false,
+ );
+ if (section.id === "labs") {
+ expect(animatedRow.contains(screen.getByText(/Experimental$/))).toBe(
+ true,
+ );
+ }
+ }
+ expect(animate).toHaveBeenCalledTimes(3);
+ expect(cancelAnimation).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-heading.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-heading.tsx
new file mode 100644
index 00000000000..01b1009276f
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-heading.tsx
@@ -0,0 +1,90 @@
+import { useLayoutEffect, useRef } from "react";
+
+import { Chip } from "@hashintel/ds-components";
+import { css } from "@hashintel/ds-helpers/css";
+
+import type { PetrinautSettingsSection } from "../../../../../../react/navigation";
+
+type HeadingSection = {
+ id: PetrinautSettingsSection;
+ label: string;
+ description: string;
+};
+
+export const SettingsHeading = ({
+ section,
+ animated,
+}: {
+ section: HeadingSection;
+ animated: boolean;
+}) => {
+ const elementRef = useRef(null);
+ const sectionId = section.id;
+ const previousSectionRef = useRef(sectionId);
+
+ useLayoutEffect(() => {
+ const previousSection = previousSectionRef.current;
+ previousSectionRef.current = sectionId;
+ const element = elementRef.current;
+ if (
+ previousSection === sectionId ||
+ !animated ||
+ !element?.animate ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ return;
+ }
+
+ const animation = element.animate(
+ [
+ {
+ opacity: 0,
+ filter: "blur(2px)",
+ transform: "translateY(3px)",
+ },
+ { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" },
+ ],
+ { duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
+ );
+ return () => animation.cancel();
+ }, [sectionId, animated]);
+
+ return (
+
+
+
+ {section.label}
+
+ {(section.id === "simulation" || section.id === "labs") && (
+
+ Experimental
+
+ )}
+
+
+ {section.description}
+
+
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-panel.tsx
new file mode 100644
index 00000000000..6644958685d
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/settings-panel.tsx
@@ -0,0 +1,172 @@
+import { Tabs } from "@ark-ui/react/tabs";
+
+import { css } from "@hashintel/ds-helpers/css";
+
+import { useScrollOverflow } from "../../../../../hooks/use-scroll-overflow";
+
+import type { PetrinautSettingsSection } from "../../../../../../react/navigation";
+import type { ReactNode, RefObject } from "react";
+
+const edgeFadeStyle = css({
+ position: "absolute",
+ insetInline: "0",
+ height: "16",
+ pointerEvents: "none",
+ zIndex: "[1]",
+ "&[data-edge='top']": { top: "0" },
+ "&[data-edge='bottom']": { bottom: "0", transform: "[rotate(180deg)]" },
+ "& > span, &::after": {
+ position: "absolute",
+ insetInline: "0",
+ opacity: "0",
+ },
+ "& > span": {
+ top: "0",
+ maskImage: "[linear-gradient(to bottom, black, transparent)]",
+ },
+ _after: {
+ content: '""',
+ top: "[-1px]",
+ height: "[calc(100% + 1px)]",
+ background:
+ "[linear-gradient(to bottom, {colors.neutral.s00} 1px, transparent)]",
+ },
+ "&[data-visible] > span, &[data-visible]::after": { opacity: "1" },
+ "&[data-animated] > span, &[data-animated]::after": {
+ transition: "[opacity 120ms ease]",
+ "@media (prefers-reduced-motion: reduce)": { transition: "[none]" },
+ },
+});
+
+const edgeBlurStyles = [
+ css({
+ height: "16",
+ backdropFilter: "[blur(0.5px)]",
+ }),
+ css({
+ height: "12",
+ backdropFilter: "[blur(1px)]",
+ }),
+ css({
+ height: "8",
+ backdropFilter: "[blur(2px)]",
+ }),
+];
+
+const ScrollEdgeFade = ({
+ edge,
+ visible,
+ animated,
+}: {
+ edge: "top" | "bottom";
+ visible: boolean;
+ animated: boolean;
+}) => (
+
+ {edgeBlurStyles.map((className) => (
+
+ ))}
+
+);
+
+const scrollAreaStyle = css({
+ position: "relative",
+ flex: "[1 1 0]",
+ minHeight: "0",
+});
+
+const contentStyle = css({
+ position: "relative",
+ height: "full",
+ minWidth: "0",
+ overflowY: "auto",
+ overscrollBehavior: "contain",
+ scrollbarGutter: "stable",
+ paddingX: "5",
+ paddingBottom: "5",
+ outline: "none",
+ "&[hidden]": { display: "none" },
+ "@media (max-width: 600px)": { paddingX: "3", paddingBottom: "3" },
+});
+
+const SettingsScrollBody = ({
+ section,
+ animated,
+ children,
+}: {
+ section: PetrinautSettingsSection;
+ animated: boolean;
+ children: ReactNode;
+}) => {
+ const { scrollRef, canScrollUp, canScrollDown, onScroll } =
+ useScrollOverflow();
+
+ return (
+
+ );
+};
+
+export const SettingsPanel = ({
+ section,
+ headingRef,
+ heading,
+ animated,
+ children,
+}: {
+ section: PetrinautSettingsSection;
+ headingRef: RefObject;
+ heading: ReactNode;
+ animated: boolean;
+ children: ReactNode;
+}) => (
+
+
+
+ {children}
+
+
+);
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx
index 69d2e7d386f..47b87268d89 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx
@@ -848,6 +848,8 @@ export function FakeEditorProvider({
setPropertiesPanelWidth: () => {},
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 ;
+};
+
+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..6e20a487745 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 { type ComponentProps, type ReactNode, use, useState } from "react";
import { expect, userEvent, waitFor, 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: () => ,
};
@@ -850,7 +899,7 @@ export const CollapsedVoiceIssues: Story = {
const indicator = canvas.getByRole("button", {
name: "Show 2 Voice issues",
});
- const { x, y, width, height } = indicator.getBoundingClientRect();
+ const { width, height } = indicator.getBoundingClientRect();
await userEvent.click(
canvas.getByRole("button", { name: "Show conversation" }),
);
@@ -858,17 +907,22 @@ export const CollapsedVoiceIssues: Story = {
canvas.getByRole("button", { name: "Show 2 Voice issues" }),
).toBe(indicator);
await expect(indicator.getBoundingClientRect()).toMatchObject({
- x,
- y,
width,
height,
});
+ await userEvent.click(indicator);
+ await waitFor(() =>
+ expect(
+ canvas.getByText(
+ "Voice connection interrupted. Check your connection before reconnecting.",
+ ),
+ ).toBeVisible(),
+ );
+ await userEvent.click(canvas.getByRole("button", { name: /^Close$/ }));
await userEvent.click(
canvas.getByRole("button", { name: "Hide conversation" }),
);
await expect(indicator.getBoundingClientRect()).toMatchObject({
- x,
- y,
width,
height,
});
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..d6f3e2c65a3 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
@@ -27,8 +27,14 @@ import {
} from "../../../../../react/voice-session/use-voice-session";
import { AiAssistantIcon } from "../../../../components/ai-assistant-icon";
import { HorizontalTabsHeader } from "../../../../components/sub-view/horizontal/horizontal-tabs-container";
+import {
+ ExperimentalIcon,
+ useExperimentalIconMotionAllowed,
+} from "../../../../experimental-icons";
import { ResizeHandle } from "../../../../resize/resize-handle";
import { AiVoiceModeIcon } from "../../components/ai-voice-mode-button";
+import { FloatingResizeHandles } from "../../shared/floating-resize-handles";
+import { useFloatingPanel } from "../../shared/use-floating-panel";
import {
ExperimentCard,
type AiExperimentState,
@@ -89,7 +95,6 @@ export type AiAssistantContentsProps = {
onSubmit: () => void;
onVoiceDockCollapsedChange?: (collapsed: boolean) => void;
promptChips?: PromptChip[];
- rightOffset?: number;
status: AiAssistantStatus;
stopped?: boolean;
voiceHandoffPending?: boolean;
@@ -98,66 +103,78 @@ 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: {},
- },
- 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 +183,33 @@ const cardStyle = cva({
position: "relative",
display: "flex",
flexDirection: "column",
+ height: "full",
+ overflow: "hidden",
+ backgroundColor: "neutral.s00",
+ borderLeft: "[1px solid {colors.neutral.s40}]",
+ 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: {},
+ true: { overflow: "visible" },
},
- 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({
@@ -224,35 +243,65 @@ const voiceModeStyle = cva({
});
const headerStyle = css({
+ position: "relative",
+ userSelect: "none",
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({
+ position: "absolute",
+ inset: "[0]",
+ width: "[100%]",
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",
+ minWidth: "[0]",
+ color: "neutral.fg.heading",
+ fontSize: "sm",
fontWeight: "medium",
- lineHeight: "[12px]",
- overflow: "hidden",
whiteSpace: "nowrap",
- textOverflow: "ellipsis",
+ border: "none",
+ padding: "[0 12px]",
+ backgroundColor: "[transparent]",
+ textAlign: "left",
+ _enabled: {
+ cursor: "grab",
+ touchAction: "none",
+ _active: { cursor: "grabbing" },
+ },
+ _focusVisible: {
+ outline: "[2px solid {colors.blue.s50}]",
+ outlineOffset: "[-2px]",
+ },
+ '&[data-icon-motion="true"] > svg': {
+ transition: "[transform 180ms ease-out]",
+ },
+ '&[data-icon-motion="true"]:is(:hover, :focus-visible) > svg': {
+ transform: "[rotate(8deg) scale(1.06)]",
+ },
+ '&[data-icon-motion="true"]:active > svg': {
+ transform: "[rotate(-8deg) scale(0.94)]",
+ },
+});
+
+const headerTabsStyle = css({
+ position: "relative",
+ flex: "[1]",
+ minWidth: "[0]",
+ marginLeft: "[28px]",
+ pointerEvents: "none",
+ "& button": { pointerEvents: "auto" },
});
const headerButtonStyle = css({
+ position: "relative",
color: "neutral.s90",
_hover: {
color: "neutral.s110",
@@ -266,7 +315,8 @@ const messagesStyle = css({
flex: "[1]",
minHeight: "[0]",
overflowY: "auto",
- padding: "2",
+ padding: "3",
+ overscrollBehavior: "contain",
});
const emptyStyle = css({
@@ -290,27 +340,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 +384,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]`,
@@ -360,15 +408,30 @@ const composerActionGlyphStyle = css({
},
});
+const composerActionButtonStyle = css({
+ flexShrink: 0,
+ width: "[30px]",
+ height: "[30px]",
+ minWidth: "[30px]",
+ borderRadius: "[calc({radii.lg} - {spacing.1})]",
+});
+
const composerStyle = css({
display: "flex",
alignItems: "flex-end",
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
@@ -379,9 +442,7 @@ const composerMaxHeight = 160;
const composerTextareaStyle = css({
flex: "[1]",
minWidth: "[0]",
- // Matches the previous single-line `size="sm"` input height so the
- // collapsed composer looks unchanged.
- minHeight: "[28px]",
+ minHeight: "[30px]",
maxHeight: `[${composerMaxHeight}px]`,
paddingX: "2",
paddingY: "[5px]",
@@ -571,7 +632,6 @@ export const AiAssistantContents = ({
onSubmit,
onVoiceDockCollapsedChange,
promptChips,
- rightOffset = 0,
status,
stopped = false,
voiceHandoffPending = false,
@@ -647,21 +707,39 @@ 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 HeaderLabel = isFloating ? "button" : "div";
+ const iconMotionAllowed = useExperimentalIconMotionAllowed();
+ const {
+ panelRef,
+ isInteracting,
+ handleProps,
+ getResizeHandleProps,
+ style: floatingPositionStyle,
+ } = useFloatingPanel({
+ width: assistantWidth,
+ onWidthChange: setAssistantWidth,
+ });
+ 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 +752,7 @@ export const AiAssistantContents = ({
observer.disconnect();
reportDockHeight(null);
};
- }, [isOpen, isVoiceDockCollapsed]);
+ }, [isOpen, isVoiceDockCollapsed, panelRef]);
const [chipsDismissed, setChipsDismissed] = useState(false);
@@ -735,7 +813,6 @@ export const AiAssistantContents = ({
) : null;
const inputRef = useRef(null);
- const messagesEndRef = useRef(null);
const messagesRef = useRef(null);
const messagesScrollKey = getMessagesScrollKey(messages);
@@ -781,9 +858,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 +893,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 +906,340 @@ export const AiAssistantContents = ({
}, [messagesScrollKey, status]);
return (
-