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() {