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] + ); +}