feat(webui): add tabbed pane workbench (#5322)
This commit is contained in:
+861
-151
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
|
||||
interface DeleteConfirmProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
count?: number;
|
||||
automations?: SessionAutomationJob[];
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
|
||||
export function DeleteConfirm({
|
||||
open,
|
||||
title,
|
||||
count = 1,
|
||||
automations = [],
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@@ -33,6 +35,7 @@ export function DeleteConfirm({
|
||||
const { t } = useTranslation();
|
||||
const locale = currentLocale();
|
||||
const hasAutomations = automations.length > 0;
|
||||
const multiple = count > 1;
|
||||
const visibleAutomations = automations.slice(0, 4);
|
||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||
return (
|
||||
@@ -47,12 +50,25 @@ export function DeleteConfirm({
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||
{t("deleteConfirm.title", { title })}
|
||||
{multiple
|
||||
? t("deleteConfirm.titleMany", {
|
||||
defaultValue: "Delete {{count}} conversations?",
|
||||
count,
|
||||
})
|
||||
: t("deleteConfirm.title", { title })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||
{hasAutomations
|
||||
? t("deleteConfirm.automationsDescription")
|
||||
: t("deleteConfirm.description")}
|
||||
? multiple
|
||||
? t("deleteConfirm.automationsDescriptionMany", {
|
||||
defaultValue: "Linked automations will also be deleted.",
|
||||
})
|
||||
: t("deleteConfirm.automationsDescription")
|
||||
: multiple
|
||||
? t("deleteConfirm.descriptionMany", {
|
||||
defaultValue: "This action cannot be undone.",
|
||||
})
|
||||
: t("deleteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
{hasAutomations ? (
|
||||
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import {
|
||||
ChatList,
|
||||
type SidebarDeleteItem,
|
||||
type SidebarPaneGroup,
|
||||
} from "@/components/ChatList";
|
||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||
import {
|
||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||
@@ -39,10 +43,20 @@ interface SidebarProps {
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onRequestRenameTab?: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
onReorderSessions: (keys: string[]) => void;
|
||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onCreateTab?: (paneKey: string) => void;
|
||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||
onDissolveTab?: (tabKey: string) => void;
|
||||
onAttachPane?: (
|
||||
paneKey: string,
|
||||
tabKey: string,
|
||||
) => void;
|
||||
onToggleGroup: (groupId: string) => void;
|
||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||
onNewChatInProject: (projectPath: string, projectName: string) => void;
|
||||
@@ -60,6 +74,8 @@ interface SidebarProps {
|
||||
collapsed?: boolean;
|
||||
pinnedKeys?: string[];
|
||||
archivedKeys?: string[];
|
||||
pinnedPaneKeys?: string[];
|
||||
archivedPaneKeys?: string[];
|
||||
sessionOrder?: string[];
|
||||
titleOverrides?: Record<string, string>;
|
||||
projectNameOverrides?: Record<string, string>;
|
||||
@@ -230,15 +246,24 @@ export function Sidebar(props: SidebarProps) {
|
||||
onSelect={props.onSelect}
|
||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
onRequestDeleteMany={props.onRequestDeleteMany}
|
||||
onTogglePin={props.onTogglePin}
|
||||
onRequestRename={props.onRequestRename}
|
||||
onRequestRenameTab={props.onRequestRenameTab}
|
||||
onToggleArchive={props.onToggleArchive}
|
||||
onReorderSessions={props.onReorderSessions}
|
||||
paneGroups={props.paneGroups}
|
||||
onSelectPane={props.onSelectPane}
|
||||
onCreateTab={props.onCreateTab}
|
||||
onDetachPane={props.onDetachPane}
|
||||
onDissolveTab={props.onDissolveTab}
|
||||
onAttachPane={props.onAttachPane}
|
||||
onToggleGroup={props.onToggleGroup}
|
||||
onRequestRenameProject={props.onRequestRenameProject}
|
||||
onNewChatInProject={props.onNewChatInProject}
|
||||
pinnedKeys={props.pinnedKeys}
|
||||
archivedKeys={props.archivedKeys}
|
||||
pinnedPaneKeys={props.pinnedPaneKeys}
|
||||
archivedPaneKeys={props.archivedPaneKeys}
|
||||
sessionOrder={props.sessionOrder}
|
||||
titleOverrides={props.titleOverrides}
|
||||
projectNameOverrides={props.projectNameOverrides}
|
||||
|
||||
@@ -186,6 +186,7 @@ interface ThreadComposerProps {
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
isStreaming?: boolean;
|
||||
modelLabel?: string | null;
|
||||
modelDetail?: string | null;
|
||||
@@ -940,6 +941,7 @@ export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputAriaLabel,
|
||||
isStreaming = false,
|
||||
modelLabel = null,
|
||||
modelDetail = null,
|
||||
@@ -2400,7 +2402,7 @@ export function ThreadComposer({
|
||||
rows={1}
|
||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||
disabled={interactionDisabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
inputTextClasses,
|
||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||
|
||||
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideTitle?: boolean;
|
||||
actions?: ReactNode;
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
@@ -33,8 +36,11 @@ export function ThreadHeader({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideTitle = false,
|
||||
actions,
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
@@ -54,19 +60,21 @@ export function ThreadHeader({
|
||||
)}
|
||||
>
|
||||
<div className="relative flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||
hideSidebarToggleForHostChrome && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!minimal ? (
|
||||
{!hideSidebarToggle ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||
hideSidebarToggleForHostChrome && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{!minimal && !hideTitle ? (
|
||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||
</div>
|
||||
@@ -76,6 +84,7 @@ export function ThreadHeader({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{actions}
|
||||
{onTemporaryChatEnabledChange ? (
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
@@ -311,9 +312,18 @@ interface ThreadShellProps {
|
||||
theme?: "light" | "dark";
|
||||
onToggleTheme?: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideHeaderTitle?: boolean;
|
||||
hideHeader?: boolean;
|
||||
headerActions?: ReactNode;
|
||||
headerPortalTarget?: HTMLElement | null;
|
||||
headerActive?: boolean;
|
||||
composerPortalTarget?: HTMLElement | null;
|
||||
composerActive?: boolean;
|
||||
composerInputAriaLabel?: string;
|
||||
emptyComposerVariant?: "hero" | "thread";
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
@@ -598,9 +608,18 @@ export function ThreadShell({
|
||||
theme = "light",
|
||||
onToggleTheme = () => {},
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideHeaderTitle = false,
|
||||
hideHeader = false,
|
||||
headerActions,
|
||||
headerPortalTarget,
|
||||
headerActive = true,
|
||||
composerPortalTarget,
|
||||
composerActive = true,
|
||||
composerInputAriaLabel,
|
||||
emptyComposerVariant = "hero",
|
||||
workspaceScope = null,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
@@ -843,6 +862,7 @@ export function ThreadShell({
|
||||
]);
|
||||
|
||||
const showHeroComposer = displayMessages.length === 0 && !loading;
|
||||
const composerVariant = showHeroComposer ? emptyComposerVariant : "thread";
|
||||
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
||||
const sessionModelPreset = session?.modelPreset?.trim() || null;
|
||||
const [localModelPreset, setLocalModelPreset] = useState<string | null>(null);
|
||||
@@ -1405,9 +1425,10 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
composerVariant === "hero"
|
||||
? t("thread.composer.placeholderHero")
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
@@ -1421,7 +1442,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
variant={composerVariant}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
@@ -1449,6 +1470,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleWelcomeSend}
|
||||
disabled={booting}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
booting
|
||||
@@ -1508,28 +1530,33 @@ export function ThreadShell({
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
const threadHeader = !hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hideSidebarToggle={hideSidebarToggle}
|
||||
hostChromeTitleInset={hostChromeTitleInset}
|
||||
hideThemeButton={hideThemeButton}
|
||||
hideTitle={hideHeaderTitle}
|
||||
actions={headerActions}
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
temporaryChatDisabled={booting || turnActive}
|
||||
onTemporaryChatEnabledChange={
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{!hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hostChromeTitleInset={hostChromeTitleInset}
|
||||
hideThemeButton={hideThemeButton}
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
temporaryChatDisabled={booting || turnActive}
|
||||
onTemporaryChatEnabledChange={
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{headerPortalTarget === undefined ? threadHeader : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||
>
|
||||
@@ -1539,7 +1566,7 @@ export function ThreadShell({
|
||||
temporary={temporary}
|
||||
isStreaming={turnActive}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
composer={composerPortalTarget === undefined ? composer : null}
|
||||
activeTurnId={viewportTurnId}
|
||||
activeTurnStartedHere={activeTurnStartedHere}
|
||||
conversationKey={historyKey}
|
||||
@@ -1559,6 +1586,19 @@ export function ThreadShell({
|
||||
/>
|
||||
</FilePreviewAvailabilityProvider>
|
||||
</div>
|
||||
{headerPortalTarget && headerActive
|
||||
? createPortal(threadHeader, headerPortalTarget)
|
||||
: null}
|
||||
{composerPortalTarget ? createPortal(
|
||||
<div
|
||||
hidden={!composerActive}
|
||||
aria-hidden={!composerActive}
|
||||
data-testid={composerActive ? "active-pane-composer" : undefined}
|
||||
>
|
||||
{composer}
|
||||
</div>,
|
||||
composerPortalTarget,
|
||||
) : null}
|
||||
{filePreviewPath && historyKey ? (
|
||||
<FilePreviewPanel
|
||||
sessionKey={historyKey}
|
||||
|
||||
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
composer?: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
activeTurnId?: string | null;
|
||||
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const NEAR_TOP_PX = 96;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||
? forkBoundaryMessageCount - hiddenMessageCount
|
||||
: null;
|
||||
const hasComposer = composer !== null && composer !== undefined;
|
||||
const scrollButtonBottom =
|
||||
keyboardInsetBottom
|
||||
+ (composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||
: hasComposer
|
||||
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
|
||||
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
|
||||
const scrollViewportStyle =
|
||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||
|
||||
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||
data-layout={hasMessages ? "thread" : "hero"}
|
||||
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
||||
className={cn(
|
||||
"thread-layout mx-auto grid min-h-full w-full",
|
||||
hasMessages
|
||||
@@ -699,58 +703,67 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
||||
<div
|
||||
className={cn(
|
||||
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
||||
hasComposer && "sm:items-end sm:pb-8",
|
||||
)}
|
||||
>
|
||||
{emptyState}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
onInputCapture={(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
||||
threadMotionRef.current?.handleComposerInput();
|
||||
}
|
||||
}}
|
||||
onInput={(event) => {
|
||||
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
||||
const previousScrollTop = composerInputScrollTopRef.current;
|
||||
composerInputScrollTopRef.current = null;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && previousScrollTop !== null) {
|
||||
// Textarea autosizing briefly collapses to `height: auto` while
|
||||
// measuring. Chrome can clamp the sibling thread scrollport in
|
||||
// that intermediate layout; restore it before paint, then let
|
||||
// ResizeObserver handle any real final composer height change.
|
||||
scrollEl.scrollTop = previousScrollTop;
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"row-start-2 z-10 w-full",
|
||||
hasMessages ? "relative bg-background" : "relative self-center",
|
||||
)}
|
||||
>
|
||||
{hasComposer ? (
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
onInputCapture={(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
||||
threadMotionRef.current?.handleComposerInput();
|
||||
}
|
||||
}}
|
||||
onInput={(event) => {
|
||||
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
||||
const previousScrollTop = composerInputScrollTopRef.current;
|
||||
composerInputScrollTopRef.current = null;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && previousScrollTop !== null) {
|
||||
// Textarea autosizing briefly collapses to `height: auto` while
|
||||
// measuring. Chrome can clamp the sibling thread scrollport in
|
||||
// that intermediate layout; restore it before paint, then let
|
||||
// ResizeObserver handle any real final composer height change.
|
||||
scrollEl.scrollTop = previousScrollTop;
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
hasMessages
|
||||
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
||||
: "",
|
||||
"row-start-2 z-10 w-full",
|
||||
hasMessages ? "relative bg-background" : "relative self-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="thread-composer-motion"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
className={cn(
|
||||
hasMessages
|
||||
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{composer}
|
||||
<div
|
||||
data-testid="thread-composer-motion"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||
/>
|
||||
{hasComposer ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Circle } from "lucide-react";
|
||||
import { ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import {
|
||||
floatingItemClassName,
|
||||
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const menuItemClassName =
|
||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||
@@ -115,6 +116,41 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(menuItemClassName, inset && "pl-8", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -123,5 +159,8 @@ export {
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
import {
|
||||
Columns2,
|
||||
Grid2X2,
|
||||
PanelLeft,
|
||||
PanelsTopLeft,
|
||||
Plus,
|
||||
Rows2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
createWorkbenchLayoutGeometry,
|
||||
resizeHandleRatio,
|
||||
resizeHandleStyle,
|
||||
splitRatioBounds,
|
||||
type WorkbenchResizeHandle,
|
||||
} from "@/components/workbench/workbench-layout";
|
||||
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WorkbenchPane {
|
||||
key: string;
|
||||
reactKey?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface PaneRenderContext {
|
||||
active: boolean;
|
||||
headerPortalTarget: HTMLElement | null | undefined;
|
||||
composerPortalTarget: HTMLElement | null | undefined;
|
||||
headerActions: ReactNode;
|
||||
}
|
||||
|
||||
interface PaneWorkbenchProps {
|
||||
panes: WorkbenchPane[];
|
||||
activePaneKey: string;
|
||||
layout: WorkbenchLayout;
|
||||
chrome?: boolean;
|
||||
showLayoutControl: boolean;
|
||||
addPaneDisabled?: boolean;
|
||||
addPaneDisabledLabel?: string;
|
||||
onActivatePane: (key: string) => void;
|
||||
onAddPane: () => void;
|
||||
onLayoutChange: (layout: WorkbenchLayout) => void;
|
||||
onPaneOrderChange: (paneKeys: string[]) => void;
|
||||
splitRatios?: number[];
|
||||
onSplitRatiosChange?: (splitRatios: number[]) => void;
|
||||
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
|
||||
}
|
||||
|
||||
const LAYOUT_MOTION_DURATION_MS = 260;
|
||||
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
const EMPTY_SPLIT_RATIOS: number[] = [];
|
||||
const IGNORE_SPLIT_RATIO_CHANGE = () => {};
|
||||
|
||||
const LAYOUT_CONTROLS: Array<{
|
||||
icon: LucideIcon;
|
||||
layout: WorkbenchLayout;
|
||||
label: string;
|
||||
}> = [
|
||||
{ icon: Columns2, layout: "columns", label: "Columns" },
|
||||
{ icon: Rows2, layout: "rows", label: "Rows" },
|
||||
{ icon: Grid2X2, layout: "grid", label: "Grid" },
|
||||
{ icon: PanelsTopLeft, layout: "bsp", label: "BSP" },
|
||||
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
|
||||
];
|
||||
|
||||
function isPaneAction(target: EventTarget | null): boolean {
|
||||
return target instanceof Element
|
||||
&& target.closest("[data-workbench-pane-action]") !== null;
|
||||
}
|
||||
|
||||
function movePaneToSlot(
|
||||
paneKeys: readonly string[],
|
||||
paneKey: string,
|
||||
targetKey: string,
|
||||
): string[] {
|
||||
const fromIndex = paneKeys.indexOf(paneKey);
|
||||
const targetIndex = paneKeys.indexOf(targetKey);
|
||||
if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return [...paneKeys];
|
||||
const next = paneKeys.filter((key) => key !== paneKey);
|
||||
next.splice(targetIndex, 0, paneKey);
|
||||
return next;
|
||||
}
|
||||
|
||||
function paneSlotAtPoint(
|
||||
rects: readonly DOMRect[],
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
for (const [index, rect] of rects.entries()) {
|
||||
if (
|
||||
x >= rect.left
|
||||
&& x <= rect.right
|
||||
&& y >= rect.top
|
||||
&& y <= rect.bottom
|
||||
) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paneInDirection(
|
||||
rects: ReadonlyMap<string, DOMRect>,
|
||||
paneKey: string,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
): string | null {
|
||||
const source = rects.get(paneKey);
|
||||
if (!source) return null;
|
||||
const sourceX = source.left + source.width / 2;
|
||||
const sourceY = source.top + source.height / 2;
|
||||
let best: { key: string; score: number } | null = null;
|
||||
for (const [key, rect] of rects) {
|
||||
if (key === paneKey) continue;
|
||||
const deltaX = rect.left + rect.width / 2 - sourceX;
|
||||
const deltaY = rect.top + rect.height / 2 - sourceY;
|
||||
const primary = direction === "left"
|
||||
? -deltaX
|
||||
: direction === "right"
|
||||
? deltaX
|
||||
: direction === "up"
|
||||
? -deltaY
|
||||
: deltaY;
|
||||
if (primary <= 1) continue;
|
||||
const cross = direction === "left" || direction === "right"
|
||||
? Math.abs(deltaY)
|
||||
: Math.abs(deltaX);
|
||||
const score = primary + cross * 0.35;
|
||||
if (!best || score < best.score) best = { key, score };
|
||||
}
|
||||
return best?.key ?? null;
|
||||
}
|
||||
|
||||
function HeaderIconButton({
|
||||
disabled,
|
||||
disabledLabel,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
disabledLabel?: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={disabled ? disabledLabel : undefined}
|
||||
onClick={onClick}
|
||||
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{disabled ? disabledLabel ?? label : label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneWorkbench({
|
||||
panes,
|
||||
activePaneKey,
|
||||
layout,
|
||||
chrome = true,
|
||||
showLayoutControl,
|
||||
addPaneDisabled = false,
|
||||
addPaneDisabledLabel,
|
||||
onActivatePane,
|
||||
onAddPane,
|
||||
onLayoutChange,
|
||||
onPaneOrderChange,
|
||||
splitRatios = EMPTY_SPLIT_RATIOS,
|
||||
onSplitRatiosChange = IGNORE_SPLIT_RATIO_CHANGE,
|
||||
renderPane,
|
||||
}: PaneWorkbenchProps) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useMediaQuery("(max-width: 767px)");
|
||||
const effectiveLayout = layout;
|
||||
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const paneRefs = useRef(new Map<string, HTMLElement>());
|
||||
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
||||
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||
const animationsRef = useRef(new Map<string, Animation>());
|
||||
const sourceSplitRatiosKey = splitRatios.join("\u0000");
|
||||
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
|
||||
const previewSplitRatiosRef = useRef(splitRatios);
|
||||
const resizeGestureRef = useRef<{
|
||||
pointerId: number;
|
||||
handle: WorkbenchResizeHandle;
|
||||
changed: boolean;
|
||||
} | null>(null);
|
||||
const [resizingRatioIndex, setResizingRatioIndex] = useState<number | null>(null);
|
||||
const sourcePaneOrder = useMemo(
|
||||
() => panes.map((pane) => pane.key),
|
||||
[panes],
|
||||
);
|
||||
const sourcePaneOrderKey = sourcePaneOrder.join("\u0000");
|
||||
const [previewPaneKeys, setPreviewPaneKeys] = useState(sourcePaneOrder);
|
||||
const previewPaneKeysRef = useRef(sourcePaneOrder);
|
||||
const dragGestureRef = useRef<{
|
||||
pointerId: number;
|
||||
paneKey: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
slotRects: DOMRect[];
|
||||
started: boolean;
|
||||
} | null>(null);
|
||||
const [draggingPaneKey, setDraggingPaneKey] = useState<string | null>(null);
|
||||
const orderedPanes = useMemo(() => {
|
||||
const byKey = new Map(panes.map((pane) => [pane.key, pane]));
|
||||
return [
|
||||
...previewPaneKeys.map((key) => byKey.get(key)).filter(
|
||||
(pane): pane is WorkbenchPane => pane !== undefined,
|
||||
),
|
||||
...panes.filter((pane) => !previewPaneKeys.includes(pane.key)),
|
||||
];
|
||||
}, [panes, previewPaneKeys]);
|
||||
const displayedPanes = useMemo(() => {
|
||||
if (!compact) return orderedPanes;
|
||||
const activePane = orderedPanes.find((pane) => pane.key === activePaneKey)
|
||||
?? orderedPanes[0];
|
||||
return activePane ? [activePane] : [];
|
||||
}, [activePaneKey, compact, orderedPanes]);
|
||||
const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000");
|
||||
|
||||
useEffect(() => {
|
||||
if (dragGestureRef.current) return;
|
||||
previewPaneKeysRef.current = sourcePaneOrder;
|
||||
setPreviewPaneKeys(sourcePaneOrder);
|
||||
}, [sourcePaneOrder, sourcePaneOrderKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resizeGestureRef.current) return;
|
||||
previewSplitRatiosRef.current = splitRatios;
|
||||
setPreviewSplitRatios(splitRatios);
|
||||
}, [sourceSplitRatiosKey, splitRatios]);
|
||||
|
||||
const measurePanes = useCallback(() => {
|
||||
const rects = new Map<string, DOMRect>();
|
||||
for (const [key, element] of paneRefs.current) {
|
||||
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
|
||||
}
|
||||
return rects;
|
||||
}, []);
|
||||
|
||||
const captureLayout = useCallback(() => {
|
||||
pendingRectsRef.current = measurePanes();
|
||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||
animationsRef.current.clear();
|
||||
}, [measurePanes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
||||
pendingRectsRef.current = null;
|
||||
const nextRects = measurePanes();
|
||||
const reduceMotion = typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
if (!reduceMotion) {
|
||||
for (const [key, nextRect] of nextRects) {
|
||||
const previousRect = previousRects.get(key);
|
||||
const element = paneRefs.current.get(key);
|
||||
if (!element) continue;
|
||||
if (!previousRect) {
|
||||
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
||||
const animation = element.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
|
||||
{ opacity: 1, transform: "translateY(0) scale(1)" },
|
||||
],
|
||||
{
|
||||
duration: 180,
|
||||
easing: LAYOUT_MOTION_EASING,
|
||||
fill: "backwards",
|
||||
},
|
||||
);
|
||||
animationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (animationsRef.current.get(key) === animation) {
|
||||
animationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
continue;
|
||||
}
|
||||
if (previousRect.width === 0 || previousRect.height === 0) continue;
|
||||
const deltaX = previousRect.left - nextRect.left;
|
||||
const deltaY = previousRect.top - nextRect.top;
|
||||
const scaleX = previousRect.width / nextRect.width;
|
||||
const scaleY = previousRect.height / nextRect.height;
|
||||
if (
|
||||
Math.abs(deltaX) < 0.5
|
||||
&& Math.abs(deltaY) < 0.5
|
||||
&& Math.abs(scaleX - 1) < 0.002
|
||||
&& Math.abs(scaleY - 1) < 0.002
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (typeof element.animate !== "function") continue;
|
||||
const animation = element.animate(
|
||||
[
|
||||
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
|
||||
{ transform: "translate(0, 0) scale(1, 1)" },
|
||||
],
|
||||
{
|
||||
duration: LAYOUT_MOTION_DURATION_MS,
|
||||
easing: LAYOUT_MOTION_EASING,
|
||||
},
|
||||
);
|
||||
animationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (animationsRef.current.get(key) === animation) {
|
||||
animationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
lastRectsRef.current = nextRects;
|
||||
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||
}, []);
|
||||
|
||||
const activatePane = useCallback((key: string, target: EventTarget | null) => {
|
||||
if (key === activePaneKey || isPaneAction(target)) return;
|
||||
captureLayout();
|
||||
onActivatePane(key);
|
||||
}, [activePaneKey, captureLayout, onActivatePane]);
|
||||
|
||||
const handlePanePointerDown = useCallback((
|
||||
key: string,
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
) => {
|
||||
activatePane(key, event.target);
|
||||
}, [activatePane]);
|
||||
|
||||
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
|
||||
activatePane(key, event.target);
|
||||
}, [activatePane]);
|
||||
|
||||
const applyPaneOrder = useCallback((paneKey: string, targetKey: string) => {
|
||||
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
|
||||
if (next.every((key, index) => previewPaneKeysRef.current[index] === key)) return;
|
||||
captureLayout();
|
||||
previewPaneKeysRef.current = next;
|
||||
setPreviewPaneKeys(next);
|
||||
onPaneOrderChange(next);
|
||||
}, [captureLayout, onPaneOrderChange]);
|
||||
|
||||
const handleMovePointerDown = useCallback((
|
||||
paneKey: string,
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (event.button !== 0 || compact || panes.length < 2) return;
|
||||
const paneRects = measurePanes();
|
||||
dragGestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
paneKey,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
slotRects: previewPaneKeysRef.current
|
||||
.map((key) => paneRects.get(key))
|
||||
.filter((rect): rect is DOMRect => rect !== undefined),
|
||||
started: false,
|
||||
};
|
||||
setDraggingPaneKey(paneKey);
|
||||
}, [compact, measurePanes, panes.length]);
|
||||
|
||||
const handleMovePointerMove = useCallback((event: globalThis.PointerEvent) => {
|
||||
const gesture = dragGestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
const distance = Math.hypot(
|
||||
event.clientX - gesture.startX,
|
||||
event.clientY - gesture.startY,
|
||||
);
|
||||
if (!gesture.started) {
|
||||
if (distance < 8) return;
|
||||
gesture.started = true;
|
||||
}
|
||||
event.preventDefault();
|
||||
const targetIndex = paneSlotAtPoint(gesture.slotRects, event.clientX, event.clientY);
|
||||
const targetKey = targetIndex === null
|
||||
? null
|
||||
: previewPaneKeysRef.current[targetIndex] ?? null;
|
||||
if (targetKey) applyPaneOrder(gesture.paneKey, targetKey);
|
||||
}, [applyPaneOrder]);
|
||||
|
||||
const finishMoveGesture = useCallback(() => {
|
||||
if (!dragGestureRef.current) return;
|
||||
dragGestureRef.current = null;
|
||||
setDraggingPaneKey(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingPaneKey) return;
|
||||
|
||||
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||
const gesture = dragGestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
if ((event.buttons & 1) === 0) {
|
||||
finishMoveGesture();
|
||||
return;
|
||||
}
|
||||
handleMovePointerMove(event);
|
||||
};
|
||||
const handlePointerEnd = (event: globalThis.PointerEvent) => {
|
||||
if (dragGestureRef.current?.pointerId === event.pointerId) finishMoveGesture();
|
||||
};
|
||||
const root = document.documentElement;
|
||||
const previousCursor = root.style.cursor;
|
||||
root.style.cursor = "grabbing";
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", handlePointerEnd);
|
||||
window.addEventListener("pointercancel", handlePointerEnd);
|
||||
window.addEventListener("blur", finishMoveGesture);
|
||||
return () => {
|
||||
root.style.cursor = previousCursor;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerEnd);
|
||||
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||
window.removeEventListener("blur", finishMoveGesture);
|
||||
};
|
||||
}, [draggingPaneKey, finishMoveGesture, handleMovePointerMove]);
|
||||
|
||||
const handleMoveKeyDown = useCallback((
|
||||
paneKey: string,
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const direction = (() => {
|
||||
switch (event.key) {
|
||||
case "ArrowLeft": return "left";
|
||||
case "ArrowRight": return "right";
|
||||
case "ArrowUp": return "up";
|
||||
case "ArrowDown": return "down";
|
||||
default: return null;
|
||||
}
|
||||
})();
|
||||
if (!direction) return;
|
||||
const targetKey = paneInDirection(measurePanes(), paneKey, direction);
|
||||
if (!targetKey) return;
|
||||
event.preventDefault();
|
||||
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
|
||||
captureLayout();
|
||||
previewPaneKeysRef.current = next;
|
||||
setPreviewPaneKeys(next);
|
||||
onPaneOrderChange(next);
|
||||
}, [captureLayout, measurePanes, onPaneOrderChange]);
|
||||
|
||||
const layoutGeometry = useMemo(() => createWorkbenchLayoutGeometry(
|
||||
effectiveLayout,
|
||||
displayedPanes.length,
|
||||
previewSplitRatios,
|
||||
), [displayedPanes.length, effectiveLayout, previewSplitRatios]);
|
||||
|
||||
const handleResizePointerDown = useCallback((
|
||||
handle: WorkbenchResizeHandle,
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (event.button !== 0 || compact) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
previewSplitRatiosRef.current = layoutGeometry.splitRatios;
|
||||
setPreviewSplitRatios(layoutGeometry.splitRatios);
|
||||
resizeGestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
handle,
|
||||
changed: false,
|
||||
};
|
||||
setResizingRatioIndex(handle.ratioIndex);
|
||||
}, [compact, layoutGeometry.splitRatios]);
|
||||
|
||||
const handleResizePointerMove = useCallback((event: globalThis.PointerEvent) => {
|
||||
const gesture = resizeGestureRef.current;
|
||||
const grid = gridRef.current;
|
||||
if (!gesture || !grid || gesture.pointerId !== event.pointerId) return;
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const axisExtent = gesture.handle.axis === "vertical" ? rect.width : rect.height;
|
||||
if (axisExtent <= 0) return;
|
||||
const normalizedPosition = gesture.handle.axis === "vertical"
|
||||
? (event.clientX - rect.left) / rect.width
|
||||
: (event.clientY - rect.top) / rect.height;
|
||||
const ratio = resizeHandleRatio(gesture.handle, normalizedPosition, axisExtent);
|
||||
const current = previewSplitRatiosRef.current;
|
||||
if (Math.abs((current[gesture.handle.ratioIndex] ?? 0) - ratio) < 0.0001) return;
|
||||
event.preventDefault();
|
||||
const next = [...current];
|
||||
next[gesture.handle.ratioIndex] = ratio;
|
||||
gesture.changed = true;
|
||||
previewSplitRatiosRef.current = next;
|
||||
setPreviewSplitRatios(next);
|
||||
}, []);
|
||||
|
||||
const finishResizeGesture = useCallback(() => {
|
||||
const gesture = resizeGestureRef.current;
|
||||
if (!gesture) return;
|
||||
resizeGestureRef.current = null;
|
||||
setResizingRatioIndex(null);
|
||||
if (gesture.changed) onSplitRatiosChange([...previewSplitRatiosRef.current]);
|
||||
}, [onSplitRatiosChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resizingRatioIndex === null) return;
|
||||
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||
const gesture = resizeGestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
if ((event.buttons & 1) === 0) {
|
||||
finishResizeGesture();
|
||||
return;
|
||||
}
|
||||
handleResizePointerMove(event);
|
||||
};
|
||||
const handlePointerEnd = (event: globalThis.PointerEvent) => {
|
||||
if (resizeGestureRef.current?.pointerId === event.pointerId) finishResizeGesture();
|
||||
};
|
||||
const root = document.documentElement;
|
||||
const previousCursor = root.style.cursor;
|
||||
const previousUserSelect = root.style.userSelect;
|
||||
root.style.cursor = resizeGestureRef.current?.handle.axis === "vertical"
|
||||
? "col-resize"
|
||||
: "row-resize";
|
||||
root.style.userSelect = "none";
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", handlePointerEnd);
|
||||
window.addEventListener("pointercancel", handlePointerEnd);
|
||||
window.addEventListener("blur", finishResizeGesture);
|
||||
return () => {
|
||||
root.style.cursor = previousCursor;
|
||||
root.style.userSelect = previousUserSelect;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerEnd);
|
||||
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||
window.removeEventListener("blur", finishResizeGesture);
|
||||
};
|
||||
}, [finishResizeGesture, handleResizePointerMove, resizingRatioIndex]);
|
||||
|
||||
const handleResizeKeyDown = useCallback((
|
||||
handle: WorkbenchResizeHandle,
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const decreasing = handle.axis === "vertical" ? event.key === "ArrowLeft" : event.key === "ArrowUp";
|
||||
const increasing = handle.axis === "vertical" ? event.key === "ArrowRight" : event.key === "ArrowDown";
|
||||
if (!decreasing && !increasing && event.key !== "Home" && event.key !== "End") return;
|
||||
const rect = gridRef.current?.getBoundingClientRect();
|
||||
const axisExtent = handle.axis === "vertical" ? rect?.width : rect?.height;
|
||||
const bounds = splitRatioBounds(handle, axisExtent && axisExtent > 0 ? axisExtent : 1000);
|
||||
const current = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
|
||||
const step = event.shiftKey ? 0.1 : 0.03;
|
||||
const nextRatio = event.key === "Home"
|
||||
? bounds.min
|
||||
: event.key === "End"
|
||||
? bounds.max
|
||||
: Math.min(bounds.max, Math.max(bounds.min, current + (increasing ? step : -step)));
|
||||
event.preventDefault();
|
||||
const next = [...layoutGeometry.splitRatios];
|
||||
next[handle.ratioIndex] = nextRatio;
|
||||
previewSplitRatiosRef.current = next;
|
||||
setPreviewSplitRatios(next);
|
||||
onSplitRatiosChange(next);
|
||||
}, [layoutGeometry.splitRatios, onSplitRatiosChange]);
|
||||
|
||||
const gridStyle = layoutGeometry.gridStyle;
|
||||
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
|
||||
?? LAYOUT_CONTROLS[0];
|
||||
const headerActions = chrome && !compact ? (
|
||||
<div
|
||||
data-workbench-pane-action
|
||||
className="host-no-drag flex items-center gap-0.5"
|
||||
>
|
||||
{showLayoutControl ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("workbench.layout", {
|
||||
defaultValue: "Pane layout",
|
||||
})}
|
||||
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<currentLayout.icon className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("workbench.layout", { defaultValue: "Pane layout" })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={layout}
|
||||
onValueChange={(value) => {
|
||||
const next = value as WorkbenchLayout;
|
||||
if (next === layout) return;
|
||||
captureLayout();
|
||||
onLayoutChange(next);
|
||||
}}
|
||||
>
|
||||
{LAYOUT_CONTROLS.map((control) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={control.layout}
|
||||
value={control.layout}
|
||||
>
|
||||
<control.icon aria-hidden />
|
||||
{t(`workbench.layouts.${control.layout}`, {
|
||||
defaultValue: control.label,
|
||||
})}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<HeaderIconButton
|
||||
disabled={addPaneDisabled}
|
||||
disabledLabel={addPaneDisabledLabel}
|
||||
icon={Plus}
|
||||
label={t("workbench.addPane", { defaultValue: "Add pane" })}
|
||||
onClick={() => {
|
||||
captureLayout();
|
||||
onAddPane();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
|
||||
>
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
{chrome ? (
|
||||
<header className="shrink-0 bg-background">
|
||||
<div
|
||||
ref={setHeaderPortalTarget}
|
||||
data-testid="workbench-header-host"
|
||||
/>
|
||||
</header>
|
||||
) : null}
|
||||
<div className="relative min-h-0 flex-1 bg-background">
|
||||
<div
|
||||
ref={gridRef}
|
||||
data-testid="pane-grid"
|
||||
data-layout={effectiveLayout}
|
||||
className={cn(
|
||||
"grid h-full min-h-0 min-w-0 overflow-hidden",
|
||||
chrome && displayedPanes.length > 1 && "gap-px bg-border/55",
|
||||
)}
|
||||
style={gridStyle}
|
||||
>
|
||||
{displayedPanes.map((pane, index) => {
|
||||
const active = pane.key === activePaneKey;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={pane.reactKey ?? pane.key}
|
||||
ref={(element) => {
|
||||
if (element) paneRefs.current.set(pane.key, element);
|
||||
else paneRefs.current.delete(pane.key);
|
||||
}}
|
||||
aria-label={pane.title}
|
||||
data-active={active ? "true" : "false"}
|
||||
data-dragging={draggingPaneKey === pane.key ? "true" : undefined}
|
||||
data-testid={`workbench-pane-${pane.key}`}
|
||||
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
|
||||
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
|
||||
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
style={layoutGeometry.paneStyles[index]}
|
||||
>
|
||||
{renderPane(pane, {
|
||||
active,
|
||||
headerPortalTarget: chrome ? headerPortalTarget : undefined,
|
||||
composerPortalTarget: chrome ? composerPortalTarget : undefined,
|
||||
headerActions,
|
||||
})}
|
||||
{chrome && panes.length > 1 && !compact ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={active ? 0 : -1}
|
||||
aria-hidden={active ? undefined : true}
|
||||
aria-label={active ? t("workbench.movePane", {
|
||||
defaultValue: "Move {{title}} pane",
|
||||
title: pane.title,
|
||||
}) : undefined}
|
||||
aria-keyshortcuts={active ? "ArrowLeft ArrowRight ArrowUp ArrowDown" : undefined}
|
||||
data-workbench-pane-action
|
||||
data-workbench-move-handle
|
||||
data-testid={`pane-move-handle-${pane.key}`}
|
||||
onPointerDown={(event) => handleMovePointerDown(pane.key, event)}
|
||||
onKeyDown={(event) => handleMoveKeyDown(pane.key, event)}
|
||||
className={cn(
|
||||
"group host-no-drag absolute bottom-0 left-1/2 z-30 flex h-6 w-12 -translate-x-1/2 touch-none items-center justify-center rounded-full",
|
||||
"cursor-grab focus-visible:outline-none active:cursor-grabbing",
|
||||
"transition-opacity duration-100 motion-reduce:transition-none",
|
||||
active ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-[3px] w-9 rounded-full bg-foreground/35 transition-[height,background-color] duration-100 motion-reduce:transition-none",
|
||||
"group-focus-visible:h-[5px] group-focus-visible:bg-foreground/70",
|
||||
draggingPaneKey === pane.key
|
||||
? "bg-foreground/65"
|
||||
: "group-hover:bg-foreground/50",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{active ? (
|
||||
<TooltipContent side="top">
|
||||
{t("workbench.movePaneHint", {
|
||||
defaultValue: "Drag to move · Arrow keys also work",
|
||||
})}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{chrome && panes.length > 1 && !compact ? layoutGeometry.resizeHandles.map(
|
||||
(handle, index) => {
|
||||
const ratio = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
|
||||
const vertical = handle.axis === "vertical";
|
||||
const bounds = splitRatioBounds(handle, 1000);
|
||||
return (
|
||||
<button
|
||||
key={`${handle.axis}-${handle.ratioIndex}`}
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-orientation={handle.axis}
|
||||
aria-label={t("workbench.resizePaneBoundary", {
|
||||
defaultValue: "Resize pane boundary {{index}}",
|
||||
index: index + 1,
|
||||
})}
|
||||
aria-valuemin={Math.round(bounds.min * 100)}
|
||||
aria-valuemax={Math.round(bounds.max * 100)}
|
||||
aria-valuenow={Math.round(ratio * 100)}
|
||||
aria-keyshortcuts={vertical
|
||||
? "ArrowLeft ArrowRight Home End"
|
||||
: "ArrowUp ArrowDown Home End"}
|
||||
data-workbench-pane-action
|
||||
data-workbench-resize-handle={handle.ratioIndex}
|
||||
onPointerDown={(event) => handleResizePointerDown(handle, event)}
|
||||
onKeyDown={(event) => handleResizeKeyDown(handle, event)}
|
||||
className={cn(
|
||||
"group host-no-drag absolute z-20 touch-none border-0 bg-transparent p-0 focus-visible:outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
vertical ? "w-3 cursor-col-resize" : "h-3 cursor-row-resize",
|
||||
)}
|
||||
style={resizeHandleStyle(handle)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute bg-transparent transition-colors duration-100 motion-reduce:transition-none",
|
||||
"group-hover:bg-foreground/20 group-focus-visible:bg-foreground/25",
|
||||
resizingRatioIndex === handle.ratioIndex && "bg-foreground/30",
|
||||
vertical
|
||||
? "inset-y-0 left-1/2 w-px -translate-x-1/2"
|
||||
: "inset-x-0 top-1/2 h-px -translate-y-1/2",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{chrome ? (
|
||||
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||
<div
|
||||
ref={setComposerPortalTarget}
|
||||
data-testid="workbench-composer-host"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
/>
|
||||
</footer>
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||
|
||||
interface PaneCell {
|
||||
xStart: number;
|
||||
xEnd: number;
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface WorkbenchResizeHandle {
|
||||
axis: "horizontal" | "vertical";
|
||||
ratioIndex: number;
|
||||
position: number;
|
||||
crossStart: number;
|
||||
crossEnd: number;
|
||||
localStart: number;
|
||||
localEnd: number;
|
||||
beforeUnitCount: number;
|
||||
afterUnitCount: number;
|
||||
}
|
||||
|
||||
export interface WorkbenchLayoutGeometry {
|
||||
gridStyle: CSSProperties;
|
||||
paneStyles: Array<CSSProperties | undefined>;
|
||||
resizeHandles: WorkbenchResizeHandle[];
|
||||
splitRatios: number[];
|
||||
}
|
||||
|
||||
const MIN_RATIO = 0.05;
|
||||
const MAX_RATIO = 0.95;
|
||||
const MIN_PANE_EXTENT_PX = 160;
|
||||
const MAIN_PANE_RATIO = 1.65 / 2.65;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function ratioAt(
|
||||
ratios: readonly number[],
|
||||
index: number,
|
||||
fallback: number,
|
||||
): number {
|
||||
const value = ratios[index];
|
||||
return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback;
|
||||
}
|
||||
|
||||
function tracksTemplate(tracks: readonly Track[]): string {
|
||||
return tracks
|
||||
.map((track) => {
|
||||
const weight = Number(((track.end - track.start) * 1000).toFixed(6));
|
||||
return `minmax(0, ${weight}fr)`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function sequentialTracks(
|
||||
count: number,
|
||||
ratios: readonly number[],
|
||||
ratioOffset: number,
|
||||
): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } {
|
||||
const tracks: Track[] = [];
|
||||
const handles: WorkbenchResizeHandle[] = [];
|
||||
const resolvedRatios: number[] = [];
|
||||
let start = 0;
|
||||
|
||||
for (let index = 0; index < count - 1; index += 1) {
|
||||
const remainingPaneCount = count - index;
|
||||
const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount);
|
||||
const end = start + (1 - start) * ratio;
|
||||
tracks.push({ start, end });
|
||||
handles.push({
|
||||
axis: "vertical",
|
||||
ratioIndex: ratioOffset + index,
|
||||
position: end,
|
||||
crossStart: 0,
|
||||
crossEnd: 1,
|
||||
localStart: start,
|
||||
localEnd: 1,
|
||||
beforeUnitCount: 1,
|
||||
afterUnitCount: remainingPaneCount - 1,
|
||||
});
|
||||
resolvedRatios.push(ratio);
|
||||
start = end;
|
||||
}
|
||||
tracks.push({ start, end: 1 });
|
||||
return { tracks, handles, resolvedRatios };
|
||||
}
|
||||
|
||||
function cellStyle(
|
||||
columnStart: number,
|
||||
columnEnd: number,
|
||||
rowStart: number,
|
||||
rowEnd: number,
|
||||
): CSSProperties {
|
||||
return {
|
||||
gridColumn: `${columnStart} / ${columnEnd}`,
|
||||
gridRow: `${rowStart} / ${rowEnd}`,
|
||||
};
|
||||
}
|
||||
|
||||
function singlePaneGeometry(): WorkbenchLayoutGeometry {
|
||||
return {
|
||||
gridStyle: {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
},
|
||||
paneStyles: [undefined],
|
||||
resizeHandles: [],
|
||||
splitRatios: [],
|
||||
};
|
||||
}
|
||||
|
||||
function columnsGeometry(
|
||||
paneCount: number,
|
||||
ratios: readonly number[],
|
||||
): WorkbenchLayoutGeometry {
|
||||
const split = sequentialTracks(paneCount, ratios, 0);
|
||||
return {
|
||||
gridStyle: {
|
||||
gridTemplateColumns: tracksTemplate(split.tracks),
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
},
|
||||
paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)),
|
||||
resizeHandles: split.handles,
|
||||
splitRatios: split.resolvedRatios,
|
||||
};
|
||||
}
|
||||
|
||||
function rowsGeometry(
|
||||
paneCount: number,
|
||||
ratios: readonly number[],
|
||||
): WorkbenchLayoutGeometry {
|
||||
const split = sequentialTracks(paneCount, ratios, 0);
|
||||
return {
|
||||
gridStyle: {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: tracksTemplate(split.tracks),
|
||||
},
|
||||
paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)),
|
||||
resizeHandles: split.handles.map((handle) => ({
|
||||
...handle,
|
||||
axis: "horizontal",
|
||||
})),
|
||||
splitRatios: split.resolvedRatios,
|
||||
};
|
||||
}
|
||||
|
||||
function gridGeometry(
|
||||
paneCount: number,
|
||||
ratios: readonly number[],
|
||||
): WorkbenchLayoutGeometry {
|
||||
const columnCount = Math.ceil(Math.sqrt(paneCount));
|
||||
const rowCount = Math.ceil(paneCount / columnCount);
|
||||
const columns = sequentialTracks(columnCount, ratios, 0);
|
||||
const rows = sequentialTracks(rowCount, ratios, columnCount - 1);
|
||||
const paneCells = Array.from({ length: paneCount }, (_, index) => ({
|
||||
column: index % columnCount,
|
||||
row: Math.floor(index / columnCount),
|
||||
}));
|
||||
const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({
|
||||
...handle,
|
||||
beforeUnitCount: boundaryIndex + 1,
|
||||
afterUnitCount: columnCount - boundaryIndex - 1,
|
||||
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
||||
const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({
|
||||
...handle,
|
||||
axis: "horizontal" as const,
|
||||
beforeUnitCount: boundaryIndex + 1,
|
||||
afterUnitCount: rowCount - boundaryIndex - 1,
|
||||
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
||||
|
||||
return {
|
||||
gridStyle: {
|
||||
gridTemplateColumns: tracksTemplate(columns.tracks),
|
||||
gridTemplateRows: tracksTemplate(rows.tracks),
|
||||
},
|
||||
paneStyles: paneCells.map((cell) => cellStyle(
|
||||
cell.column + 1,
|
||||
cell.column + 2,
|
||||
cell.row + 1,
|
||||
cell.row + 2,
|
||||
)),
|
||||
resizeHandles: [...verticalHandles, ...horizontalHandles],
|
||||
splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios],
|
||||
};
|
||||
}
|
||||
|
||||
function mainStackGeometry(
|
||||
paneCount: number,
|
||||
ratios: readonly number[],
|
||||
): WorkbenchLayoutGeometry {
|
||||
const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO);
|
||||
const stack = sequentialTracks(paneCount - 1, ratios, 1);
|
||||
const verticalHandle: WorkbenchResizeHandle = {
|
||||
axis: "vertical",
|
||||
ratioIndex: 0,
|
||||
position: columnRatio,
|
||||
crossStart: 0,
|
||||
crossEnd: 1,
|
||||
localStart: 0,
|
||||
localEnd: 1,
|
||||
beforeUnitCount: 1,
|
||||
afterUnitCount: 1,
|
||||
};
|
||||
const stackHandles = stack.handles.map((handle) => ({
|
||||
...handle,
|
||||
axis: "horizontal" as const,
|
||||
crossStart: columnRatio,
|
||||
}));
|
||||
|
||||
return {
|
||||
gridStyle: {
|
||||
gridTemplateColumns: tracksTemplate([
|
||||
{ start: 0, end: columnRatio },
|
||||
{ start: columnRatio, end: 1 },
|
||||
]),
|
||||
gridTemplateRows: tracksTemplate(stack.tracks),
|
||||
},
|
||||
paneStyles: [
|
||||
cellStyle(1, 2, 1, paneCount),
|
||||
...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)),
|
||||
],
|
||||
resizeHandles: [verticalHandle, ...stackHandles],
|
||||
splitRatios: [columnRatio, ...stack.resolvedRatios],
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueBoundaries(values: readonly number[]): number[] {
|
||||
return Array.from(new Set(values.map((value) => value.toFixed(8))))
|
||||
.map(Number)
|
||||
.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function boundaryIndex(boundaries: readonly number[], value: number): number {
|
||||
return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001);
|
||||
}
|
||||
|
||||
function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number {
|
||||
const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical"
|
||||
? [cell.xStart, cell.xEnd]
|
||||
: [cell.yStart, cell.yEnd]));
|
||||
return Math.max(1, boundaries.length - 1);
|
||||
}
|
||||
|
||||
function bspGeometry(
|
||||
paneCount: number,
|
||||
ratios: readonly number[],
|
||||
): WorkbenchLayoutGeometry {
|
||||
const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }];
|
||||
const handles: WorkbenchResizeHandle[] = [];
|
||||
const resolvedRatios: number[] = [];
|
||||
|
||||
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
|
||||
const leaf = cells.pop();
|
||||
if (!leaf) break;
|
||||
const ratioIndex = paneIndex - 1;
|
||||
const ratio = ratioAt(ratios, ratioIndex, 0.5);
|
||||
resolvedRatios.push(ratio);
|
||||
if (paneIndex % 2 === 1) {
|
||||
const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio;
|
||||
cells.push(
|
||||
{ ...leaf, xEnd: position },
|
||||
{ ...leaf, xStart: position },
|
||||
);
|
||||
handles.push({
|
||||
axis: "vertical",
|
||||
ratioIndex,
|
||||
position,
|
||||
crossStart: leaf.yStart,
|
||||
crossEnd: leaf.yEnd,
|
||||
localStart: leaf.xStart,
|
||||
localEnd: leaf.xEnd,
|
||||
beforeUnitCount: 0,
|
||||
afterUnitCount: 0,
|
||||
});
|
||||
} else {
|
||||
const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio;
|
||||
cells.push(
|
||||
{ ...leaf, yEnd: position },
|
||||
{ ...leaf, yStart: position },
|
||||
);
|
||||
handles.push({
|
||||
axis: "horizontal",
|
||||
ratioIndex,
|
||||
position,
|
||||
crossStart: leaf.xStart,
|
||||
crossEnd: leaf.xEnd,
|
||||
localStart: leaf.yStart,
|
||||
localEnd: leaf.yEnd,
|
||||
beforeUnitCount: 0,
|
||||
afterUnitCount: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const handle of handles) {
|
||||
if (handle.axis === "vertical") {
|
||||
const beforeCells = cells.filter((cell) => (
|
||||
cell.xStart >= handle.localStart
|
||||
&& cell.xEnd <= handle.position + Number.EPSILON
|
||||
&& cell.yStart >= handle.crossStart
|
||||
&& cell.yEnd <= handle.crossEnd
|
||||
));
|
||||
const afterCells = cells.filter((cell) => (
|
||||
cell.xStart >= handle.position - Number.EPSILON
|
||||
&& cell.xEnd <= handle.localEnd
|
||||
&& cell.yStart >= handle.crossStart
|
||||
&& cell.yEnd <= handle.crossEnd
|
||||
));
|
||||
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
||||
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
||||
} else {
|
||||
const beforeCells = cells.filter((cell) => (
|
||||
cell.yStart >= handle.localStart
|
||||
&& cell.yEnd <= handle.position + Number.EPSILON
|
||||
&& cell.xStart >= handle.crossStart
|
||||
&& cell.xEnd <= handle.crossEnd
|
||||
));
|
||||
const afterCells = cells.filter((cell) => (
|
||||
cell.yStart >= handle.position - Number.EPSILON
|
||||
&& cell.yEnd <= handle.localEnd
|
||||
&& cell.xStart >= handle.crossStart
|
||||
&& cell.xEnd <= handle.crossEnd
|
||||
));
|
||||
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
||||
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
||||
}
|
||||
}
|
||||
|
||||
const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd]));
|
||||
const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd]));
|
||||
const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({
|
||||
start,
|
||||
end: columnBoundaries[index + 1],
|
||||
}));
|
||||
const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({
|
||||
start,
|
||||
end: rowBoundaries[index + 1],
|
||||
}));
|
||||
|
||||
return {
|
||||
gridStyle: {
|
||||
gridTemplateColumns: tracksTemplate(columnTracks),
|
||||
gridTemplateRows: tracksTemplate(rowTracks),
|
||||
},
|
||||
paneStyles: cells.map((cell) => cellStyle(
|
||||
boundaryIndex(columnBoundaries, cell.xStart) + 1,
|
||||
boundaryIndex(columnBoundaries, cell.xEnd) + 1,
|
||||
boundaryIndex(rowBoundaries, cell.yStart) + 1,
|
||||
boundaryIndex(rowBoundaries, cell.yEnd) + 1,
|
||||
)),
|
||||
resizeHandles: handles,
|
||||
splitRatios: resolvedRatios,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkbenchLayoutGeometry(
|
||||
layout: WorkbenchLayout,
|
||||
paneCount: number,
|
||||
splitRatios: readonly number[],
|
||||
): WorkbenchLayoutGeometry {
|
||||
const count = Math.max(1, paneCount);
|
||||
if (count === 1) return singlePaneGeometry();
|
||||
switch (layout) {
|
||||
case "columns":
|
||||
return columnsGeometry(count, splitRatios);
|
||||
case "rows":
|
||||
return rowsGeometry(count, splitRatios);
|
||||
case "grid":
|
||||
return gridGeometry(count, splitRatios);
|
||||
case "main-stack":
|
||||
return mainStackGeometry(count, splitRatios);
|
||||
case "bsp":
|
||||
return bspGeometry(count, splitRatios);
|
||||
}
|
||||
}
|
||||
|
||||
export function splitRatioBounds(
|
||||
handle: WorkbenchResizeHandle,
|
||||
axisExtentPx: number,
|
||||
): { min: number; max: number } {
|
||||
const localExtentPx = Math.max(
|
||||
1,
|
||||
axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart),
|
||||
);
|
||||
const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount);
|
||||
const paneExtentPx = Math.min(
|
||||
MIN_PANE_EXTENT_PX,
|
||||
localExtentPx * 0.8 / paneCount,
|
||||
);
|
||||
const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx);
|
||||
const max = Math.min(
|
||||
MAX_RATIO,
|
||||
1 - paneExtentPx * handle.afterUnitCount / localExtentPx,
|
||||
);
|
||||
return min <= max ? { min, max } : { min: 0.4, max: 0.6 };
|
||||
}
|
||||
|
||||
export function resizeHandleRatio(
|
||||
handle: WorkbenchResizeHandle,
|
||||
normalizedPosition: number,
|
||||
axisExtentPx: number,
|
||||
): number {
|
||||
const localRatio = (normalizedPosition - handle.localStart)
|
||||
/ Math.max(0.01, handle.localEnd - handle.localStart);
|
||||
const bounds = splitRatioBounds(handle, axisExtentPx);
|
||||
return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4));
|
||||
}
|
||||
|
||||
export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties {
|
||||
if (handle.axis === "vertical") {
|
||||
return {
|
||||
left: `${handle.position * 100}%`,
|
||||
top: `${handle.crossStart * 100}%`,
|
||||
height: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: `${handle.position * 100}%`,
|
||||
left: `${handle.crossStart * 100}%`,
|
||||
width: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
||||
transform: "translateY(-50%)",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import type {
|
||||
WorkbenchLayout,
|
||||
WorkbenchState,
|
||||
WorkbenchTabState,
|
||||
} from "@/lib/types";
|
||||
|
||||
export type {
|
||||
WorkbenchLayout,
|
||||
WorkbenchState,
|
||||
WorkbenchTabState,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const MAX_WORKBENCH_PANES = 4;
|
||||
|
||||
export const WORKBENCH_LAYOUTS = [
|
||||
"columns",
|
||||
"rows",
|
||||
"grid",
|
||||
"bsp",
|
||||
"main-stack",
|
||||
] as const;
|
||||
|
||||
export interface WorkbenchTabMatch {
|
||||
tabKey: string;
|
||||
tab: WorkbenchTabState;
|
||||
}
|
||||
|
||||
export interface OrderedWorkbenchTab extends WorkbenchTabMatch {
|
||||
paneKeys: string[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
|
||||
version: 1,
|
||||
tabs: {},
|
||||
};
|
||||
|
||||
function isLayout(value: unknown): value is WorkbenchLayout {
|
||||
return typeof value === "string"
|
||||
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function uniqueKeys(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(new Set(
|
||||
value.filter((key): key is string => typeof key === "string" && key.length > 0),
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeTitle(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const title = value.trim();
|
||||
return title || null;
|
||||
}
|
||||
|
||||
function normalizeSplitRatios(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((ratio): ratio is number => typeof ratio === "number" && Number.isFinite(ratio))
|
||||
.slice(0, MAX_WORKBENCH_PANES - 1)
|
||||
.map((ratio) => Number(Math.min(0.95, Math.max(0.05, ratio)).toFixed(4)));
|
||||
}
|
||||
|
||||
function normalizeTab(value: unknown): WorkbenchTabState {
|
||||
const candidate = value && typeof value === "object"
|
||||
? value as Partial<WorkbenchTabState>
|
||||
: {};
|
||||
const paneKeys = uniqueKeys(candidate.paneKeys).slice(0, MAX_WORKBENCH_PANES);
|
||||
const requestedLayoutPaneKeys = uniqueKeys(candidate.layoutPaneKeys)
|
||||
.filter((key) => paneKeys.includes(key));
|
||||
const layoutPaneKeys = [
|
||||
...requestedLayoutPaneKeys,
|
||||
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
|
||||
];
|
||||
return {
|
||||
explicit: candidate.explicit === true,
|
||||
title: normalizeTitle(candidate.title),
|
||||
paneKeys,
|
||||
layoutPaneKeys,
|
||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||
splitRatios: normalizeSplitRatios(candidate.splitRatios),
|
||||
};
|
||||
}
|
||||
|
||||
function standaloneTabKeyBase(paneKey: string): string {
|
||||
return `tab:${paneKey}`;
|
||||
}
|
||||
|
||||
function availableStandaloneTabKey(
|
||||
tabs: Readonly<Record<string, WorkbenchTabState>>,
|
||||
paneKey: string,
|
||||
): string {
|
||||
const base = standaloneTabKeyBase(paneKey);
|
||||
if (!tabs[base]) return base;
|
||||
let suffix = 2;
|
||||
while (tabs[`${base}:${suffix}`]) suffix += 1;
|
||||
return `${base}:${suffix}`;
|
||||
}
|
||||
|
||||
function defaultWorkbenchTab(
|
||||
paneKey: string,
|
||||
title: string | null = null,
|
||||
): WorkbenchTabState {
|
||||
return {
|
||||
explicit: false,
|
||||
title: normalizeTitle(title),
|
||||
paneKeys: [paneKey],
|
||||
layoutPaneKeys: [paneKey],
|
||||
layout: "columns",
|
||||
splitRatios: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkbenchState(raw: unknown): WorkbenchState {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return EMPTY_WORKBENCH_STATE;
|
||||
}
|
||||
const parsed = raw as { version?: unknown; tabs?: unknown };
|
||||
if (parsed.version !== 1 || !parsed.tabs
|
||||
|| typeof parsed.tabs !== "object" || Array.isArray(parsed.tabs)) {
|
||||
return EMPTY_WORKBENCH_STATE;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
tabs: Object.fromEntries(
|
||||
Object.entries(parsed.tabs)
|
||||
.map(([tabKey, tab]) => [tabKey, normalizeTab(tab)] as const)
|
||||
.filter(([, tab]) => tab.paneKeys.length > 1 || tab.explicit),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function workbenchTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
): WorkbenchTabState | null {
|
||||
return state.tabs[tabKey] ?? null;
|
||||
}
|
||||
|
||||
export function workbenchTabForPane(
|
||||
state: WorkbenchState,
|
||||
paneKey: string,
|
||||
): WorkbenchTabMatch {
|
||||
const match = Object.entries(state.tabs).find(([, tab]) => tab.paneKeys.includes(paneKey));
|
||||
if (match) return { tabKey: match[0], tab: match[1] };
|
||||
return {
|
||||
tabKey: availableStandaloneTabKey(state.tabs, paneKey),
|
||||
tab: defaultWorkbenchTab(paneKey),
|
||||
};
|
||||
}
|
||||
|
||||
function updateTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
update: (tab: WorkbenchTabState) => WorkbenchTabState,
|
||||
): WorkbenchState {
|
||||
const current = state.tabs[tabKey];
|
||||
if (!current) return state;
|
||||
const next = update(current);
|
||||
if (next === current) return state;
|
||||
return {
|
||||
version: 1,
|
||||
tabs: {
|
||||
...state.tabs,
|
||||
[tabKey]: next,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function addWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
anchorPaneKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
if (!anchorPaneKey || !paneKey || anchorPaneKey === paneKey) return state;
|
||||
const target = workbenchTabForPane(state, anchorPaneKey);
|
||||
if (state.tabs[target.tabKey]) return attachWorkbenchPane(state, target.tabKey, paneKey);
|
||||
const withTarget = {
|
||||
version: 1 as const,
|
||||
tabs: {
|
||||
...state.tabs,
|
||||
[target.tabKey]: target.tab,
|
||||
},
|
||||
};
|
||||
return attachWorkbenchPane(withTarget, target.tabKey, paneKey);
|
||||
}
|
||||
|
||||
export function createWorkbenchTab(
|
||||
state: WorkbenchState,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
if (!paneKey) return state;
|
||||
const match = workbenchTabForPane(state, paneKey);
|
||||
const persisted = state.tabs[match.tabKey];
|
||||
if (persisted) {
|
||||
return updateTab(state, match.tabKey, (tab) => (
|
||||
tab.explicit ? tab : { ...tab, explicit: true }
|
||||
));
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
tabs: {
|
||||
...state.tabs,
|
||||
[match.tabKey]: { ...match.tab, explicit: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function detachWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
const tab = state.tabs[tabKey];
|
||||
if (!tab || !tab.paneKeys.includes(paneKey)) return state;
|
||||
if (tab.paneKeys.length === 1) {
|
||||
const tabs = { ...state.tabs };
|
||||
delete tabs[tabKey];
|
||||
return { version: 1, tabs };
|
||||
}
|
||||
|
||||
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
|
||||
const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey);
|
||||
const tabs = { ...state.tabs };
|
||||
const nextTab = {
|
||||
...tab,
|
||||
paneKeys,
|
||||
layoutPaneKeys,
|
||||
splitRatios: [],
|
||||
};
|
||||
if (nextTab.explicit || paneKeys.length > 1) tabs[tabKey] = nextTab;
|
||||
else delete tabs[tabKey];
|
||||
return {
|
||||
version: 1,
|
||||
tabs,
|
||||
};
|
||||
}
|
||||
|
||||
export function dissolveWorkbenchTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
): WorkbenchState {
|
||||
const tab = state.tabs[tabKey];
|
||||
if (!tab) return state;
|
||||
const tabs = { ...state.tabs };
|
||||
delete tabs[tabKey];
|
||||
return { version: 1, tabs };
|
||||
}
|
||||
|
||||
export function attachWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
targetTabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
if (!targetTabKey || !paneKey) return state;
|
||||
const target = state.tabs[targetTabKey];
|
||||
if (!target) return state;
|
||||
|
||||
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
|
||||
tab.paneKeys.includes(paneKey)
|
||||
));
|
||||
const sourceTabKey = sourceEntry?.[0];
|
||||
const sourceTab = sourceEntry?.[1];
|
||||
if (sourceTabKey === targetTabKey) {
|
||||
return state;
|
||||
}
|
||||
if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tabs = { ...state.tabs };
|
||||
if (sourceTabKey && sourceTab) {
|
||||
const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
|
||||
const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.filter((key) => key !== paneKey);
|
||||
if (sourcePaneKeys.length === 0 || (!sourceTab.explicit && sourcePaneKeys.length === 1)) {
|
||||
delete tabs[sourceTabKey];
|
||||
} else {
|
||||
tabs[sourceTabKey] = {
|
||||
...sourceTab,
|
||||
paneKeys: sourcePaneKeys,
|
||||
layoutPaneKeys: sourceLayoutPaneKeys,
|
||||
splitRatios: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nextTarget = tabs[targetTabKey];
|
||||
if (!nextTarget) return state;
|
||||
const paneKeys = nextTarget.paneKeys.includes(paneKey)
|
||||
? nextTarget.paneKeys
|
||||
: [...nextTarget.paneKeys, paneKey];
|
||||
const layoutPaneKeys = nextTarget.layoutPaneKeys.includes(paneKey)
|
||||
? nextTarget.layoutPaneKeys
|
||||
: [...nextTarget.layoutPaneKeys, paneKey];
|
||||
tabs[targetTabKey] = {
|
||||
...nextTarget,
|
||||
paneKeys,
|
||||
layoutPaneKeys,
|
||||
splitRatios: [],
|
||||
};
|
||||
return { version: 1, tabs };
|
||||
}
|
||||
|
||||
export function renameWorkbenchTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
title: string,
|
||||
): WorkbenchState {
|
||||
const normalized = normalizeTitle(title);
|
||||
if (!normalized) return state;
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.title === normalized ? tab : { ...tab, title: normalized }
|
||||
));
|
||||
}
|
||||
|
||||
export function setWorkbenchLayout(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
layout: WorkbenchLayout,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.layout === layout ? tab : { ...tab, layout, splitRatios: [] }
|
||||
));
|
||||
}
|
||||
|
||||
export function setWorkbenchSplitRatios(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
splitRatios: readonly number[],
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
const normalized = normalizeSplitRatios(splitRatios);
|
||||
return normalized.length === tab.splitRatios.length
|
||||
&& normalized.every((ratio, index) => ratio === tab.splitRatios[index])
|
||||
? tab
|
||||
: { ...tab, splitRatios: normalized };
|
||||
});
|
||||
}
|
||||
|
||||
export function setWorkbenchPaneLayoutOrder(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKeys: readonly string[],
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
const requested = uniqueKeys(paneKeys).filter((key) => tab.paneKeys.includes(key));
|
||||
const layoutPaneKeys = [
|
||||
...requested,
|
||||
...tab.paneKeys.filter((key) => !requested.includes(key)),
|
||||
];
|
||||
return layoutPaneKeys.every((key, index) => tab.layoutPaneKeys[index] === key)
|
||||
? tab
|
||||
: { ...tab, layoutPaneKeys };
|
||||
});
|
||||
}
|
||||
|
||||
export function reconcileWorkbench(
|
||||
state: WorkbenchState,
|
||||
validKeys: ReadonlySet<string>,
|
||||
): WorkbenchState {
|
||||
const tabs: Record<string, WorkbenchTabState> = {};
|
||||
const claimedPaneKeys = new Set<string>();
|
||||
|
||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
||||
const paneKeys = tab.paneKeys
|
||||
.filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key))
|
||||
.slice(0, MAX_WORKBENCH_PANES);
|
||||
if (paneKeys.length === 0) continue;
|
||||
const nextTab = {
|
||||
...tab,
|
||||
paneKeys,
|
||||
layoutPaneKeys: [
|
||||
...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)),
|
||||
...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)),
|
||||
],
|
||||
splitRatios: paneKeys.length === tab.paneKeys.length
|
||||
&& paneKeys.every((key, index) => key === tab.paneKeys[index])
|
||||
? tab.splitRatios
|
||||
: [],
|
||||
};
|
||||
if (!nextTab.explicit && paneKeys.length === 1) continue;
|
||||
for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey);
|
||||
tabs[tabKey] = nextTab;
|
||||
}
|
||||
|
||||
return JSON.stringify(state.tabs) === JSON.stringify(tabs)
|
||||
? state
|
||||
: { version: 1, tabs };
|
||||
}
|
||||
|
||||
export function orderWorkbenchTabs(
|
||||
state: WorkbenchState,
|
||||
orderedSessionKeys: readonly string[],
|
||||
updatedAtByKey: ReadonlyMap<string, string | null | undefined>,
|
||||
): OrderedWorkbenchTab[] {
|
||||
const rank = new Map(orderedSessionKeys.map((key, index) => [key, index]));
|
||||
const validKeys = new Set(orderedSessionKeys);
|
||||
const reconciled = reconcileWorkbench(state, validKeys);
|
||||
const projectedTabs = { ...reconciled.tabs };
|
||||
const claimedPaneKeys = new Set(
|
||||
Object.values(projectedTabs).flatMap((tab) => tab.paneKeys),
|
||||
);
|
||||
for (const paneKey of orderedSessionKeys) {
|
||||
if (claimedPaneKeys.has(paneKey)) continue;
|
||||
const tabKey = availableStandaloneTabKey(projectedTabs, paneKey);
|
||||
projectedTabs[tabKey] = defaultWorkbenchTab(paneKey);
|
||||
}
|
||||
const tabs = Object.entries(projectedTabs).map(([tabKey, tab]) => {
|
||||
const paneKeys = tab.paneKeys
|
||||
.filter((key) => validKeys.has(key))
|
||||
.sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity));
|
||||
const updatedAt = paneKeys.reduce<string | null>((latest, paneKey) => {
|
||||
const candidate = updatedAtByKey.get(paneKey) ?? null;
|
||||
return dateToTime(candidate) > dateToTime(latest) ? candidate : latest;
|
||||
}, null);
|
||||
return { tabKey, tab, paneKeys, updatedAt };
|
||||
});
|
||||
|
||||
return tabs.sort((left, right) => {
|
||||
const updateOrder = dateToTime(right.updatedAt) - dateToTime(left.updatedAt);
|
||||
if (updateOrder !== 0) return updateOrder;
|
||||
const leftRank = Math.min(...left.paneKeys.map((key) => rank.get(key) ?? Infinity));
|
||||
const rightRank = Math.min(...right.paneKeys.map((key) => rank.get(key) ?? Infinity));
|
||||
return leftRank - rightRank;
|
||||
});
|
||||
}
|
||||
|
||||
function dateToTime(value: string | null | undefined): number {
|
||||
const timestamp = Date.parse(value ?? "");
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user