From b4ce4d6eeff86560ce51a1e69087de9b93b70a2c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Almeida?=
<57680069+zeval@users.noreply.github.com>
Date: Mon, 18 May 2026 23:08:14 +0100
Subject: [PATCH] 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
---
apps/web/components/kanban-board-grid.tsx | 58 +++++----
.../kanban/kanban-grid-template.test.ts | 12 ++
.../components/kanban/kanban-grid-template.ts | 6 +
apps/web/components/kanban/kanban-header.tsx | 47 ++++++--
.../kanban/swimlane-kanban-content.tsx | 18 ++-
.../task/dockview-desktop-layout-hooks.ts | 26 ++++
.../task/dockview-desktop-layout.tsx | 26 ++--
.../task/mobile/session-tablet-layout.tsx | 2 +-
.../web/components/task/quick-chat-button.tsx | 12 +-
apps/web/components/task/task-layout.tsx | 7 +-
.../layout/compact-desktop-responsive.spec.ts | 80 ++++++++++++
apps/web/hooks/use-mobile.ts | 2 +-
apps/web/hooks/use-panel-actions.test.ts | 96 +++++++++++++++
apps/web/hooks/use-panel-actions.ts | 35 +++---
.../hooks/use-responsive-breakpoint.test.ts | 114 ++++++++++++++++++
apps/web/hooks/use-responsive-breakpoint.ts | 84 +++++++++----
apps/web/lib/state/dockview-store.ts | 38 +++++-
apps/web/lib/state/layout-manager/index.ts | 10 +-
apps/web/lib/state/layout-manager/intent.ts | 2 +
.../lib/state/layout-manager/presets.test.ts | 29 +++++
apps/web/lib/state/layout-manager/presets.ts | 38 +++++-
21 files changed, 636 insertions(+), 106 deletions(-)
create mode 100644 apps/web/components/kanban/kanban-grid-template.test.ts
create mode 100644 apps/web/components/kanban/kanban-grid-template.ts
create mode 100644 apps/web/components/task/dockview-desktop-layout-hooks.ts
create mode 100644 apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts
create mode 100644 apps/web/hooks/use-panel-actions.test.ts
create mode 100644 apps/web/hooks/use-responsive-breakpoint.test.ts
create mode 100644 apps/web/lib/state/layout-manager/presets.test.ts
diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx
index 04b8209c25..ef437876a8 100644
--- a/apps/web/components/kanban-board-grid.tsx
+++ b/apps/web/components/kanban-board-grid.tsx
@@ -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 (
<>
-
+
{showLoading || steps.length === 0 ? (
) : (
{steps.map((step) => (
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 = (
-
+
);
}
diff --git a/apps/web/components/kanban/kanban-grid-template.test.ts b/apps/web/components/kanban/kanban-grid-template.test.ts
new file mode 100644
index 0000000000..60422ab7b5
--- /dev/null
+++ b/apps/web/components/kanban/kanban-grid-template.test.ts
@@ -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))");
+ });
+});
diff --git a/apps/web/components/kanban/kanban-grid-template.ts b/apps/web/components/kanban/kanban-grid-template.ts
new file mode 100644
index 0000000000..4f3d80c40d
--- /dev/null
+++ b/apps/web/components/kanban/kanban-grid-template.ts
@@ -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))`;
+}
diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx
index fa112a506b..c6bb929fef 100644
--- a/apps/web/components/kanban/kanban-header.tsx
+++ b/apps/web/components/kanban/kanban-header.tsx
@@ -332,6 +332,36 @@ function TabletHeader({
);
}
+function CreateTaskTopbarButton({
+ onCreateTask,
+ compact,
+}: {
+ onCreateTask: () => void;
+ compact: boolean;
+}) {
+ const button = (
+
+ );
+
+ if (!compact) return button;
+
+ return (
+
+ {button}
+ Add task
+
+ );
+}
+
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 ? (
{searchInput}
) : null;
+ const actionsSearch = !isHome || isNarrow ? searchInput : null;
const leftActions = isHome ? (
) : (
@@ -396,17 +427,9 @@ function DesktopHeader({
leftActions={leftActions}
actions={
<>
- {!isHome && searchInput}
-
-
+ {actionsSearch}
+
+
diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx
index eabc9e904b..051fca597d 100644
--- a/apps/web/components/kanban/swimlane-kanban-content.tsx
+++ b/apps/web/components/kanban/swimlane-kanban-content.tsx
@@ -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;
onToggleSelect?: (taskId: string) => void;
isMultiSelectMode?: boolean;
+ isCompactDesktop: boolean;
}) {
const getTasksForStep = useTasksByStep(tasks);
return (
{steps.map((step) => (
;
} else {
- layoutContent =
;
+ layoutContent = (
+
+
+
+ );
}
return (
diff --git a/apps/web/components/task/dockview-desktop-layout-hooks.ts b/apps/web/components/task/dockview-desktop-layout-hooks.ts
new file mode 100644
index 0000000000..35e6fa63f7
--- /dev/null
+++ b/apps/web/components/task/dockview-desktop-layout-hooks.ts
@@ -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
| null>,
+ readyDisposersRef: RefObject 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]);
+}
diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx
index 5af1289400..bf40226597 100644
--- a/apps/web/components/task/dockview-desktop-layout.tsx
+++ b/apps/web/components/task/dockview-desktop-layout.tsx
@@ -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 (
+
["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
);
diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx
index d804475318..a850223977 100644
--- a/apps/web/components/task/task-layout.tsx
+++ b/apps/web/components/task/task-layout.tsx
@@ -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 (
);
});
diff --git a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts
new file mode 100644
index 0000000000..3e58a8c713
--- /dev/null
+++ b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts
@@ -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();
+ }
+ });
+});
diff --git a/apps/web/hooks/use-mobile.ts b/apps/web/hooks/use-mobile.ts
index 502fd32393..39b38c10d8 100644
--- a/apps/web/hooks/use-mobile.ts
+++ b/apps/web/hooks/use-mobile.ts
@@ -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(undefined);
diff --git a/apps/web/hooks/use-panel-actions.test.ts b/apps/web/hooks/use-panel-actions.test.ts
new file mode 100644
index 0000000000..57ec17439f
--- /dev/null
+++ b/apps/web/hooks/use-panel-actions.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/hooks/use-panel-actions.ts b/apps/web/hooks/use-panel-actions.ts
index 2d38aa1eb0..90dce84889 100644
--- a/apps/web/hooks/use-panel-actions.ts
+++ b/apps/web/hooks/use-panel-actions.ts
@@ -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 {
diff --git a/apps/web/hooks/use-responsive-breakpoint.test.ts b/apps/web/hooks/use-responsive-breakpoint.test.ts
new file mode 100644
index 0000000000..6a15b8c538
--- /dev/null
+++ b/apps/web/hooks/use-responsive-breakpoint.test.ts
@@ -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();
+
+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);
+ });
+});
diff --git a/apps/web/hooks/use-responsive-breakpoint.ts b/apps/web/hooks/use-responsive-breakpoint.ts
index 09ed701d50..fb3d3013dd 100644
--- a/apps/web/hooks/use-responsive-breakpoint.ts
+++ b/apps/web/hooks/use-responsive-breakpoint.ts
@@ -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("desktop");
+ const [state, setState] = React.useState(() =>
+ 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;
}
diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts
index 58b56f343f..3ca8ae4db0 100644
--- a/apps/web/lib/state/dockview-store.ts
+++ b/apps/web/lib/state/dockview-store.ts
@@ -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;
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((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: [],
diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts
index bd2d02b287..944cf1ac95 100644
--- a/apps/web/lib/state/layout-manager/index.ts
+++ b/apps/web/lib/state/layout-manager/index.ts
@@ -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,
diff --git a/apps/web/lib/state/layout-manager/intent.ts b/apps/web/lib/state/layout-manager/intent.ts
index a200ce3af5..c4df8a0902 100644
--- a/apps/web/lib/state/layout-manager/intent.ts
+++ b/apps/web/lib/state/layout-manager/intent.ts
@@ -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 = {
+ [INTENT_COMPACT]: { preset: "compact" },
[INTENT_PLAN]: { preset: "plan" },
[INTENT_PR_REVIEW]: {
panels: [
diff --git a/apps/web/lib/state/layout-manager/presets.test.ts b/apps/web/lib/state/layout-manager/presets.test.ts
new file mode 100644
index 0000000000..b5215b4b72
--- /dev/null
+++ b/apps/web/lib/state/layout-manager/presets.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/lib/state/layout-manager/presets.ts b/apps/web/lib/state/layout-manager/presets.ts
index 31f5a939d6..afa4abfe3b 100644
--- a/apps/web/lib/state/layout-manager/presets.ts
+++ b/apps/web/lib/state/layout-manager/presets.ts
@@ -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 LayoutState> = {
default: defaultLayout,
+ compact: compactLayout,
plan: planLayout,
preview: previewLayout,
vscode: vscodeLayout,
@@ -112,3 +139,10 @@ const PRESET_MAP: Record 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]
+ );
+}