let a maximized panel take the whole window, header included

Maximize had one depth: fill the content region, leave the nav header visible. That was never a choice
about how much room to take — the region is an `absolute z-2` stacking context and the header is a
`fixed z-10` sibling, so no z-index a panel gives itself can paint over the nav. `top-[56px]` was the
workaround.

So full screen is cooperative rather than a bigger overlay. The panel asks, and the shell hides its own
header for it; `inset-0` is then genuinely the window. Still the same element and the same class swap —
no portal, no remount, so scroll position and playback survive the step between depths the way they
already survived maximize.

The mode rides beside the maximized panel id in sessionStorage as one value, so the two cannot drift;
a tab open across this change reads the old bare string, gets undefined for `.id`, and lands on
"nothing is maximized".

The toggle is offered from every state, so taking the window is one click from a tiled panel, and it
steps back to a maximized panel rather than all the way out. The amber light is present at both depths
and always goes all the way out, so neither is a trap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 21:56:58 +01:00
co-authored by Claude Opus 5
parent 9974736587
commit 417860b892
10 changed files with 301 additions and 65 deletions
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
import { useDock, MusicPlayerHost } from 'officerdev';
import { useDock, MusicPlayerHost, usePanelFullscreen } from 'officerdev';
import { useCapabilities } from 'hooks/useCapabilities';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
@@ -24,13 +24,17 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const isTouch = useIsTouch();
const { pathname } = useLocation();
usePageTitleSync();
// A panel can maximize into the content region on its own, but it cannot paint over this header — the
// region below is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling of it. So
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
const panelFullscreen = usePanelFullscreen();
// The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
// reveal boundary from this element, so it always sits just above whatever's at the bottom.
const regionRef = useRef<HTMLElement | null>(null);
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
<Header dockItems={visibleItems} />
<Header dockItems={visibleItems} hidden={panelFullscreen} />
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
@@ -53,14 +53,20 @@ function EditablePageTitle() {
type HeaderProps = {
dockItems?: DockItem[];
/**
* Stand down for a panel that has taken the whole window. `display: none` rather than an unmount: the
* header holds a half-typed tab rename and its own popovers, and a panel going fullscreen is not a
* reason to throw those away. It is `fixed`, so hiding it costs no layout shift either.
*/
hidden?: boolean;
};
export function Header({ dockItems }: HeaderProps) {
export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false);
const isTouch = useIsTouch();
return (
<header className="fixed z-10 w-full">
<header className={`fixed z-10 w-full ${hidden ? 'hidden' : ''}`}>
<div
className="relative shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
@@ -52,7 +52,7 @@ type Calls = {
remove: string[];
zoom: [string, number][];
swap: [string, string][];
maximize: (string | null)[];
maximize: [string | null, string | undefined][];
swapSource: (string | null)[];
mobileBack: number;
};
@@ -83,7 +83,7 @@ function mount(options: MountOptions = {}) {
onMobileBack: null,
onSwap: (source, target) => calls.swap.push([source, target]),
setSwapSourceId: (id) => calls.swapSource.push(id),
setMaximizedPanelId: (id) => calls.maximize.push(id),
setMaximizedPanelId: (id, mode) => calls.maximize.push([id, mode]),
onSetZoom: (id, z) => calls.zoom.push([id, z]),
...options.context,
};
@@ -170,19 +170,22 @@ describe('which chrome each mode gets', () => {
expect(screen.queryByTitle('Close panel')).toBeNull();
fireEvent.click(screen.getByTitle('Restore'));
expect(calls.maximize).toEqual([null]);
expect(calls.maximize).toEqual([[null, undefined]]);
expect(calls.remove).toEqual([]);
});
test('maximize sets this panel, and a locked maximize toggles', () => {
const view = mount();
fireEvent.click(screen.getByTitle('Maximize'));
expect(calls.maximize).toEqual(['p1']);
expect(calls.maximize).toEqual([['p1', undefined]]);
view.unmount();
mount({ locked: true, context: { maximizedPanelId: 'p1' } });
fireEvent.click(screen.getByTitle('Restore'));
expect(calls.maximize).toEqual(['p1', null]);
expect(calls.maximize).toEqual([
['p1', undefined],
[null, undefined],
]);
});
test('noHeader drops the whole header bar but keeps the app', () => {
@@ -194,6 +197,74 @@ describe('which chrome each mode gets', () => {
});
});
describe('full screen is the second depth of maximize', () => {
const wrapper = () => document.querySelector<HTMLElement>('[data-panel-id] > div');
const fullscreen = { maximizedPanelId: 'p1', maximizeMode: 'screen' as const };
test('offered from a tiled panel, so taking the window is one click and not two', () => {
mount();
fireEvent.click(screen.getByTitle('Full screen'));
expect(calls.maximize).toEqual([['p1', 'screen']]);
});
test('the toggle steps back to a maximized panel, and the amber light is still the way out', () => {
// Two different exits on purpose, both one click: the toggle undoes the depth it added, the way a mac
// window does, and Restore undoes maximize entirely. Neither is a trap — a fullscreen panel that
// could only be escaped through a state you never asked for would be.
mount({ context: fullscreen });
fireEvent.click(screen.getByTitle('Exit full screen'));
fireEvent.click(screen.getByTitle('Restore'));
expect(calls.maximize).toEqual([
['p1', 'panel'],
[null, undefined],
]);
});
test('locked screens get it too — it edits nothing', () => {
mount({ locked: true });
fireEvent.click(screen.getByTitle('Full screen'));
expect(calls.maximize).toEqual([['p1', 'screen']]);
expect(screen.queryByTitle('Close panel')).toBeNull();
});
test('no app opts out: unlike zoom, this is a property of the frame', () => {
mount({ panel: panel('terminal') });
expect(screen.queryByTitle('Zoom in')).toBeNull();
expect(screen.getByTitle('Full screen')).toBeTruthy();
});
test('not on mobile and not on an inert render', () => {
const view = mount({ context: { isMobile: true } });
expect(screen.queryByTitle('Full screen')).toBeNull();
view.unmount();
mount({ interactive: false });
expect(screen.queryByTitle('Full screen')).toBeNull();
});
test('it drops the gutter and the rounding that say "floating above the app"', () => {
// The geometry is the feature — `inset-0` is only the whole window because the shell hides its own
// header for it (see panel-fullscreen.ts). Maximize keeps the gutter and clears the nav at
// `top-[56px]`, which is what this is pinned against.
const view = mount({ context: fullscreen });
expect(wrapper()?.className).toContain('inset-0');
expect(wrapper()?.className).not.toContain('rounded-lg');
expect(wrapper()?.className).not.toContain('top-[56px]');
view.unmount();
mount({ context: { maximizedPanelId: 'p1' } });
expect(wrapper()?.className).toContain('top-[56px]');
expect(wrapper()?.className).toContain('rounded-lg');
});
});
describe('the empty panel', () => {
test('offers the apps, and picking one sets it', () => {
mount({ panel: panel(null) });
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react';
import { useCallback } from 'react';
import { ArrowLeftRight, ChevronLeft, X, Minus, ZoomIn, ZoomOut } from 'lucide-react';
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, Minus, ZoomIn, ZoomOut } from 'lucide-react';
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
import { useWorkspace } from './WorkspaceContext';
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils';
@@ -54,16 +54,13 @@ const PanelContextMenu = ({
onClearApp,
children,
}: PanelContextMenuProps) => {
const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
const { swapSourceId, setSwapSourceId } = useWorkspace();
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
<MaximizeMenuItems panelId={panelId} />
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
@@ -237,6 +234,52 @@ const MaximizeButton = ({ panelId }: { panelId: string }) => {
);
};
// Full screen is the second DEPTH of maximize, not a rival to it. `panel` fills the dashboard's content
// region and leaves the nav header reachable; `screen` takes the whole browser window, header included.
//
// Offered from every state rather than only from a maximized panel, so it is one click from a tiled
// panel — and it steps back to a maximized panel rather than all the way out, the way a mac window does.
// The amber light is the way out, and it is present at both depths, so neither state is a trap.
//
// Hidden until the header is hovered, like the zoom buttons — except while it is active, where it stays
// visible because the panel's own header is then the only chrome on screen.
const FullscreenToggle = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, maximizeMode, setMaximizedPanelId } = useWorkspace();
const isFullscreen = maximizedPanelId === panelId && maximizeMode === 'screen';
const Icon = isFullscreen ? Minimize2 : Maximize2;
return (
<button
type="button"
onClick={() => setMaximizedPanelId(panelId, isFullscreen ? 'panel' : 'screen')}
title={isFullscreen ? 'Exit full screen' : 'Full screen'}
className={`cursor-pointer rounded p-1 transition-all hover:bg-black/10 focus-visible:opacity-100 ${isFullscreen ? '' : 'opacity-0 group-hover/header:opacity-100'}`}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
};
// The two menus below are the locked and unlocked halves of the same panel, and maximize is the one
// thing both of them keep — so it is written once. Restore from either depth goes all the way out,
// matching the amber light rather than the fullscreen toggle.
const MaximizeMenuItems = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, maximizeMode, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
const isFullscreen = isMaximized && maximizeMode === 'screen';
return (
<>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
<ContextMenuItem onClick={() => setMaximizedPanelId(panelId, isFullscreen ? 'panel' : 'screen')}>
{isFullscreen ? 'Exit full screen' : 'Full screen'}
</ContextMenuItem>
</>
);
};
// Per-panel content scale. The percentage only appears once the panel is off 100%, so an untouched
// header looks exactly as it did before; the +/- buttons reveal on header hover. Clicking the
// percentage resets to 100%.
@@ -304,16 +347,11 @@ const MaximizeContextMenu = ({
zoom: number | null;
children: React.ReactNode;
}) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
<MaximizeMenuItems panelId={panelId} />
{zoom !== null && <ZoomMenuItems panelId={panelId} zoom={zoom} />}
</ContextMenuContent>
</ContextMenu>
@@ -350,8 +388,9 @@ export const PanelSlot = ({
onSplit,
onRemove,
}: PanelSlotProps) => {
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const { maximizedPanelId, maximizeMode, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id;
const isFullscreen = isMaximized && maximizeMode === 'screen';
const rawPanelComponent = components?.[panel.id];
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
@@ -491,6 +530,9 @@ export const PanelSlot = ({
const zoomControls =
interactive && !isMobile && zoom !== null ? <ZoomControls panelId={panel.id} zoom={zoom} /> : null;
// Unlike zoom, no app opts out: taking the window is a property of the frame, not of what is inside it.
const fullscreenToggle = interactive && !isMobile ? <FullscreenToggle panelId={panel.id} /> : null;
const headerContent = (
<div className="group/header shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
{mobileBackButton}
@@ -500,11 +542,12 @@ export const PanelSlot = ({
<X className="h-3.5 w-3.5" />
</button>
)}
{(zoomControls || trafficLights) && (
{(zoomControls || fullscreenToggle || trafficLights) && (
// One shrink-to-fit container so TrafficLights' own ml-auto has no free space left to claim —
// two competing auto margins would split the header's slack and strand the zoom control mid-row.
<div className="ml-auto flex shrink-0 items-center gap-2">
{zoomControls}
{fullscreenToggle}
{trafficLights}
</div>
)}
@@ -567,12 +610,22 @@ export const PanelSlot = ({
);
// Maximize is a CSS state toggle on THE SAME element — no portal, no remount — so the panel's content
// (scroll position, media playback, in-flight state) is preserved exactly across maximize/restore.
// It fills the content REGION (starting below the global Header), not the whole viewport: the panel is
// confined to the layout's `absolute z-2` content stacking context, which sits under the fixed nav
// Header (z-10) — so a full-viewport overlay would hide the panel's own header/Restore behind the nav.
// Clearing the header keeps it visible + the nav accessible. (Maximize only happens in the dashboard
// shell; previews/mobile no-op it, so the header offset is always the right reference.)
// (scroll position, media playback, in-flight state) is preserved exactly across maximize/restore, and
// across the step between the two depths.
//
// `panel` fills the content REGION, starting below the global Header. That is not a design choice
// about how much room to take: the panel is confined to the layout's `absolute z-2` content stacking
// context, which sits under the fixed nav Header (z-10), so no z-index it can give itself will paint
// over the nav — a full-viewport overlay would simply put the nav on top of the panel's own header and
// its Restore button. Clearing the header keeps both visible.
//
// `screen` gets the rest by the only route available: the shell stands its own header down while a
// panel is fullscreen (`panel-fullscreen.ts`), so `inset-0` is genuinely the whole window. It loses the
// gutter, the rounding and the shadow with it — those read as "a window floating above the app", and
// at this depth there is no app left to float above.
//
// (Maximize only happens in the dashboard shell; previews/mobile no-op it, so the header offset is
// always the right reference.)
// Denser glass than a tiled panel, not a darker one. Panel chrome — header text, icons, the zoom
// control — is black throughout, so the old near-opaque dark fill left every one of them unreadable
// the moment you maximised. This is the nav Header's exact recipe (`rgba(255,255,255,0.25)` over a
@@ -591,9 +644,11 @@ export const PanelSlot = ({
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
<div
className={
isMaximized
? 'fixed inset-x-2 bottom-2 top-[56px] z-50 overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl md:top-[68px]'
: 'relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col'
isFullscreen
? 'fixed inset-0 z-50 overflow-hidden backdrop-blur-xl p-2 flex flex-col'
: isMaximized
? 'fixed inset-x-2 bottom-2 top-[56px] z-50 overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl md:top-[68px]'
: 'relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col'
}
style={panelStyle}
>
@@ -1,7 +1,7 @@
import { createContext, useContext } from 'react';
import type { DropPosition } from './layout-utils';
import type { PanelConfig } from './types';
import type { MaximizeMode, PanelConfig } from './types';
import type { WorkspaceIdentity } from './workspace-identity';
export type WorkspaceContextValue = {
@@ -25,7 +25,12 @@ export type WorkspaceContextValue = {
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
onSetZoom: (panelId: string, zoom: number) => void;
maximizedPanelId: string | null;
setMaximizedPanelId: (id: string | null) => void;
/** How far the maximized panel goes. Only meaningful for `maximizedPanelId`; `'panel'` otherwise. */
maximizeMode: MaximizeMode;
// `mode` defaults to `'panel'`, so every existing caller means what it always meant: the green light
// and the context menu maximize into the content region, and only the fullscreen control asks for the
// window.
setMaximizedPanelId: (id: string | null, mode?: MaximizeMode) => void;
transitioningPanelId: string | null;
isMobile: boolean;
onMobileBack: (() => void) | null;
@@ -55,6 +60,7 @@ export const inertInteraction = {
onMove: noop,
onSetZoom: noop,
maximizedPanelId: null,
maximizeMode: 'panel' as const,
setMaximizedPanelId: noop,
transitioningPanelId: null,
} satisfies Partial<WorkspaceContextValue>;
@@ -3,15 +3,32 @@ import { flushSync } from 'react-dom';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
import { useIsMobile } from 'hooks/useIsMobile';
import { useSessionState } from 'hooks/useSessionState';
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
import type { LayoutNode, DashboardState, EphemeralPanels, MaximizeMode, PanelComponents, PanelConfig } from './types';
import type { DropPosition } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs, findPanelApp, normalizeLayout } from './layout-utils';
import {
splitPanel,
removePanel,
setApp,
updateSizes,
swapPanels,
movePanel,
countPanels,
setZoom,
setPanelConfig,
collectPanelConfigs,
findPanelApp,
normalizeLayout,
} from './layout-utils';
import type { WorkspaceContextValue } from './WorkspaceContext';
import { WorkspaceProvider } from './WorkspaceContext';
import { parseWorkspaceKey } from './workspace-identity';
import { WorkspaceRenderer } from './WorkspaceRenderer';
import { usePublishPanelFullscreen } from './panel-fullscreen';
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
/** What this tab has blown up, and how far. Stored as one value so the two cannot drift apart. */
type MaximizedPanel = { id: string; mode: MaximizeMode };
type WorkspaceViewProps = {
workspace: DashboardState;
locked?: boolean;
@@ -36,7 +53,17 @@ type WorkspaceViewProps = {
const noop = () => {};
export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
export const WorkspaceView = ({
workspace,
locked,
appTypes,
cwd = '~',
root,
components,
ephemeral,
mobilePanelId,
onMobilePanelChange,
}: WorkspaceViewProps) => {
const { registry } = useAppRegistry();
const isMobile = useIsMobile();
@@ -61,22 +88,36 @@ export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, co
// has since been removed simply maximises nothing — maximize is a style toggle on the panel itself, so
// there is nothing to strand.
const maximizedKey = `MAXIMIZED_PANEL:${workspace.key}`;
const [maximizedPanelId, setMaximizedPanelId] = useSessionState<string | null>(maximizedKey, null);
const [maximized, setMaximized] = useSessionState<MaximizedPanel | null>(maximizedKey, null);
// Read through `?.`, not destructured or cast: a tab that was already open when `mode` was added still
// has the old bare-string value under this key, and reading a property off a string yields undefined
// rather than throwing. So the stale shape lands on "nothing is maximized" — the same harmless place a
// stored id whose panel has since been removed lands.
const maximizedPanelId = maximized?.id ?? null;
const maximizeMode: MaximizeMode = maximized?.mode === 'screen' ? 'screen' : 'panel';
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
const setMaximizedAnimated = useCallback((id: string | null) => {
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
const panelId = maximizedPanelId ?? id;
if (doc.startViewTransition && panelId) {
setTransitioningPanelId(panelId);
requestAnimationFrame(() => {
const transition = doc.startViewTransition(() => flushSync(() => setMaximizedPanelId(id)));
transition.finished.finally(() => setTransitioningPanelId(null));
});
} else {
setMaximizedPanelId(id);
}
}, [maximizedPanelId]);
// The shell hides the nav header for a fullscreen panel — a panel cannot paint over it from inside the
// content region's stacking context. See `panel-fullscreen.ts`.
usePublishPanelFullscreen(maximizedPanelId !== null && maximizeMode === 'screen');
const setMaximizedAnimated = useCallback(
(id: string | null, mode: MaximizeMode = 'panel') => {
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
const panelId = maximizedPanelId ?? id;
const next = id ? { id, mode } : null;
if (doc.startViewTransition && panelId) {
setTransitioningPanelId(panelId);
requestAnimationFrame(() => {
const transition = doc.startViewTransition(() => flushSync(() => setMaximized(next)));
transition.finished.finally(() => setTransitioningPanelId(null));
});
} else {
setMaximized(next);
}
},
[maximizedPanelId, setMaximized],
);
// What each panel wants done when it is closed, by panel id, stamped with the workspace it was
// registered on. A ref rather than state: nothing renders from it, and a panel registering during its
@@ -204,13 +245,10 @@ export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, co
[onLayoutChange],
);
const startDrag = useCallback(
(id: string | null) => {
setSwapSourceId(null);
setDragSourceId(id);
},
[],
);
const startDrag = useCallback((id: string | null) => {
setSwapSourceId(null);
setDragSourceId(id);
}, []);
// Escape cancels a *pending* swap or drag — modes you are halfway through, where it is the only way
// out. Maximize is not one of those: it is a state you chose and that now outlives a refresh, so a key
@@ -278,6 +316,7 @@ export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, co
onMove: handleMove,
onSetZoom: handleSetZoom,
maximizedPanelId,
maximizeMode,
setMaximizedPanelId: setMaximizedAnimated,
transitioningPanelId,
isMobile,
@@ -298,6 +337,7 @@ export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, co
handleMove,
handleSetZoom,
maximizedPanelId,
maximizeMode,
setMaximizedAnimated,
transitioningPanelId,
isMobile,
@@ -338,13 +378,7 @@ export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, co
/>
</ResizablePanel>
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
<ResizablePanel
ref={ephemeralPanelRef}
collapsible
collapsedSize={0}
defaultSize={0}
minSize={15}
>
<ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
{isEphemeralOpen ? (
<WorkspaceRenderer
layout={ephemeral.layout}
@@ -32,7 +32,9 @@ export {
} from './layout-utils';
export type { WorkspaceIdentity } from './workspace-identity';
export { parseWorkspaceKey } from './workspace-identity';
export type { MaximizeMode } from './types';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
export { usePanelFullscreen } from './panel-fullscreen';
export { usePanelConfig } from './usePanelConfig';
export { usePanelClose } from './usePanelClose';
export { WorkspaceView } from './WorkspaceView';
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react';
import { useGlobal } from 'hooks/useGlobal';
// ── "A panel has taken the whole window" ──
//
// Read by the app shell, written by whichever `WorkspaceView` owns the maximized panel. It has to be a
// global rather than context or a prop, for two reasons that both point the same way:
//
// - `DashboardLayout` renders the nav Header *above* every screen, so it is an ancestor of the
// `WorkspaceView` doing the maximizing. Nothing flows upward.
// - CSS cannot do it either. The content region is `absolute inset-0 z-2` and the Header is a `fixed
// z-10` sibling, so the region is a stacking context the panel can never paint out of however high
// its own z-index — which is exactly why plain maximize stops at `top-[56px]`. The panel does not
// cover the header; the shell stands the header down.
//
// In memory only, deliberately: this is derived from the maximize state that IS persisted per tab, so a
// reload republishes it from `WorkspaceView` a frame later. Persisting it separately would be a second
// copy of one fact, and the failure mode of a stale copy is a screen with no header and nothing
// maximized to explain it.
const PANEL_FULLSCREEN = 'PANEL_FULLSCREEN';
/** Whether a panel is currently filling the window. For the shell — the chrome that has to get out of the way. */
export const usePanelFullscreen = () => useGlobal<boolean>(PANEL_FULLSCREEN, false)[0];
/**
* Publish this workspace's fullscreen state, and stand it down when the workspace goes away.
*
* The cleanup is the load-bearing half: navigating off a screen with a fullscreen panel unmounts the
* view without anything clicking Restore, and a flag left set is an app with no header and no way to
* get one back short of a reload.
*/
export function usePublishPanelFullscreen(active: boolean) {
const [, setActive] = useGlobal<boolean>(PANEL_FULLSCREEN, false);
// Through a ref: `useGlobal` rebuilds its setter every render, so as a dependency it would re-run this
// effect — and therefore the cleanup's `false` — on every render of the workspace.
const setActiveRef = useRef(setActive);
setActiveRef.current = setActive;
useEffect(() => {
setActiveRef.current(active);
return () => setActiveRef.current(false);
}, [active]);
}
@@ -34,6 +34,18 @@ export type PanelConfig = Record<string, unknown>;
export type LayoutNode = LayoutGroup | LayoutPanel;
/**
* How far a maximized panel goes.
*
* `panel` fills the dashboard's content region and leaves the nav header visible and clickable.
* `screen` takes the whole browser window, header included — the shell hides its own chrome for it
* (see `panel-fullscreen.ts`), because a panel cannot paint out of the region's stacking context.
*
* Not persisted with the layout: which panel is blown up, and how far, is a fact about *this tab* the
* way `zoom` is a fact about the panel. It lives beside the maximized panel id in sessionStorage.
*/
export type MaximizeMode = 'panel' | 'screen';
export type HomeRoot = 'home' | '~' | 'officer.dev';
export type DashboardDefinition = {
+2
View File
@@ -129,6 +129,8 @@ export { resolveIcon, availableIconNames } from './utils/resolve-icon';
// Workspace
export { WorkspaceView, WorkspaceLayout, WorkspaceProvider, useWorkspace } from './components/Workspace';
// For the app shell: a fullscreen panel takes the window, so the nav header has to get out of its way.
export { usePanelFullscreen } from './components/Workspace';
export {
createDefaultLayout,
splitPanel,