diff --git a/packages/studio/src/components/StudioHeader.embed.dom.test.tsx b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx
new file mode 100644
index 00000000000..c6c30e47468
--- /dev/null
+++ b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx
@@ -0,0 +1,79 @@
+// @vitest-environment happy-dom
+
+/** Embedded in promptwise: no header; the Timeline tab's toolbar drives Inspector and Export by message. */
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vitest";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const layout = { rightCollapsed: true, setRightCollapsed: vi.fn(), setRightPanelTab: vi.fn() };
+
+vi.mock("../promptwise/embed", () => ({ embeddedInPromptwise: true }));
+vi.mock("../contexts/StudioContext", () => ({
+ useStudioShellContext: () => ({
+ projectId: "773d81ab-4ac0-4f9d-acd7-4cd8838312dd",
+ renderQueue: { isRendering: false, ffmpegMissing: false },
+ }),
+}));
+vi.mock("../contexts/PanelLayoutContext", () => ({ usePanelLayoutContext: () => layout }));
+vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
+
+const { StudioHeader } = await import("./StudioHeader");
+
+function command(name: string) {
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { type: "promptwise:command", command: name },
+ source: window.parent,
+ }),
+ );
+ });
+}
+
+it("draws no header, runs the toolbar's presses and reports their state", () => {
+ const posted: unknown[] = [];
+ const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => {
+ posted.push(message);
+ });
+ const onExport = vi.fn();
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+
+ expect(host.innerHTML).toBe("");
+ expect(posted).toEqual([{ type: "promptwise:state", inspectorOpen: false, rendering: false }]);
+
+ command("inspector");
+ expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("design");
+ expect(layout.setRightCollapsed).toHaveBeenLastCalledWith(false);
+
+ command("export");
+ expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("renders");
+ expect(onExport).toHaveBeenCalledOnce();
+
+ // Only the parent page may drive Studio, and only with the commands it knows.
+ window.dispatchEvent(
+ new MessageEvent("message", { data: { type: "promptwise:command", command: "export" } }),
+ );
+ command("delete-everything");
+ expect(onExport).toHaveBeenCalledOnce();
+
+ act(() => root.unmount());
+ host.remove();
+ postMessage.mockRestore();
+});
diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx
index 87a3a019592..4347e015756 100644
--- a/packages/studio/src/components/StudioHeader.tsx
+++ b/packages/studio/src/components/StudioHeader.tsx
@@ -7,6 +7,8 @@ import { Button, cn, Tooltip } from "./ui";
import { ButtonGroup, ButtonGroupItem } from "../boardui/components/base/buttons/button-group";
import { Dock } from "./dock/Dock";
import { PromptwiseMark } from "../promptwise/PromptwiseMark";
+import { embeddedInPromptwise } from "../promptwise/embed";
+import { usePromptwiseToolbar } from "../promptwise/usePromptwiseToolbar";
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
interface StudioHeaderProps {
@@ -55,6 +57,39 @@ export const StudioHeader = memo(function StudioHeader({
const isRendering = renderQueue.isRendering;
const ffmpegMissing = renderQueue.ffmpegMissing;
+ const toggleInspector = () => {
+ if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
+ setRightPanelTab("design");
+ setRightCollapsed(false);
+ return;
+ }
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
+ // Keep the current selection when collapsing the Inspector — closing
+ // the panel shouldn't deselect the element.
+ setRightCollapsed(true);
+ };
+
+ const exportVideo = () => {
+ if (isRendering) return;
+ setRightPanelTab("renders");
+ setRightCollapsed(false);
+ // Without an encoder this render cannot finish, so the click
+ // delivers the user to the prompt that fixes it instead of
+ // queueing a job that exists only to fail. Disabling the button
+ // would leave them staring at a dead control with no route to
+ // the explanation.
+ if (ffmpegMissing) return;
+ onExport?.();
+ };
+
+ usePromptwiseToolbar(
+ { inspectorOpen: inspectorButtonActive, rendering: isRendering },
+ { inspector: toggleInspector, export: exportVideo },
+ );
+ // promptwise's Timeline tab has its own toolbar and tab name, so a second header would only repeat them.
+ if (embeddedInPromptwise) return null;
+
return (
@@ -104,18 +139,7 @@ export const StudioHeader = memo(function StudioHeader({
aria-pressed={inspectorButtonActive}
selected={inspectorButtonActive}
leadingIcon={RiLayoutRightLine}
- onClick={() => {
- if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
- trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
- setRightPanelTab("design");
- setRightCollapsed(false);
- return;
- }
- trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
- // Keep the current selection when collapsing the Inspector — closing
- // the panel shouldn't deselect the element.
- setRightCollapsed(true);
- }}
+ onClick={toggleInspector}
>
Inspector
@@ -137,18 +161,7 @@ export const StudioHeader = memo(function StudioHeader({
variant="primary"
data-testid="header-export"
disabled={isRendering}
- onClick={() => {
- if (isRendering) return;
- setRightPanelTab("renders");
- setRightCollapsed(false);
- // Without an encoder this render cannot finish, so the click
- // delivers the user to the prompt that fixes it instead of
- // queueing a job that exists only to fail. Disabling the button
- // would leave them staring at a dead control with no route to
- // the explanation.
- if (ffmpegMissing) return;
- onExport?.();
- }}
+ onClick={exportVideo}
>
{isRendering ? "Rendering…" : "Export"}
diff --git a/packages/studio/src/components/dock/dockLayout.test.ts b/packages/studio/src/components/dock/dockLayout.test.ts
index bddf62e8da8..40c3a1271c2 100644
--- a/packages/studio/src/components/dock/dockLayout.test.ts
+++ b/packages/studio/src/components/dock/dockLayout.test.ts
@@ -132,3 +132,40 @@ describe("applySideMinimums", () => {
expect(timeline.group.minimumWidth).toBe(120);
});
});
+
+describe("buildEditLayout on a narrow dock", () => {
+ let api: DockviewApi;
+ beforeEach(() => {
+ ({ api } = mountBareDockview());
+ });
+ afterEach(() => {
+ api.dispose();
+ document.body.innerHTML = "";
+ });
+
+ it("puts the preview over the timeline and one hidden side group beside it, as in promptwise's tab", () => {
+ api.layout(640, 820);
+ buildEditLayout(api, 640);
+ const side = api.getPanel("design")!.group;
+ expect(side.panels.map((panel) => panel.id)).toEqual([
+ "design",
+ "layers",
+ "renders",
+ "compositions",
+ "assets",
+ ]);
+ expect(side.api.isVisible).toBe(false);
+ expect(api.getPanel("preview")!.group.api.isVisible).toBe(true);
+ expect(api.getPanel("preview")!.group.width).toBe(640);
+ expect(api.getPanel("timeline")!.group.width).toBe(640);
+ expect(api.getPanel("timeline")!.group.height).toBe(260);
+ expect(api.getPanel("code")).toBeUndefined();
+ });
+
+ it("keeps the three columns on a wide one", () => {
+ api.layout(1200, 700);
+ buildEditLayout(api, 1200);
+ expect(api.getPanel("compositions")!.group).not.toBe(api.getPanel("design")!.group);
+ expect(api.getPanel("design")!.group.api.isVisible).toBe(true);
+ });
+});
diff --git a/packages/studio/src/components/dock/dockLayout.ts b/packages/studio/src/components/dock/dockLayout.ts
index 94886d93aea..acd9f8c1a35 100644
--- a/packages/studio/src/components/dock/dockLayout.ts
+++ b/packages/studio/src/components/dock/dockLayout.ts
@@ -10,6 +10,11 @@ const MIN_SIDE_W_FLOOR = 120;
const DEFAULT_TIMELINE_H = 360;
const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const;
const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const;
+/** Below this dock width (promptwise's Timeline tab beside its chat) two side columns would crush the preview. */
+const COMPACT_DOCK_W = 1000;
+const COMPACT_SIDE = ["design", "layers", "renders", "compositions", "assets"] as const;
+/** Room for three tracks under the toolbar and ruler; the preview gets the rest. */
+const COMPACT_TIMELINE_H = 260;
/** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
export function defaultSideWidths(viewportWidth: number) {
@@ -99,12 +104,20 @@ function addSideColumn(
return first;
}
-/** The default Edit layout: [library | preview | inspector] over a full-width timeline. */
+/**
+ * The default Edit layout: [library | preview | inspector] over a full-width timeline, or on a narrow
+ * dock the preview over the timeline with one side group beside the preview, shown when asked for
+ * (Inspector, a selection) since it would leave the preview too little room.
+ */
export function buildEditLayout(
api: DockviewApi,
viewportWidth: number,
panels: readonly PanelId[] = PANEL_IDS,
) {
+ if (viewportWidth < COMPACT_DOCK_W) {
+ buildCompactLayout(api, viewportWidth, panels);
+ return;
+ }
api.clear();
const widths = defaultSideWidths(viewportWidth);
addRegisteredPanel(api, "preview");
@@ -116,3 +129,15 @@ export function buildEditLayout(
if (right) api.getPanel(right)?.group.api.setSize({ width: widths.right });
api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H });
}
+
+function buildCompactLayout(api: DockviewApi, viewportWidth: number, panels: readonly PanelId[]) {
+ api.clear();
+ addRegisteredPanel(api, "preview");
+ addRegisteredPanel(api, "timeline", { referencePanel: "preview", direction: "below" });
+ const side = addSideColumn(api, COMPACT_SIDE, panels, "right");
+ applySideMinimums(api, viewportWidth);
+ api.getPanel("timeline")?.group.api.setSize({ height: COMPACT_TIMELINE_H });
+ const group = side ? api.getPanel(side)?.group : undefined;
+ group?.api.setSize({ width: Math.max(MIN_SIDE_W, defaultSideWidths(viewportWidth).right) });
+ group?.api.setVisible(false);
+}
diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx
new file mode 100644
index 00000000000..2b21a6f6328
--- /dev/null
+++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx
@@ -0,0 +1,37 @@
+// @vitest-environment happy-dom
+
+/** In promptwise most changes go through the agent, so its button leads the empty Design panel. */
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vitest";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock("../../promptwise/embed", async (original) => ({
+ ...(await original()),
+ embeddedInPromptwise: true,
+}));
+
+const { PropertyPanelEmptyState } = await import("./PropertyPanelEmptyState");
+
+it("puts the agent first, as the panel's one primary button, and asks promptwise to open its chat", () => {
+ const posted: unknown[] = [];
+ const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => {
+ posted.push(message);
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => root.render());
+
+ const text = host.textContent ?? "";
+ expect(text.indexOf("Ask the agent")).toBeLessThan(text.indexOf("Record a gesture"));
+ const agent = host.querySelector("button")!;
+ expect(agent.textContent).toContain("Ask the agent");
+ act(() => agent.click());
+ expect(posted).toEqual([{ type: "promptwise:ask-agent" }]);
+
+ act(() => root.unmount());
+ host.remove();
+ postMessage.mockRestore();
+});
diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx
index 2848c1ee0e3..1578a77bb71 100644
--- a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx
+++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx
@@ -28,7 +28,7 @@ describe("PropertyPanelEmptyState — flat empty", () => {
expect(host.textContent).toContain("Nothing selected");
expect(host.textContent).toContain("Record a gesture");
// The agent chip only shows inside promptwise, whose chat it opens.
- expect(host.textContent).not.toContain("Describe a change to the agent");
+ expect(host.textContent).not.toContain("Ask the agent");
act(() => root.unmount());
});
});
diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx
index 067d9068210..8385c190baa 100644
--- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx
+++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditingTypes";
import { canHideSelections } from "../../utils/timelineInspector";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { askPromptwiseAgent, embeddedInPromptwise } from "../../promptwise/embed";
+import { Button } from "../ui";
function FlatEmptyState() {
return (
@@ -24,6 +25,17 @@ function FlatEmptyState() {
Click any element on the canvas to edit it, or drag to select several.
+ {/* In promptwise the agent is the way most changes are made, so it comes first. */}
+ {embeddedInPromptwise && (
+
+ )}
●
@@ -33,16 +45,6 @@ function FlatEmptyState() {
R
- {embeddedInPromptwise && (
-
- )}
);
diff --git a/packages/studio/src/components/editor/SnapToolbar.tsx b/packages/studio/src/components/editor/SnapToolbar.tsx
index 8d5c72a2d55..63637d484b5 100644
--- a/packages/studio/src/components/editor/SnapToolbar.tsx
+++ b/packages/studio/src/components/editor/SnapToolbar.tsx
@@ -5,6 +5,10 @@ import { usePlayerStore } from "../../player/store/playerStore";
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
import { ownsPlainKeys } from "../../utils/typingTarget";
+/** A preview overlay button that's off: BoardUI's light surface with a hairline, readable over any frame. */
+const overlayButtonOff =
+ "bg-background-primary-default/90 text-foreground-icon-secondary shadow-xs ring-1 ring-border-button-default hover:bg-background-primary-hover hover:text-foreground-icon-primary";
+
// fallow-ignore-next-line complexity
export const SnapToolbar = memo(function SnapToolbar() {
const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
@@ -87,9 +91,7 @@ export const SnapToolbar = memo(function SnapToolbar() {