refactor(web): improve compact desktop layouts (#937)
* refactor(web): improve compact desktop layouts * fix(web): address compact desktop review feedback * fix(web): address responsive review followups * fix(web): preserve compact sidebar restore sizing * fix(web): harden compact preset sidebar test * refactor(web): extract dockview layout hooks
This commit is contained in:
@@ -20,6 +20,7 @@ import { MobileDropTargets } from "./kanban/mobile-drop-targets";
|
||||
import { MobileFab } from "./kanban/mobile-fab";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { getKanbanColumnGridTemplate } from "./kanban/kanban-grid-template";
|
||||
|
||||
export type KanbanBoardGridProps = {
|
||||
steps: WorkflowStep[];
|
||||
@@ -209,16 +210,24 @@ function DesktopLayout({
|
||||
archivingTaskId,
|
||||
showLoading,
|
||||
activeTask,
|
||||
}: ColumnGridProps & { showLoading: boolean; activeTask: Task | null }) {
|
||||
isCompactDesktop,
|
||||
}: ColumnGridProps & {
|
||||
showLoading: boolean;
|
||||
activeTask: Task | null;
|
||||
isCompactDesktop: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 px-4 pb-4">
|
||||
<div className="flex-1 min-h-0 overflow-x-auto px-4 pb-4">
|
||||
{showLoading || steps.length === 0 ? (
|
||||
<EmptyState showLoading={showLoading} />
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2 rounded-lg h-full"
|
||||
style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}
|
||||
data-testid="desktop-kanban-layout"
|
||||
className="grid h-full min-w-full gap-2 rounded-lg"
|
||||
style={{
|
||||
gridTemplateColumns: getKanbanColumnGridTemplate(steps.length, isCompactDesktop),
|
||||
}}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<KanbanColumn
|
||||
@@ -258,6 +267,22 @@ function useShowLoading(isLoading: boolean | undefined, stepsLength: number) {
|
||||
);
|
||||
}
|
||||
|
||||
function useKanbanBoardSensors() {
|
||||
return useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanBoardGrid({
|
||||
steps,
|
||||
tasks,
|
||||
@@ -277,24 +302,10 @@ export function KanbanBoardGrid({
|
||||
onCreateTask,
|
||||
isLoading,
|
||||
}: KanbanBoardGridProps) {
|
||||
const { isMobile, isTablet } = useResponsiveBreakpoint();
|
||||
const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint();
|
||||
const activeColumnIndex = useAppStore((state) => state.mobileKanban.activeColumnIndex);
|
||||
const setActiveColumnIndex = useAppStore((state) => state.setMobileKanbanColumnIndex);
|
||||
|
||||
// Use TouchSensor with delay for mobile (long-press to drag)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sensors = useKanbanBoardSensors();
|
||||
|
||||
// Calculate task counts per step for tabs
|
||||
const taskCounts = useMemo(() => {
|
||||
@@ -357,7 +368,12 @@ export function KanbanBoardGrid({
|
||||
);
|
||||
} else {
|
||||
layoutContent = (
|
||||
<DesktopLayout {...columnProps} showLoading={!!showLoading} activeTask={activeTask} />
|
||||
<DesktopLayout
|
||||
{...columnProps}
|
||||
showLoading={!!showLoading}
|
||||
activeTask={activeTask}
|
||||
isCompactDesktop={isCompactDesktop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getKanbanColumnGridTemplate } from "./kanban-grid-template";
|
||||
|
||||
describe("getKanbanColumnGridTemplate", () => {
|
||||
it("keeps full desktop columns fluid", () => {
|
||||
expect(getKanbanColumnGridTemplate(4, false)).toBe("repeat(4, minmax(0, 1fr))");
|
||||
});
|
||||
|
||||
it("gives compact desktop columns a usable minimum width", () => {
|
||||
expect(getKanbanColumnGridTemplate(4, true)).toBe("repeat(4, minmax(260px, 1fr))");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export const COMPACT_KANBAN_COLUMN_MIN_PX = 260;
|
||||
|
||||
export function getKanbanColumnGridTemplate(stepCount: number, isCompactDesktop: boolean): string {
|
||||
const minWidth = isCompactDesktop ? `${COMPACT_KANBAN_COLUMN_MIN_PX}px` : "0";
|
||||
return `repeat(${stepCount}, minmax(${minWidth}, 1fr))`;
|
||||
}
|
||||
@@ -332,6 +332,36 @@ function TabletHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTaskTopbarButton({
|
||||
onCreateTask,
|
||||
compact,
|
||||
}: {
|
||||
onCreateTask: () => void;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
onClick={onCreateTask}
|
||||
size={compact ? "icon-lg" : "lg"}
|
||||
className="cursor-pointer"
|
||||
data-testid="create-task-button"
|
||||
aria-label={compact ? "Add task" : undefined}
|
||||
>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
{!compact && "Add task"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!compact) return button;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>Add task</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopHeader({
|
||||
onCreateTask,
|
||||
workspaceId,
|
||||
@@ -371,7 +401,7 @@ function DesktopHeader({
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search tasks..."
|
||||
isLoading={isSearchLoading}
|
||||
className="w-72 xl:w-80 [&_input]:h-8"
|
||||
className={`${isNarrow ? "w-44" : "w-72 xl:w-80"} [&_input]:h-8`}
|
||||
/>
|
||||
) : null;
|
||||
const isHome = title === "Home";
|
||||
@@ -379,6 +409,7 @@ function DesktopHeader({
|
||||
isHome && searchInput && !isNarrow ? (
|
||||
<div data-testid="kanban-header-search">{searchInput}</div>
|
||||
) : null;
|
||||
const actionsSearch = !isHome || isNarrow ? searchInput : null;
|
||||
const leftActions = isHome ? (
|
||||
<HomeLeftActions workspaceId={workspaceId} />
|
||||
) : (
|
||||
@@ -396,17 +427,9 @@ function DesktopHeader({
|
||||
leftActions={leftActions}
|
||||
actions={
|
||||
<>
|
||||
{!isHome && searchInput}
|
||||
<Button
|
||||
onClick={onCreateTask}
|
||||
size="lg"
|
||||
className="cursor-pointer"
|
||||
data-testid="create-task-button"
|
||||
>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Add task
|
||||
</Button>
|
||||
<QuickChatButton workspaceId={workspaceId} size="lg" />
|
||||
{actionsSearch}
|
||||
<CreateTaskTopbarButton onCreateTask={onCreateTask} compact={isNarrow} />
|
||||
<QuickChatButton workspaceId={workspaceId} size="lg" compact={isNarrow} />
|
||||
<TooltipProvider>
|
||||
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { MobileColumnTabs } from "./mobile-column-tabs";
|
||||
import { SwipeableColumns } from "./swipeable-columns";
|
||||
import { MobileDropTargets } from "./mobile-drop-targets";
|
||||
import { getKanbanColumnGridTemplate } from "./kanban-grid-template";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
export type SwimlaneKanbanContentProps = {
|
||||
@@ -328,6 +329,7 @@ function DesktopKanbanLayout({
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
isMultiSelectMode,
|
||||
isCompactDesktop,
|
||||
}: {
|
||||
steps: WorkflowStep[];
|
||||
tasks: Task[];
|
||||
@@ -343,13 +345,17 @@ function DesktopKanbanLayout({
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
isCompactDesktop: boolean;
|
||||
}) {
|
||||
const getTasksForStep = useTasksByStep(tasks);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-0"
|
||||
style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}
|
||||
data-testid="desktop-kanban-layout"
|
||||
className="grid min-w-full gap-0"
|
||||
style={{
|
||||
gridTemplateColumns: getKanbanColumnGridTemplate(steps.length, isCompactDesktop),
|
||||
}}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<KanbanColumn
|
||||
@@ -392,7 +398,7 @@ export function SwimlaneKanbanContent({
|
||||
onToggleSelect,
|
||||
isMultiSelectMode,
|
||||
}: SwimlaneKanbanContentProps) {
|
||||
const { isMobile, isTablet } = useResponsiveBreakpoint();
|
||||
const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint();
|
||||
const { activeIndex, setActiveIndex } = useMobileColumnIndex(steps, tasks);
|
||||
const { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask } =
|
||||
useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError });
|
||||
@@ -429,7 +435,11 @@ export function SwimlaneKanbanContent({
|
||||
} else if (isTablet) {
|
||||
layoutContent = <TabletKanbanLayout {...sharedProps} />;
|
||||
} else {
|
||||
layoutContent = <DesktopKanbanLayout {...sharedProps} />;
|
||||
layoutContent = (
|
||||
<div className="h-full overflow-x-auto">
|
||||
<DesktopKanbanLayout {...sharedProps} isCompactDesktop={isCompactDesktop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
|
||||
export function useCompactDockviewDefault(compact: boolean) {
|
||||
const setDefaultPreset = useDockviewStore((s) => s.setDefaultPreset);
|
||||
useEffect(() => {
|
||||
setDefaultPreset(compact ? "compact" : "default");
|
||||
return () => setDefaultPreset("default");
|
||||
}, [compact, setDefaultPreset]);
|
||||
}
|
||||
|
||||
export function useDockviewUnmountCleanup(
|
||||
saveTimerRef: RefObject<ReturnType<typeof setTimeout> | null>,
|
||||
readyDisposersRef: RefObject<Array<() => void>>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const timerRef = saveTimerRef;
|
||||
const disposersRef = readyDisposersRef;
|
||||
return () => {
|
||||
for (const dispose of disposersRef.current.splice(0)) dispose();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
panelPortalManager.releaseAll();
|
||||
};
|
||||
}, [readyDisposersRef, saveTimerRef]);
|
||||
}
|
||||
@@ -53,6 +53,10 @@ import {
|
||||
useAutoSessionTab,
|
||||
useAutoPRPanel,
|
||||
} from "./dockview-session-tabs";
|
||||
import {
|
||||
useCompactDockviewDefault,
|
||||
useDockviewUnmountCleanup,
|
||||
} from "./dockview-desktop-layout-hooks";
|
||||
import { TerminalPanel } from "./terminal-panel";
|
||||
import { BrowserPanel } from "./browser-panel";
|
||||
import { VscodePanel } from "./vscode-panel";
|
||||
@@ -67,7 +71,7 @@ import type { Repository, RepositoryScript } from "@/lib/types/http";
|
||||
import type { Terminal } from "@/hooks/domains/session/use-terminals";
|
||||
|
||||
// Portal system
|
||||
import { panelPortalManager, setPanelTitle } from "@/lib/layout/panel-portal-manager";
|
||||
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
|
||||
import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -468,12 +472,14 @@ type DockviewDesktopLayoutProps = {
|
||||
initialScripts?: RepositoryScript[];
|
||||
initialTerminals?: Terminal[];
|
||||
initialLayout?: string | null;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
sessionId,
|
||||
repository,
|
||||
initialLayout,
|
||||
compact = false,
|
||||
}: DockviewDesktopLayoutProps) {
|
||||
const setApi = useDockviewStore((s) => s.setApi);
|
||||
const buildDefaultLayout = useDockviewStore((s) => s.buildDefaultLayout);
|
||||
@@ -495,6 +501,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
useLspFileOpener();
|
||||
useEditorKeybinds();
|
||||
usePlanPanelAutoOpen();
|
||||
useCompactDockviewDefault(compact);
|
||||
|
||||
useEffect(() => {
|
||||
envIdRef.current = effectiveEnvId;
|
||||
@@ -509,7 +516,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
// If a layout intent was passed via URL, skip saved layout restoration
|
||||
const restored = !initialLayout && tryRestoreLayout(api, currentEnvId, VALID_COMPONENTS);
|
||||
if (!restored) {
|
||||
buildDefaultLayout(api, initialLayout ?? undefined);
|
||||
buildDefaultLayout(api, initialLayout ?? (compact ? "compact" : undefined));
|
||||
}
|
||||
|
||||
useDockviewStore.setState({ currentLayoutEnvId: currentEnvId });
|
||||
@@ -521,7 +528,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
setupPortalCleanup(api, appStore);
|
||||
readyDisposersRef.current.push(setupContainerResizeSync(api));
|
||||
},
|
||||
[setApi, buildDefaultLayout, initialLayout, appStore],
|
||||
[setApi, buildDefaultLayout, initialLayout, compact, appStore],
|
||||
);
|
||||
|
||||
// Release session-scoped portals + trigger layout switch on session change.
|
||||
@@ -535,17 +542,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
|
||||
// Auto-show PR detail panel when the task has an associated PR
|
||||
useAutoPRPanel();
|
||||
|
||||
// Clean up on unmount (e.g. navigating away from session page)
|
||||
useEffect(() => {
|
||||
const timerRef = saveTimerRef;
|
||||
const disposersRef = readyDisposersRef;
|
||||
return () => {
|
||||
for (const dispose of disposersRef.current.splice(0)) dispose();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
panelPortalManager.releaseAll();
|
||||
};
|
||||
}, []);
|
||||
useDockviewUnmountCleanup(saveTimerRef, readyDisposersRef);
|
||||
|
||||
// Visual masking: hide the dockview container during slow-path layout
|
||||
// switches (full fromJSON rebuild) to prevent the old layout from flashing.
|
||||
@@ -553,6 +550,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="dockview-task-layout"
|
||||
className="flex-1 min-h-0 grid grid-rows-[1fr_auto]"
|
||||
aria-busy={isRestoringLayout}
|
||||
style={{
|
||||
|
||||
@@ -149,7 +149,7 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 px-2 pb-2">
|
||||
<div className="flex-1 min-h-0 px-2 pb-2" data-testid="tablet-task-layout">
|
||||
<PreviewController sessionId={sessionForPreview} hasDevScript={hasDevScript} />
|
||||
<Group
|
||||
orientation="horizontal"
|
||||
|
||||
@@ -11,10 +11,15 @@ import type { ComponentProps } from "react";
|
||||
type QuickChatButtonProps = {
|
||||
workspaceId?: string | null;
|
||||
size?: ComponentProps<typeof Button>["size"];
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
/** Quick Chat button that opens the quick chat modal */
|
||||
export function QuickChatButton({ workspaceId, size = "default" }: QuickChatButtonProps) {
|
||||
export function QuickChatButton({
|
||||
workspaceId,
|
||||
size = "default",
|
||||
compact = false,
|
||||
}: QuickChatButtonProps) {
|
||||
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
|
||||
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
|
||||
const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts);
|
||||
@@ -25,12 +30,13 @@ export function QuickChatButton({ workspaceId, size = "default" }: QuickChatButt
|
||||
<KeyboardShortcutTooltip shortcut={quickChatShortcut} description="Quick Chat">
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
size={compact ? "icon-lg" : size}
|
||||
className="cursor-pointer gap-2"
|
||||
aria-label={compact ? "Quick Chat" : undefined}
|
||||
onClick={handleOpenQuickChat}
|
||||
>
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
Chat
|
||||
{!compact && "Chat"}
|
||||
</Button>
|
||||
</KeyboardShortcutTooltip>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
remoteStatusError,
|
||||
initialLayout,
|
||||
}: TaskLayoutProps) {
|
||||
const { isMobile, isTablet } = useResponsiveBreakpoint();
|
||||
const { isMobile, usesDesktopWorkbench, isFullDesktop } = useResponsiveBreakpoint();
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
@@ -81,8 +81,8 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
);
|
||||
}
|
||||
|
||||
// Tablet layout
|
||||
if (isTablet) {
|
||||
// Tablet fallback for coarse-pointer half-screen devices.
|
||||
if (!usesDesktopWorkbench) {
|
||||
return (
|
||||
<SessionTabletLayout
|
||||
workspaceId={workspaceId}
|
||||
@@ -104,6 +104,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
initialScripts={initialScripts}
|
||||
initialTerminals={initialTerminals}
|
||||
initialLayout={initialLayout}
|
||||
compact={!isFullDesktop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const COMPACT_DESKTOP_VIEWPORT = { width: 900, height: 800 };
|
||||
|
||||
test.describe("compact desktop responsive layout", () => {
|
||||
test("task page keeps the Dockview workbench at half-screen width", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize(COMPACT_DESKTOP_VIEWPORT);
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Compact Desktop Task",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible();
|
||||
await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0);
|
||||
await expect(session.sidebar).toBeVisible();
|
||||
await expect(session.activeChat()).toBeVisible();
|
||||
await expect(testPage.getByTestId("dockview-add-panel-btn")).toBeVisible();
|
||||
|
||||
const tabs = testPage.locator(".dv-tab");
|
||||
await expect(
|
||||
tabs.filter({ has: testPage.locator('[data-testid^="session-tab-"]') }),
|
||||
).toBeVisible();
|
||||
await expect(tabs.filter({ hasText: "Files" })).toBeVisible();
|
||||
await expect(tabs.filter({ hasText: "Changes" })).toBeVisible();
|
||||
await expect(tabs.filter({ hasText: "Terminal" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("kanban keeps desktop controls and usable columns at half-screen width", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize(COMPACT_DESKTOP_VIEWPORT);
|
||||
|
||||
for (const step of seedData.steps) {
|
||||
await apiClient.createTask(seedData.workspaceId, `Compact ${step.title}`, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const desktopLayout = testPage.getByTestId("desktop-kanban-layout");
|
||||
await expect(desktopLayout).toHaveCount(1);
|
||||
await expect(desktopLayout).toBeVisible();
|
||||
await expect(desktopLayout).toHaveAttribute("style", /minmax\(260px, 1fr\)/);
|
||||
await expect(testPage.getByTestId("tablet-kanban-layout")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("mobile-kanban-layout")).toHaveCount(0);
|
||||
await expect(testPage.getByRole("button", { name: "Open menu" })).toHaveCount(0);
|
||||
|
||||
await expect(testPage.getByPlaceholder("Search tasks...")).toBeVisible();
|
||||
await expect(kanban.createTaskButton).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: "Quick Chat" })).toBeVisible();
|
||||
await expect(kanban.viewTogglePipeline).toBeVisible();
|
||||
|
||||
for (const step of seedData.steps) {
|
||||
await expect(kanban.columnByStepId(step.id)).toBeAttached();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const MOBILE_BREAKPOINT = 640;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePanelActions } from "./use-panel-actions";
|
||||
|
||||
const responsiveState = vi.hoisted(() => ({
|
||||
value: { usesDesktopWorkbench: true },
|
||||
}));
|
||||
|
||||
const dockActions = vi.hoisted(() => ({
|
||||
addBrowserPanel: vi.fn(),
|
||||
addPlanPanel: vi.fn(),
|
||||
addChatPanel: vi.fn(),
|
||||
addChangesPanel: vi.fn(),
|
||||
addTerminalPanel: vi.fn(),
|
||||
addVscodePanel: vi.fn(),
|
||||
}));
|
||||
|
||||
const layoutActions = vi.hoisted(() => ({
|
||||
openDocument: vi.fn(),
|
||||
openPreview: vi.fn(),
|
||||
}));
|
||||
|
||||
const appState = vi.hoisted(() => ({
|
||||
value: {
|
||||
tasks: {
|
||||
activeSessionId: "session-1",
|
||||
activeTaskId: "task-1",
|
||||
},
|
||||
setActiveDocument: vi.fn(),
|
||||
setPlanMode: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const editorActions = vi.hoisted(() => ({
|
||||
openFile: vi.fn(),
|
||||
openFileInMarkdownPreview: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => responsiveState.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/state/dockview-store", () => ({
|
||||
useDockviewStore: (selector: (state: typeof dockActions) => unknown) => selector(dockActions),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/state/layout-store", () => {
|
||||
const useLayoutStore = (selector: (state: typeof layoutActions) => unknown) =>
|
||||
selector(layoutActions);
|
||||
useLayoutStore.getState = () => layoutActions;
|
||||
return { useLayoutStore };
|
||||
});
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: typeof appState.value) => unknown) => selector(appState.value),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-file-editors", () => ({
|
||||
useFileEditors: () => editorActions,
|
||||
}));
|
||||
|
||||
describe("usePanelActions", () => {
|
||||
beforeEach(() => {
|
||||
responsiveState.value = { usesDesktopWorkbench: true };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("routes compact desktop panel actions through Dockview", () => {
|
||||
const { result } = renderHook(() => usePanelActions());
|
||||
|
||||
act(() => {
|
||||
result.current.addPlan();
|
||||
result.current.openFile("README.md");
|
||||
});
|
||||
|
||||
expect(dockActions.addPlanPanel).toHaveBeenCalledOnce();
|
||||
expect(editorActions.openFile).toHaveBeenCalledWith("README.md");
|
||||
expect(layoutActions.openDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps non-workbench tablet fallbacks on the legacy layout store", () => {
|
||||
responsiveState.value = { usesDesktopWorkbench: false };
|
||||
const { result } = renderHook(() => usePanelActions());
|
||||
|
||||
act(() => {
|
||||
result.current.addBrowser();
|
||||
result.current.addPlan();
|
||||
});
|
||||
|
||||
expect(dockActions.addBrowserPanel).not.toHaveBeenCalled();
|
||||
expect(dockActions.addPlanPanel).not.toHaveBeenCalled();
|
||||
expect(layoutActions.openPreview).toHaveBeenCalledWith("session-1");
|
||||
expect(layoutActions.openDocument).toHaveBeenCalledWith("session-1");
|
||||
expect(appState.value.setPlanMode).toHaveBeenCalledWith("session-1", true);
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,7 @@ import { useFileEditors } from "@/hooks/use-file-editors";
|
||||
* On mobile/tablet: delegates to layout store (kept for backward compat).
|
||||
*/
|
||||
export function usePanelActions() {
|
||||
const { isMobile, isTablet } = useResponsiveBreakpoint();
|
||||
const isDesktop = !isMobile && !isTablet;
|
||||
const { usesDesktopWorkbench } = useResponsiveBreakpoint();
|
||||
|
||||
// Desktop: dockview store
|
||||
const dockAddBrowser = useDockviewStore((s) => s.addBrowserPanel);
|
||||
@@ -37,18 +36,18 @@ export function usePanelActions() {
|
||||
|
||||
const addBrowser = useCallback(
|
||||
(url?: string) => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockAddBrowser(url);
|
||||
} else if (activeSessionId) {
|
||||
// Mobile/tablet: use layout store to open preview
|
||||
useLayoutStore.getState().openPreview(activeSessionId);
|
||||
}
|
||||
},
|
||||
[isDesktop, dockAddBrowser, activeSessionId],
|
||||
[usesDesktopWorkbench, dockAddBrowser, activeSessionId],
|
||||
);
|
||||
|
||||
const addPlan = useCallback(() => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockAddPlan();
|
||||
} else if (activeSessionId && activeTaskId) {
|
||||
// Mobile/tablet: open document panel with plan
|
||||
@@ -57,7 +56,7 @@ export function usePanelActions() {
|
||||
setPlanMode(activeSessionId, true);
|
||||
}
|
||||
}, [
|
||||
isDesktop,
|
||||
usesDesktopWorkbench,
|
||||
dockAddPlan,
|
||||
activeSessionId,
|
||||
activeTaskId,
|
||||
@@ -67,48 +66,48 @@ export function usePanelActions() {
|
||||
]);
|
||||
|
||||
const addChat = useCallback(() => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockAddChat();
|
||||
}
|
||||
}, [isDesktop, dockAddChat]);
|
||||
}, [usesDesktopWorkbench, dockAddChat]);
|
||||
|
||||
const addChanges = useCallback(() => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockAddChanges();
|
||||
}
|
||||
}, [isDesktop, dockAddChanges]);
|
||||
}, [usesDesktopWorkbench, dockAddChanges]);
|
||||
|
||||
const addTerminal = useCallback(
|
||||
(terminalId?: string) => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockAddTerminal(terminalId);
|
||||
}
|
||||
},
|
||||
[isDesktop, dockAddTerminal],
|
||||
[usesDesktopWorkbench, dockAddTerminal],
|
||||
);
|
||||
|
||||
const addVscode = useCallback(() => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockAddVscode();
|
||||
}
|
||||
}, [isDesktop, dockAddVscode]);
|
||||
}, [usesDesktopWorkbench, dockAddVscode]);
|
||||
|
||||
const openFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockOpenFile(filePath);
|
||||
}
|
||||
},
|
||||
[isDesktop, dockOpenFile],
|
||||
[usesDesktopWorkbench, dockOpenFile],
|
||||
);
|
||||
|
||||
const openFileInMarkdownPreview = useCallback(
|
||||
(filePath: string) => {
|
||||
if (isDesktop) {
|
||||
if (usesDesktopWorkbench) {
|
||||
dockOpenFileInPreview(filePath);
|
||||
}
|
||||
},
|
||||
[isDesktop, dockOpenFileInPreview],
|
||||
[usesDesktopWorkbench, dockOpenFileInPreview],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useResponsiveBreakpoint } from "./use-responsive-breakpoint";
|
||||
|
||||
type Listener = (event: MediaQueryListEvent) => void;
|
||||
|
||||
function matchesWidthQuery(query: string, width: number) {
|
||||
const minWidth = query.match(/min-width:\s*(\d+)px/);
|
||||
const maxWidth = query.match(/max-width:\s*(\d+)px/);
|
||||
if (!minWidth && !maxWidth) return false;
|
||||
|
||||
const minMatches = !minWidth || width >= Number(minWidth[1]);
|
||||
const maxMatches = !maxWidth || width <= Number(maxWidth[1]);
|
||||
return minMatches && maxMatches;
|
||||
}
|
||||
|
||||
function setViewport(
|
||||
width: number,
|
||||
pointer: "fine" | "coarse" = "fine",
|
||||
hover: "hover" | "none" = pointer === "fine" ? "hover" : "none",
|
||||
) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: width,
|
||||
});
|
||||
|
||||
window.matchMedia = vi.fn((query: string) => {
|
||||
const mql = {
|
||||
media: query,
|
||||
matches:
|
||||
(query.includes("pointer: fine") && pointer === "fine") ||
|
||||
(query.includes("hover: hover") && hover === "hover") ||
|
||||
(query.includes("pointer: coarse") && pointer === "coarse") ||
|
||||
matchesWidthQuery(query, width),
|
||||
onchange: null,
|
||||
addEventListener: vi.fn((_event: string, listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((_event: string, listener: Listener) => {
|
||||
listeners.delete(listener);
|
||||
}),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
return mql as unknown as MediaQueryList;
|
||||
});
|
||||
}
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function notifyResize(width: number, pointer: "fine" | "coarse" = "fine") {
|
||||
setViewport(width, pointer);
|
||||
act(() => {
|
||||
for (const listener of listeners) {
|
||||
listener({ matches: true } as MediaQueryListEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("useResponsiveBreakpoint", () => {
|
||||
beforeEach(() => {
|
||||
listeners.clear();
|
||||
setViewport(1024, "fine");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("treats half-screen fine-pointer widths as compact desktop workbench", () => {
|
||||
setViewport(900, "fine");
|
||||
|
||||
const { result } = renderHook(() => useResponsiveBreakpoint());
|
||||
|
||||
expect(result.current.breakpoint).toBe("compactDesktop");
|
||||
expect(result.current.isDesktop).toBe(true);
|
||||
expect(result.current.isTablet).toBe(false);
|
||||
expect(result.current.usesDesktopWorkbench).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps coarse-pointer half-screen widths in tablet fallback", () => {
|
||||
setViewport(900, "coarse");
|
||||
|
||||
const { result } = renderHook(() => useResponsiveBreakpoint());
|
||||
|
||||
expect(result.current.breakpoint).toBe("tablet");
|
||||
expect(result.current.isTablet).toBe(true);
|
||||
expect(result.current.usesDesktopWorkbench).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps hover-only hybrid half-screen widths in tablet fallback", () => {
|
||||
setViewport(900, "coarse", "hover");
|
||||
|
||||
const { result } = renderHook(() => useResponsiveBreakpoint());
|
||||
|
||||
expect(result.current.breakpoint).toBe("tablet");
|
||||
expect(result.current.isFinePointer).toBe(false);
|
||||
expect(result.current.usesDesktopWorkbench).toBe(false);
|
||||
});
|
||||
|
||||
it("updates workbench mode when crossing compact desktop boundary", () => {
|
||||
setViewport(640, "fine");
|
||||
const { result } = renderHook(() => useResponsiveBreakpoint());
|
||||
|
||||
expect(result.current.usesDesktopWorkbench).toBe(false);
|
||||
|
||||
notifyResize(768, "fine");
|
||||
|
||||
expect(result.current.breakpoint).toBe("compactDesktop");
|
||||
expect(result.current.usesDesktopWorkbench).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,95 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 640;
|
||||
const TABLET_BREAKPOINT = 1024;
|
||||
const COMPACT_DESKTOP_BREAKPOINT = 768;
|
||||
const DESKTOP_BREAKPOINT = 1024;
|
||||
|
||||
export type Breakpoint = "mobile" | "tablet" | "desktop";
|
||||
export type Breakpoint = "mobile" | "tablet" | "compactDesktop" | "desktop";
|
||||
|
||||
export type ResponsiveBreakpoint = {
|
||||
breakpoint: Breakpoint;
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
isCompactDesktop: boolean;
|
||||
isFullDesktop: boolean;
|
||||
isFinePointer: boolean;
|
||||
usesDesktopWorkbench: boolean;
|
||||
};
|
||||
|
||||
function getBreakpoint(width: number): Breakpoint {
|
||||
if (width < MOBILE_BREAKPOINT) return "mobile";
|
||||
if (width < TABLET_BREAKPOINT) return "tablet";
|
||||
function getBreakpoint(width: number, isFinePointer: boolean): Breakpoint {
|
||||
if (width < MOBILE_BREAKPOINT) {
|
||||
return "mobile";
|
||||
}
|
||||
// Fine-pointer devices below 768px stay on the tablet layout; that range is
|
||||
// too narrow to host the full workbench even with a mouse.
|
||||
if (width >= COMPACT_DESKTOP_BREAKPOINT && width < DESKTOP_BREAKPOINT && isFinePointer) {
|
||||
return "compactDesktop";
|
||||
}
|
||||
if (width < DESKTOP_BREAKPOINT) {
|
||||
return "tablet";
|
||||
}
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
function buildResponsiveBreakpoint(width: number, isFinePointer: boolean): ResponsiveBreakpoint {
|
||||
const breakpoint = getBreakpoint(width, isFinePointer);
|
||||
const usesDesktopWorkbench = breakpoint === "compactDesktop" || breakpoint === "desktop";
|
||||
return {
|
||||
breakpoint,
|
||||
isMobile: breakpoint === "mobile",
|
||||
isTablet: breakpoint === "tablet",
|
||||
isDesktop: usesDesktopWorkbench,
|
||||
isCompactDesktop: breakpoint === "compactDesktop",
|
||||
isFullDesktop: breakpoint === "desktop",
|
||||
isFinePointer,
|
||||
usesDesktopWorkbench,
|
||||
};
|
||||
}
|
||||
|
||||
function getPointerMode(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return true;
|
||||
}
|
||||
return window.matchMedia("(pointer: fine)").matches;
|
||||
}
|
||||
|
||||
function getCurrentResponsiveBreakpoint(): ResponsiveBreakpoint {
|
||||
if (typeof window === "undefined") {
|
||||
return buildResponsiveBreakpoint(DESKTOP_BREAKPOINT, true);
|
||||
}
|
||||
return buildResponsiveBreakpoint(window.innerWidth, getPointerMode());
|
||||
}
|
||||
|
||||
export function useResponsiveBreakpoint(): ResponsiveBreakpoint {
|
||||
const [breakpoint, setBreakpoint] = React.useState<Breakpoint>("desktop");
|
||||
const [state, setState] = React.useState<ResponsiveBreakpoint>(() =>
|
||||
buildResponsiveBreakpoint(DESKTOP_BREAKPOINT, true),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateBreakpoint = () => {
|
||||
setBreakpoint(getBreakpoint(window.innerWidth));
|
||||
setState(getCurrentResponsiveBreakpoint());
|
||||
};
|
||||
|
||||
// Set initial value
|
||||
updateBreakpoint();
|
||||
|
||||
// Listen for resize events
|
||||
const mqlMobile = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const mqlTablet = window.matchMedia(
|
||||
`(min-width: ${MOBILE_BREAKPOINT}px) and (max-width: ${TABLET_BREAKPOINT - 1}px)`,
|
||||
);
|
||||
const mediaQueries = [
|
||||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
||||
`(min-width: ${MOBILE_BREAKPOINT}px) and (max-width: ${COMPACT_DESKTOP_BREAKPOINT - 1}px)`,
|
||||
`(min-width: ${COMPACT_DESKTOP_BREAKPOINT}px) and (max-width: ${DESKTOP_BREAKPOINT - 1}px)`,
|
||||
`(min-width: ${DESKTOP_BREAKPOINT}px)`,
|
||||
"(pointer: fine)",
|
||||
];
|
||||
const mediaQueryLists = mediaQueries.map((query) => window.matchMedia(query));
|
||||
|
||||
mqlMobile.addEventListener("change", updateBreakpoint);
|
||||
mqlTablet.addEventListener("change", updateBreakpoint);
|
||||
mediaQueryLists.forEach((mql) => mql.addEventListener("change", updateBreakpoint));
|
||||
|
||||
return () => {
|
||||
mqlMobile.removeEventListener("change", updateBreakpoint);
|
||||
mqlTablet.removeEventListener("change", updateBreakpoint);
|
||||
mediaQueryLists.forEach((mql) => mql.removeEventListener("change", updateBreakpoint));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
breakpoint,
|
||||
isMobile: breakpoint === "mobile",
|
||||
isTablet: breakpoint === "tablet",
|
||||
isDesktop: breakpoint === "desktop",
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
CENTER_GROUP,
|
||||
RIGHT_TOP_GROUP,
|
||||
RIGHT_BOTTOM_GROUP,
|
||||
TERMINAL_DEFAULT_ID,
|
||||
getPresetLayout,
|
||||
getPresetSidebarColumn,
|
||||
applyLayout,
|
||||
getRootSplitview,
|
||||
fromDockviewApi,
|
||||
@@ -40,6 +42,8 @@ import { preserveChatScrollDuringLayout } from "./dockview-scroll-preserve";
|
||||
import { measureDockviewContainer } from "./dockview-measure";
|
||||
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
|
||||
|
||||
const RIGHT_PANEL_IDS = new Set(["changes", "files", TERMINAL_DEFAULT_ID]);
|
||||
|
||||
// Re-export types and constants used by other modules
|
||||
export type { BuiltInPreset } from "./layout-manager";
|
||||
export {
|
||||
@@ -139,6 +143,8 @@ type DockviewStore = {
|
||||
setSidebarVisible: (visible: boolean) => void;
|
||||
setRightPanelsVisible: (visible: boolean) => void;
|
||||
applyBuiltInPreset: (preset: BuiltInPreset) => void;
|
||||
defaultPreset: BuiltInPreset;
|
||||
setDefaultPreset: (preset: BuiltInPreset) => void;
|
||||
applyCustomLayout: (layout: SavedLayoutConfig) => void;
|
||||
captureCurrentLayout: () => Record<string, unknown>;
|
||||
isRestoringLayout: boolean;
|
||||
@@ -245,6 +251,25 @@ function applyLayoutAndSet(
|
||||
return ids;
|
||||
}
|
||||
|
||||
function removeRightPanelTabs(state: LayoutState): LayoutState {
|
||||
const columns = state.columns
|
||||
.map((col) => {
|
||||
const groups = col.groups
|
||||
.map((group) => {
|
||||
const panels = group.panels.filter((panel) => !RIGHT_PANEL_IDS.has(panel.id));
|
||||
if (panels.length === group.panels.length) return group;
|
||||
const activePanel = panels.some((panel) => panel.id === group.activePanel)
|
||||
? group.activePanel
|
||||
: panels[0]?.id;
|
||||
return { ...group, panels, activePanel };
|
||||
})
|
||||
.filter((group) => group.panels.length > 0);
|
||||
return { ...col, groups };
|
||||
})
|
||||
.filter((col) => col.groups.length > 0);
|
||||
return { columns };
|
||||
}
|
||||
|
||||
function buildVisibilityActions(set: StoreSet, get: StoreGet) {
|
||||
return {
|
||||
toggleSidebar: () => {
|
||||
@@ -267,7 +292,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
|
||||
});
|
||||
} else {
|
||||
const current = fromDockviewApi(api);
|
||||
const sidebarCol = defaultLayout().columns[0];
|
||||
const sidebarCol = getPresetSidebarColumn(get().defaultPreset);
|
||||
const withSidebar: LayoutState = {
|
||||
columns: [sidebarCol, ...current.columns],
|
||||
};
|
||||
@@ -281,8 +306,9 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
|
||||
}
|
||||
},
|
||||
toggleRightPanels: () => {
|
||||
const { api, rightPanelsVisible } = get();
|
||||
const { api, rightPanelsVisible, defaultPreset } = get();
|
||||
if (!api) return;
|
||||
if (!rightPanelsVisible && defaultPreset === "compact") return;
|
||||
const liveWidths = captureLiveWidths(api, set);
|
||||
preserveChatScrollDuringLayout();
|
||||
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
|
||||
@@ -305,7 +331,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
|
||||
const defLayout = defaultLayout();
|
||||
const rightCol = defLayout.columns.find((c) => c.id === "right");
|
||||
if (!rightCol) return;
|
||||
const current = fromDockviewApi(api);
|
||||
const current = removeRightPanelTabs(fromDockviewApi(api));
|
||||
const withRight: LayoutState = {
|
||||
columns: [...current.columns, rightCol],
|
||||
};
|
||||
@@ -522,7 +548,7 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
|
||||
safeWidth: measured.width,
|
||||
safeHeight: measured.height,
|
||||
buildDefault: (a) => get().buildDefaultLayout(a),
|
||||
getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout("default"),
|
||||
getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout(get().defaultPreset),
|
||||
});
|
||||
set(ids);
|
||||
set({ isRestoringLayout: false });
|
||||
@@ -620,7 +646,7 @@ function performBuildDefault(
|
||||
const basePreset = intent?.preset as BuiltInPreset | undefined;
|
||||
let state = basePreset
|
||||
? getPresetLayout(basePreset)
|
||||
: (userDefaultLayout ?? getPresetLayout("default"));
|
||||
: (userDefaultLayout ?? getPresetLayout(get().defaultPreset));
|
||||
|
||||
if (intent?.panels?.length) {
|
||||
state = injectIntentPanels(state, intent.panels);
|
||||
@@ -721,6 +747,8 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
|
||||
setUserDefaultLayout: (layout) => set({ userDefaultLayout: layout }),
|
||||
...buildVisibilityActions(set, get),
|
||||
...buildPresetActions(set, get),
|
||||
defaultPreset: "default",
|
||||
setDefaultPreset: (preset) => set({ defaultPreset: preset }),
|
||||
isRestoringLayout: false,
|
||||
currentLayoutEnvId: null,
|
||||
deferredPanelActions: [],
|
||||
|
||||
@@ -29,7 +29,14 @@ export {
|
||||
} from "./constants";
|
||||
|
||||
// Presets
|
||||
export { defaultLayout, planLayout, previewLayout, getPresetLayout } from "./presets";
|
||||
export {
|
||||
defaultLayout,
|
||||
compactLayout,
|
||||
planLayout,
|
||||
previewLayout,
|
||||
getPresetLayout,
|
||||
getPresetSidebarColumn,
|
||||
} from "./presets";
|
||||
export type { BuiltInPreset } from "./presets";
|
||||
|
||||
// Sizing
|
||||
@@ -50,6 +57,7 @@ export { layoutStructuresMatch, savedLayoutMatchesLive } from "./comparator";
|
||||
|
||||
// Intent
|
||||
export {
|
||||
INTENT_COMPACT,
|
||||
INTENT_PLAN,
|
||||
INTENT_PR_REVIEW,
|
||||
injectIntentPanels,
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { LayoutState, LayoutIntent, LayoutIntentPanel, LayoutPanel } from "
|
||||
import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP } from "./constants";
|
||||
|
||||
/** Well-known intent name constants (used as URL ?layout= values). */
|
||||
export const INTENT_COMPACT = "compact";
|
||||
export const INTENT_PLAN = "plan";
|
||||
export const INTENT_PR_REVIEW = "pr-review";
|
||||
|
||||
/** Registry of named layout intents, keyed by URL param value. */
|
||||
const NAMED_INTENTS: Record<string, LayoutIntent> = {
|
||||
[INTENT_COMPACT]: { preset: "compact" },
|
||||
[INTENT_PLAN]: { preset: "plan" },
|
||||
[INTENT_PR_REVIEW]: {
|
||||
panels: [
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compactLayout, defaultLayout, getPresetSidebarColumn } from "./presets";
|
||||
|
||||
describe("layout presets", () => {
|
||||
it("keeps the compact workbench on Dockview while prioritizing the center panel", () => {
|
||||
const compact = compactLayout();
|
||||
const compactSidebar = compact.columns.find((column) => column.id === "sidebar");
|
||||
const defaultSidebarMaxWidth =
|
||||
defaultLayout().columns.find((column) => column.id === "sidebar")?.maxWidth ??
|
||||
Number.POSITIVE_INFINITY;
|
||||
|
||||
expect(compact.columns.map((column) => column.id)).toEqual(["sidebar", "center"]);
|
||||
const compactSidebarWidth = compactSidebar?.width ?? Number.POSITIVE_INFINITY;
|
||||
const compactSidebarMaxWidth = compactSidebar?.maxWidth ?? Number.POSITIVE_INFINITY;
|
||||
expect(compactSidebarWidth).toBeLessThan(defaultSidebarMaxWidth);
|
||||
expect(compactSidebarMaxWidth).toBeLessThan(defaultSidebarMaxWidth);
|
||||
expect(compact.columns.find((column) => column.id === "center")?.groups[0].panels[0].id).toBe(
|
||||
"chat",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compact sidebar sizing for compact preset restoration", () => {
|
||||
const compactSidebar = compactLayout().columns.find((column) => column.id === "sidebar");
|
||||
|
||||
expect(getPresetSidebarColumn("compact")).toEqual(compactSidebar);
|
||||
expect(getPresetSidebarColumn("compact").width).toBe(220);
|
||||
expect(getPresetSidebarColumn("compact").maxWidth).toBe(260);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LayoutState } from "./types";
|
||||
import type { LayoutColumn, LayoutState } from "./types";
|
||||
import {
|
||||
LAYOUT_SIDEBAR_MAX_PX,
|
||||
LAYOUT_RIGHT_MAX_PX,
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
panel,
|
||||
} from "./constants";
|
||||
|
||||
const COMPACT_SIDEBAR_WIDTH = 220;
|
||||
const COMPACT_SIDEBAR_MAX_PX = 260;
|
||||
|
||||
export function defaultLayout(): LayoutState {
|
||||
return {
|
||||
columns: [
|
||||
@@ -36,6 +39,29 @@ export function defaultLayout(): LayoutState {
|
||||
};
|
||||
}
|
||||
|
||||
export function compactLayout(): LayoutState {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
id: "sidebar",
|
||||
pinned: true,
|
||||
width: COMPACT_SIDEBAR_WIDTH,
|
||||
maxWidth: COMPACT_SIDEBAR_MAX_PX,
|
||||
groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
groups: [
|
||||
{
|
||||
id: CENTER_GROUP,
|
||||
panels: [panel("chat"), panel("files"), panel("changes"), panel("terminal-default")],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function planLayout(): LayoutState {
|
||||
return {
|
||||
columns: [
|
||||
@@ -100,10 +126,11 @@ export function vscodeLayout(): LayoutState {
|
||||
};
|
||||
}
|
||||
|
||||
export type BuiltInPreset = "default" | "plan" | "preview" | "vscode";
|
||||
export type BuiltInPreset = "default" | "compact" | "plan" | "preview" | "vscode";
|
||||
|
||||
const PRESET_MAP: Record<BuiltInPreset, () => LayoutState> = {
|
||||
default: defaultLayout,
|
||||
compact: compactLayout,
|
||||
plan: planLayout,
|
||||
preview: previewLayout,
|
||||
vscode: vscodeLayout,
|
||||
@@ -112,3 +139,10 @@ const PRESET_MAP: Record<BuiltInPreset, () => LayoutState> = {
|
||||
export function getPresetLayout(preset: BuiltInPreset): LayoutState {
|
||||
return PRESET_MAP[preset]();
|
||||
}
|
||||
|
||||
export function getPresetSidebarColumn(preset: BuiltInPreset): LayoutColumn {
|
||||
return (
|
||||
getPresetLayout(preset).columns.find((column) => column.id === "sidebar") ??
|
||||
defaultLayout().columns[0]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user