Recipe Studio: full-height canvas and in-app maximize control (#7394)

* studio recipes: full-height canvas and in-app maximize control

- Recipe editor fills its container (drop the outer padding and the fixed
  75vh height); the canvas reaches the window edges
- Viewport controls: the fit button now reads as center (it always
  fit/centered); add an expand-to-full-view button that collapses the
  sidebar and maximizes the canvas in-app, toggling back to restore

* recipe studio: exit full view when leaving the editor tab

Addresses review: the Exit full view control lives inside the editor
canvas, which unmounts on the Easy/Runs tabs. Clear maximized (and restore
the sidebar) when activeView leaves "editor" so those views aren't left
stuck under the fixed full-view overlay.

* recipe studio: keep full view below titlebar and off the sidebar state
This commit is contained in:
Leo Borcherding 2026-07-24 20:37:00 -05:00 committed by GitHub
commit 938e786eb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 77 additions and 10 deletions

View file

@ -1,10 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { type ReactElement, useCallback } from "react";
import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react";
import { Panel, useReactFlow } from "@xyflow/react";
import { Button } from "@/components/ui/button";
import { Panel, useReactFlow } from "@xyflow/react";
import {
Focus,
Lock,
LockOpen,
Maximize2,
Minimize2,
Minus,
Plus,
} from "lucide-react";
import { type ReactElement, useCallback } from "react";
import { buildFitViewOptions } from "../../utils/graph/fit-view";
import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class";
@ -12,12 +20,16 @@ type ViewportControlsProps = {
interactive: boolean;
lockDisabled?: boolean;
onToggleInteractive: () => void;
maximized: boolean;
onToggleMaximize: () => void;
};
export function ViewportControls({
interactive,
lockDisabled = false,
onToggleInteractive,
maximized,
onToggleMaximize,
}: ViewportControlsProps): ReactElement {
const { zoomIn, zoomOut, fitView, getNodes } = useReactFlow();
@ -61,9 +73,23 @@ export function ViewportControls({
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleFitView}
aria-label="Fit view"
aria-label="Center view"
>
<Maximize2 className="size-4" />
<Focus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onToggleMaximize}
aria-label={maximized ? "Exit full view" : "Expand to full view"}
>
{maximized ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</Button>
<Button
type="button"
@ -74,7 +100,11 @@ export function ViewportControls({
onClick={onToggleInteractive}
aria-label={interactive ? "Lock interaction" : "Unlock interaction"}
>
{interactive ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
{interactive ? (
<LockOpen className="size-4" />
) : (
<Lock className="size-4" />
)}
</Button>
</Panel>
);

View file

@ -237,6 +237,7 @@ export function RecipeStudioPage({
}, [setActiveView]);
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
const [maximized, setMaximized] = useState(false);
const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false);
const [recentCompletedExecution, setRecentCompletedExecution] =
useState<RecipeExecutionRecord | null>(null);
@ -569,6 +570,16 @@ export function RecipeStudioPage({
[reactFlowInstance],
);
const toggleMaximize = useCallback(() => {
// The maximized surface is a fixed z-50 overlay that already covers the
// app sidebar (z-10/z-20), so we don't touch the sidebar's own state — that
// state is persisted in pin mode and mutating it here would leak the
// temporary collapse into the next page/session.
setMaximized((prev) => !prev);
// Container size changes; refit once the layout settles.
scheduleFitView({ delayMs: TAB_SWITCH_FIT_DELAY_MS });
}, [scheduleFitView]);
useEffect(() => {
if (
previousActiveViewRef.current !== activeView &&
@ -587,6 +598,15 @@ export function RecipeStudioPage({
}
}, [activeView, reactFlowInstance]);
// The "Exit full view" control lives inside the editor canvas, which unmounts
// on other tabs. Drop full-view mode when leaving the editor so Easy/Runs
// aren't left under the fixed overlay.
useEffect(() => {
if (activeView !== "editor" && maximized) {
setMaximized(false);
}
}, [activeView, maximized]);
useEffect(() => {
if (
!reactFlowInstance ||
@ -732,6 +752,8 @@ export function RecipeStudioPage({
interactive={canvasInteractive}
lockDisabled={executionLocked}
onToggleInteractive={toggleInteractive}
maximized={maximized}
onToggleMaximize={toggleMaximize}
/>
{islandExecution &&
(isExecutionInProgress(islandExecution.status) ||
@ -773,10 +795,25 @@ export function RecipeStudioPage({
}
return (
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="w-full px-6 py-8">
<div
className={
maximized
? "fixed inset-x-0 bottom-0 z-50 flex flex-col bg-background"
: "flex h-full min-h-0 flex-1 flex-col bg-background"
}
style={
maximized
? {
// Start below the custom/mac window titlebar so the header and
// its controls aren't hidden under (or click-blocked by) it.
top: "var(--studio-non-chat-content-top-inset, var(--studio-content-top-inset, 0px))",
}
: undefined
}
>
<main className="flex min-h-0 w-full flex-1 flex-col">
<div
className="relative w-full overflow-hidden rounded-2xl corner-squircle border"
className="relative flex min-h-0 w-full flex-1 flex-col overflow-hidden border"
ref={setSheetContainer}
>
<RecipeStudioHeader
@ -794,7 +831,7 @@ export function RecipeStudioPage({
}}
/>
<div
className="h-[75vh] w-full rounded-t-none"
className="flex min-h-0 w-full flex-1 rounded-t-none"
ref={flowContainerRef}
>
{activeView === "easy" ? (