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:
José Almeida
2026-05-18 23:08:14 +01:00
committed by GitHub
parent af9d3547a6
commit b4ce4d6eef
21 changed files with 636 additions and 106 deletions
+37 -21
View File
@@ -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))`;
}
+35 -12
View File
@@ -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>
);
+4 -3
View File
@@ -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 -1
View File
@@ -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);
+96
View File
@@ -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);
});
});
+17 -18
View File
@@ -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);
});
});
+63 -21
View File
@@ -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;
}
+33 -5
View File
@@ -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: [],
+9 -1
View File
@@ -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);
});
});
+36 -2
View File
@@ -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]
);
}