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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/studio/src/components/StudioHeader.embed.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StudioHeader
captureFrameHref="blob:frame"
captureFrameFilename="frame.png"
handleCaptureFrameClick={vi.fn()}
refreshCaptureFrameTime={vi.fn()}
inspectorButtonActive={false}
inspectorPanelActive={false}
onExport={onExport}
/>,
),
);

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();
});
61 changes: 37 additions & 24 deletions packages/studio/src/components/StudioHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border-button-default bg-background-primary-default px-3">
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -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
</ButtonGroupItem>
Expand All @@ -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"}
</Button>
Expand Down
37 changes: 37 additions & 0 deletions packages/studio/src/components/dock/dockLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
27 changes: 26 additions & 1 deletion packages/studio/src/components/dock/dockLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../promptwise/embed")>()),
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(<PropertyPanelEmptyState flat multiSelectCount={0} />));

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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
});
Expand Down
22 changes: 12 additions & 10 deletions packages/studio/src/components/editor/PropertyPanelEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -24,6 +25,17 @@ function FlatEmptyState() {
Click any element on the canvas to edit it, or drag to select several.
</div>
<div className="mt-2 flex w-full flex-col gap-1.5">
{/* In promptwise the agent is the way most changes are made, so it comes first. */}
{embeddedInPromptwise && (
<Button
variant="primary"
onClick={askPromptwiseAgent}
icon={<span aria-hidden>✦</span>}
title="Describe a change to the agent"
>
Ask the agent
</Button>
)}
<span className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-bg px-3 py-2">
<span className="flex items-center gap-2 text-caption-2-regular text-panel-text-2">
<span className="text-danger-ink">●</span>
Expand All @@ -33,16 +45,6 @@ function FlatEmptyState() {
R
</span>
</span>
{embeddedInPromptwise && (
<button
type="button"
onClick={askPromptwiseAgent}
className="flex items-center gap-2 rounded-lg border border-panel-border bg-panel-bg px-3 py-2 text-caption-2-regular text-panel-text-2 hover:bg-hover"
>
<span className="text-accent-ink">✦</span>
Describe a change to the agent
</button>
)}
</div>
</div>
);
Expand Down
Loading