diff --git a/apps/web/components/task/branch-picker-list.test.tsx b/apps/web/components/task/branch-picker-list.test.tsx index 1e67068f39..0c6f08b6f8 100644 --- a/apps/web/components/task/branch-picker-list.test.tsx +++ b/apps/web/components/task/branch-picker-list.test.tsx @@ -25,8 +25,8 @@ describe("BranchPickerList", () => { const options = screen.getAllByRole("option"); expect(options.map((option) => option.textContent?.trim())).toEqual([ "main", - "feature/local", "develop", + "feature/local", ]); expect(options[0].getAttribute("aria-selected")).toBe("true"); expect(options[0].className).toContain("bg-card"); @@ -74,4 +74,23 @@ describe("BranchPickerList", () => { expect(screen.queryByTestId("recovery-branch-option-feature/local-only")).toBeNull(); expect(screen.getByTestId("recovery-branch-option-main")).toBeTruthy(); }); + + it("puts the default branch first when the current branch is unavailable", () => { + render( + {}} + remoteOnly + />, + ); + + expect(screen.getAllByRole("option")[0]?.getAttribute("data-testid")).toBe( + "base-branch-picker-option-main", + ); + }); }); diff --git a/apps/web/components/task/branch-picker-list.tsx b/apps/web/components/task/branch-picker-list.tsx index b0f5c9e495..f032078654 100644 --- a/apps/web/components/task/branch-picker-list.tsx +++ b/apps/web/components/task/branch-picker-list.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; import { prioritizeSelectedOption } from "@/lib/utils/selector-options"; import type { Branch } from "@/lib/types/http"; import { useTranslation } from "react-i18next"; +import { sortBranches } from "../branch-picker-options"; type BranchPickerListProps = { branches: Branch[]; @@ -44,7 +45,7 @@ export function BranchPickerList({ }, [filter, uniqueByName]); const orderedBranches = useMemo(() => { if (filter.trim()) return filtered; - return prioritizeSelectedOption(filtered, currentBase, (branch) => branch.name); + return prioritizeSelectedOption(sortBranches(filtered), currentBase, (branch) => branch.name); }, [currentBase, filter, filtered]); return ( diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index b6d0b8cd86..8b2a94b475 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -13,6 +13,7 @@ import { resolveRestoredLayoutProfile, useDockviewStore, performLayoutSwitch, + hasRightColumn, } from "@/lib/state/dockview-store"; import { restoreEnvLayout } from "./dockview-layout-restore"; import { @@ -65,7 +66,9 @@ import { resolveEffectiveDefaultLayout, type LayoutProfileIdentity, } from "@/lib/layout/layout-profiles"; -import type { LayoutState } from "@/lib/state/layout-manager"; +import { fromDockviewApi, type LayoutState } from "@/lib/state/layout-manager"; +import { getEnvLayout } from "@/lib/local-storage"; +import { getRightPaneToggleState, readHiddenRightPane } from "@/lib/state/dockview-right-pane"; import { registerDockviewRoot, unregisterDockviewRoot } from "@/lib/state/dockview-measure"; // --------------------------------------------------------------------------- @@ -348,14 +351,23 @@ function setupReadyDockview({ api, appStore, layout, refs }: ReadyDockviewSetup) const restored = !layout.initialLayout && restoreEnvLayout(api, currentEnvId, appStore, DESKTOP_VALID_COMPONENTS); + const hiddenRightPane = + restored && currentEnvId ? readHiddenRightPane(getEnvLayout(currentEnvId)) : null; + useDockviewStore.setState({ hiddenRightPane }); if (!restored) { layout.buildDefaultLayout( api, layout.initialLayout ?? (layout.compact ? "compact" : undefined), ); } else { + const restoredLayout = fromDockviewApi(api); + const preMaximizeLayout = useDockviewStore.getState().preMaximizeLayout; + const paneState = getRightPaneToggleState(preMaximizeLayout ?? restoredLayout, hiddenRightPane); useDockviewStore.setState({ activeLayoutProfile: resolveRestoredLayoutProfile(api, currentEnvId), + rightPanelsVisible: hasRightColumn(preMaximizeLayout ?? restoredLayout), + rightPaneVisible: paneState.visible, + rightPaneAvailable: paneState.available, }); } diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index ab0c2c4177..3ea81f6d1b 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -2,14 +2,8 @@ import { useCallback, useState } from "react"; import { type IDockviewHeaderActionsProps } from "dockview-react"; -import { - IconPlus, - IconTerminal2, - IconPlayerPlay, - IconLayoutSidebarRightCollapse, -} from "@tabler/icons-react"; +import { IconPlus, IconTerminal2, IconPlayerPlay } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, @@ -39,8 +33,6 @@ import { useOptionalPortForwardingVisibility } from "./port-forwarding-visibilit const HEADER_ACTION_BUTTON_CLASS = "h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:bg-muted/70 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"; -const RAW_HEADER_ACTION_BUTTON_CLASS = - "inline-flex h-6 w-6 items-center justify-center rounded-[5px] text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"; const HEADER_ICON_CLASS = "h-3.5 w-3.5"; function useLeftHeaderState( @@ -261,20 +253,17 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) { const { group } = props; const centerGroupId = useDockviewStore((s) => s.centerGroupId); const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId); - const rightTopGroupId = useDockviewStore((s) => s.rightTopGroupId); const rightBottomGroupId = useDockviewStore((s) => s.rightBottomGroupId); const isSidebarGroup = group.id === sidebarGroupId; if (isSidebarGroup) return ; const isCenterGroup = group.id === centerGroupId; - const isRightTopGroup = group.id === rightTopGroupId; const isTerminalGroup = group.id === rightBottomGroupId; return (
{isCenterGroup && } - {isRightTopGroup && } {isTerminalGroup && }
@@ -353,26 +342,6 @@ function SidebarRightActions() { ); } -function RightTopGroupActions() { - const { t } = useTranslation(); - const toggleRightPanels = useDockviewStore((s) => s.toggleRightPanels); - return ( - - - - - {t("task:hideRightPanels")} - - ); -} - function CenterRightActions() { const hasDevScript = Boolean(useActiveSessionDevScript()); diff --git a/apps/web/components/task/dockview-layout-restore.ts b/apps/web/components/task/dockview-layout-restore.ts index b0ef342b8b..5f93fba08d 100644 --- a/apps/web/components/task/dockview-layout-restore.ts +++ b/apps/web/components/task/dockview-layout-restore.ts @@ -1,6 +1,6 @@ import type { DockviewReadyEvent, SerializedDockview } from "dockview-react"; import type { StoreApi } from "zustand"; -import { useDockviewStore } from "@/lib/state/dockview-store"; +import { hasRightColumn, useDockviewStore } from "@/lib/state/dockview-store"; import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders"; import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health"; import { measureDockviewContainer } from "@/lib/state/dockview-measure"; @@ -15,6 +15,7 @@ import { removeEnvMaximizeState, } from "@/lib/local-storage"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { stripHiddenRightPaneMetadata } from "@/lib/state/dockview-right-pane"; const debug = createDebugLogger("dockview:restore"); @@ -167,6 +168,7 @@ function applySavedMaximize( const { width, height } = measureDockviewContainer(api); api.layout(width, height); const ids = applyLayoutFixups(api, undefined, manualRightWidth); + const preMaximizeLayout = savedMax.preMaximizeLayout as unknown as LayoutState; // The maximize JSON is 2-column — captureRightTarget skips it (sv.length < 3). // Seed the right target directly so enforcePinnedTargets can snap the column // back to the saved width when the user exits maximize mode. @@ -175,8 +177,9 @@ function applySavedMaximize( } useDockviewStore.setState({ ...ids, - preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LayoutState, + preMaximizeLayout, maximizedGroupId: ids.centerGroupId, + rightPanelsVisible: hasRightColumn(preMaximizeLayout), }); } @@ -236,7 +239,7 @@ function tryRestoreEnvLayout( phantomSessionIds: phantomSessionIds ? Array.from(phantomSessionIds) : [], }); } - const sanitized = sanitizeLayout(envLayout, validComponents, { + const sanitized = sanitizeLayout(stripHiddenRightPaneMetadata(envLayout), validComponents, { excludeSessionIds: phantomSessionIds, }); if (!sanitized) { diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts index 7d9510d4ce..33fb56a18f 100644 --- a/apps/web/components/task/dockview-layout-setup.ts +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -19,6 +19,7 @@ import { } from "@/lib/local-storage"; import { resolveResponsiveRightWidth } from "@/lib/state/layout-manager/right-width"; import { setSashDragging as setPinnedEnforcementSashDragging } from "@/lib/state/dockview-pinned-enforce"; +import { withHiddenRightPaneMetadata } from "@/lib/state/dockview-right-pane"; import { getDockviewElement, measureDockviewGridWidth } from "@/lib/state/dockview-measure"; import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; import { stopVscode } from "@/lib/api/domains/vscode-api"; @@ -364,7 +365,11 @@ export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void { useDockviewStore.setState({ activeGroupId: group?.id ?? null }); }); useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null }); - const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api)); + const d2 = api.onDidLayoutChange(() => { + trackPinnedWidths(api); + const store = useDockviewStore.getState(); + if (!store.isRestoringLayout) store.refreshRightPaneState(); + }); trackPinnedWidths(api); return () => { d1.dispose(); @@ -392,7 +397,7 @@ export function setupLayoutPersistence( const envId = envIdRef.current; localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json)); if (envId) { - setEnvLayout(envId, json); + setEnvLayout(envId, withHiddenRightPaneMetadata(json, live.hiddenRightPane)); setEnvLayoutProfile(envId, live.activeLayoutProfile); } if (isDebug()) { diff --git a/apps/web/components/task/document/document-controls.tsx b/apps/web/components/task/document/document-controls.tsx index f6db56f668..c4adf76f6a 100644 --- a/apps/web/components/task/document/document-controls.tsx +++ b/apps/web/components/task/document/document-controls.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarRightCollapse } from "@tabler/icons-react"; +import { IconLayoutSidebarLeftCollapse } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useLayoutStore } from "@/lib/state/layout-store"; @@ -15,7 +15,6 @@ export function DocumentControls({ activeSessionId }: DocumentControlsProps) { const { t } = useTranslation(); const toggleColumn = useLayoutStore((state) => state.toggleColumn); const showColumn = useLayoutStore((state) => state.showColumn); - const toggleRightPanel = useLayoutStore((state) => state.toggleRightPanel); const layoutBySession = useLayoutStore((state) => state.columnsBySessionId); const layoutState = useMemo(() => { @@ -28,7 +27,6 @@ export function DocumentControls({ activeSessionId }: DocumentControlsProps) { } const leftHidden = !layoutState.left; - const rightHidden = !layoutState.right; return (
@@ -61,35 +59,6 @@ export function DocumentControls({ activeSessionId }: DocumentControlsProps) { {t("task:hideSidebar")} )} - {rightHidden ? ( - - - - - {t("task:showRightPanel")} - - ) : ( - - - - - {t("task:hideRightPanel")} - - )}
); } diff --git a/apps/web/components/task/mobile/session-tablet-layout.test.tsx b/apps/web/components/task/mobile/session-tablet-layout.test.tsx new file mode 100644 index 0000000000..234ae9fe0f --- /dev/null +++ b/apps/web/components/task/mobile/session-tablet-layout.test.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLayoutStore } from "@/lib/state/layout-store"; +import { SessionTabletLayout } from "./session-tablet-layout"; + +vi.mock("react-resizable-panels", () => ({ + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Panel: ({ id, children }: { id: string; children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("./session-task-switcher-sheet", () => ({ + SessionTaskSwitcherSheet: () => null, +})); + +vi.mock("../task-center-panel", () => ({ + TaskCenterPanel: () =>
, +})); + +vi.mock("../task-right-panel", () => ({ + TaskRightPanel: () =>
, +})); + +vi.mock("../task-files-panel", () => ({ + TaskFilesPanel: () =>
, +})); + +vi.mock("@/components/task/browser-panel", () => ({ + BrowserPanel: () =>
, +})); + +vi.mock("@/components/task/preview-controller", () => ({ + PreviewController: () => null, +})); + +vi.mock("@/lib/layout/use-default-layout", () => ({ + useDefaultLayout: () => ({ + defaultLayout: undefined, + onLayoutChanged: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-session-layout-state", () => ({ + useSessionLayoutState: () => ({ + activeTaskId: "task-1", + effectiveSessionId: "session-1", + sessionKey: "session-1", + selectedDiff: null, + handleClearSelectedDiff: vi.fn(), + openFileRequest: null, + handleOpenFile: vi.fn(), + handleFileOpenHandled: vi.fn(), + isTaskSwitcherOpen: false, + setMobileSessionTaskSwitcherOpen: vi.fn(), + }), +})); + +vi.mock("../dockview-review-dialog", () => ({ + TaskReviewDialogMount: () => null, +})); + +afterEach(cleanup); + +describe("SessionTabletLayout right-panel visibility", () => { + beforeEach(() => { + useLayoutStore.setState({ + columnsBySessionId: { + "session-1": { + left: true, + chat: true, + right: false, + preview: false, + document: false, + }, + }, + }); + }); + + it("does not render the right column when the session layout hides it", () => { + render(); + + expect(screen.queryByTestId("tablet-right-panel")).toBeNull(); + expect(screen.getByTestId("tablet-center-panel")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/mobile/session-tablet-layout.tsx b/apps/web/components/task/mobile/session-tablet-layout.tsx index 1fa9b9f8df..871e4dfdae 100644 --- a/apps/web/components/task/mobile/session-tablet-layout.tsx +++ b/apps/web/components/task/mobile/session-tablet-layout.tsx @@ -174,13 +174,15 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({ {/* Right Panel: Files + Terminal stacked */} - - - + {layoutState.right && ( + + + + )} {/* Task Switcher Sheet - same as mobile */} diff --git a/apps/web/components/task/task-right-panels-toggle.test.tsx b/apps/web/components/task/task-right-panels-toggle.test.tsx new file mode 100644 index 0000000000..d77c8cf718 --- /dev/null +++ b/apps/web/components/task/task-right-panels-toggle.test.tsx @@ -0,0 +1,150 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + state: { + isSupported: true, + isReady: true, + isMaximized: false, + isAvailable: true, + rightPanelsVisible: true, + toggleRightPanels: vi.fn(), + }, +})); +const HIDE_RIGHT_PANE = "Hide right pane"; +const SHOW_RIGHT_PANE = "Show right pane"; +const RIGHT_PANE_UNAVAILABLE = "No separate right pane to hide"; +const RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED = + "Right pane is unavailable while a panel is maximized"; +const TOGGLE_TEST_ID = "task-right-panels-toggle"; +const ARIA_LABEL_ATTRIBUTE = "aria-label"; + +vi.mock("@/hooks/use-task-right-panels-toggle", () => ({ + useTaskRightPanelsToggle: () => mocks.state, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + if (key === "task:hideRightPane") return HIDE_RIGHT_PANE; + if (key === "task:showRightPane") return SHOW_RIGHT_PANE; + if (key === "task:rightPaneUnavailable") return RIGHT_PANE_UNAVAILABLE; + if (key === "task:rightPaneUnavailableWhileMaximized") { + return RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED; + } + return key; + }, + }), +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import { TaskRightPanelsToggle } from "./task-right-panels-toggle"; + +afterEach(cleanup); + +beforeEach(() => { + mocks.state = { + isSupported: true, + isReady: true, + isMaximized: false, + isAvailable: true, + rightPanelsVisible: true, + toggleRightPanels: vi.fn(), + }; +}); + +describe("TaskRightPanelsToggle", () => { + it("keeps the action in one button position and exposes the next action", () => { + render(); + + const button = screen.getByTestId(TOGGLE_TEST_ID); + expect(button.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(HIDE_RIGHT_PANE); + expect(button.getAttribute("aria-expanded")).toBe("true"); + expect(button.getAttribute("title")).toBe(HIDE_RIGHT_PANE); + + button.focus(); + fireEvent.click(button); + expect(mocks.state.toggleRightPanels).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(button); + }); + + it("switches to the show action when right panels are hidden", () => { + mocks.state.rightPanelsVisible = false; + render(); + + const button = screen.getByTestId(TOGGLE_TEST_ID); + expect(button.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(SHOW_RIGHT_PANE); + expect(button.getAttribute("aria-expanded")).toBe("false"); + }); + + it("uses the shared touch-sized icon button", () => { + render(); + + expect(screen.getByTestId(TOGGLE_TEST_ID).className).toContain( + "[@media(pointer:coarse)]:size-11", + ); + }); + + it("disables the action while the selected layout is not ready", () => { + mocks.state.isReady = false; + render(); + + expect((screen.getByTestId(TOGGLE_TEST_ID) as HTMLButtonElement).disabled).toBe(true); + }); + + it("explains why the action is disabled while a panel is maximized", () => { + mocks.state.isMaximized = true; + mocks.state.isReady = false; + render(); + + const button = screen.getByTestId(TOGGLE_TEST_ID) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(button.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBeNull(); + expect(button.getAttribute("title")).toBe(RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED); + expect(button.parentElement?.tagName).toBe("SPAN"); + expect(button.parentElement?.getAttribute("tabindex")).toBe("0"); + expect(button.parentElement?.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe( + RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED, + ); + }); + + it("explains when the current layout has no separate right pane", () => { + mocks.state.isAvailable = false; + render(); + + const button = screen.getByTestId(TOGGLE_TEST_ID) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(button.getAttribute("title")).toBe(RIGHT_PANE_UNAVAILABLE); + expect(button.parentElement?.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(RIGHT_PANE_UNAVAILABLE); + }); + + it("restores focus after a layout transition disables the button", () => { + const toggleRightPanels = vi.fn(() => { + mocks.state.isReady = false; + }); + mocks.state.toggleRightPanels = toggleRightPanels; + const { rerender } = render(); + const button = screen.getByTestId(TOGGLE_TEST_ID); + + button.focus(); + fireEvent.click(button); + rerender(); + expect((button as HTMLButtonElement).disabled).toBe(true); + + mocks.state.isReady = true; + rerender(); + expect(document.activeElement).toBe(button); + }); + + it("does not render on phones", () => { + mocks.state.isSupported = false; + render(); + + expect(screen.queryByTestId(TOGGLE_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-right-panels-toggle.tsx b/apps/web/components/task/task-right-panels-toggle.tsx new file mode 100644 index 0000000000..cc5d2c0bbf --- /dev/null +++ b/apps/web/components/task/task-right-panels-toggle.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTaskRightPanelsToggle } from "@/hooks/use-task-right-panels-toggle"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +type TaskRightPanelsToggleProps = { + sessionId?: string | null; +}; + +export function TaskRightPanelsToggle({ sessionId = null }: TaskRightPanelsToggleProps) { + const { t } = useTranslation(); + const { isSupported, isReady, isMaximized, isAvailable, rightPanelsVisible, toggleRightPanels } = + useTaskRightPanelsToggle(sessionId); + const buttonRef = useRef(null); + const restoreFocusRef = useRef(false); + const previousReadyRef = useRef(isReady); + + useEffect(() => { + if (isReady && !previousReadyRef.current && restoreFocusRef.current) { + buttonRef.current?.focus(); + restoreFocusRef.current = false; + } + previousReadyRef.current = isReady; + }, [isReady]); + + if (!isSupported) return null; + + let label: string; + if (isMaximized) { + label = t("task:rightPaneUnavailableWhileMaximized"); + } else if (!isAvailable) { + label = t("task:rightPaneUnavailable"); + } else if (rightPanelsVisible) { + label = t("task:hideRightPane"); + } else { + label = t("task:showRightPane"); + } + + const canActivate = isReady && isAvailable; + + return ( + + + + + + + {label} + + ); +} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 97175d73b4..cb1257740d 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -10,6 +10,7 @@ import { useOfficeProject } from "@/hooks/use-office-workspace-data"; import { TaskTopBarTitle } from "@/components/task/task-top-bar-title"; import { EditorsMenu } from "@/components/task/editors-menu"; import { LayoutPresetSelector } from "@/components/task/layout-preset-selector"; +import { TaskRightPanelsToggle } from "@/components/task/task-right-panels-toggle"; import { DocumentControls } from "@/components/task/document/document-controls"; import { PRTopbarButton } from "@/components/github/pr-topbar-button"; import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button"; @@ -346,15 +347,16 @@ function TopbarToolsGroup({ const showDebugToggle = isDebugUI() && onToggleDebugOverlay; return ( - + + {!isArchived && ( - <> +
- +
)} {showDebugToggle && ( { + testPage: async ({ browser, backend, apiClient, seedData }, use, testInfo) => { await backend.ensureReady(); // A suite-level test may restart the worker backend after the worker-scoped // seed fixture ran. Health only proves that the listener is serving; it does @@ -410,6 +410,7 @@ export const test = backendFixture.extend< }); }); const context = await browser.newContext({ + ...(testInfo.project.name === "mobile-chrome" ? devices["Pixel 5"] : {}), baseURL: backend.frontendUrl, }); const page = await context.newPage(); diff --git a/apps/web/e2e/tests/layout/mobile-right-panel-visibility.spec.ts b/apps/web/e2e/tests/layout/mobile-right-panel-visibility.spec.ts new file mode 100644 index 0000000000..0d1b49fe11 --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-right-panel-visibility.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { openTaskSession } from "../../helpers/session"; + +async function createTask(apiClient: ApiClient, seedData: SeedData) { + return apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile right-panel navigation", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); +} + +test.describe("mobile right-panel navigation", () => { + test("keeps Files and Terminal in the existing full-screen navigation", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await createTask(apiClient, seedData); + const session = await openTaskSession(testPage, task.id); + + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); + await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0); + await expect(testPage.getByTestId("task-right-panels-toggle")).toHaveCount(0); + expect((await testPage.viewportSize())?.width).toBe(393); + await expect + .poll(() => testPage.evaluate(() => matchMedia("(any-pointer: coarse)").matches)) + .toBe(true); + + const chat = testPage.getByRole("button", { name: "Chat", exact: true }); + const files = testPage.getByRole("button", { name: "Files", exact: true }); + const terminal = testPage.getByRole("button", { name: "Terminal", exact: true }); + await expect(chat).toBeVisible(); + await expect(files).toBeVisible(); + await expect(terminal).toBeVisible(); + + await files.tap(); + await expect(testPage.getByTestId("file-tree-scroll")).toBeVisible(); + await terminal.tap(); + await expect(session.terminal).toBeVisible(); + await chat.tap(); + await expect(session.activeChat()).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "mobile right-panel navigation"); + }); +}); diff --git a/apps/web/e2e/tests/layout/narrow-tab-strip.spec.ts b/apps/web/e2e/tests/layout/narrow-tab-strip.spec.ts index baab68de10..3e53c4d451 100644 --- a/apps/web/e2e/tests/layout/narrow-tab-strip.spec.ts +++ b/apps/web/e2e/tests/layout/narrow-tab-strip.spec.ts @@ -80,6 +80,10 @@ const rowOverflow = (row: Locator): Promise => const rowScrollLeft = (row: Locator): Promise => row.evaluate((element) => element.scrollLeft); +// Leave less room than the two-tab minimum after Dockview's header actions are +// accounted for, while staying above the production column minimum. +const NARROW_RIGHT_COLUMN_WIDTH = 200; + test.describe("narrow tab strip", () => { test("squeezes tab titles rather than the close buttons", async ({ testPage, @@ -94,7 +98,7 @@ test.describe("narrow tab strip", () => { expect(wide.map((tab) => tab.truncated)).not.toContain(true); expect(wide.every((tab) => tab.closeWidth > 0)).toBe(true); - await resizeColumnViaSplitview(testPage, "right", 240); + await resizeColumnViaSplitview(testPage, "right", NARROW_RIGHT_COLUMN_WIDTH); const narrow = await probeTabStrip(strip); expect(narrow.map((tab) => tab.title)).toEqual(wide.map((tab) => tab.title)); @@ -112,7 +116,7 @@ test.describe("narrow tab strip", () => { seedData, }) => { await openWideTask(testPage, apiClient, seedData, "Horizontal tab scroll"); - await resizeColumnViaSplitview(testPage, "right", 240); + await resizeColumnViaSplitview(testPage, "right", NARROW_RIGHT_COLUMN_WIDTH); const { row } = await markFilesGroup(testPage); const filesTab = row.locator(".dv-default-tab").filter({ hasText: /^Files$/ }); diff --git a/apps/web/e2e/tests/layout/right-panel-visibility.spec.ts b/apps/web/e2e/tests/layout/right-panel-visibility.spec.ts new file mode 100644 index 0000000000..70c7aab99a --- /dev/null +++ b/apps/web/e2e/tests/layout/right-panel-visibility.spec.ts @@ -0,0 +1,377 @@ +import { expect, type Page } from "@playwright/test"; +import { test, type SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { getDockviewGroupWidth } from "../../helpers/dockview-resize"; +import { SessionPage } from "../../pages/session-page"; + +async function createTask(apiClient: ApiClient, seedData: SeedData, title: string) { + return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); +} + +type DesktopTaskOptions = { + viewport: { width: number; height: number }; + layout?: string; +}; + +async function openDesktopTask( + page: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, + options: DesktopTaskOptions, +) { + await page.setViewportSize(options.viewport); + const task = await createTask(apiClient, seedData, title); + if (!task.session_id) throw new Error(`${title} did not return a session_id`); + await page.goto(`/t/${task.id}${options.layout ? `?layout=${options.layout}` : ""}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForDockviewReady(); + return { session, sessionId: task.session_id }; +} + +test.describe("right-panel visibility", () => { + test("desktop hides and restores the right column while reclaiming center width", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session, sessionId } = await openDesktopTask( + testPage, + apiClient, + seedData, + "Desktop right-panel visibility", + { viewport: { width: 1600, height: 900 } }, + ); + const toggle = testPage.getByTestId("task-right-panels-toggle"); + await expect(toggle).toBeVisible(); + await expect(toggle).toBeEnabled(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(session.files).toBeVisible(); + await expect(session.terminal).toBeVisible(); + + const centerWidthBefore = await testPage.evaluate((id) => { + type Api = { + getPanel: (panelId: string) => { group: { width: number } } | undefined; + }; + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + return api?.getPanel(`session:${id}`)?.group.width ?? 0; + }, sessionId); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(session.files).toHaveCount(0); + await expect(session.terminal).toHaveCount(0); + await expect(session.activeChat()).toBeVisible(); + + await expect + .poll( + () => + testPage.evaluate((id) => { + type Api = { + getPanel: (panelId: string) => { group: { width: number } } | undefined; + }; + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + return api?.getPanel(`session:${id}`)?.group.width ?? 0; + }, sessionId), + { message: "center group did not reclaim the right-column width" }, + ) + .toBeGreaterThan(centerWidthBefore + 100); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForDockviewReady(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(session.files).toHaveCount(0); + await expect(session.terminal).toHaveCount(0); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(session.files).toBeVisible(); + await expect(session.terminal).toBeVisible(); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForDockviewReady(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(session.files).toBeVisible(); + await expect(session.terminal).toBeVisible(); + await session.expectLayoutHealthy(); + }); + + test("compact desktop disables the action when it has one workbench region", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session } = await openDesktopTask( + testPage, + apiClient, + seedData, + "Compact desktop right-panel visibility", + { viewport: { width: 900, height: 800 } }, + ); + await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0); + const toggle = testPage.getByTestId("task-right-panels-toggle"); + await expect(toggle).toBeVisible(); + await expect(toggle).toBeDisabled(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toHaveAttribute("title", "No separate right pane to hide"); + await expect(toggle.locator("..")).toHaveAttribute( + "aria-label", + "No separate right pane to hide", + ); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForDockviewReady(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toBeDisabled(); + await expect(session.activeChat()).toBeVisible(); + }); + + test("Plan and Preview layouts toggle their contextual right pane", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session } = await openDesktopTask( + testPage, + apiClient, + seedData, + "Plan contextual right pane", + { viewport: { width: 1600, height: 900 }, layout: "plan" }, + ); + const toggle = testPage.getByTestId("task-right-panels-toggle"); + const plan = testPage.getByTestId("plan-panel"); + await expect(toggle).toBeEnabled(); + await expect(plan).toBeVisible(); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(plan).toHaveCount(0); + await expect(session.activeChat()).toBeVisible(); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(plan).toBeVisible(); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForDockviewReady(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(plan).toBeVisible(); + }); + + test("Preview layout toggles Browser instead of a standard right stack", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session } = await openDesktopTask( + testPage, + apiClient, + seedData, + "Preview contextual right pane", + { viewport: { width: 1600, height: 900 } }, + ); + await testPage.getByTestId("layout-preset-trigger").click(); + await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="preview"]').click(); + const toggle = testPage.getByTestId("task-right-panels-toggle"); + const browser = testPage.getByTestId("browser-panel"); + await expect(toggle).toBeEnabled(); + await expect(browser).toBeVisible(); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(browser).toHaveCount(0); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(browser).toBeVisible(); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForDockviewReady(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(browser).toBeVisible(); + }); + + test("keeps the toggle disabled while maximized and restores visibility after exit and reload", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session } = await openDesktopTask( + testPage, + apiClient, + seedData, + "Maximized right-panel visibility", + { viewport: { width: 1600, height: 900 } }, + ); + const toggle = testPage.getByTestId("task-right-panels-toggle"); + await expect(toggle).toBeEnabled(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + + await session.clickTab("Files"); + await session.clickMaximize(); + await session.expectMaximized(); + + await expect(toggle).toBeDisabled(); + await expect(toggle).toHaveAttribute( + "title", + "Right pane is unavailable while a panel is maximized", + ); + await expect(toggle.locator("..")).toHaveAttribute( + "aria-label", + "Right pane is unavailable while a panel is maximized", + ); + + await session.clickMaximize(); + await expect(toggle).toBeEnabled(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(session.files).toBeVisible(); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForDockviewReady(); + await expect(toggle).toBeEnabled(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(session.files).toBeVisible(); + await session.expectLayoutHealthy(); + }); + + test("supports keyboard activation while keeping focus on the persistent control", async ({ + testPage, + apiClient, + seedData, + }) => { + await openDesktopTask(testPage, apiClient, seedData, "Keyboard right-panel visibility", { + viewport: { width: 1600, height: 900 }, + }); + const toggle = testPage.getByTestId("task-right-panels-toggle"); + await expect(toggle).toBeEnabled(); + + await toggle.focus(); + await expect(toggle).toBeFocused(); + await toggle.press("Enter"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toBeFocused(); + + await toggle.press("Space"); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(toggle).toBeFocused(); + }); + + test("tablet hides Files and Terminal, preserves Chat, and restores the session choice", async ({ + tabletTestPage, + apiClient, + seedData, + }) => { + const task = await createTask(apiClient, seedData, "Tablet right-panel visibility"); + if (!task.session_id) throw new Error("tablet task did not return a session_id"); + const { sessions } = await apiClient.listTaskSessions(task.id); + const environmentId = sessions.find( + (session) => session.id === task.session_id, + )?.task_environment_id; + if (!environmentId) throw new Error("tablet task is missing an environment id"); + const terminal = await apiClient.wsRequest<{ terminal_id: string }>("user_shell.create", { + task_id: task.id, + task_environment_id: environmentId, + }); + await tabletTestPage.goto(`/t/${task.id}`); + const session = new SessionPage(tabletTestPage); + const tabletFiles = tabletTestPage.getByTestId("file-tree-scroll"); + const tabletTerminalTab = tabletTestPage.getByTestId(`terminal-tab-${terminal.terminal_id}`); + await session.waitForLoad(); + await expect(tabletTestPage.getByTestId("tablet-task-layout")).toBeVisible(); + + const toggle = tabletTestPage.getByTestId("task-right-panels-toggle"); + await expect(toggle).toBeVisible(); + await expect(toggle).toBeEnabled(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(tabletFiles).toBeVisible(); + await expect(tabletTerminalTab).toBeVisible(); + + await toggle.tap(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(tabletFiles).toHaveCount(0); + await expect(tabletTerminalTab).toHaveCount(0); + await expect(session.activeChat()).toBeVisible(); + await assertNoDocumentHorizontalOverflow(tabletTestPage, "tablet right-panel visibility"); + + const stored = await tabletTestPage.evaluate((sessionId) => { + const raw = window.localStorage.getItem("layout-columns-by-session"); + const layouts = raw ? (JSON.parse(raw) as Record) : {}; + return layouts[sessionId]?.right; + }, task.session_id); + expect(stored).toBe(false); + + await tabletTestPage.reload(); + await session.waitForLoad(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(tabletFiles).toHaveCount(0); + + await toggle.tap(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(tabletFiles).toBeVisible(); + await expect(tabletTerminalTab).toBeVisible(); + }); +}); + +for (const layout of ["default", "plan", "preview"] as const) { + test(`restores manually resized ${layout} pane width after toggling`, async ({ + testPage, + apiClient, + seedData, + }) => { + const { session } = await openDesktopTask( + testPage, + apiClient, + seedData, + `Resized ${layout} pane`, + { viewport: { width: 1600, height: 900 } }, + ); + if (layout !== "default") { + await testPage.getByTestId("layout-preset-trigger").click(); + await testPage + .locator(`[data-testid="layout-preset-item"][data-preset-id="${layout}"]`) + .click(); + await expect( + testPage.getByTestId(layout === "plan" ? "plan-panel" : "browser-panel"), + ).toBeVisible(); + } + const panelId = { default: "files", preview: "browser", plan: "plan" }[layout]!; + const before = await getDockviewGroupWidth(testPage, panelId); + const sashBox = await testPage.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const api = (window as any).__dockviewApi__; + const sashes = api.component.gridview.root.splitview.sashes; + const rect = sashes.at(-1).container.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + 100 }; + }); + await testPage.mouse.move(sashBox.x, sashBox.y); + await testPage.mouse.down(); + await testPage.mouse.move(sashBox.x - 100, sashBox.y, { steps: 10 }); + await testPage.mouse.up(); + await expect.poll(() => getDockviewGroupWidth(testPage, panelId)).toBeGreaterThan(before + 50); + const resized = await getDockviewGroupWidth(testPage, panelId); + const toggle = testPage.getByTestId("task-right-panels-toggle"); + for (let cycle = 0; cycle < 3; cycle++) { + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await session.waitForDockviewReady(); + await expect + .poll(async () => Math.abs((await getDockviewGroupWidth(testPage, panelId)) - resized)) + .toBeLessThanOrEqual(2); + } + }); +} diff --git a/apps/web/e2e/tests/session/session-resume.spec.ts b/apps/web/e2e/tests/session/session-resume.spec.ts index e4f9a24b8f..06cead7a97 100644 --- a/apps/web/e2e/tests/session/session-resume.spec.ts +++ b/apps/web/e2e/tests/session/session-resume.spec.ts @@ -44,6 +44,43 @@ async function openTaskSession(page: Page, title: string): Promise type SessionTabHistoryEntry = { id: string; text: string }; +type E2EStoreWindow = Window & { + __KANDEV_E2E_STORE__?: { + getState: () => { + kanbanMulti: { + snapshots: Record< + string, + { + steps: Array<{ id: string }>; + tasks: Array<{ id: string; workflowStepId: string }>; + } + >; + }; + workflows: { activeId: string | null }; + }; + }; +}; + +/** Wait for the workflow snapshot source that renders the Kanban cards to hydrate. */ +async function waitForKanbanTask(page: Page, workflowId: string, taskId: string): Promise { + await page.waitForFunction( + ({ expectedWorkflowId, expectedTaskId }) => { + const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__; + const state = store?.getState(); + if (!state) return false; + if (state.workflows.activeId !== expectedWorkflowId) return false; + const snapshot = state.kanbanMulti.snapshots[expectedWorkflowId]; + if (!snapshot) return false; + const stepIds = new Set(snapshot.steps.map((step) => step.id)); + return snapshot.tasks.some( + (task) => task.id === expectedTaskId && stepIds.has(task.workflowStepId), + ); + }, + { expectedWorkflowId: workflowId, expectedTaskId: taskId }, + { timeout: 30_000 }, + ); +} + async function recordSessionTabHistory(page: Page): Promise { await page.addInitScript(() => { const history: SessionTabHistoryEntry[][] = []; @@ -265,10 +302,13 @@ test.describe("Session resume (ACP mode)", () => { const visibleWorkspacePath = session.files.getByTestId("file-browser-workspace-path"); await expect(visibleWorkspacePath).toHaveText(expectedDisplayPath, { timeout: 30_000 }); - // Keeping Files active makes reload reconstruct the workspace-only execution - // before automatic session resume promotes and persists that execution. + // Reload can foreground Changes while the resumed execution refreshes its + // working tree. Wait for the hydrated workbench, then select Files before + // asserting the workspace path. await backend.restart(); await testPage.reload(); + await session.waitForLoad(); + await session.clickTab("Files"); await expect(session.files).toBeVisible({ timeout: 30_000 }); await session.clickSessionChatTab(); @@ -415,16 +455,22 @@ test.describe("Session resume (TUI passthrough mode)", () => { const tuiProfile = await createTUIProfile(apiClient, "TUI Resume"); // 2. Create task with TUI agent - await apiClient.createTaskWithAgent(seedData.workspaceId, "TUI Resume Task", tuiProfile.id, { - description: "hello from resume test", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "TUI Resume Task", + tuiProfile.id, + { + description: "hello from resume test", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); // 3. Navigate and wait for TUI terminal to load const kanban = new KanbanPage(testPage); await kanban.goto(); + await waitForKanbanTask(testPage, seedData.workflowId, task.id); const card = kanban.taskCardByTitle("TUI Resume Task"); await expect(card).toBeVisible({ timeout: 15_000 }); @@ -490,7 +536,7 @@ test.describe("Session resume (TUI passthrough mode)", () => { // 2. TUI profile + multi-repo task const tuiProfile = await createTUIProfile(apiClient, "TUI Multi-Repo Resume"); - await apiClient.createTaskWithAgent( + const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "TUI Multi-Repo Resume Task", tuiProfile.id, @@ -504,6 +550,7 @@ test.describe("Session resume (TUI passthrough mode)", () => { const kanban = new KanbanPage(testPage); await kanban.goto(); + await waitForKanbanTask(testPage, seedData.workflowId, task.id); const card = kanban.taskCardByTitle("TUI Multi-Repo Resume Task"); await expect(card).toBeVisible({ timeout: 15_000 }); await card.click(); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts index 8fae70f1ee..c5175f5e9d 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts @@ -399,6 +399,8 @@ test.describe("@-mention autocomplete: adversarial QA", () => { const reference = dialog.getByTestId("task-prompt-reference").first(); const remove = reference.getByTestId("task-prompt-reference-remove"); + await expect(reference.getByTestId("custom-prompt-mention")).toBeVisible(); + await expect(remove).toBeVisible(); const metrics = await readPromptReferenceMetrics(reference); expect(metrics.shell.height).toBeCloseTo(24, 0); expect(metrics.labelFontSize).toBe("12px"); @@ -490,6 +492,8 @@ test.describe("@-mention autocomplete: adversarial QA", () => { const mention = reference.getByTestId("custom-prompt-mention"); const remove = reference.getByTestId("task-prompt-reference-remove"); await expect(mention).toHaveAttribute("aria-label", `Custom prompt: ${promptName}`); + await expect(mention).toBeVisible(); + await expect(remove).toBeVisible(); const metrics = await readPromptReferenceMetrics(reference); expect(metrics.labelText).toBe(`@${promptName}`); expect(metrics.labelScrollWidth).toBeGreaterThan(metrics.labelClientWidth); diff --git a/apps/web/hooks/use-task-right-panels-toggle.test.ts b/apps/web/hooks/use-task-right-panels-toggle.test.ts new file mode 100644 index 0000000000..41004445b5 --- /dev/null +++ b/apps/web/hooks/use-task-right-panels-toggle.test.ts @@ -0,0 +1,169 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + breakpoint: { + isMobile: false, + isTablet: false, + usesDesktopWorkbench: true, + }, + dock: { + api: null as object | null, + rightPanelsVisible: true, + rightPaneVisible: true, + rightPaneAvailable: true, + isRestoringLayout: false, + preMaximizeLayout: null as object | null, + toggleRightPanels: vi.fn(), + }, + layout: { + columnsBySessionId: {} as Record, + toggleRightPanel: vi.fn(), + }, +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => mocks.breakpoint, +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: (selector: (state: typeof mocks.dock) => unknown) => selector(mocks.dock), +})); + +vi.mock("@/lib/state/layout-store", () => ({ + useLayoutStore: (selector: (state: typeof mocks.layout) => unknown) => selector(mocks.layout), +})); + +import { useTaskRightPanelsToggle } from "./use-task-right-panels-toggle"; + +function setDesktop(overrides: Partial = {}) { + mocks.breakpoint = { + isMobile: false, + isTablet: false, + usesDesktopWorkbench: true, + }; + Object.assign(mocks.dock, { + api: {}, + rightPanelsVisible: true, + rightPaneVisible: true, + rightPaneAvailable: true, + isRestoringLayout: false, + preMaximizeLayout: null, + ...overrides, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.breakpoint = { + isMobile: false, + isTablet: false, + usesDesktopWorkbench: true, + }; + mocks.dock.api = null; + mocks.dock.rightPanelsVisible = true; + mocks.dock.rightPaneVisible = true; + mocks.dock.rightPaneAvailable = true; + mocks.dock.isRestoringLayout = false; + mocks.dock.preMaximizeLayout = null; + mocks.layout.columnsBySessionId = {}; +}); + +describe("useTaskRightPanelsToggle", () => { + it("waits for the desktop API and settled restoration before enabling", () => { + const { result, rerender } = renderHook(() => useTaskRightPanelsToggle("session-1")); + + expect(result.current.isSupported).toBe(true); + expect(result.current.isReady).toBe(false); + + setDesktop({ isRestoringLayout: true }); + rerender(); + expect(result.current.isReady).toBe(false); + + mocks.dock.isRestoringLayout = false; + rerender(); + expect(result.current.isReady).toBe(true); + + act(() => result.current.toggleRightPanels()); + expect(mocks.dock.toggleRightPanels).toHaveBeenCalledTimes(1); + expect(mocks.layout.toggleRightPanel).not.toHaveBeenCalled(); + }); + + it("routes a coarse-pointer tablet toggle to the effective session layout", () => { + mocks.breakpoint = { + isMobile: false, + isTablet: true, + usesDesktopWorkbench: false, + }; + mocks.dock.api = {}; + mocks.dock.rightPanelsVisible = true; + mocks.layout.columnsBySessionId = { + "session-1": { right: false }, + }; + + const { result } = renderHook(() => useTaskRightPanelsToggle("session-1")); + + expect(result.current.isSupported).toBe(true); + expect(result.current.isReady).toBe(true); + expect(result.current.rightPanelsVisible).toBe(false); + + act(() => result.current.toggleRightPanels()); + expect(mocks.layout.toggleRightPanel).toHaveBeenCalledWith("session-1"); + expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled(); + }); + + it("disables the desktop toggle while a group is maximized", () => { + setDesktop({ preMaximizeLayout: { columns: [] } }); + + const { result } = renderHook(() => useTaskRightPanelsToggle("session-1")); + + expect(result.current.isMaximized).toBe(true); + expect(result.current.isReady).toBe(false); + act(() => result.current.toggleRightPanels()); + expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled(); + }); + + it("does not write an empty tablet session key", () => { + mocks.breakpoint = { + isMobile: false, + isTablet: true, + usesDesktopWorkbench: false, + }; + + const { result } = renderHook(() => useTaskRightPanelsToggle(null)); + + expect(result.current.isSupported).toBe(true); + expect(result.current.isReady).toBe(false); + act(() => result.current.toggleRightPanels()); + expect(mocks.layout.toggleRightPanel).not.toHaveBeenCalled(); + }); + + it("disables the desktop toggle when the live layout has no separate right pane", () => { + setDesktop({ rightPaneAvailable: false }); + + const { result } = renderHook(() => useTaskRightPanelsToggle("session-1")); + + expect(result.current.isAvailable).toBe(false); + expect(result.current.isReady).toBe(true); + act(() => result.current.toggleRightPanels()); + expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled(); + }); + + it("does not expose or mutate wider layouts on phones", () => { + mocks.breakpoint = { + isMobile: true, + isTablet: false, + usesDesktopWorkbench: false, + }; + mocks.dock.api = {}; + mocks.layout.columnsBySessionId = { "session-1": { right: false } }; + + const { result } = renderHook(() => useTaskRightPanelsToggle("session-1")); + + expect(result.current.isSupported).toBe(false); + expect(result.current.isReady).toBe(false); + act(() => result.current.toggleRightPanels()); + expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled(); + expect(mocks.layout.toggleRightPanel).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/use-task-right-panels-toggle.ts b/apps/web/hooks/use-task-right-panels-toggle.ts new file mode 100644 index 0000000000..c2b3b36492 --- /dev/null +++ b/apps/web/hooks/use-task-right-panels-toggle.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback } from "react"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useLayoutStore } from "@/lib/state/layout-store"; + +export type TaskRightPanelsToggleState = { + isSupported: boolean; + isReady: boolean; + isMaximized: boolean; + isAvailable: boolean; + rightPanelsVisible: boolean; + toggleRightPanels: () => void; +}; + +/** + * Select the layout owner for the task header's right-panel action. + * + * Fine-pointer workbenches use Dockview's environment layout. Coarse-pointer + * tablet workspaces use the session layout store, even if a Dockview API from + * a previous desktop render is still present. + */ +export function useTaskRightPanelsToggle( + effectiveSessionId: string | null | undefined, +): TaskRightPanelsToggleState { + const { isMobile, isTablet, usesDesktopWorkbench } = useResponsiveBreakpoint(); + const dockviewApi = useDockviewStore((state) => state.api); + const dockviewVisible = useDockviewStore((state) => state.rightPaneVisible); + const dockviewAvailable = useDockviewStore((state) => state.rightPaneAvailable); + const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout); + const preMaximizeLayout = useDockviewStore((state) => state.preMaximizeLayout); + const toggleDockviewRightPanels = useDockviewStore((state) => state.toggleRightPanels); + const columnsBySessionId = useLayoutStore((state) => state.columnsBySessionId); + const toggleTabletRightPanel = useLayoutStore((state) => state.toggleRightPanel); + + const isSupported = !isMobile && (isTablet || usesDesktopWorkbench); + let rightPanelsVisible = dockviewVisible; + if (isTablet && effectiveSessionId) { + rightPanelsVisible = columnsBySessionId[effectiveSessionId]?.right ?? true; + } + const isMaximized = usesDesktopWorkbench && preMaximizeLayout !== null; + const isReady = isTablet + ? Boolean(effectiveSessionId) + : usesDesktopWorkbench && Boolean(dockviewApi) && !isRestoringLayout && !isMaximized; + const isAvailable = isTablet ? Boolean(effectiveSessionId) : dockviewAvailable; + + const toggleRightPanels = useCallback(() => { + if (!isSupported || !isReady || !isAvailable) return; + if (isTablet) { + if (effectiveSessionId) toggleTabletRightPanel(effectiveSessionId); + return; + } + toggleDockviewRightPanels(); + }, [ + effectiveSessionId, + isReady, + isSupported, + isAvailable, + isTablet, + toggleDockviewRightPanels, + toggleTabletRightPanel, + ]); + + return { + isSupported, + isReady: isSupported && isReady, + isMaximized, + isAvailable, + rightPanelsVisible, + toggleRightPanels, + }; +} diff --git a/apps/web/lib/state/dockview-env-switch-action.test.ts b/apps/web/lib/state/dockview-env-switch-action.test.ts index 01311ca71c..bc6d4141a3 100644 --- a/apps/web/lib/state/dockview-env-switch-action.test.ts +++ b/apps/web/lib/state/dockview-env-switch-action.test.ts @@ -23,6 +23,14 @@ vi.mock("@/lib/layout/panel-portal-manager", () => ({ }, })); +vi.mock("./layout-manager", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fromDockviewApi: vi.fn(() => ({ columns: [] })), + }; +}); + import { getEnvLayoutProfile, getEnvMaximizeState, @@ -30,6 +38,7 @@ import { setEnvLayoutProfile, } from "@/lib/local-storage"; import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; +import { fromDockviewApi } from "./layout-manager"; function makeMockApi(): DockviewApi { return { @@ -48,6 +57,51 @@ function makeMockApi(): DockviewApi { } as unknown as DockviewApi; } +function flushRaf(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +const visibleRightLayout = { + columns: [ + { + id: "center", + groups: [ + { + id: "runtime-center", + panels: [{ id: "session:session-b", component: "chat", title: "Agent" }], + }, + ], + }, + { + id: "right", + pinned: true, + groups: [ + { + id: "group-right-top", + panels: [{ id: "files", component: "files", title: "Files" }], + }, + ], + }, + ], +}; + +const hiddenRightLayout = { + columns: [ + { + id: "center", + groups: [ + { + id: "runtime-center", + panels: [ + { id: "session:session-a", component: "chat", title: "Agent" }, + { id: "files", component: "files", title: "Files" }, + ], + }, + ], + }, + ], +}; + function testNormalizesStaleSessionPanelsOnSavedMaximize(): void { const savedMaximizedJson = { grid: { @@ -132,9 +186,12 @@ function testNormalizesStaleSessionPanelsOnSavedMaximize(): void { expect(closeStale).toHaveBeenCalledOnce(); } +// eslint-disable-next-line max-lines-per-function describe("switchEnvLayout — root fix for terminal/layout swapping", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(fromDockviewApi).mockReset().mockReturnValue({ columns: [] }); + vi.mocked(getEnvMaximizeState).mockReset().mockReturnValue(null); useDockviewStore.setState({ api: null, currentLayoutEnvId: null, @@ -189,6 +246,41 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => { expect(state.pendingChatScrollTop).toBeNull(); }); + it("refreshes right-panel visibility across env switches and both toggle directions", async () => { + const api = makeMockApi(); + useDockviewStore.setState({ + api, + currentLayoutEnvId: "env-a", + rightPanelsVisible: false, + }); + + vi.mocked(fromDockviewApi).mockReturnValue(visibleRightLayout); + useDockviewStore.getState().switchEnvLayout("env-a", "env-b", "session-b"); + await flushRaf(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(true); + + // B's visible layout follows the hide action and must update its label/state. + vi.mocked(fromDockviewApi).mockReturnValue(visibleRightLayout); + useDockviewStore.getState().toggleRightPanels(); + await flushRaf(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(false); + + // Returning to A restores its hidden layout, rather than retaining B's + // outgoing visibility value. + vi.mocked(fromDockviewApi).mockReturnValue(hiddenRightLayout); + useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a"); + await flushRaf(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(false); + + // A has one workbench region and no retained recovery metadata, so Show + // remains disabled instead of fabricating the standard sidebar. + vi.mocked(fromDockviewApi).mockReturnValue(hiddenRightLayout); + useDockviewStore.getState().toggleRightPanels(); + await flushRaf(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(false); + expect(useDockviewStore.getState().rightPaneAvailable).toBe(false); + }); + it("ignores completion from a superseded env-switch placement", () => { const api = makeMockApi(); useDockviewStore.setState({ api, currentLayoutEnvId: "env-a" }); @@ -273,9 +365,12 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => { * forgets `maximizedGroupId` — leaving the store in an inconsistent * half-maximized state on the way back. */ +// eslint-disable-next-line max-lines-per-function describe("switchEnvLayout — maximize+sidebar-switch regression", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(fromDockviewApi).mockReset().mockReturnValue({ columns: [] }); + vi.mocked(getEnvMaximizeState).mockReset().mockReturnValue(null); useDockviewStore.setState({ api: null, currentLayoutEnvId: null, @@ -338,7 +433,15 @@ describe("switchEnvLayout — maximize+sidebar-switch regression", () => { }; vi.mocked(getEnvMaximizeState).mockImplementation((envId) => envId === "env-a" - ? { preMaximizeLayout: { columns: [] }, maximizedDockviewJson: savedMaximizedJson } + ? { + preMaximizeLayout: { + columns: [ + { id: "center", groups: [{ id: "group-center", panels: [] }] }, + { id: "right", groups: [{ id: "group-right-top", panels: [] }] }, + ], + }, + maximizedDockviewJson: savedMaximizedJson, + } : null, ); @@ -348,6 +451,53 @@ describe("switchEnvLayout — maximize+sidebar-switch regression", () => { const state = useDockviewStore.getState(); expect(state.preMaximizeLayout).not.toBeNull(); expect(state.maximizedGroupId).toBeTruthy(); + expect(state.rightPanelsVisible).toBe(true); + }); + + it("restores hidden right-panel visibility with a saved maximize state", () => { + const api = makeMockApi(); + const savedMaximizedJson = { + grid: { + root: { + type: "leaf", + size: 600, + data: { id: "g-center", views: ["chat", "files"] }, + }, + height: 600, + width: 800, + orientation: "HORIZONTAL", + }, + panels: { + chat: { id: "chat", contentComponent: "chat" }, + files: { id: "files", contentComponent: "files" }, + }, + activeGroup: "g-center", + }; + vi.mocked(getEnvMaximizeState).mockReturnValue({ + preMaximizeLayout: { + columns: [ + { + id: "center", + groups: [ + { + id: "runtime-center", + panels: [ + { id: "session:session-a", component: "chat", title: "Agent" }, + { id: "files", component: "files", title: "Files" }, + ], + }, + ], + }, + ], + }, + maximizedDockviewJson: savedMaximizedJson, + }); + + useDockviewStore.setState({ api, currentLayoutEnvId: "env-b" }); + useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a"); + + expect(useDockviewStore.getState().preMaximizeLayout).not.toBeNull(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(false); }); it("normalizes stale session panels when restoring a saved maximize layout", () => { diff --git a/apps/web/lib/state/dockview-env-switch.ts b/apps/web/lib/state/dockview-env-switch.ts index 61e42a7c4f..ff01abd6ff 100644 --- a/apps/web/lib/state/dockview-env-switch.ts +++ b/apps/web/lib/state/dockview-env-switch.ts @@ -30,6 +30,7 @@ import { restoreSavedActiveViews, } from "./dockview-env-switch-active-views"; import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "./dockview-env-scoped-components"; +import { stripHiddenRightPaneMetadata } from "./dockview-right-pane"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import { snapshotColumnWidths, @@ -68,7 +69,24 @@ const EPHEMERAL_COMPONENTS = ENV_SCOPED_DOCKVIEW_COMPONENTS; function getHealthyEnvLayout(envId: string): object | null { const saved = getEnvLayout(envId); if (!saved) return null; - return isLayoutShapeHealthy(saved) ? saved : null; + const dockviewLayout = stripHiddenRightPaneMetadata(saved); + return isLayoutShapeHealthy(dockviewLayout) ? dockviewLayout : null; +} + +function restoreSerializedDockview(api: DockviewApi, next: SerializedDockview): void { + const previous = typeof api.toJSON === "function" ? api.toJSON() : null; + try { + api.fromJSON(next); + } catch (error) { + if (previous) { + try { + api.fromJSON(previous); + } catch { + // A failed rollback cannot be repaired by retrying without risking another partial mutation. + } + } + throw error; + } } /** Check whether a serialized dockview layout contains ephemeral panels. */ @@ -597,7 +615,7 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds { `savedRight=${savedRightColumnWidth(saved as SerializedDockview) ?? "-"}`, ); } - api.fromJSON(saved as SerializedDockview); + restoreSerializedDockview(api, saved as SerializedDockview); // Saved layout may carry a stale session panel from a previously-deleted // task (phantom). Replace stale session panels with the incoming active // session in the same (group, tab-index), then close the stale ones — diff --git a/apps/web/lib/state/dockview-preset-persistence.test.ts b/apps/web/lib/state/dockview-preset-persistence.test.ts index b4043e6cd5..ce54fa18b4 100644 --- a/apps/web/lib/state/dockview-preset-persistence.test.ts +++ b/apps/web/lib/state/dockview-preset-persistence.test.ts @@ -83,7 +83,6 @@ import { removeEnvMaximizeState, setEnvLayout } from "@/lib/local-storage"; import { persistEnvLayoutNow, useDockviewStore } from "./dockview-store"; import { applyLayout, - defaultLayout, fromDockviewApi, getPresetLayout, resolveNamedIntent, @@ -179,6 +178,7 @@ describe("persistEnvLayoutNow", () => { // previously held isRestoringLayout=true for the whole rAF and never wrote. function resetStoreForIntegration() { vi.clearAllMocks(); + vi.mocked(fromDockviewApi).mockReset().mockReturnValue({ columns: [] }); useDockviewStore.setState({ api: null, currentLayoutEnvId: null, @@ -188,6 +188,7 @@ function resetStoreForIntegration() { pinnedWidths: new Map(), userDefaultLayout: null, userDefaultLayoutProfile: { kind: "built-in", id: "default" }, + defaultPreset: "default", }); } @@ -462,95 +463,6 @@ describe("buildDefaultLayout — effective default widths", () => { }); }); -describe("toggleRightPanels — center fallback", () => { - beforeEach(resetStoreForIntegration); - - it("keeps a center fallback PR Details tab when hiding right panels", async () => { - const api = makeStoreApi(); - vi.mocked(fromDockviewApi).mockReturnValue({ - columns: [ - { - id: "center", - groups: [ - { - id: "group-center", - panels: [ - { id: "chat", component: "chat", title: "Agent" }, - { id: "pr-detail", component: "pr-detail", title: "PR Details" }, - ], - }, - ], - }, - { - id: "right", - pinned: true, - groups: [ - { - id: "group-right-top", - panels: [{ id: "files", component: "files", title: "Files" }], - }, - ], - }, - ], - }); - useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" }); - - useDockviewStore.getState().toggleRightPanels(); - - const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1]; - expect(appliedState?.columns.map((column) => column.id)).toEqual(["center"]); - expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([ - "chat", - "pr-detail", - ]); - await flushRaf(); - }); - - it("keeps a center fallback PR Details tab when showing right panels", async () => { - const api = makeStoreApi(); - vi.mocked(fromDockviewApi).mockReturnValue({ - columns: [ - { - id: "center", - groups: [ - { - id: "group-center", - panels: [ - { id: "chat", component: "chat", title: "Agent" }, - { id: "pr-detail", component: "pr-detail", title: "PR Details" }, - ], - }, - ], - }, - ], - }); - vi.mocked(defaultLayout).mockReturnValue({ - columns: [ - { - id: "right", - pinned: true, - groups: [ - { - id: "group-right-top", - panels: [{ id: "files", component: "files", title: "Files" }], - }, - ], - }, - ], - }); - useDockviewStore.setState({ api, rightPanelsVisible: false, defaultPreset: "default" }); - - useDockviewStore.getState().toggleRightPanels(); - - const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1]; - expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([ - "chat", - "pr-detail", - ]); - await flushRaf(); - }); -}); - describe("toggleRightPanels", () => { beforeEach(resetStoreForIntegration); diff --git a/apps/web/lib/state/dockview-right-pane.test.ts b/apps/web/lib/state/dockview-right-pane.test.ts new file mode 100644 index 0000000000..b8017d3493 --- /dev/null +++ b/apps/web/lib/state/dockview-right-pane.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from "vitest"; +import type { LayoutColumn, LayoutState } from "./layout-manager/types"; +import { toSerializedDockview } from "./layout-manager/serializer"; +import { + captureRightPane, + getRightPaneToggleState, + readHiddenRightPane, + restoreRightPane, + stripHiddenRightPaneMetadata, + withHiddenRightPaneMetadata, +} from "./dockview-right-pane"; + +const panel = (id: string, component = id, params?: Record) => ({ + id, + component, + title: id, + ...(params ? { params } : {}), +}); + +function centerColumn(): LayoutColumn { + return { + id: "center", + groups: [ + { + id: "center-group", + activePanel: "session:session-a", + panels: [panel("session:session-a", "chat"), panel("changes")], + }, + ], + }; +} + +function nestedRightColumn(): LayoutColumn { + const top = { + id: "right-top", + activePanel: "browser", + panels: [panel("browser", "browser", { url: "https://example.test" }), panel("plan")], + }; + const bottom = { + id: "right-bottom", + activePanel: "terminal", + panels: [panel("terminal", "terminal", { terminalId: "terminal-a" })], + }; + return { + id: "browser-region", + width: 420, + groups: [top, bottom], + tree: { + type: "branch", + size: 420, + children: [ + { type: "leaf", size: 280, group: top }, + { type: "leaf", size: 140, group: bottom }, + ], + }, + }; +} + +function visibleLayout(columns: LayoutColumn[]): LayoutState { + return { columns, rootOrientation: "HORIZONTAL" }; +} + +describe("contextual right-pane selection and recovery", () => { + it("captures the actual final region and restores its nested tree, tabs, and parameters", () => { + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.8 + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.9 + const original = visibleLayout([centerColumn(), nestedRightColumn()]); + const captured = captureRightPane(original); + + expect(captured?.layout.columns.map((column) => column.id)).toEqual(["center"]); + expect(captured?.hiddenRightPane.column).toEqual(original.columns[1]); + + const changedRemaining = { + ...captured!.layout, + columns: [ + { + ...captured!.layout.columns[0], + groups: [ + { + ...captured!.layout.columns[0].groups[0], + activePanel: "changes", + }, + ], + }, + ], + }; + const restored = restoreRightPane(changedRemaining, captured!.hiddenRightPane); + + expect(restored?.columns[0]?.groups[0]?.activePanel).toBe("changes"); + expect(restored?.columns[1]).toEqual(original.columns[1]); + expect(restored?.columns[1]?.tree).toEqual(nestedRightColumn().tree); + }); + + it("selects only the outermost final region in a three-column layout", () => { + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.8 + const layout = visibleLayout([ + centerColumn(), + { id: "plan", groups: [{ panels: [panel("plan")] }] }, + { id: "browser", groups: [{ panels: [panel("browser")] }] }, + ]); + const captured = captureRightPane(layout); + + expect(captured?.hiddenRightPane.sourceColumnId).toBe("browser"); + expect(captured?.layout.columns.map((column) => column.id)).toEqual(["center", "plan"]); + }); + + it("does not expose a target for a single region, a vertical split, or an Agent region", () => { + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.3 + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.8 + const single = visibleLayout([centerColumn()]); + const vertical = { + ...visibleLayout([centerColumn(), { id: "stack", groups: [{ panels: [panel("files")] }] }]), + rootOrientation: "VERTICAL" as const, + }; + const agentOnRight = visibleLayout([ + centerColumn(), + { id: "other", groups: [{ panels: [panel("session:session-b", "chat")] }] }, + ]); + + expect(getRightPaneToggleState(single, null)).toEqual({ + available: false, + visible: false, + hidden: false, + }); + expect(getRightPaneToggleState(vertical, null).available).toBe(false); + expect(getRightPaneToggleState(agentOnRight, null).available).toBe(false); + }); +}); + +it("deduplicates panels reopened in the remaining layout without losing the retained subtree", () => { + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.10 + const original = visibleLayout([ + centerColumn(), + { + id: "right", + groups: [{ id: "right-group", panels: [panel("files"), panel("shared")] }], + }, + ]); + const captured = captureRightPane(original)!; + const current = visibleLayout([ + { + ...centerColumn(), + groups: [ + { + ...centerColumn().groups[0], + panels: [...centerColumn().groups[0].panels, panel("shared")], + }, + ], + }, + ]); + + const restored = restoreRightPane(current, captured.hiddenRightPane); + + expect(restored?.columns[1]?.groups[0]?.panels.map((item) => item.id)).toEqual(["files"]); + const ids = restored!.columns.flatMap((column) => + column.groups.flatMap((group) => group.panels.map((item) => item.id)), + ); + expect(new Set(ids).size).toBe(ids.length); +}); + +it("rejects recovery metadata that cannot produce a safe serialized layout", () => { + const captured = captureRightPane(visibleLayout([centerColumn(), nestedRightColumn()]))!; + const malformedGroupId = JSON.parse(JSON.stringify(captured.hiddenRightPane)) as { + column: { groups: Array<{ id?: unknown }> }; + }; + malformedGroupId.column.groups[0].id = 42; + + const malformedTree = { + ...captured.hiddenRightPane, + column: { + ...captured.hiddenRightPane.column, + tree: { + ...captured.hiddenRightPane.column.tree!, + size: Number.NaN, + }, + }, + }; + + expect(readHiddenRightPane({ kandevHiddenRightPane: malformedGroupId })).toBeNull(); + expect(readHiddenRightPane({ kandevHiddenRightPane: malformedTree })).toBeNull(); + + const current = captured.layout; + expect(restoreRightPane(current, malformedTree)).toBeNull(); + expect(current.columns.map((column) => column.id)).toEqual(["center"]); +}); + +it("keeps the split axis when deduplication removes a nested sibling", () => { + const browserGroup = { id: "browser-group", panels: [panel("browser")] }; + const planGroup = { id: "plan-group", panels: [panel("plan")] }; + const terminalGroup = { + id: "terminal-group", + activePanel: "terminal", + panels: [panel("terminal", "terminal")], + }; + const original = visibleLayout([ + { + ...centerColumn(), + groups: [ + ...centerColumn().groups, + { id: "reopened-terminal", panels: [panel("terminal", "terminal")] }, + ], + }, + { + id: "browser-region", + width: 420, + groups: [browserGroup, planGroup, terminalGroup], + tree: { + type: "branch", + size: 420, + children: [ + { + type: "branch", + size: 280, + children: [ + { type: "leaf", size: 140, group: browserGroup }, + { type: "leaf", size: 140, group: planGroup }, + ], + }, + { type: "leaf", size: 140, group: terminalGroup }, + ], + }, + }, + ]); + const captured = captureRightPane(original)!; + const restored = restoreRightPane(captured.layout, captured.hiddenRightPane); + + expect(restored?.columns[1]?.tree).toMatchObject({ type: "branch" }); + const tree = restored?.columns[1]?.tree; + expect(tree?.type).toBe("branch"); + if (!tree || tree.type !== "branch") return; + expect(tree.children).toHaveLength(1); + expect(tree.children[0]?.type).toBe("branch"); + + const serialized = toSerializedDockview(restored!, 1200, 800, new Map()); + const root = serialized as unknown as { + grid: { + root: { + data: Array< + { type: "leaf"; data: { views: string[] } } | { type: "branch"; data: unknown[] } + >; + }; + }; + }; + const rightNode = root.grid.root.data[1]; + expect(rightNode?.type).toBe("branch"); + if (!rightNode || rightNode.type !== "branch") return; + expect(rightNode.data).toHaveLength(1); + const survivingSplit = rightNode.data[0] as { + type: "branch"; + data: Array<{ type: "leaf"; data: { views: string[] } }>; + }; + expect(survivingSplit.type).toBe("branch"); + expect(survivingSplit.data.map((child) => child.data.views)).toEqual([["browser"], ["plan"]]); + expect(survivingSplit.data).not.toContainEqual({ type: "leaf", data: { views: [] } }); +}); + +it("keeps generated group IDs distinct from live explicit IDs", () => { + const original = visibleLayout([ + { + ...centerColumn(), + groups: [{ ...centerColumn().groups[0], id: "group-1" }], + }, + { id: "plan", groups: [{ panels: [panel("plan")] }] }, + ]); + const captured = captureRightPane(original)!; + const restored = restoreRightPane(captured.layout, captured.hiddenRightPane)!; + const serialized = toSerializedDockview(restored, 1200, 800, new Map()) as unknown as { + grid: { + root: { + data: Array< + | { type: "leaf"; data: { id: string } } + | { type: "branch"; data: Array<{ type: "leaf"; data: { id: string } }> } + >; + }; + }; + }; + const ids = serialized.grid.root.data.flatMap((node) => + node.type === "leaf" ? [node.data.id] : node.data.map((child) => child.data.id), + ); + + expect(ids).toEqual(["group-1", "group-2"]); + expect(new Set(ids).size).toBe(ids.length); +}); + +it("restores non-pinned pane width across repeated hide and show cycles", () => { + let current = visibleLayout([ + { ...centerColumn(), width: 600 }, + { id: "plan", width: 600, groups: [{ id: "plan-group", panels: [panel("plan")] }] }, + ]); + + for (let cycle = 0; cycle < 3; cycle++) { + const captured = captureRightPane(current)!; + current = restoreRightPane(captured.layout, captured.hiddenRightPane, { + totalWidth: 1200, + pinnedWidths: new Map(), + })!; + expect(current.columns.map((column) => column.width)).toEqual([600, 600]); + + const serialized = toSerializedDockview(current, 1200, 800, new Map()); + const root = serialized as unknown as { + grid: { root: { data: Array<{ size: number }> } }; + }; + expect(root.grid.root.data.map((node) => node.size)).toEqual([600, 600]); + } +}); + +it("clamps a retained non-pinned pane to viewport space while preserving live proportions", () => { + const original = visibleLayout([ + { ...centerColumn(), width: 400 }, + { id: "preview", width: 200, groups: [{ panels: [panel("preview")] }] }, + { id: "plan", width: 600, groups: [{ panels: [panel("plan")] }] }, + ]); + const captured = captureRightPane(original)!; + const edited = { + ...captured.layout, + columns: captured.layout.columns.map((column, index) => ({ + ...column, + width: index === 0 ? 800 : 200, + })), + }; + + const narrow = restoreRightPane(edited, captured.hiddenRightPane, { + totalWidth: 900, + pinnedWidths: new Map(), + })!; + expect(narrow.columns.map((column) => column.width)).toEqual([288, 72, 540]); + + const wide = restoreRightPane(edited, captured.hiddenRightPane, { + totalWidth: 1600, + pinnedWidths: new Map(), + })!; + expect(wide.columns.map((column) => column.width)).toEqual([800, 200, 600]); +}); + +it("keeps a retained pane authoritative until its layout context is invalid", () => { + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.5 + // @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.10 + const captured = captureRightPane( + visibleLayout([centerColumn(), { id: "plan", groups: [{ panels: [panel("plan")] }] }]), + )!; + + expect(getRightPaneToggleState(captured.layout, captured.hiddenRightPane)).toEqual({ + available: true, + visible: false, + hidden: true, + }); + expect( + getRightPaneToggleState( + visibleLayout([{ id: "unrelated", groups: [{ panels: [panel("files")] }] }]), + captured.hiddenRightPane, + ), + ).toEqual({ available: false, visible: false, hidden: false }); + + const reopened = visibleLayout([ + centerColumn(), + { id: "reopened", groups: [{ panels: [panel("plan")] }] }, + ]); + expect(getRightPaneToggleState(reopened, captured.hiddenRightPane)).toEqual({ + available: true, + visible: true, + hidden: false, + }); +}); + +it("round-trips and strips environment recovery metadata", () => { + const captured = captureRightPane( + visibleLayout([centerColumn(), { id: "plan", groups: [{ panels: [panel("plan")] }] }]), + )!; + const serialized = withHiddenRightPaneMetadata( + { grid: { root: { type: "branch" } }, panels: {} }, + captured.hiddenRightPane, + ); + + expect(readHiddenRightPane(serialized)).toEqual(captured.hiddenRightPane); + expect(stripHiddenRightPaneMetadata(serialized)).toEqual({ + grid: { root: { type: "branch" } }, + panels: {}, + }); + expect(readHiddenRightPane({ kandevHiddenRightPane: { version: 99 } })).toBeNull(); +}); diff --git a/apps/web/lib/state/dockview-right-pane.ts b/apps/web/lib/state/dockview-right-pane.ts new file mode 100644 index 0000000000..0cc4d260a5 --- /dev/null +++ b/apps/web/lib/state/dockview-right-pane.ts @@ -0,0 +1,519 @@ +import type { + LayoutColumn, + LayoutGroup, + LayoutNode, + LayoutOrientation, + LayoutPanel, + LayoutState, +} from "./layout-manager/types"; +import { LAYOUT_PINNED_MIN_PX } from "./layout-manager/caps"; +import { getPinnedWidth } from "./layout-manager/sizing"; + +export const HIDDEN_RIGHT_PANE_METADATA_KEY = "kandevHiddenRightPane"; +const HIDDEN_RIGHT_PANE_VERSION = 1 as const; + +export type HiddenRightPane = { + version: typeof HIDDEN_RIGHT_PANE_VERSION; + sourceIndex: number; + sourceColumnId: string; + rootOrientation: LayoutOrientation; + column: LayoutColumn; + context: { + columnIds: string[]; + panelIds: string[]; + }; +}; + +export type RightPaneToggleState = { + available: boolean; + visible: boolean; + hidden: boolean; +}; + +type PaneSelection = { + index: number; + column: LayoutColumn; +}; + +function groupsInColumn(column: LayoutColumn): LayoutGroup[] { + if (!column.tree) return column.groups; + return collectGroups(column.tree); +} + +function collectGroups(node: LayoutNode): LayoutGroup[] { + return node.type === "leaf" ? [node.group] : node.children.flatMap(collectGroups); +} + +function panelIdsInColumn(column: LayoutColumn): string[] { + return groupsInColumn(column).flatMap((group) => group.panels.map((panel) => panel.id)); +} + +function panelIdsInLayout(layout: LayoutState): string[] { + return layout.columns.flatMap(panelIdsInColumn); +} + +function hasAgentPanel(column: LayoutColumn): boolean { + return panelIdsInColumn(column).some( + (id) => id === "chat" || id.startsWith("session:") || id === "agent", + ); +} + +function hasPanels(column: LayoutColumn): boolean { + return panelIdsInColumn(column).length > 0; +} + +function isHorizontal(layout: LayoutState): boolean { + return (layout.rootOrientation ?? "HORIZONTAL") === "HORIZONTAL"; +} + +function workbenchColumnEntries(layout: LayoutState): Array { + return layout.columns.flatMap((column, index) => + column.id === "sidebar" ? [] : [{ index, column }], + ); +} + +/** Select the actual final side-by-side workbench region. */ +export function selectRightPane(layout: LayoutState): PaneSelection | null { + if (!isHorizontal(layout)) return null; + const columns = workbenchColumnEntries(layout); + if (columns.length < 2) return null; + const target = columns.at(-1); + if (!target || !hasPanels(target.column) || hasAgentPanel(target.column)) return null; + return target; +} + +function makeContext(layout: LayoutState, sourceIndex: number): HiddenRightPane["context"] { + const remaining = layout.columns.filter((_, index) => index !== sourceIndex); + return { + columnIds: remaining.filter((column) => column.id !== "sidebar").map((column) => column.id), + panelIds: panelIdsInLayout({ ...layout, columns: remaining }).sort(), + }; +} + +/** Capture the selected pane and the layout left after hiding it. */ +export function captureRightPane( + layout: LayoutState, +): { layout: LayoutState; hiddenRightPane: HiddenRightPane } | null { + const selection = selectRightPane(layout); + if (!selection) return null; + const { index, column } = selection; + return { + layout: { ...layout, columns: layout.columns.filter((_, i) => i !== index) }, + hiddenRightPane: { + version: HIDDEN_RIGHT_PANE_VERSION, + sourceIndex: index, + sourceColumnId: column.id, + rootOrientation: layout.rootOrientation ?? "HORIZONTAL", + column, + context: makeContext(layout, index), + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function isSerializableValue(value: unknown, ancestors = new Set()): boolean { + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (typeof value !== "object") return false; + if (ancestors.has(value)) return false; + + ancestors.add(value); + const valid = Array.isArray(value) + ? value.every((item) => isSerializableValue(item, ancestors)) + : Object.values(value).every((item) => isSerializableValue(item, ancestors)); + ancestors.delete(value); + return valid; +} + +function isLayoutPanel(value: unknown): value is LayoutPanel { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value.id) && + isNonEmptyString(value.component) && + typeof value.title === "string" && + (value.tabComponent === undefined || isNonEmptyString(value.tabComponent)) && + (value.params === undefined || (isRecord(value.params) && isSerializableValue(value.params))) + ); +} + +function isLayoutGroup(value: unknown): value is LayoutGroup { + if (!isRecord(value) || !Array.isArray(value.panels)) return false; + if ( + value.panels.length === 0 || + !value.panels.every(isLayoutPanel) || + (value.id !== undefined && !isNonEmptyString(value.id)) || + (value.activePanel !== undefined && !isNonEmptyString(value.activePanel)) + ) { + return false; + } + return ( + value.activePanel === undefined || value.panels.some((panel) => panel.id === value.activePanel) + ); +} + +function isLayoutNode(value: unknown): value is LayoutNode { + if (!isRecord(value) || (value.type !== "leaf" && value.type !== "branch")) return false; + if (value.size !== undefined && !isFiniteNonNegative(value.size)) return false; + if (value.type === "leaf") return isLayoutGroup(value.group); + return ( + Array.isArray(value.children) && value.children.length > 0 && value.children.every(isLayoutNode) + ); +} + +function isOptionalNonNegative(value: unknown): boolean { + return value === undefined || isFiniteNonNegative(value); +} + +function isOptionalPositive(value: unknown): boolean { + return value === undefined || (isFiniteNonNegative(value) && value > 0); +} + +function hasValidColumnGeometry(value: Record): boolean { + if (!isOptionalNonNegative(value.width)) return false; + if (!isOptionalPositive(value.minWidth) || !isOptionalPositive(value.maxWidth)) return false; + if (typeof value.minWidth !== "number" || typeof value.maxWidth !== "number") return true; + return value.maxWidth >= value.minWidth; +} + +function isLayoutColumn(value: unknown): value is LayoutColumn { + if (!isRecord(value) || !isNonEmptyString(value.id)) return false; + if (value.pinned !== undefined && typeof value.pinned !== "boolean") return false; + if (!hasValidColumnGeometry(value)) return false; + if (!Array.isArray(value.groups) || !value.groups.every(isLayoutGroup)) return false; + if (value.tree !== undefined && !isLayoutNode(value.tree)) return false; + if (value.tree === undefined) return true; + + const treeGroups = collectGroups(value.tree); + const groups = value.groups as LayoutGroup[]; + return ( + treeGroups.length === groups.length && + treeGroups.every((group, index) => sameLayoutGroup(group, groups[index])) + ); +} + +function sameSerializableValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((item, index) => sameSerializableValue(item, right[index])) + ); + } + if (isRecord(left) || isRecord(right)) { + if (!isRecord(left) || !isRecord(right)) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => key in right && sameSerializableValue(left[key], right[key])) + ); + } + return false; +} + +function sameLayoutPanel(left: LayoutPanel, right: LayoutPanel): boolean { + return ( + left.id === right.id && + left.component === right.component && + left.title === right.title && + left.tabComponent === right.tabComponent && + sameSerializableValue(left.params, right.params) + ); +} + +function sameLayoutGroup(left: LayoutGroup, right: LayoutGroup): boolean { + return ( + left.id === right.id && + left.activePanel === right.activePanel && + left.panels.length === right.panels.length && + left.panels.every((panel, index) => sameLayoutPanel(panel, right.panels[index])) + ); +} + +function hasUniquePanelIds(column: LayoutColumn): boolean { + const ids = panelIdsInColumn(column); + return new Set(ids).size === ids.length; +} + +function hasUniqueGroupIds(column: LayoutColumn): boolean { + const ids = groupsInColumn(column) + .map((group) => group.id) + .filter((id): id is string => id !== undefined); + return new Set(ids).size === ids.length; +} + +function isValidLayoutState(value: unknown): value is LayoutState { + if (!isRecord(value) || !Array.isArray(value.columns)) return false; + if ( + value.rootOrientation !== undefined && + value.rootOrientation !== "HORIZONTAL" && + value.rootOrientation !== "VERTICAL" + ) { + return false; + } + const columns = value.columns as unknown[]; + if (!columns.every(isLayoutColumn)) return false; + const validColumns = columns as LayoutColumn[]; + + const columnIds = validColumns.map((column) => column.id); + if (new Set(columnIds).size !== columnIds.length) return false; + if (!validColumns.every(hasUniqueGroupIds)) return false; + const groupIds = validColumns + .flatMap(groupsInColumn) + .map((group) => group.id) + .filter((id): id is string => id !== undefined); + if (new Set(groupIds).size !== groupIds.length) return false; + const panelIds = panelIdsInLayout({ columns: validColumns }); + return new Set(panelIds).size === panelIds.length; +} + +function isUniqueNonEmptyStringList(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.length === new Set(value).size && value.every(isNonEmptyString) + ); +} + +function isValidHiddenContext(value: unknown): value is HiddenRightPane["context"] { + return ( + isRecord(value) && + isUniqueNonEmptyStringList(value.columnIds) && + isUniqueNonEmptyStringList(value.panelIds) + ); +} + +function isHiddenRightPane(value: unknown): value is HiddenRightPane { + if (!isRecord(value)) return false; + if ( + value.version !== HIDDEN_RIGHT_PANE_VERSION || + typeof value.sourceIndex !== "number" || + !Number.isInteger(value.sourceIndex) || + value.sourceIndex < 0 || + !isNonEmptyString(value.sourceColumnId) || + (value.rootOrientation !== "HORIZONTAL" && value.rootOrientation !== "VERTICAL") + ) { + return false; + } + if (!isLayoutColumn(value.column)) return false; + if (!hasUniquePanelIds(value.column) || !hasUniqueGroupIds(value.column)) return false; + if (value.sourceColumnId !== value.column.id) return false; + return isValidHiddenContext(value.context); +} + +/** Read and validate optional recovery metadata from an env layout record. */ +export function readHiddenRightPane(record: object | null): HiddenRightPane | null { + if (!isRecord(record)) return null; + const value = record[HIDDEN_RIGHT_PANE_METADATA_KEY]; + return isHiddenRightPane(value) ? value : null; +} + +/** Remove app-owned metadata before passing a record to Dockview. */ +export function stripHiddenRightPaneMetadata(record: object): object { + if (!isRecord(record) || !(HIDDEN_RIGHT_PANE_METADATA_KEY in record)) return record; + const { [HIDDEN_RIGHT_PANE_METADATA_KEY]: _metadata, ...dockviewRecord } = record; + return dockviewRecord; +} + +/** Compose an env layout record without storing hidden recovery in portable layouts. */ +export function withHiddenRightPaneMetadata( + layout: object, + hiddenRightPane: HiddenRightPane | null, +): object { + const record = { ...(isRecord(layout) ? layout : {}) } as Record; + if (hiddenRightPane) record[HIDDEN_RIGHT_PANE_METADATA_KEY] = hiddenRightPane; + else delete record[HIDDEN_RIGHT_PANE_METADATA_KEY]; + return record; +} + +function hasContextOverlap(layout: LayoutState, hiddenRightPane: HiddenRightPane): boolean { + const currentColumnIds = new Set( + layout.columns.filter((column) => column.id !== "sidebar").map((column) => column.id), + ); + const currentPanelIds = new Set(panelIdsInLayout(layout)); + return ( + hiddenRightPane.context.columnIds.length === 0 || + hiddenRightPane.context.columnIds.some((id) => currentColumnIds.has(id)) || + hiddenRightPane.context.panelIds.some((id) => currentPanelIds.has(id)) + ); +} + +function isCompatibleWithCurrentLayout( + layout: LayoutState, + hiddenRightPane: HiddenRightPane, +): boolean { + if (!isValidLayoutState(layout) || !isHiddenRightPane(hiddenRightPane)) return false; + if (!isHorizontal(layout)) return false; + if ((layout.rootOrientation ?? "HORIZONTAL") !== hiddenRightPane.rootOrientation) return false; + if (workbenchColumnEntries(layout).length === 0) return false; + return hasContextOverlap(layout, hiddenRightPane); +} + +function isPaneFullyPresent(layout: LayoutState, hiddenRightPane: HiddenRightPane): boolean { + const currentPanelIds = new Set(panelIdsInLayout(layout)); + const hiddenPanelIds = panelIdsInColumn(hiddenRightPane.column); + return hiddenPanelIds.length > 0 && hiddenPanelIds.every((id) => currentPanelIds.has(id)); +} + +function filterGroup(group: LayoutGroup, usedPanelIds: Set): LayoutGroup | null { + const panels = group.panels.filter((panel) => { + if (usedPanelIds.has(panel.id)) return false; + usedPanelIds.add(panel.id); + return true; + }); + if (panels.length === 0) return null; + const activePanel = panels.some((panel) => panel.id === group.activePanel) + ? group.activePanel + : panels[0].id; + return { ...group, panels, activePanel }; +} + +function filterNode(node: LayoutNode, usedPanelIds: Set): LayoutNode | null { + if (node.type === "leaf") { + const group = filterGroup(node.group, usedPanelIds); + return group ? { ...node, group } : null; + } + const children = node.children + .map((child) => filterNode(child, usedPanelIds)) + .filter(Boolean) as LayoutNode[]; + if (children.length === 0) return null; + return { ...node, children }; +} + +function filterColumn(column: LayoutColumn, usedPanelIds: Set): LayoutColumn | null { + if (column.tree) { + const tree = filterNode(column.tree, usedPanelIds); + if (!tree) return null; + return { ...column, tree, groups: collectGroups(tree) }; + } + const groups = column.groups + .map((group) => filterGroup(group, usedPanelIds)) + .filter(Boolean) as LayoutGroup[]; + return groups.length > 0 ? { ...column, groups } : null; +} + +function rebalanceRestoredFlexWidths( + columns: LayoutColumn[], + restoredColumnId: string, + options: { totalWidth?: number; pinnedWidths?: ReadonlyMap }, +): LayoutColumn[] { + const { totalWidth, pinnedWidths } = options; + if (typeof totalWidth !== "number" || !Number.isFinite(totalWidth) || totalWidth <= 0) { + return columns; + } + + const restoredColumn = columns.find((column) => column.id === restoredColumnId); + if (!restoredColumn || restoredColumn.pinned || restoredColumn.width === undefined) { + return columns; + } + + const pinnedTotal = columns.reduce( + (total, column) => + total + + (column.pinned ? getPinnedWidth(column, totalWidth, pinnedWidths?.get(column.id)) : 0), + 0, + ); + const availableFlex = Math.floor(totalWidth - pinnedTotal); + const flexColumns = columns.filter((column) => !column.pinned); + const remainingFlex = flexColumns.filter((column) => column.id !== restoredColumnId); + if (availableFlex <= 0 || remainingFlex.length === 0) { + return availableFlex > 0 + ? columns.map((column) => + column.id === restoredColumnId ? { ...column, width: availableFlex } : column, + ) + : columns; + } + + const minimum = Math.max( + 1, + Math.min(LAYOUT_PINNED_MIN_PX, Math.floor(availableFlex / flexColumns.length)), + ); + const targetWidth = Math.max( + minimum, + Math.min(restoredColumn.width, availableFlex - minimum * remainingFlex.length), + ); + const remainingWidth = availableFlex - targetWidth; + const weights = remainingFlex.map((column) => + typeof column.width === "number" && Number.isFinite(column.width) && column.width > 0 + ? column.width + : 1, + ); + const totalWeight = weights.reduce((total, width) => total + width, 0); + let allocated = 0; + let remainingIndex = 0; + + return columns.map((column) => { + if (column.id === restoredColumnId) return { ...column, width: targetWidth }; + if (column.pinned) return column; + + const weight = weights[remainingIndex] ?? 0; + const isLast = remainingIndex === remainingFlex.length - 1; + const width = isLast + ? remainingWidth - allocated + : Math.round((remainingWidth * weight) / totalWeight); + allocated += width; + remainingIndex++; + return { ...column, width }; + }); +} + +/** Restore a retained pane into the current layout, preserving live edits. */ +export function restoreRightPane( + layout: LayoutState, + hiddenRightPane: HiddenRightPane, + options: { + totalWidth?: number; + pinnedWidths?: ReadonlyMap; + } = {}, +): LayoutState | null { + if ( + !isHiddenRightPane(hiddenRightPane) || + !isCompatibleWithCurrentLayout(layout, hiddenRightPane) + ) { + return null; + } + if (layout.columns.some((column) => column.id === hiddenRightPane.sourceColumnId)) { + return null; + } + const usedPanelIds = new Set(panelIdsInLayout(layout)); + const column = filterColumn(hiddenRightPane.column, usedPanelIds); + if (!column) return null; + + const columns = [...layout.columns]; + const insertionIndex = Math.min(hiddenRightPane.sourceIndex, columns.length); + columns.splice(insertionIndex, 0, column); + const restored = { + ...layout, + columns: rebalanceRestoredFlexWidths(columns, column.id, options), + }; + return isValidLayoutState(restored) ? restored : null; +} + +/** Derive the control state. A retained pane always wins over a new target. */ +export function getRightPaneToggleState( + layout: LayoutState, + hiddenRightPane: HiddenRightPane | null, +): RightPaneToggleState { + if ( + hiddenRightPane && + isCompatibleWithCurrentLayout(layout, hiddenRightPane) && + !isPaneFullyPresent(layout, hiddenRightPane) + ) { + return { available: true, visible: false, hidden: true }; + } + const selection = selectRightPane(layout); + return selection + ? { available: true, visible: true, hidden: false } + : { available: false, visible: false, hidden: false }; +} diff --git a/apps/web/lib/state/dockview-right-panel-visibility.test.ts b/apps/web/lib/state/dockview-right-panel-visibility.test.ts new file mode 100644 index 0000000000..b11ffc6cc5 --- /dev/null +++ b/apps/web/lib/state/dockview-right-panel-visibility.test.ts @@ -0,0 +1,591 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { DockviewApi, SerializedDockview } from "dockview-react"; +import { removeEnvMaximizeState, setEnvLayout } from "@/lib/local-storage"; + +const CENTER_COLUMN_ID = "center"; +const CENTER_GROUP_ID = "group-center"; +const RIGHT_COLUMN_ID = "right"; +const RIGHT_GROUP_ID = "group-right-top"; +const SESSION_PANEL_ID = "session:session-a"; +const PR_DETAILS_PANEL_ID = "pr-detail"; +const PR_DETAILS_PANEL_TITLE = "PR Details"; +const MOCK_TERMINAL = vi.hoisted(() => ({ id: "terminal-default" })); + +vi.mock("@/lib/local-storage", () => ({ + setEnvLayout: vi.fn(), + getEnvLayout: vi.fn(() => null), + getEnvLayoutProfile: vi.fn(() => null), + setEnvLayoutProfile: vi.fn(), + getEnvMaximizeState: vi.fn(() => null), + setEnvMaximizeState: vi.fn(), + removeEnvMaximizeState: vi.fn(), + getGlobalSidebarWidth: vi.fn(() => null), + setGlobalSidebarWidth: vi.fn(), + clearGlobalSidebarWidth: vi.fn(), +})); + +vi.mock("@/lib/layout/panel-portal-manager", () => ({ + panelPortalManager: { releaseByEnv: vi.fn(), reconcile: vi.fn() }, +})); + +vi.mock("./dockview-scroll-preserve", () => ({ + preserveChatScrollDuringLayout: vi.fn(), +})); + +vi.mock("./dockview-measure", () => ({ + measureDockviewContainer: vi.fn(() => ({ width: 800, height: 600 })), +})); + +vi.mock("./dockview-pinned-enforce", () => ({ + enforcePinnedTargets: vi.fn(), +})); + +vi.mock("./dockview-layout-builders", () => ({ + applyLayoutFixups: vi.fn(() => ({ + sidebarGroupId: "g-sidebar", + centerGroupId: "g-center", + rightTopGroupId: "g-right-top", + rightBottomGroupId: "g-right-bottom", + })), + focusOrAddPanel: vi.fn(), +})); + +vi.mock("./layout-manager", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SIDEBAR_GROUP: "sidebar", + CENTER_GROUP: "group-center", + RIGHT_TOP_GROUP: "group-right-top", + RIGHT_BOTTOM_GROUP: "group-right-bottom", + TERMINAL_DEFAULT_ID: MOCK_TERMINAL.id, + LAYOUT_SIDEBAR_RATIO: 0.2, + LAYOUT_RIGHT_RATIO: 0.25, + LAYOUT_PINNED_MIN_PX: 200, + computeSidebarMaxPx: vi.fn(() => 350), + computeRightMaxPx: vi.fn(() => 500), + getPresetLayout: vi.fn(() => ({ columns: [] })), + applyLayout: vi.fn(() => ({ + sidebarGroupId: "g-sidebar", + centerGroupId: "g-center", + rightTopGroupId: "g-right-top", + rightBottomGroupId: "g-right-bottom", + })), + getPinnedWidth: vi.fn(() => 350), + getRootSplitview: vi.fn(() => null), + fromDockviewApi: vi.fn(() => ({ columns: [] })), + filterEphemeral: vi.fn((state: unknown) => state), + defaultLayout: vi.fn(() => ({ columns: [] })), + mergeCurrentPanelsIntoPreset: vi.fn((_api: unknown, preset: unknown) => preset), + toSerializedDockview: vi.fn((state: unknown) => state), + injectIntentPanels: vi.fn(), + applyActivePanelOverrides: vi.fn(), + resolveNamedIntent: vi.fn(), + setPinnedTarget: vi.fn(), + clearPinnedTarget: vi.fn(), + getPinnedTarget: vi.fn(() => undefined), + layoutStructuresMatch: vi.fn(() => false), + savedLayoutMatchesLive: vi.fn(() => false), + }; +}); + +import { applyLayout, defaultLayout, fromDockviewApi } from "./layout-manager"; +import { captureRightPane } from "./dockview-right-pane"; +import { hasRightColumn, useDockviewStore } from "./dockview-store"; + +function makeStoreApi(): DockviewApi { + return { + width: 800, + height: 600, + panels: [], + groups: [], + layout: vi.fn(), + fromJSON: vi.fn(), + toJSON: vi.fn(() => ({ columns: [{ id: CENTER_COLUMN_ID }] })), + getPanel: vi.fn(() => null), + addPanel: vi.fn(), + activeGroup: null, + hasMaximizedGroup: vi.fn(() => false), + exitMaximizedGroup: vi.fn(), + onDidActivePanelChange: vi.fn(() => ({ dispose: vi.fn() })), + } as unknown as DockviewApi; +} + +function flushRaf(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function resetStore() { + vi.clearAllMocks(); + useDockviewStore.setState({ + api: null, + currentLayoutEnvId: null, + preMaximizeLayout: null, + maximizedGroupId: null, + isRestoringLayout: false, + pinnedWidths: new Map(), + userDefaultLayout: null, + userDefaultLayoutProfile: { kind: "built-in", id: "default" }, + activeLayoutProfile: { kind: "built-in", id: "default" }, + defaultPreset: "default", + rightPanelsVisible: true, + rightPaneVisible: true, + rightPaneAvailable: false, + hiddenRightPane: null, + }); +} + +beforeEach(resetStore); + +it("hides the actual rightmost Plan region instead of rebuilding the default sidebar", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }], + }, + ], + }, + { + id: "plan", + groups: [{ id: "group-plan", panels: [{ id: "plan", component: "plan", title: "Plan" }] }], + }, + ], + }); + useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "plan" }); + + useDockviewStore.getState().toggleRightPanels(); + + const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1]; + expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]); + await flushRaf(); +}); + +it("retains the hidden pane when applying a toggle layout fails", () => { + const api = makeStoreApi(); + const originalDockview = { + grid: { root: { type: "branch", data: ["agent", "plan"], size: 600 } }, + panels: { [SESSION_PANEL_ID]: { id: SESSION_PANEL_ID, contentComponent: "chat" } }, + } as unknown as SerializedDockview; + let liveDockview: SerializedDockview = originalDockview; + let fromJsonCalls = 0; + vi.mocked(api.toJSON).mockImplementation(() => liveDockview); + vi.mocked(api.fromJSON).mockImplementation((next) => { + liveDockview = next; + if (fromJsonCalls++ === 0) throw new Error("Dockview rejected the new layout"); + }); + const fullLayout = { + rootOrientation: "HORIZONTAL" as const, + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }], + }, + ], + }, + { + id: "plan", + groups: [{ id: "group-plan", panels: [{ id: "plan", component: "plan", title: "Plan" }] }], + }, + ], + }; + const captured = captureRightPane(fullLayout)!; + vi.mocked(fromDockviewApi).mockReturnValue(captured.layout); + vi.mocked(applyLayout).mockImplementationOnce((dockviewApi) => { + dockviewApi.fromJSON({ grid: { root: { type: "branch", data: [] } }, panels: {} } as never); + throw new Error("layout failed"); + }); + useDockviewStore.setState({ + api, + hiddenRightPane: captured.hiddenRightPane, + rightPaneVisible: false, + rightPaneAvailable: true, + }); + + useDockviewStore.getState().toggleRightPanels(); + + expect(useDockviewStore.getState().hiddenRightPane).toEqual(captured.hiddenRightPane); + expect(useDockviewStore.getState().rightPaneVisible).toBe(false); + expect(useDockviewStore.getState().rightPaneAvailable).toBe(true); + expect(useDockviewStore.getState().isRestoringLayout).toBe(false); + expect(api.fromJSON).toHaveBeenCalledTimes(2); + expect(api.fromJSON).toHaveBeenLastCalledWith(originalDockview); + expect(liveDockview).toBe(originalDockview); + expect(setEnvLayout).not.toHaveBeenCalled(); +}); + +it("recognizes the right column from its stable column or group identity", () => { + expect( + hasRightColumn({ + columns: [ + { id: CENTER_COLUMN_ID, groups: [{ id: CENTER_GROUP_ID, panels: [] }] }, + { id: RIGHT_COLUMN_ID, groups: [] }, + ], + }), + ).toBe(true); + expect( + hasRightColumn({ + columns: [{ id: "custom-column", groups: [{ id: RIGHT_GROUP_ID, panels: [] }] }], + }), + ).toBe(true); + expect( + hasRightColumn({ + columns: [{ id: CENTER_COLUMN_ID, groups: [{ id: CENTER_GROUP_ID, panels: [] }] }], + }), + ).toBe(false); +}); + +it("does not fabricate a right column when the layout has one workbench region", async () => { + const api = makeStoreApi(); + vi.mocked(defaultLayout).mockReturnValue({ + columns: [ + { + id: RIGHT_COLUMN_ID, + pinned: true, + groups: [ + { + id: RIGHT_GROUP_ID, + panels: [ + { id: "files", component: "files", title: "Files" }, + { id: "changes", component: "changes", title: "Changes" }, + ], + }, + { + id: "group-right-bottom", + panels: [{ id: MOCK_TERMINAL.id, component: "terminal", title: "Terminal" }], + }, + ], + }, + ], + }); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [ + { id: SESSION_PANEL_ID, component: "chat", title: "Agent" }, + { id: "browser", component: "browser", title: "Browser" }, + { + id: PR_DETAILS_PANEL_ID, + component: PR_DETAILS_PANEL_ID, + title: PR_DETAILS_PANEL_TITLE, + }, + { id: "files", component: "files", title: "Files" }, + { id: "changes", component: "changes", title: "Changes" }, + { id: MOCK_TERMINAL.id, component: "terminal", title: "Terminal" }, + ], + }, + ], + }, + ], + }); + useDockviewStore.setState({ + api, + rightPanelsVisible: false, + defaultPreset: "compact", + activeLayoutProfile: { kind: "built-in", id: "compact" }, + }); + + useDockviewStore.getState().toggleRightPanels(); + + expect(applyLayout).not.toHaveBeenCalled(); + expect(defaultLayout).not.toHaveBeenCalled(); + expect(useDockviewStore.getState().rightPaneAvailable).toBe(false); + await flushRaf(); +}); + +it("does not mutate the temporary maximized layout", () => { + const api = makeStoreApi(); + useDockviewStore.setState({ + api, + rightPanelsVisible: true, + preMaximizeLayout: { + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }], + }, + ], + }, + { + id: RIGHT_COLUMN_ID, + groups: [ + { id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] }, + ], + }, + ], + }, + }); + + useDockviewStore.getState().toggleRightPanels(); + + expect(applyLayout).not.toHaveBeenCalled(); + expect(api.layout).not.toHaveBeenCalled(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(true); +}); + +it("keeps visibility coherent through maximize, exit, and regular-layout persistence", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }], + }, + ], + }, + { + id: RIGHT_COLUMN_ID, + groups: [ + { id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] }, + ], + }, + ], + }); + useDockviewStore.setState({ + api, + currentLayoutEnvId: "env-maximize", + rightPanelsVisible: false, + }); + + useDockviewStore.getState().maximizeGroup(CENTER_GROUP_ID); + expect(useDockviewStore.getState().preMaximizeLayout).not.toBeNull(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(true); + await flushRaf(); + + useDockviewStore.getState().exitMaximizedLayout(); + expect(useDockviewStore.getState().preMaximizeLayout).toBeNull(); + expect(useDockviewStore.getState().rightPanelsVisible).toBe(true); + await flushRaf(); + + expect(removeEnvMaximizeState).toHaveBeenCalledWith("env-maximize"); + expect(setEnvLayout).toHaveBeenCalledWith("env-maximize", expect.anything()); +}); + +it("preserves right-named panels that belong to a mixed center column", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [ + { id: SESSION_PANEL_ID, component: "chat", title: "Agent" }, + { id: "files", component: "files", title: "Files" }, + { + id: PR_DETAILS_PANEL_ID, + component: PR_DETAILS_PANEL_ID, + title: PR_DETAILS_PANEL_TITLE, + }, + ], + }, + ], + }, + { + id: RIGHT_COLUMN_ID, + groups: [ + { + id: RIGHT_GROUP_ID, + panels: [{ id: "terminal", component: "terminal", title: "Terminal" }], + }, + ], + }, + ], + }); + useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" }); + + useDockviewStore.getState().toggleRightPanels(); + + const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1]; + expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]); + expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([ + SESSION_PANEL_ID, + "files", + PR_DETAILS_PANEL_ID, + ]); + await flushRaf(); +}); + +it("removes an identified right column even when it contains a custom panel", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }], + }, + ], + }, + { + id: RIGHT_COLUMN_ID, + groups: [ + { id: RIGHT_GROUP_ID, panels: [{ id: "plugin", component: "plugin", title: "Plugin" }] }, + ], + }, + ], + }); + useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" }); + + useDockviewStore.getState().toggleRightPanels(); + + const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1]; + expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]); + await flushRaf(); +}); + +it("does not restore a fabricated right column when only the center remains", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [ + { id: SESSION_PANEL_ID, component: "chat", title: "Agent" }, + { id: "files", component: "files", title: "Files" }, + ], + }, + ], + }, + ], + }); + useDockviewStore.setState({ api, rightPanelsVisible: false, defaultPreset: "default" }); + + useDockviewStore.getState().toggleRightPanels(); + + expect(applyLayout).not.toHaveBeenCalled(); + expect(defaultLayout).not.toHaveBeenCalled(); + expect(useDockviewStore.getState().rightPaneAvailable).toBe(false); + await flushRaf(); +}); + +it("keeps a center fallback PR Details tab when hiding right panels", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [ + { id: SESSION_PANEL_ID, component: "chat", title: "Agent" }, + { + id: PR_DETAILS_PANEL_ID, + component: PR_DETAILS_PANEL_ID, + title: PR_DETAILS_PANEL_TITLE, + }, + ], + }, + ], + }, + { + id: RIGHT_COLUMN_ID, + pinned: true, + groups: [ + { id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] }, + ], + }, + ], + }); + useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" }); + + useDockviewStore.getState().toggleRightPanels(); + + const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1]; + expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]); + expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([ + SESSION_PANEL_ID, + PR_DETAILS_PANEL_ID, + ]); + await flushRaf(); +}); + +it("does not show a default sidebar when showing from a single workbench region", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [ + { id: SESSION_PANEL_ID, component: "chat", title: "Agent" }, + { + id: PR_DETAILS_PANEL_ID, + component: PR_DETAILS_PANEL_ID, + title: PR_DETAILS_PANEL_TITLE, + }, + ], + }, + ], + }, + ], + }); + useDockviewStore.setState({ api, rightPanelsVisible: false, defaultPreset: "default" }); + + useDockviewStore.getState().toggleRightPanels(); + + expect(applyLayout).not.toHaveBeenCalled(); + expect(defaultLayout).not.toHaveBeenCalled(); + expect(useDockviewStore.getState().rightPaneAvailable).toBe(false); + await flushRaf(); +}); + +it("persists a settled visibility toggle for the current environment", async () => { + const api = makeStoreApi(); + vi.mocked(fromDockviewApi).mockReturnValue({ + columns: [ + { + id: CENTER_COLUMN_ID, + groups: [ + { + id: CENTER_GROUP_ID, + panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }], + }, + ], + }, + { + id: RIGHT_COLUMN_ID, + groups: [ + { id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] }, + ], + }, + ], + }); + useDockviewStore.setState({ + api, + currentLayoutEnvId: "env-1", + rightPanelsVisible: true, + defaultPreset: "default", + }); + + useDockviewStore.getState().toggleRightPanels(); + await flushRaf(); + + expect(setEnvLayout).toHaveBeenCalledWith("env-1", expect.anything()); +}); diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 0cc7bd321a..c34b25c720 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -21,7 +21,6 @@ import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP, - TERMINAL_DEFAULT_ID, getPresetLayout, applyLayout, computePinnedMaxPxFor, @@ -29,8 +28,8 @@ import { getRootSplitview, LAYOUT_PINNED_MIN_PX, fromDockviewApi, + isRightColumn as isLayoutRightColumn, filterEphemeral, - defaultLayout, mergeCurrentPanelsIntoPreset, toSerializedDockview, normalizeReusableSessionPanels, @@ -40,6 +39,14 @@ import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manage import type { CommitDetailTarget } from "@/components/task/changes-diff-target"; import type { ReviewItemSummary } from "@/lib/plugins/types"; import { performEnvSwitch, replaceStaleSessionPanels } from "./dockview-env-switch"; +import { + captureRightPane, + getRightPaneToggleState, + readHiddenRightPane, + restoreRightPane, + withHiddenRightPaneMetadata, + type HiddenRightPane, +} from "./dockview-right-pane"; import { enforcePinnedTargets } from "./dockview-pinned-enforce"; import { injectIntentPanels, @@ -68,8 +75,6 @@ const debugSwitch = createDebugLogger("dockview:store-switch"); const debugSave = createDebugLogger("dockview:save"); const debugWidths = createDebugLogger("dockview:widths"); -const RIGHT_PANEL_IDS = new Set(["changes", "files", TERMINAL_DEFAULT_ID]); - const DEFAULT_LAYOUT_PROFILE: LayoutProfileIdentity = { kind: "built-in", id: "default" }; function profileForCustomLayout(layout: Pick): LayoutProfileIdentity { @@ -276,12 +281,16 @@ type DockviewStore = { sidebarGroupId: string; sidebarVisible: boolean; rightPanelsVisible: boolean; + rightPaneVisible: boolean; + rightPaneAvailable: boolean; + hiddenRightPane: HiddenRightPane | null; /** Identity of the active built-in or saved custom layout profile. */ activeLayoutProfile: LayoutProfileIdentity; toggleSidebar: () => void; toggleRightPanels: () => void; setSidebarVisible: (visible: boolean) => void; setRightPanelsVisible: (visible: boolean) => void; + refreshRightPaneState: () => void; applyBuiltInPreset: (preset: BuiltInPreset, resetWidths?: boolean) => void; defaultPreset: BuiltInPreset; setDefaultPreset: (preset: BuiltInPreset) => void; @@ -369,10 +378,9 @@ function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelActio * Build the pinnedWidths updates for a width sync, tracking only the VISIBLE * default right column. * - * In plan/preview/vscode layouts the side column inherits merged files/changes - * panels and `fromDockviewApi` mislabels it "right"; storing its width as the - * right override would then leak into the default layout when toggling back - * (e.g. plan-mode off snapping the right column to the plan column's width). + * In plan/preview/vscode layouts a side column can inherit merged files or + * changes panels. The serializer preserves the center/right ownership before + * this sync runs, so only the canonical right column receives the override. * * Sidebar is intentionally excluded: its persisted width is a global pref * written only by explicit sash drag, and syncing live layout-change widths @@ -574,69 +582,66 @@ function applyLayoutAndSet( // `pane-resize-sidebar.spec.ts:41` flake mode: cap=301 from a 601px stale // measurement clamps the 430px sidebar override down to 301). const measured = preMeasured ?? measureDockviewContainer(api); - const ids = applyLayout(api, state, pinnedWidths, measured.width, measured.height); - set(ids); - return ids; + const previousLayout = typeof api.toJSON === "function" ? api.toJSON() : undefined; + try { + const ids = applyLayout(api, state, pinnedWidths, measured.width, measured.height); + set(ids); + return ids; + } catch (error) { + // Dockview can clear its grid before reporting a deserialization error. Roll back once so + // every programmatic layout transition either commits completely or leaves the live grid intact. + try { + if (previousLayout) { + api.fromJSON(previousLayout); + } + } catch { + // A failed rollback cannot be repaired by retrying without risking another partial mutation. + } + throw error; + } } -/** True when the column is the default right column or hosts a right-side group. */ -function isRightColumn(column: LayoutState["columns"][number]): boolean { - return ( - column.id === "right" || - column.groups.some((group) => group.id === RIGHT_TOP_GROUP || group.id === RIGHT_BOTTOM_GROUP) - ); +function restoreSerializedDockview(api: DockviewApi, next: SerializedDockview): void { + const previous = api.toJSON(); + try { + api.fromJSON(next); + } catch (error) { + try { + api.fromJSON(previous); + } catch { + // A failed rollback cannot be repaired by retrying without risking another partial mutation. + } + throw error; + } } -/** - * True when the column holds at least one right-owned panel (changes, files, - * terminal — or pr-detail when the column is a right column). - */ -function columnHasRightPanel(column: LayoutState["columns"][number]): boolean { - const includesLayoutOwnedPRDetails = isRightColumn(column); - return column.groups.some((group) => - group.panels.some( - (panel) => - RIGHT_PANEL_IDS.has(panel.id) || (includesLayoutOwnedPRDetails && panel.id === "pr-detail"), - ), - ); +/** True when the column belongs to the right-side workbench surface. */ +export const isRightColumn = isLayoutRightColumn; + +export function hasRightColumn(state: LayoutState): boolean { + return state.columns.some(isRightColumn); } -/** - * Return a copy of the layout with right-owned tabs (changes/files/terminal, - * plus pr-detail in right columns) stripped from their groups; groups and - * columns left empty are dropped, and `activePanel` falls back to the first - * remaining panel. - */ -function removeRightPanelTabs(state: LayoutState): LayoutState { - const columns = state.columns - .map((col) => { - const includesLayoutOwnedPRDetails = isRightColumn(col); - const groups = col.groups - .map((group) => { - const panels = group.panels.filter( - (panel) => - !RIGHT_PANEL_IDS.has(panel.id) && - !(includesLayoutOwnedPRDetails && panel.id === "pr-detail"), - ); - 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 visibilityForLayout( + layout: LayoutState, + hiddenRightPane: HiddenRightPane | null, +): Pick { + if (!Array.isArray(layout.columns)) { + return { + rightPanelsVisible: false, + rightPaneVisible: false, + rightPaneAvailable: false, + }; + } + const paneState = getRightPaneToggleState(layout, hiddenRightPane); + return { + rightPanelsVisible: hasRightColumn(layout), + rightPaneVisible: paneState.visible, + rightPaneAvailable: paneState.available, + }; } -/** - * Build the store's visibility actions. `toggleRightPanels` shows/hides the - * right column, capturing live widths and chat scroll before the layout swap - * and re-enforcing pinned targets afterwards; the legacy sidebar toggles are - * kept as no-ops since the embedded sidebar moved to the unified AppSidebar. - */ +/** Build the store's visibility actions for the contextual right pane. */ function buildVisibilityActions(set: StoreSet, get: StoreGet) { return { // Legacy dockview-embedded sidebar is gone after the unified AppSidebar @@ -646,52 +651,81 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) { /* moved to UI slice: toggleAppSidebar */ }, toggleRightPanels: () => { - const { api, rightPanelsVisible, defaultPreset } = get(); - if (!api) return; - if (!rightPanelsVisible && defaultPreset === "compact") return; + const { api, hiddenRightPane, preMaximizeLayout } = get(); + if (!api || preMaximizeLayout) return; + const targetEnvId = get().currentLayoutEnvId; const liveWidths = captureLiveWidths(api, set); preserveChatScrollDuringLayout(); const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); - if (rightPanelsVisible) { - const current = fromDockviewApi(api); - const withoutRight: LayoutState = { - columns: current.columns.filter((column) => !columnHasRightPanel(column)), - }; - set({ isRestoringLayout: true, rightPanelsVisible: false }); - applyLayoutAndSet(api, withoutRight, liveWidths, set); - requestAnimationFrame(() => { - api.layout(safeWidth, safeHeight); - enforceFromStore(api, get); - syncPinnedWidthsFromApi(api, set); - set({ isRestoringLayout: false }); + const current = fromDockviewApi(api); + let nextLayout: LayoutState; + let nextHiddenRightPane: HiddenRightPane | null; + if (hiddenRightPane) { + const restored = restoreRightPane(current, hiddenRightPane, { + totalWidth: safeWidth, + pinnedWidths: liveWidths, }); + if (!restored) { + set({ hiddenRightPane: null, ...visibilityForLayout(current, null) }); + return; + } + nextLayout = restored; + nextHiddenRightPane = null; } else { - const defLayout = defaultLayout(); - const rightCol = defLayout.columns.find((c) => c.id === "right"); - if (!rightCol) return; - const current = removeRightPanelTabs(fromDockviewApi(api)); - const withRight: LayoutState = { - columns: [...current.columns, rightCol], - }; - set({ isRestoringLayout: true, rightPanelsVisible: true }); - applyLayoutAndSet(api, withRight, liveWidths, set); - requestAnimationFrame(() => { - api.layout(safeWidth, safeHeight); - enforceFromStore(api, get); - syncPinnedWidthsFromApi(api, set); - set({ isRestoringLayout: false }); - }); + const captured = captureRightPane(current); + if (!captured) { + set({ hiddenRightPane: null, ...visibilityForLayout(current, null) }); + return; + } + nextLayout = captured.layout; + nextHiddenRightPane = captured.hiddenRightPane; } + + set({ + isRestoringLayout: true, + hiddenRightPane: nextHiddenRightPane, + ...visibilityForLayout(nextLayout, nextHiddenRightPane), + }); + + try { + applyLayoutAndSet(api, nextLayout, liveWidths, set); + } catch { + set({ + isRestoringLayout: false, + hiddenRightPane, + ...visibilityForLayout(current, hiddenRightPane), + }); + return; + } + + requestAnimationFrame(() => { + api.layout(safeWidth, safeHeight); + enforceFromStore(api, get); + syncPinnedWidthsFromApi(api, set); + set({ isRestoringLayout: false }); + const { currentLayoutEnvId, preMaximizeLayout } = get(); + if (currentLayoutEnvId === targetEnvId) { + persistEnvLayoutNow(api, targetEnvId, preMaximizeLayout); + } + }); }, setSidebarVisible: (_visible: boolean) => { /* moved to UI slice: setAppSidebarCollapsed */ }, setRightPanelsVisible: (visible: boolean) => { - const { rightPanelsVisible } = get(); - if (rightPanelsVisible === visible) return; + const { rightPaneVisible } = get(); + if (rightPaneVisible === visible) return; get().toggleRightPanels(); }, + refreshRightPaneState: () => { + const { api, hiddenRightPane } = get(); + if (!api) return; + const current = fromDockviewApi(api); + const paneState = getRightPaneToggleState(current, hiddenRightPane); + const nextHidden = hiddenRightPane && !paneState.hidden ? null : hiddenRightPane; + set({ hiddenRightPane: nextHidden, ...visibilityForLayout(current, nextHidden) }); + }, }; } @@ -712,6 +746,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); set({ isRestoringLayout: true, + hiddenRightPane: null, activeLayoutProfile: { kind: "built-in", id: preset }, }); const presetState = getPresetLayout(preset); @@ -724,7 +759,10 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { safeWidth, resetWidths, ); - const ids = applyLayout(api, state, cleanedWidths, safeWidth, safeHeight); + const ids = applyLayoutAndSet(api, state, cleanedWidths, set, { + width: safeWidth, + height: safeHeight, + }); if (isDebug()) { const applied = [...cleanedWidths].map(([k, v]) => `${k}:${Math.round(v)}`).join(",") || "-"; @@ -736,7 +774,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { set({ ...ids, sidebarVisible: true, - rightPanelsVisible: preset === "default", + ...visibilityForLayout(state, null), pinnedWidths: cleanedWidths, }); const targetEnvId = get().currentLayoutEnvId; @@ -764,6 +802,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); set({ isRestoringLayout: true, + hiddenRightPane: null, activeLayoutProfile: profileForCustomLayout(layout), }); const { appliedState, oldFormatRestoreFailed } = restoreCustomLayout({ @@ -775,10 +814,12 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { set, }); const hasSidebar = !!api.getPanel("sidebar"); - const colCount = appliedState?.columns?.length ?? api.groups.length; - const sidebarCols = hasSidebar ? 1 : 0; - const hasRight = colCount > sidebarCols + 1; - set({ sidebarVisible: hasSidebar, rightPanelsVisible: hasRight }); + const restoredLayout = fromDockviewApi(api); + const effectiveLayout = restoredLayout.columns.length > 0 ? restoredLayout : appliedState; + set({ + sidebarVisible: hasSidebar, + ...visibilityForLayout(effectiveLayout, null), + }); const targetEnvId = get().currentLayoutEnvId; requestAnimationFrame(() => { api.layout(safeWidth, safeHeight); @@ -830,15 +871,16 @@ function restoreCustomLayout({ opts?.sessionIds ?? [], ); const savedWidths = resolveCustomLayoutPinnedWidths(activeState.columns, safeWidth); - set({ - ...applyLayout(api, activeState, savedWidths, safeWidth, safeHeight), - pinnedWidths: savedWidths, + const ids = applyLayoutAndSet(api, activeState, savedWidths, set, { + width: safeWidth, + height: safeHeight, }); + set({ ...ids, pinnedWidths: savedWidths }); return { appliedState: activeState, oldFormatRestoreFailed: false }; } try { - api.fromJSON(layout.layout as unknown as SerializedDockview); + restoreSerializedDockview(api, layout.layout as unknown as SerializedDockview); replaceStaleSessionPanels(api, opts?.activeSessionId ?? null, opts?.sessionIds ?? []); set(applyLayoutFixups(api)); return { appliedState: state, oldFormatRestoreFailed: false }; @@ -871,7 +913,7 @@ function restoreMaximizeFromStorage( const saved = getEnvMaximizeState(envId); if (!saved) return false; try { - api.fromJSON(saved.maximizedDockviewJson as SerializedDockview); + restoreSerializedDockview(api, saved.maximizedDockviewJson as SerializedDockview); replaceStaleSessionPanels(api, activeSessionId, currentSessionIds); // After fromJSON, `api.width/height` reflect the JSON's recorded grid // dims, which may not match the live container. Always lay out against @@ -886,7 +928,12 @@ function restoreMaximizeFromStorage( // `centerGroupId`. Tracking it keeps the store consistent with what // `maximizeGroup` would have set (so toggle/exit logic doesn't operate on // a half-restored maximize). - set({ ...ids, preMaximizeLayout: preMax, maximizedGroupId: ids.centerGroupId }); + set({ + ...ids, + preMaximizeLayout: preMax, + maximizedGroupId: ids.centerGroupId, + ...visibilityForLayout(preMax, readHiddenRightPane(getEnvLayout(envId))), + }); } catch { // Drop the bad blob so the next switch/reload doesn't keep reattempting // the same failing fromJSON before falling back. Self-healing. @@ -942,13 +989,19 @@ export function persistEnvLayoutNow( // While maximized, api.toJSON() is the 2-column overlay; the regular layout has its own slot via saveOutgoingEnv. if (preMaximizeLayout !== null) return; try { - setEnvLayout(envId, api.toJSON()); + const state = useDockviewStore.getState(); + setEnvLayout(envId, withHiddenRightPaneMetadata(api.toJSON(), state.hiddenRightPane)); setEnvLayoutProfile(envId, useDockviewStore.getState().activeLayoutProfile); } catch { /* ignore serialization/storage failures */ } } +function resolveEnvDefaultPinnedWidths(get: StoreGet, width: number): Map { + const defaultLayout = get().userDefaultLayout; + return defaultLayout ? resolveCustomLayoutPinnedWidths(defaultLayout.columns, width) : new Map(); +} + /** Save the outgoing env's layout & maximize state, then release its portals. */ function saveOutgoingEnv( api: DockviewApi, @@ -991,7 +1044,13 @@ function saveOutgoingEnv( // shrunken layout that resurfaces on the next reload. const { width, height } = measureDockviewContainer(api); const preMaxSerialized = toSerializedDockview(preMaximizeLayout, width, height, pinnedWidths); - setEnvLayout(oldEnvId, preMaxSerialized as unknown as object); + setEnvLayout( + oldEnvId, + withHiddenRightPaneMetadata( + preMaxSerialized as unknown as object, + useDockviewStore.getState().hiddenRightPane, + ), + ); setEnvLayoutProfile(oldEnvId, useDockviewStore.getState().activeLayoutProfile); } catch (err) { console.warn("saveOutgoingEnv: serialize failed", err); @@ -1001,7 +1060,10 @@ function saveOutgoingEnv( removeEnvMaximizeState(oldEnvId); try { const json = api.toJSON(); - setEnvLayout(oldEnvId, json); + setEnvLayout( + oldEnvId, + withHiddenRightPaneMetadata(json, useDockviewStore.getState().hiddenRightPane), + ); setEnvLayoutProfile(oldEnvId, useDockviewStore.getState().activeLayoutProfile); if (isDebug()) { debugWidths( @@ -1073,10 +1135,12 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) { set({ preMaximizeLayout: null, maximizedGroupId: null }); const manualRightWidth = getManualRightWidth(newEnvId); const savedEnvLayout = getEnvLayout(newEnvId); + const hiddenRightPane = readHiddenRightPane(savedEnvLayout); const savedLayoutProfile = getEnvLayoutProfile(newEnvId); set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId, + hiddenRightPane, pinnedWidths: manualRightWidth === null ? new Map() : new Map([["right", manualRightWidth]]), }); try { @@ -1105,16 +1169,13 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) { safeHeight: measured.height, buildDefault: (a, intentName) => get().buildDefaultLayout(a, intentName), getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout(get().defaultPreset), - getDefaultPinnedWidths: (width) => { - const defaultLayout = get().userDefaultLayout; - return defaultLayout - ? resolveCustomLayoutPinnedWidths(defaultLayout.columns, width) - : new Map(); - }, + getDefaultPinnedWidths: (width) => resolveEnvDefaultPinnedWidths(get, width), initialLayout, }); + const restoredLayout = fromDockviewApi(api); set({ ...ids, + ...visibilityForLayout(restoredLayout, get().hiddenRightPane), activeLayoutProfile: resolveEnvSwitchProfile({ hasFirstAdoptionRouteLayout, savedEnvLayout, @@ -1178,7 +1239,12 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) { groups: [{ panels: targetGroup.panels, activePanel: targetGroup.activePanel }], }); const maximizedLayout: LayoutState = { columns }; - set({ isRestoringLayout: true, preMaximizeLayout: current, maximizedGroupId: groupId }); + set({ + isRestoringLayout: true, + preMaximizeLayout: current, + maximizedGroupId: groupId, + ...visibilityForLayout(current, get().hiddenRightPane), + }); const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); applyLayoutAndSet(api, maximizedLayout, liveWidths, set); requestAnimationFrame(() => { @@ -1200,7 +1266,12 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) { const safeWidth = measured.width; const safeHeight = measured.height; const liveWidths = get().pinnedWidths; - set({ isRestoringLayout: true, preMaximizeLayout: null, maximizedGroupId: null }); + set({ + isRestoringLayout: true, + preMaximizeLayout: null, + maximizedGroupId: null, + ...visibilityForLayout(preMaximizeLayout, get().hiddenRightPane), + }); if (currentLayoutEnvId) { removeEnvMaximizeState(currentLayoutEnvId); } @@ -1210,6 +1281,7 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) { enforceFromStore(api, get); syncPinnedWidthsFromApi(api, set); set({ isRestoringLayout: false }); + persistEnvLayoutNow(api, currentLayoutEnvId, null); }); }, }; @@ -1276,12 +1348,19 @@ function performBuildDefault( basePreset, safeWidth, ); - set({ isRestoringLayout: true, pinnedWidths, activeLayoutProfile }); + set({ + isRestoringLayout: true, + pinnedWidths, + activeLayoutProfile, + hiddenRightPane: null, + }); - const ids = applyLayout(api, state, pinnedWidths, safeWidth, safeHeight); + const ids = applyLayoutAndSet(api, state, pinnedWidths, set, { + width: safeWidth, + height: safeHeight, + }); const hasSidebar = state.columns.some((c) => c.id === "sidebar"); - const hasRight = state.columns.length > (hasSidebar ? 2 : 1); - set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight }); + set({ ...ids, sidebarVisible: hasSidebar, ...visibilityForLayout(state, null) }); const pending = get().deferredPanelActions; if (pending.length > 0) { @@ -1312,8 +1391,10 @@ function resetToEffectiveDefault(set: StoreSet, get: StoreGet): void { return; } if (preMaximizeLayout) { - set({ preMaximizeLayout: null, maximizedGroupId: null }); + set({ preMaximizeLayout: null, maximizedGroupId: null, hiddenRightPane: null }); if (currentLayoutEnvId) removeEnvMaximizeState(currentLayoutEnvId); + } else { + set({ hiddenRightPane: null }); } get().buildDefaultLayout(api); requestAnimationFrame(() => { @@ -1375,12 +1456,8 @@ function resolveActiveFile(api: DockviewApi, panelId: string | undefined): Activ return activeFileState(null, null, activePanelComponent); } -export const useDockviewStore = create((set, get) => ({ - api: null, - activeFilePath: null, - activeFileRepo: null, - activePanelComponent: null, - setApi: (api) => { +function buildSetApiAction(set: StoreSet): DockviewStore["setApi"] { + return (api) => { set({ api, activeFilePath: null, @@ -1403,37 +1480,41 @@ export const useDockviewStore = create((set, get) => ({ w.__setPinnedTarget__ = setPinnedTarget; w.__setGlobalSidebarWidth__ = setGlobalSidebarWidth; } - if (api) { - api.onDidActivePanelChange((event) => { - set(resolveActiveFile(api, event?.id)); + if (!api) return; + + api.onDidActivePanelChange((event) => { + set(resolveActiveFile(api, event?.id)); + }); + // Track per-panel param-change subscriptions so they can be disposed when + // the panel is removed instead of relying on dockview's internal cleanup. + const paramSubs = new Map void }>(); + api.onDidAddPanel((panel) => { + // The preview file-editor panel reuses a single dockview panel and swaps + // its params.path when the user previews a different file. Dockview does + // not refire onDidActivePanelChange for params-only updates. + if (panel.id !== "preview:file-editor" && panel.id !== "preview:file-diff") return; + paramSubs.get(panel.id)?.dispose(); + const sub = panel.api.onDidParametersChange(() => { + if (!panel.api.isActive) return; + set(resolveActiveFile(api, panel.id)); }); - // Track per-panel param-change subscriptions so they can be disposed when - // the panel is removed (e.g. across env switches that re-create the - // preview panel) instead of relying on dockview's internal cleanup. - const paramSubs = new Map void }>(); - api.onDidAddPanel((panel) => { - // The preview file-editor panel reuses a single dockview panel and swaps - // its `params.path` via `updateParameters` when the user previews a - // different file. Dockview does not refire `onDidActivePanelChange` for - // params-only updates on an already-active panel, so subscribe to the - // panel's own parameter-change event and refresh the active file identity. - if (panel.id !== "preview:file-editor" && panel.id !== "preview:file-diff") return; - paramSubs.get(panel.id)?.dispose(); - const sub = panel.api.onDidParametersChange(() => { - if (!panel.api.isActive) return; - set(resolveActiveFile(api, panel.id)); - }); - paramSubs.set(panel.id, sub); - }); - api.onDidRemovePanel((panel) => { - const sub = paramSubs.get(panel.id); - if (sub) { - sub.dispose(); - paramSubs.delete(panel.id); - } - }); - } - }, + paramSubs.set(panel.id, sub); + }); + api.onDidRemovePanel((panel) => { + const sub = paramSubs.get(panel.id); + if (!sub) return; + sub.dispose(); + paramSubs.delete(panel.id); + }); + }; +} + +export const useDockviewStore = create((set, get) => ({ + api: null, + activeFilePath: null, + activeFileRepo: null, + activePanelComponent: null, + setApi: buildSetApiAction(set), activeGroupId: null, selectedDiff: null, setSelectedDiff: (diff) => set({ selectedDiff: diff }), @@ -1449,6 +1530,9 @@ export const useDockviewStore = create((set, get) => ({ sidebarGroupId: SIDEBAR_GROUP, sidebarVisible: false, rightPanelsVisible: true, + rightPaneVisible: true, + rightPaneAvailable: false, + hiddenRightPane: null, activeLayoutProfile: DEFAULT_LAYOUT_PROFILE, pinnedWidths: new Map(), setPinnedWidth: (columnId, width) => { diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts index c9f209925d..d3dba4cb97 100644 --- a/apps/web/lib/state/layout-manager/index.ts +++ b/apps/web/lib/state/layout-manager/index.ts @@ -9,6 +9,7 @@ export type { LayoutBranchNode, LayoutIntent, LayoutIntentPanel, + LayoutOrientation, } from "./types"; // Constants @@ -59,7 +60,13 @@ export type { BuiltInPreset } from "./presets"; export { computeColumnWidths, computeGroupHeights, getPinnedWidth } from "./sizing"; // Serializer -export { toSerializedDockview, fromDockviewApi, filterEphemeral } from "./serializer"; +export { + toSerializedDockview, + fromDockviewApi, + filterEphemeral, + isCenterColumn, + isRightColumn, +} from "./serializer"; // Session panel normalization for reusable saved layouts export { diff --git a/apps/web/lib/state/layout-manager/serializer.test.ts b/apps/web/lib/state/layout-manager/serializer.test.ts index 35dac751fd..5c80d5ec4b 100644 --- a/apps/web/lib/state/layout-manager/serializer.test.ts +++ b/apps/web/lib/state/layout-manager/serializer.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it } from "vitest"; -import { filterEphemeral, toSerializedDockview } from "./serializer"; -import { panel, PROMPT_HISTORY_PANEL_ID } from "./constants"; +import type { DockviewApi } from "dockview-react"; +import { filterEphemeral, fromDockviewApi, toSerializedDockview } from "./serializer"; +import { + panel, + CENTER_GROUP, + PROMPT_HISTORY_PANEL_ID, + RIGHT_BOTTOM_GROUP, + RIGHT_TOP_GROUP, +} from "./constants"; import type { LayoutPanel, LayoutState } from "./types"; +const SESSION_PANEL_ID = "session:session-a"; + /** Builds an ephemeral LayoutPanel whose title is its id. */ function ephemeralPanel(id: string, component: string): LayoutPanel { return { id, component, title: id }; @@ -21,7 +30,7 @@ function layoutWithPromptHistory(extraPanels: LayoutPanel[] = []): LayoutState { id: "center", groups: [ { - id: "group-center", + id: CENTER_GROUP, panels: [panel(PROMPT_HISTORY_PANEL_ID), ...extraPanels], }, ], @@ -30,6 +39,189 @@ function layoutWithPromptHistory(extraPanels: LayoutPanel[] = []): LayoutState { } as unknown as LayoutState; } +type CapturedGroup = { + id: string; + panels: Array<{ id: string; component: string; title?: string }>; +}; + +function makeCapturedApi(columns: CapturedGroup[][]): DockviewApi { + const leaf = (group: CapturedGroup) => ({ + view: { + id: group.id, + panels: group.panels.map((item) => ({ + id: item.id, + title: item.title ?? item.id, + view: { contentComponent: item.component }, + })), + activePanel: group.panels[0] ? { id: group.panels[0].id } : undefined, + }, + }); + const rootChildren = columns.map((groups) => + groups.length === 1 + ? leaf(groups[0]) + : { + children: groups.map(leaf), + splitview: { getViewSize: (index: number) => 300 + index * 10 }, + }, + ); + return { + component: { + gridview: { + root: { + children: rootChildren, + splitview: { getViewSize: (index: number) => 700 - index * 300 }, + }, + }, + }, + } as unknown as DockviewApi; +} + +describe("fromDockviewApi — right-column ownership", () => { + it("preserves the production root split orientation", () => { + const api = makeCapturedApi([ + [ + { + id: "runtime-agent", + panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }], + }, + ], + ]) as unknown as { + component: { gridview: { root: { splitview: { orientation: string } } } }; + }; + api.component.gridview.root.splitview.orientation = "VERTICAL"; + + const captured = fromDockviewApi(api as unknown as DockviewApi); + + expect(captured.rootOrientation).toBe("VERTICAL"); + }); + + it("captures a live Agent session as the compact center column", () => { + const captured = fromDockviewApi( + makeCapturedApi([ + [ + { + id: "runtime-center", + panels: [ + { id: SESSION_PANEL_ID, component: "chat", title: "Agent" }, + { id: "files", component: "files", title: "Files" }, + { id: "changes", component: "changes", title: "Changes" }, + { id: "terminal-default", component: "terminal", title: "Terminal" }, + ], + }, + ], + ]), + ); + + expect(captured.columns).toHaveLength(1); + expect(captured.columns[0]?.id).toBe("center"); + expect(captured.columns[0]?.pinned).toBeUndefined(); + + const serialized = toSerializedDockview(captured, 1000, 800, new Map()); + const root = (serialized as unknown as { grid: { root: { data: unknown[] } } }).grid.root; + expect((root.data[0] as { data: { id: string } }).data.id).toBe("runtime-center"); + }); + + it("keeps a mixed session center separate from a nested right column", () => { + const captured = fromDockviewApi( + makeCapturedApi([ + [ + { + id: "runtime-agent", + panels: [ + { id: SESSION_PANEL_ID, component: "chat", title: "Agent" }, + { id: "files", component: "files", title: "Files" }, + { id: "pr-detail", component: "pr-detail", title: "PR Details" }, + ], + }, + ], + [ + { + id: RIGHT_TOP_GROUP, + panels: [ + { id: "session:session-b", component: "chat" }, + { id: "files", component: "files" }, + ], + }, + { id: RIGHT_BOTTOM_GROUP, panels: [{ id: "terminal-default", component: "terminal" }] }, + ], + ]), + ); + + expect(captured.columns.map((column) => column.id)).toEqual(["center", "right"]); + expect(captured.columns[0]?.groups[0]?.panels.map((item) => item.id)).toEqual([ + SESSION_PANEL_ID, + "files", + "pr-detail", + ]); + expect(captured.columns[1]?.groups.map((group) => group.id)).toEqual([ + RIGHT_TOP_GROUP, + RIGHT_BOTTOM_GROUP, + ]); + }); +}); + +describe("contextual pane width ownership", () => { + it.each(["browser", "plan", "vscode"])( + "keeps a %s pane unpinned after merging tool tabs", + (component) => { + const captured = fromDockviewApi( + makeCapturedApi([ + [{ id: CENTER_GROUP, panels: [{ id: SESSION_PANEL_ID, component: "chat" }] }], + [ + { + id: "group-1", + panels: [ + { id: component, component }, + { id: "files", component: "files" }, + { id: "changes", component: "changes" }, + ], + }, + ], + ]), + ); + expect(captured.columns[1]).toMatchObject({ id: component, width: 400 }); + expect(captured.columns[1].pinned).toBeUndefined(); + }, + ); + + it("keeps canonical right groups pinned even with a Browser tab", () => { + const captured = fromDockviewApi( + makeCapturedApi([ + [{ id: CENTER_GROUP, panels: [{ id: SESSION_PANEL_ID, component: "chat" }] }], + [ + { + id: RIGHT_TOP_GROUP, + panels: [ + { id: "browser", component: "browser" }, + { id: "files", component: "files" }, + ], + }, + ], + ]), + ); + expect(captured.columns[1]).toMatchObject({ id: "right", pinned: true }); + }); + + it("recognizes a content pane after its tool tabs are reordered", () => { + const captured = fromDockviewApi( + makeCapturedApi([ + [{ id: CENTER_GROUP, panels: [{ id: SESSION_PANEL_ID, component: "chat" }] }], + [ + { + id: "group-1", + panels: [ + { id: "files", component: "files" }, + { id: "browser:custom", component: "browser" }, + ], + }, + ], + ]), + ); + expect(captured.columns[1]).toMatchObject({ id: "browser:custom" }); + expect(captured.columns[1].pinned).toBeUndefined(); + }); +}); + describe("filterEphemeral — prompt-history survival", () => { it("keeps the prompt-history panel when filtering a captured layout", () => { const filtered = filterEphemeral(layoutWithPromptHistory()); diff --git a/apps/web/lib/state/layout-manager/serializer.ts b/apps/web/lib/state/layout-manager/serializer.ts index 8358495bd8..26c7c36cc6 100644 --- a/apps/web/lib/state/layout-manager/serializer.ts +++ b/apps/web/lib/state/layout-manager/serializer.ts @@ -1,5 +1,12 @@ import type { DockviewApi, SerializedDockview } from "dockview-react"; -import type { LayoutState, LayoutColumn, LayoutGroup, LayoutPanel, LayoutNode } from "./types"; +import type { + LayoutState, + LayoutColumn, + LayoutGroup, + LayoutPanel, + LayoutNode, + LayoutOrientation, +} from "./types"; import { computeColumnWidths, computeGroupHeights } from "./sizing"; import { SIDEBAR_LOCK, @@ -8,6 +15,9 @@ import { STRUCTURAL_COMPONENTS, TERMINAL_DEFAULT_ID, canonicalPanelTitle, + CENTER_GROUP, + RIGHT_TOP_GROUP, + RIGHT_BOTTOM_GROUP, } from "./constants"; import { panelTitle } from "./panel-title"; @@ -36,10 +46,15 @@ type SerializedBranchNode = { type SerializedGridNode = SerializedLeafNode | SerializedBranchNode; -type SerializationCtx = { counter: number }; +type SerializationCtx = { counter: number; usedGroupIds: Set }; function nextGroupId(ctx: SerializationCtx): string { - return `group-${++ctx.counter}`; + let groupId: string; + do { + groupId = `group-${++ctx.counter}`; + } while (ctx.usedGroupIds.has(groupId)); + ctx.usedGroupIds.add(groupId); + return groupId; } // ─── Serialization: LayoutState → SerializedDockview ─────────────────────── @@ -51,6 +66,7 @@ function serializeGroup( ctx: SerializationCtx, ): SerializedLeafNode { const groupId = group.id ?? nextGroupId(ctx); + if (group.id) ctx.usedGroupIds.add(group.id); const views = group.panels.map((p) => p.id); const isSidebar = columnId === "sidebar"; return { @@ -173,7 +189,15 @@ export function toSerializedDockview( totalHeight: number, pinnedWidths: Map, ): SerializedDockview { - const ctx: SerializationCtx = { counter: 0 }; + const ctx: SerializationCtx = { + counter: 0, + usedGroupIds: new Set( + state.columns.flatMap((column) => + column.groups.flatMap((group) => (group.id ? [group.id] : [])), + ), + ), + }; + const rootOrientation = state.rootOrientation ?? "HORIZONTAL"; const widths = computeColumnWidths(state.columns, totalWidth, pinnedWidths); const root: SerializedBranchNode = { @@ -187,7 +211,7 @@ export function toSerializedDockview( root, width: totalWidth, height: totalHeight, - orientation: "HORIZONTAL", + orientation: rootOrientation, }, panels: serializePanels(state), activeGroup: undefined, @@ -248,9 +272,52 @@ function captureNode( return { type: "leaf", group, size }; } -/** Panel IDs that indicate the "right" column. */ +/** Panel IDs that indicate the "right" column when no structural identity exists. */ const RIGHT_PANEL_IDS = new Set(["files", "changes"]); +function hasCanonicalRightIdentity(column: Pick): boolean { + const groups = column.groups ?? []; + return ( + column.id === "right" || + groups.some((group) => group.id === RIGHT_TOP_GROUP || group.id === RIGHT_BOTTOM_GROUP) + ); +} + +/** True when the column contains the Agent surface or the canonical center group. */ +export function isCenterColumn(column: Pick): boolean { + if (hasCanonicalRightIdentity(column)) return false; + const groups = column.groups ?? []; + return ( + column.id === "center" || + groups.some( + (group) => + group.id === CENTER_GROUP || + group.panels.some( + (panel) => + panel.component === "chat" || panel.id === "chat" || panel.id.startsWith("session:"), + ), + ) + ); +} + +/** Content panes retain their flexible sizing when tool tabs are merged into them. */ +function contextualPanel(column: Pick): LayoutPanel | undefined { + return column.groups + .flatMap((group) => group.panels) + .find( + (panel) => + panel.component === "plan" || panel.component === "browser" || panel.component === "vscode", + ); +} + +/** True when the column owns the standard right-side groups or column ID. */ +export function isRightColumn(column: Pick): boolean { + if (hasCanonicalRightIdentity(column)) return true; + if (isCenterColumn(column) || contextualPanel(column)) return false; + const groups = column.groups ?? []; + return groups.some((group) => group.panels.some((panel) => RIGHT_PANEL_IDS.has(panel.id))); +} + /** Determine column ID and pinned status from its groups. */ function inferColumnMeta( groups: LayoutGroup[], @@ -258,22 +325,30 @@ function inferColumnMeta( ): { columnId: string; isPinned: boolean } { if (groups.length === 0) return { columnId: `col-${index}`, isPinned: false }; - const allPanelIds = new Set(groups.flatMap((g) => g.panels.map((p) => p.id))); + const column = { id: `col-${index}`, groups }; - if (allPanelIds.has("sidebar")) return { columnId: "sidebar", isPinned: true }; - if (allPanelIds.has("chat")) return { columnId: "center", isPinned: false }; - - // Column containing files/changes panels is the "right" column - for (const id of allPanelIds) { - if (RIGHT_PANEL_IDS.has(id)) return { columnId: "right", isPinned: true }; + if (groups.some((group) => group.panels.some((panel) => panel.id === "sidebar"))) { + return { columnId: "sidebar", isPinned: true }; } + if (isCenterColumn(column)) return { columnId: "center", isPinned: false }; - const firstPanelId = groups[0].panels[0]?.id; + if (isRightColumn(column)) return { columnId: "right", isPinned: true }; + + const firstPanelId = contextualPanel(column)?.id ?? groups[0].panels[0]?.id; if (firstPanelId) return { columnId: firstPanelId, isPinned: false }; return { columnId: `col-${index}`, isPinned: false }; } +function readRootOrientation( + root: { orientation?: unknown } | null | undefined, + splitview: { orientation?: unknown } | null | undefined, +): LayoutOrientation { + return root?.orientation === "VERTICAL" || splitview?.orientation === "VERTICAL" + ? "VERTICAL" + : "HORIZONTAL"; +} + /** * Walk the dockview grid tree and map back to LayoutState. * Captures both the recursive tree structure (for faithful restoration) @@ -281,9 +356,10 @@ function inferColumnMeta( */ export function fromDockviewApi(api: DockviewApi): LayoutState { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sv = (api as any).component?.gridview?.root?.splitview; + const root = (api as any).component?.gridview?.root; + const sv = root?.splitview; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rootChildren = (api as any).component?.gridview?.root?.children; + const rootChildren = root?.children; if (!rootChildren || !sv) { console.warn("fromDockviewApi: no root splitview or children found"); @@ -308,7 +384,7 @@ export function fromDockviewApi(api: DockviewApi): LayoutState { }); } - return { columns }; + return { columns, rootOrientation: readRootOrientation(root, sv) }; } // ─── Ephemeral Filtering ─────────────────────────────────────────────────── diff --git a/apps/web/lib/state/layout-manager/types.ts b/apps/web/lib/state/layout-manager/types.ts index 3d1251799a..1a52fd622c 100644 --- a/apps/web/lib/state/layout-manager/types.ts +++ b/apps/web/lib/state/layout-manager/types.ts @@ -46,9 +46,13 @@ export type LayoutColumn = { tree?: LayoutNode; }; +export type LayoutOrientation = "HORIZONTAL" | "VERTICAL"; + /** Complete declarative layout state. */ export type LayoutState = { columns: LayoutColumn[]; + /** Orientation of Dockview's outermost split. Older snapshots are horizontal. */ + rootOrientation?: LayoutOrientation; }; // ─── Layout Intent ────────────────────────────────────────────────────────── diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 9acb3ed299..a71d6339ca 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -705,7 +705,11 @@ "hidePreparationDetails": "Hide preparation details", "hidePreviousAgentError": "Hide previous agent error", "hideRightPanel": "Hide right panel", + "hideRightPane": "Hide right pane", "hideRightPanels": "Hide right panels", + "rightPaneUnavailable": "No separate right pane to hide", + "rightPaneUnavailableWhileMaximized": "Right pane is unavailable while a panel is maximized", + "rightPanelsUnavailableWhileMaximized": "Right panels are unavailable while a panel is maximized", "hideSidebar": "Hide sidebar", "hideTheCommentListTheComments": "Hide the comment list. The comments stay queued and will still be sent next time you submit (here or from the chat box).", "host": "Host", @@ -1528,6 +1532,8 @@ "showPreparationDetails": "Show preparation details", "showRawText": "Show raw text", "showRightPanel": "Show right panel", + "showRightPane": "Show right pane", + "showRightPanels": "Show right panels", "showSidebar": "Show sidebar", "sibling": "Sibling", "sidebarFilters": "Sidebar filters", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 07f7356b0a..170c7ea7e1 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -705,7 +705,11 @@ "hidePreparationDetails": "Ĥĩďē ƥŕēƥàŕàţĩōń ďēţàĩĺś", "hidePreviousAgentError": "Ĥĩďē ƥŕēvĩōũś àĝēńţ ēŕŕōŕ", "hideRightPanel": "Ĥĩďē ŕĩĝĥţ ƥàńēĺ", + "hideRightPane": "Ĥĩďē ŕĩĝĥţ ƥàńē", "hideRightPanels": "Ĥĩďē ŕĩĝĥţ ƥàńēĺś", + "rightPaneUnavailable": "Ńō śēƥàŕàţē ŕĩĝĥţ ƥàńē ƭō ĥĩďē", + "rightPaneUnavailableWhileMaximized": "Ŕĩĝĥţ ƥàńē ĩś ũńàvàĩĺàƀĺē ŵĥĩĺē à ƥàńēĺ ĩś ḿàxĩḿĩźēď", + "rightPanelsUnavailableWhileMaximized": "Ŕĩĝĥţ ƥàńēĺś àŕē ũńàvàĩĺàƀĺē ŵĥĩĺē à ƥàńēĺ ĩś ḿàxĩḿĩźēď", "hideSidebar": "Ĥĩďē śĩďēƀàŕ", "hideTheCommentListTheComments": "Ĥĩďē ţĥē ćōḿḿēńţ ĺĩśţ. Ţĥē ćōḿḿēńţś śţàŷ qũēũēď àńď ŵĩĺĺ śţĩĺĺ ƀē śēńţ ńēxţ ţĩḿē ŷōũ śũƀḿĩţ (ĥēŕē ōŕ ƒŕōḿ ţĥē ćĥàţ ƀōx).", "host": "Ĥōśţ", @@ -1528,6 +1532,8 @@ "showPreparationDetails": "Śĥōŵ ƥŕēƥàŕàţĩōń ďēţàĩĺś", "showRawText": "Śĥōŵ ŕàŵ ţēxţ", "showRightPanel": "Śĥōŵ ŕĩĝĥţ ƥàńēĺ", + "showRightPane": "Śĥōŵ ŕĩĝĥţ ƥàńē", + "showRightPanels": "Śĥōŵ ŕĩĝĥţ ƥàńēĺś", "showSidebar": "Śĥōŵ śĩďēƀàŕ", "sibling": "Śĩƀĺĩńĝ", "sidebarFilters": "Śĩďēƀàŕ ƒĩĺţēŕś", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 1a2708223e..fc0f96908a 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -686,7 +686,11 @@ "hidePreparationDetails": "Ocultar os detalhes da preparação", "hidePreviousAgentError": "Ocultar o erro anterior do agente", "hideRightPanel": "Ocultar o painel direito", + "hideRightPane": "Ocultar o painel direito", "hideRightPanels": "Ocultar os painéis da direita", + "rightPaneUnavailable": "Não existe um painel direito separado para ocultar", + "rightPaneUnavailableWhileMaximized": "O painel direito não está disponível enquanto um painel está maximizado", + "rightPanelsUnavailableWhileMaximized": "Os painéis da direita não estão disponíveis enquanto um painel está maximizado", "hideSidebar": "Ocultar a barra lateral", "hideTheCommentListTheComments": "Oculta a lista de comentários. Os comentários ficam em fila e continuam a ser enviados da próxima vez que submeter (aqui ou a partir da caixa de chat).", "host": "Anfitrião", @@ -1471,6 +1475,8 @@ "showPreparationDetails": "Mostrar os detalhes da preparação", "showRawText": "Mostrar o texto em bruto", "showRightPanel": "Mostrar o painel direito", + "showRightPane": "Mostrar o painel direito", + "showRightPanels": "Mostrar os painéis da direita", "showSidebar": "Mostrar a barra lateral", "sibling": "Irmã", "sidebarFilters": "Filtros da barra lateral", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index c5cdad0e65..09374c4579 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -685,7 +685,11 @@ "hidePreparationDetails": "隐藏准备详情", "hidePreviousAgentError": "隐藏先前的智能体错误", "hideRightPanel": "隐藏右侧面板", + "hideRightPane": "隐藏右侧窗格", "hideRightPanels": "隐藏右侧面板", + "rightPaneUnavailable": "没有可隐藏的独立右侧窗格", + "rightPaneUnavailableWhileMaximized": "面板最大化时,右侧窗格不可用", + "rightPanelsUnavailableWhileMaximized": "面板最大化时,右侧面板不可用", "hideSidebar": "隐藏侧边栏", "hideTheCommentListTheComments": "隐藏评论列表。评论仍会排队,下次提交时仍会发送(在此处或从聊天框)。", "host": "主机", @@ -1470,6 +1474,8 @@ "showPreparationDetails": "显示准备详情", "showRawText": "显示原始文本", "showRightPanel": "显示右侧面板", + "showRightPane": "显示右侧窗格", + "showRightPanels": "显示右侧面板", "showSidebar": "显示侧边栏", "sibling": "同级", "sidebarFilters": "侧边栏筛选", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 6272915a27..f2f418fc54 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -685,7 +685,11 @@ "hidePreparationDetails": "隱藏準備詳情", "hidePreviousAgentError": "隱藏先前的代理程式錯誤", "hideRightPanel": "隱藏右側面板", + "hideRightPane": "隱藏右側窗格", "hideRightPanels": "隱藏右側面板", + "rightPaneUnavailable": "沒有可隱藏的獨立右側窗格", + "rightPaneUnavailableWhileMaximized": "面板最大化時,右側窗格不可用", + "rightPanelsUnavailableWhileMaximized": "面板最大化時,右側面板不可用", "hideSidebar": "隱藏側邊欄", "hideTheCommentListTheComments": "隱藏評論列表。評論仍會排隊,下次提交時仍會傳送(在此處或從聊天框)。", "host": "主機", @@ -1470,6 +1474,8 @@ "showPreparationDetails": "顯示準備詳情", "showRawText": "顯示原始文字", "showRightPanel": "顯示右側面板", + "showRightPane": "顯示右側窗格", + "showRightPanels": "顯示右側面板", "showSidebar": "顯示側邊欄", "sibling": "同級", "sidebarFilters": "側邊欄篩選", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index b585134b65..29c40e8fb1 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -685,7 +685,11 @@ "hidePreparationDetails": "隱藏準備詳情", "hidePreviousAgentError": "隱藏先前的代理程式錯誤", "hideRightPanel": "隱藏右側面板", + "hideRightPane": "隱藏右側窗格", "hideRightPanels": "隱藏右側面板", + "rightPaneUnavailable": "沒有可隱藏的獨立右側窗格", + "rightPaneUnavailableWhileMaximized": "面板最大化時,右側窗格不可用", + "rightPanelsUnavailableWhileMaximized": "面板最大化時,右側面板不可用", "hideSidebar": "隱藏側邊欄", "hideTheCommentListTheComments": "隱藏評論清單。評論仍會排隊,下次提交時仍會傳送(在此處或從聊天框)。", "host": "主機", @@ -1470,6 +1474,8 @@ "showPreparationDetails": "顯示準備詳情", "showRawText": "顯示原始文字", "showRightPanel": "顯示右側面板", + "showRightPane": "顯示右側窗格", + "showRightPanels": "顯示右側面板", "showSidebar": "顯示側邊欄", "sibling": "同級", "sidebarFilters": "側邊欄篩選", diff --git a/docs/plans/right-panel-visibility/plan.md b/docs/plans/right-panel-visibility/plan.md new file mode 100644 index 0000000000..eb4035c510 --- /dev/null +++ b/docs/plans/right-panel-visibility/plan.md @@ -0,0 +1,277 @@ +--- +created: 2026-09-13 +status: implemented +requirements: + - REQ-UI-RIGHT-PANEL-VISIBILITY-001 +system_design: + - ../../specs/ui/system-design/right-panel-visibility.md +legacy_specs: [] +--- + +# Implementation plan: Contextual right-pane visibility + +## Overview + +The toggle hides and restores the rightmost region in the current layout. +Plan Mode targets Plan, Preview Mode targets Browser, VS Code targets its editor, and Default targets its complete right stack. +The user requested this correction on 2026-09-14 and supplied screenshots showing the Plan and Preview layouts. +Task 02 implements the correction. Historical Task 01 results remain context only and do not replace the +contextual behavior evidence below. + +## Baseline and ownership + +Current baseline: `03fe74e955a869a30819e6d9b3f59c83bb1c3c45`. +Earlier commits: `88241db53` implemented the persistent toggle; `03fe74e95` addressed review findings. +UI continues to own the layout contract. Task, agent, terminal, and portable layout-profile ownership do not change. + +`toggleRightPanels` currently filters legacy right-owned columns and reconstructs `defaultLayout()` on Show. +The new implementation selects from live geometry and retains the removed subtree for restoration. +The user explicitly replaced the previous Files/Changes/Terminal-only behavior. +This also supersedes compact-mode creation of a standard sidebar and the exclusion of custom-pane restoration. + +## Scope + +### In scope + +- Geometric target selection and exact pane restoration for built-in and custom Dockview arrangements. +- Per-environment hidden-pane recovery with existing environment layout persistence. +- Reset/preset invalidation, task switching, nested splits, active Agent protection, duplicate prevention, and maximize guards. +- Existing header placement, localized state explanations, touch targets, and tablet/phone parity. +- Focused regression coverage and public instructions updated during implementation. + +### Out of scope + +- Backend settings, new breakpoints, phone sidebars, arbitrary undo history, and terminal process lifecycle changes. +- Changes outside the task workbench visibility contract, such as backend preferences, new breakpoints, + phone sidebars, undo history, or terminal lifecycle behavior. + +## Technical approach + +Follow the revised [requirements](../../specs/ui/requirements/right-panel-visibility.md) and +[system design](../../specs/ui/system-design/right-panel-visibility.md). +Select the final child of the outer horizontal workbench split, retaining its full nested subtree. +When a hidden descriptor exists, Show restores it before any further target selection. +Persist recovery metadata atomically with the environment layout; never copy it into another environment or portable profile. +Keep remaining live edits when restoring. Do not call `defaultLayout()` as a Show fallback. +A single region without retained recovery data has no toggle target. + +## ASCII UI preview + +### UI-03: Contextual right-pane toggle + +Entry: a desktop workbench, including large tablets. Header stays fixed; pane content owns scrolling. + +```text +PLAN MODE: shown ++---------------------------------------------------+ +| Task [Hide right pane] [Layouts v] | ++--------------------------+------------------------+ +| Agent | Plan | ++--------------------------+------------------------+ + +PLAN MODE: hidden ++---------------------------------------------------+ +| Task [Show right pane] [Layouts v] | ++---------------------------------------------------+ +| Agent fills the released width | ++---------------------------------------------------+ +Show restores Plan with its tabs and split state. + +PREVIEW MODE: shown ++---------------------------------------------------+ +| Task [Hide right pane] [Layouts v] | ++--------------------------+------------------------+ +| Agent | Browser | ++--------------------------+------------------------+ +Hide -> Agent fills width. Show -> the same Browser. + +DEFAULT: shown VS CODE: shown ++---------------+-----------+ +---------------+-----------+ +| Agent | Files | | Agent | VS Code | +| | Changes | | | | +| +-----------+ +---------------+-----------+ +| | Terminal | ++---------------+-----------+ +Default toggles the whole right stack. VS Code toggles VS Code. + +CUSTOM: three side-by-side regions ++---------------+-----------+---------------+ +| Agent | Plan | Browser | ++---------------+-----------+---------------+ +Hide removes Browser only; the next click restores Browser. +It does not continue removing Plan. + +SINGLE REGION: no retained hidden pane ++---------------------------------------------------+ +| Task [right-pane icon disabled] [Layouts v] | ++---------------------------------------------------+ +| Agent / Files / Changes tabs in one group | ++---------------------------------------------------+ +Explanation: No separate right pane to hide. +``` + +Button labels in this drawing stand for localized tooltips and accessible names; the actual header keeps its existing icon. +The left navigation toggle remains independent. Spacing is illustrative; group identity and hide/show results are required. +Initialization and maximize retain their existing disabled states. A hidden target always takes precedence over a new hide target. + +### UI-02: Phone and tablet fallback + +```text +Phone: one active surface Narrow tablet fallback ++-------------------------+ +----------------+-----------+ +| Chat / Files / Terminal | | Chat/Plan/... | Files | +| | | | Terminal | ++-------------------------+ +----------------+-----------+ +| Existing bottom nav | Persistent header toggle hides ++-------------------------+ and restores this right stack. +``` + +Phone navigation, safe areas, and full-screen content remain unchanged. No phone toggle is added. +UI-03 maps to AC-UI-RIGHT-PANEL-VISIBILITY-001.1-.5 and .7-.10; UI-02 maps to .3, .5, and .6. + +## Tests and E2E matrix + +Task 02 owns these cases and exact commands. Use production-shaped Agent panels and real serializer output. + +| Scenario | Required evidence | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| Plan, Preview, VS Code, Default | Hide releases width; Show restores the exact target, not a Files sidebar | +| Custom three-column and nested target | Only outer right region toggles; tabs, parameters, selected tabs, tree, and width survive | +| One group, vertical-only split, rightmost active Agent | Disabled explanation; no deletion or fabricated sidebar | +| Repeated hide/show | Alternates the same target; unique panel IDs; remaining center content survives | +| Reopen a hidden panel elsewhere | Live instance wins; no duplicate or whole-layout reset | +| Hidden reload and A/B environment switch | Correct label and correct per-environment target survive | +| Hidden Plan then select Preview or Reset | Old target is discarded; only the new arrangement determines the next action | +| Legacy, malformed, or unavailable panel metadata | Valid visible layout survives; no stale panel resurrection | +| Maximize, rapid clicks, late callbacks | No overlay capture or cross-environment mutation | +| 1280px coarse, 900px fine/coarse, phone | Touch geometry, keyboard focus, no overflow, and unchanged phone navigation | + +## Work orders + +- [x] [Task 01: Persistent toggle](task-01-persistent-toggle.md). Completed historical scope; superseded behavior is identified there. +- [x] [Task 02: Contextual right-pane selection and restoration](task-02-contextual-right-pane.md). Done; depends on Task 01. + +Execute Task 02 as one sequential vertical slice in the primary session. + +## Current revision verification + +Task 02 implementation and validation completed on the current branch. + +- Focused unit suite: 10 files, 130 tests passed. +- `pnpm run typecheck` and `pnpm run lint` from `apps/web` passed. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` from `apps/web` passed. +- Managed Chromium E2E matrix: 12 tests passed across right-pane, tablet-persistence, and compact-desktop scenarios. +- Managed mobile-Chromium E2E: 1 phone test passed. +- The managed E2E builds passed; Vite emitted only the repository's existing chunk-size and dynamic-import warnings. +- `node --test scripts/validate-public-docs.test.mjs` and `node scripts/validate-public-docs.mjs` passed. +- `python3 scripts/list-docs.py validate` and `python3 scripts/lint-spec-files.py --all` passed. +- `git diff --check` passed. + +The browser runs cover Default, Plan, Preview, compact single-region, maximize/exit, keyboard focus, tablet, +and phone behavior. The broader matrix still includes resize handoffs, the 1280-pixel coarse-pointer case, +all four sidebar combinations, archived-task restoration, browser-level mixed-center fixtures, and the +wider-to-phone handoff; those remain separate coverage beyond this implementation run. + +## Review remediation verification (2026-09-14) + +The review findings against PR head `8b98810227f1aa5e3058d32461122eb747b86b00` are resolved in the current +worktree. Recovery metadata is rejected before mutation when IDs, active selections, parameters, geometry, +tree/flat groups, or layout-wide identities are unsafe. Layout applies retain the live serialized Dockview +state and roll back once after a mutating failure, while the contextual hidden descriptor and visibility flags +remain recoverable without partial persistence. Non-pinned restored panes reserve their captured width within +the current viewport and distribute the remaining live proportions. Duplicate-panel pruning retains split +wrappers, preserving nested axes and sizes, and generated group IDs avoid explicit live IDs. + +The current remediation checks are: + +- Exact work-order unit command: 10 files, 135 tests passed. +- Additional affected unit suite: 8 files, 79 tests passed. +- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet` passed. +- Managed Chromium E2E: 12 tests passed. +- Managed mobile-Chromium E2E: 1 test passed. +- E2E Vite builds passed with existing repository warnings only. +- The earlier PR snapshot's failed E2E shards 2/14 and 6/14 were not attributed by this remediation. The current + local focused runs pass, and current CI output will be reviewed after the fixup push. +- `git diff --check` passed. + +## PR fixup verification (2026-09-14) + +The first current-head PR run for `2bcaa1faf28f55fc2cfbdac122e791cdced628f0` reached 44 passed, 9 failed, +and 0 pending checks before the repository PR helper's 45-minute deadline. The failed E2E shards were unrelated +to this change: shard 2 reported two narrow-tab-strip assertions, shard 6 reported one fork pull-request +comparison assertion, and shard 14 reported one Plan table pixel-tolerance assertion plus flaky Agent restart +and mobile file-viewer cases. The E2E and frontend aggregate failures depended on those leaf results. + +The frontend leaf job also exposed one compatibility failure caused by the new transactional wrapper: an existing +`dockview-store.test.ts` mock omitted `api.toJSON()`. The wrapper now guards reduced test doubles while retaining +production rollback behavior. The targeted follow-up suite passed 11 files and 157 tests, and targeted ESLint and +typecheck passed. + +The PR documentation coverage job exited without evaluator output. Replaying the evaluator locally against the +current PR event and against its trusted validator revision returned `covered`, so the status requires a fresh +workflow result after the fixup push. Aggregate checks will be re-evaluated from the new head. + +## Follow-up PR fixup verification (2026-09-14) + +After the reduced-test-double compatibility fix was pushed as `8e866cf518d07f8255ecbc927f90d33eb7be8edb`, +`scripts/pr-await 3661 --deadline-min 60` waited 18 minutes and reached the terminal exact-head snapshot: +47 passed, 6 failed, and 0 pending. The PR was `MERGEABLE / BLOCKED`, with no unresolved review threads. +The base branch advanced during the run, so the result is not a current-base merge-readiness claim. + +The failed leaf E2E jobs were outside the contextual right-pane change: + +- Shard 2 failed both existing narrow-tab-strip assertions, one for equal row widths and one for zero overflow. +- Shard 6 failed the existing fork pull-request comparison assertion because the unavailable-target notice remained. +- Shard 14 failed the existing Plan table pixel assertion, receiving `59.15625` for an expected resize delta of `60`. + Its LSP capacity-release and mobile file-viewer failures were transient attempts of unrelated tests. +- The E2E aggregate and report-merge failures followed the failed leaf shards. +- The trusted PR documentation status job again exited without evaluator output. Local replay of the current and + trusted evaluators returned `covered`. + +The three relevant no-retry reproductions were run locally after the PR report: the Plan table test reproduced the +existing `59.15625` pixel delta failure, while the LSP capacity-release test passed one test in 48.8 seconds and the +mobile file-viewer test passed one test in 13.1 seconds. No additional source fix was justified by these unrelated +failures. The contextual right-pane unit, browser, typecheck, lint, localization, documentation, specification, +and diff checks remain passed as recorded above. + +## Historical Task 01 verification + +The previous standard-sidebar implementation was completed before the 2026-09-14 behavior correction. The new control is shared by desktop and tablet adapters, the tablet right column is conditional, compact desktop can reopen it, and phone navigation keeps its existing full-screen composition. + +Checks passed: + +- `pnpm install --frozen-lockfile` from `apps`. +- Review-focused unit tests after fixup: 9 files, 102 tests; the store-focused follow-up passed 45 tests in 2 files. +- Full web unit suite: 2,058 files, 17,788 passed and 4 skipped tests. +- `pnpm run typecheck` and `pnpm run lint` from `apps/web`. +- Targeted ESLint for changed source and browser files. +- Prettier check for changed TypeScript, TSX, and JSON files. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` from `apps/web`. +- `pnpm --filter @kandev/web build:vite` from `apps`. +- Managed Chromium E2E fixup run: 5 right-panel tests passed, including compact reload, maximized disabled/exit/reload, tablet persistence, and keyboard activation. +- Managed mobile-chrome E2E fixup run: 1 test passed with Pixel 5 device and coarse-pointer assertions. +- `node --test scripts/validate-public-docs.test.mjs`: 62 tests passed. +- `node scripts/validate-public-docs.mjs`: 46 published documents validated. +- `python3 scripts/list-docs.py validate`: 267 decisions and 896 specifications. +- `python3 scripts/lint-spec-files.py --all`: all specification files passed. +- `git diff --check`: passed. + +The implementation adds localized labels in all five supported catalogs and updates the public task-workspace instructions. + +The assertions above cover the review regressions. The retained browser matrix is broader than this run. The tablet component test uses mocked panel primitives and persistence callbacks, so it proves conditional composition and center identity only; the browser test proves the stored visibility round trip, while saved split geometry remains in the retained matrix. The component test proves focus retention after a click and the disabled maximized accessibility wrapper; the browser test proves native Enter and Space activation plus maximized exit/reload recovery. Resize handoffs, archived-task restoration, the 1280-pixel coarse-pointer case, all four sidebar combinations, mixed center/right browser fixtures, and the wider-to-phone handoff remain planned coverage. + +New files were inspected explicitly; work-order references resolve to the new requirement and design. +Implementation commands and browser results are recorded above and in the completed work order. + +## Risks + +- Column names can describe panel contents rather than physical placement; selection must use actual split geometry. +- Tree-based serialization can reintroduce removed panels if flat groups and nested trees disagree. +- A whole-layout restore can overwrite edits made while the pane was hidden; reinsert only the retained target. +- Recovery metadata can be lost by a save path that only serializes Dockview JSON; cover every environment save and restore path. +- Default-only width enforcement must not resize restored Plan, Browser, or custom panes incorrectly. + +## Public documentation + +Task 02 updates `docs/public/tasks-and-workflows.md` to describe the active layout target and single-region disabled state. +Requirements, system design, plan, work order, implementation, tests, and public instructions now describe the same behavior. diff --git a/docs/plans/right-panel-visibility/task-01-persistent-toggle.md b/docs/plans/right-panel-visibility/task-01-persistent-toggle.md new file mode 100644 index 0000000000..75b805b971 --- /dev/null +++ b/docs/plans/right-panel-visibility/task-01-persistent-toggle.md @@ -0,0 +1,235 @@ +--- +id: "01-persistent-toggle" +title: "Add a persistent right-panel toggle" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-RIGHT-PANEL-VISIBILITY-001 +acceptance_criteria: + - AC-UI-RIGHT-PANEL-VISIBILITY-001.1 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.2 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.3 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.4 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.5 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.6 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.7 +system_design: + - ../../specs/ui/system-design/right-panel-visibility.md +--- + +# Task 01: Add a persistent right-panel toggle + +> Historical completed work order for commits `88241db53` and `03fe74e95`. +> The 2026-09-14 user correction supersedes the standard-sidebar reconstruction, compact Show fallback, and custom-snapshot exclusion below. +> Do not implement from these historical instructions. Follow [Task 02](task-02-contextual-right-pane.md) and the revised system design. +> Historical results do not verify the new contextual target behavior. + +## Summary + +Expose right-panel visibility through a persistent task-header control. +Wire both existing layout stores, preserve independent navigation, and prove the result on desktop, tablet, and phone. + +## In scope + +Implement the control, adapter, tablet conditional layout, compact reopening, and mixed-center protection from the design. +Remove the old disappearing control and duplicate document-mode right control on these task pages. +Add localized copy in all supported catalogs. Generate Traditional Chinese and pseudo catalogs through repository scripts. +Add the unit and browser scenarios in the plan's test matrix. +Update the public task-workspace instructions after implementation. + +## Out of scope + +No backend, release flag, new storage, breakpoint change, custom-panel snapshots, or Office-specific surface change. + +## Acceptance + +- UI-01 follows the active layout, survives hiding, preserves center content, and keeps touch/keyboard access. +- UI-02 retains existing phone navigation and wider-layout preferences. +- Covered regression checks pass, and the plan records asserted behavior separately from the retained browser matrix. + +## Historical ASCII UI preview (superseded by UI-03) + +### UI-01: Desktop and tablet task header + +Entry: open a task. The task header stays fixed; panel bodies own scrolling. + +```text +BEFORE: current Dockview ++----------------------------------------------------------+ +| Task title [Layouts] [Editor] | ++---------------------------------+------------------------+ +| Chat | Files [Hide >] | +| | Terminal | ++---------------------------------+------------------------+ +After Hide: the right column AND its Hide button disappear. + +AFTER: right panels visible ++----------------------------------------------------------+ +| Task title [R >] [Layouts] [Editor] | ++---------------------------------+------------------------+ +| Chat | Files / Changes | +| +------------------------+ +| | Terminal | ++---------------------------------+------------------------+ +[R >] = Hide right panels + +AFTER: right panels hidden ++----------------------------------------------------------+ +| Task title [< R] [Layouts] [Editor] | ++----------------------------------------------------------+ +| Chat now uses the released width | +| | ++----------------------------------------------------------+ +[< R] = Show right panels; same position, one tap to restore. +``` + +The existing left-sidebar toggle remains independent, outside this cropped region. +The coarse-pointer tablet fallback uses the same header control and hides Files plus Terminal. +Its Chat/Plan/Changes tabs remain in the left content surface. +Compact desktop starts with its existing single-group default; explicit Show adds a right column. +During initialization or restoration, the same control is disabled and keeps its position. + +### UI-02: Phone task navigation + +Entry: task below 768 CSS pixels. Retain the shipped full-screen panel composition. + +```text ++------------------------------------+ +| Task context | ++------------------------------------+ +| Chat OR Files OR Terminal | +| One active content surface | +| | ++------------------------------------+ +| Chat | Plan | Changes | Files | >_ | ++------------------------------------+ +``` + +The bottom row is an excerpt of existing navigation; other existing destinations remain available. +Select Files or Terminal, then Chat to return. There is no right-sidebar toggle on phones. +Keep existing safe areas and content scrolling; do not overwrite wider-layout preferences. + +Required structure: persistent control order, independent sidebars, reclaimed width, and separate phone navigation. +Spacing and ASCII glyphs are illustrative. Use existing tokens and localized labels. +UI-01 maps to AC-UI-RIGHT-PANEL-VISIBILITY-001.1 through .5 and .7; UI-02 maps to .6. + +See the [full package preview and scenario matrix](plan.md#ascii-ui-preview). + +## TDD sequence + +1. Added the tablet `right: false` regression and observed the expected failure against current rendering. +2. Added the compact reopen and mixed-center store regressions and observed the expected behavioral failures before production changes. +3. Implemented the adapter, control, and layout fixes with their component and store tests. +4. Added browser coverage, inspected the rendered compositions through browser assertions, and ran the checks below. +5. Fixed the review findings with production-shaped serializer coverage, A/B environment restore coverage, duplicate-ID reconciliation, maximized disabled/exit/reload coverage, and compact reload coverage. +6. Updated public instructions, work-order results, and plan status while retaining unexecuted matrix cases. + +## Verification + +```bash +# From the repository root. Install once if this worktree lacks dependencies. +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run hooks/use-task-right-panels-toggle.test.ts components/task/task-right-panels-toggle.test.tsx components/task/mobile/session-tablet-layout.test.tsx lib/state/dockview-preset-persistence.test.ts lib/state/dockview-right-panel-visibility.test.ts lib/state/dockview-env-switch-action.test.ts lib/state/layout-manager/serializer.test.ts components/task/task-top-bar.test.tsx components/task/dockview-layout-restore.test.ts) +(cd apps/web && pnpm exec vitest run lib/state/dockview-store.test.ts components/task/dockview-desktop-layout.test.ts components/task/dockview-layout-restore.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +(cd apps/web && pnpm exec eslint hooks/use-task-right-panels-toggle.ts hooks/use-task-right-panels-toggle.test.ts components/task/task-right-panels-toggle.tsx components/task/task-right-panels-toggle.test.tsx components/task/task-top-bar.tsx components/task/dockview-header-actions.tsx components/task/document/document-controls.tsx components/task/mobile/session-tablet-layout.tsx components/task/mobile/session-tablet-layout.test.tsx lib/state/dockview-store.ts lib/state/dockview-preset-persistence.test.ts e2e/tests/layout/right-panel-visibility.spec.ts e2e/tests/layout/mobile-right-panel-visibility.spec.ts --max-warnings 0) +(cd apps/web && pnpm exec prettier --check components/task/dockview-desktop-layout.tsx components/task/dockview-header-actions.tsx components/task/document/document-controls.tsx components/task/mobile/session-tablet-layout.tsx components/task/mobile/session-tablet-layout.test.tsx components/task/task-right-panels-toggle.test.tsx components/task/task-right-panels-toggle.tsx components/task/task-top-bar.tsx hooks/use-task-right-panels-toggle.test.ts hooks/use-task-right-panels-toggle.ts lib/state/dockview-preset-persistence.test.ts lib/state/dockview-right-panel-visibility.test.ts lib/state/dockview-store.ts e2e/tests/layout/mobile-right-panel-visibility.spec.ts e2e/tests/layout/right-panel-visibility.spec.ts src/locales/en/task.json src/locales/pseudo/task.json src/locales/pt-pt/task.json src/locales/zh-cn/task.json src/locales/zh-hk/task.json src/locales/zh-tw/task.json) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm e2e:run --host --project chromium tests/layout/right-panel-visibility.spec.ts tests/layout/pane-persistence-tablet.spec.ts tests/layout/compact-desktop-responsive.spec.ts) +(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/layout/mobile-right-panel-visibility.spec.ts) +(cd apps && pnpm --filter @kandev/web build:vite) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +New: + +- `apps/web/components/task/task-right-panels-toggle.tsx` +- `apps/web/components/task/task-right-panels-toggle.test.tsx` +- `apps/web/hooks/use-task-right-panels-toggle.ts` +- `apps/web/hooks/use-task-right-panels-toggle.test.ts` +- `apps/web/components/task/mobile/session-tablet-layout.test.tsx` +- `apps/web/e2e/tests/layout/right-panel-visibility.spec.ts` +- `apps/web/e2e/tests/layout/mobile-right-panel-visibility.spec.ts` + +Existing: + +- `apps/web/components/task/task-top-bar.tsx` +- `apps/web/components/task/dockview-header-actions.tsx` +- `apps/web/components/task/document/document-controls.tsx` +- `apps/web/components/task/mobile/session-tablet-layout.tsx` +- `apps/web/lib/state/dockview-store.ts` +- `apps/web/lib/state/dockview-preset-persistence.test.ts` +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,pseudo}/task.json` +- `docs/public/tasks-and-workflows.md` +- This work order and `plan.md` for status/results. + +## Dependencies + +None. Execute this vertical slice sequentially. + +## Risks + +See the [plan risks](plan.md#risks). Do not replace the layout stores or bypass restoration guards. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/right-panel-visibility.md), all seven criteria. +- [System design](../../specs/ui/system-design/right-panel-visibility.md), all sections. +- `apps/web/components/task/task-page-inner.tsx` and `hooks/use-session-layout-state.ts` for active-session routing. +- `apps/web/components/task/mobile/session-mobile-bottom-nav.tsx` for phone composition. +- Existing `pane-persistence-tablet.spec.ts` and `compact-desktop-responsive.spec.ts` for fixtures and responsive assertions. +- `apps/web/AGENTS.md`, `/mobile-parity`, `/tdd`, and `/e2e` instructions. + +## Results + +Implementation is complete. The shared localized header control selects the +Dockview or tablet layout adapter, remains available after hiding the right +column, and is omitted on phones. Tablet rendering now follows stored right +column visibility, compact desktop can explicitly reopen the right column, and +mixed center panels remain intact. Maximized Dockview disables the control with +an accessible reason and restores the authoritative visibility on exit. + +The TDD sequence recorded the expected RED regressions before the production +changes, followed by GREEN review coverage. The final focused set passed 9 +files and 102 tests, and the store-focused follow-up passed 2 files and 45 +tests. The earlier full web unit suite passed 2,058 files with 17,788 passing +and 4 skipped tests. The review coverage includes production-shaped compact +capture and reload, mixed capture, A/B environment round trips, duplicate-ID +reconciliation, and maximize/exit state and persistence assertions. + +The managed Chromium fixup E2E run passed 5 right-panel tests covering desktop, +compact reload and reopen, tablet persistence, maximized disabled/exit/reload, +and keyboard activation. The managed mobile-chrome run passed 1 phone +navigation test with Pixel 5 device and coarse-pointer assertions. Typecheck, +full lint, targeted ESLint, Prettier, i18n checks and ratchet, production Vite +build, public-doc validation, specification validation, and `git diff --check` +all passed. + +The tablet component test uses mocked panel primitives and persistence callbacks; +it asserts conditional rendering and center identity. The browser test asserts +the stored tablet visibility round trip; saved split geometry remains in the +retained matrix. The component test covers focus after a click and the +accessible disabled maximized wrapper, while the browser test covers native +Enter and Space activation plus maximized exit and reload. The retained matrix +in `plan.md` still contains resize handoffs, the 1280-pixel coarse-pointer case, +all four sidebar combinations, archived-task restoration, mixed center/right +browser fixtures, and the wider-to-phone handoff, which are not claimed by +these results. + +Localized labels were added to all supported catalogs, and +`docs/public/tasks-and-workflows.md` now documents the wider-layout toggle and +phone navigation behavior. diff --git a/docs/plans/right-panel-visibility/task-02-contextual-right-pane.md b/docs/plans/right-panel-visibility/task-02-contextual-right-pane.md new file mode 100644 index 0000000000..c37420f381 --- /dev/null +++ b/docs/plans/right-panel-visibility/task-02-contextual-right-pane.md @@ -0,0 +1,338 @@ +--- +id: "02-contextual-right-pane" +title: "Toggle the actual rightmost layout pane" +status: done +wave: 2 +depends_on: + - "01-persistent-toggle" +plan: "plan.md" +requirements: + - REQ-UI-RIGHT-PANEL-VISIBILITY-001 +acceptance_criteria: + - AC-UI-RIGHT-PANEL-VISIBILITY-001.1 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.2 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.3 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.4 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.5 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.6 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.7 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.8 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.9 + - AC-UI-RIGHT-PANEL-VISIBILITY-001.10 +system_design: + - ../../specs/ui/system-design/right-panel-visibility.md +--- + +# Task 02: Toggle the actual rightmost layout pane + +## Summary + +Replace standard-sidebar reconstruction with geometric target selection and exact hidden-pane restoration. +Plan Mode toggles Plan; Preview Mode toggles Browser; VS Code toggles its editor; Default toggles its complete right stack. +This is the current implementation handoff. Task 01 is historical context only. + +## In scope + +- Capture the rightmost outer horizontal region and its nested groups from the live layout. +- Retain the hidden region and restore it into the current arrangement without losing remaining edits. +- Persist versioned recovery metadata with the existing environment layout and audit every save/restore/reset path. +- Preserve active Agent content, unique panel IDs, selections, parameters, and bounded geometry. +- Keep maximize and readiness guards. Disable the control when no separate eligible target or retained hidden pane exists. +- Update affected copy in all locales and public instructions with the implemented behavior. +- Add the complete scenario matrix below through TDD. Update historical compact Show tests to the new disabled contract. + +## Out of scope + +No backend preference, portable-profile schema, new breakpoint, phone sidebar, undo history, or terminal lifecycle change. +Do not implement the superseded `defaultLayout()` fallback from Task 01. + +## Acceptance + +- UI-03 toggles the actual rightmost region and restores its identity and arrangement through reload and environment switches. +- Single-region and unsafe targets are disabled; presets and Reset discard stale hidden targets without introducing a default sidebar. +- All listed checks and scenario evidence are recorded; UI-02 retains tablet/phone behavior. + +## ASCII UI preview + +### UI-03: Contextual right-pane toggle + +Entry: a desktop workbench, including large tablets. Header stays fixed; pane content owns scrolling. + +```text +PLAN MODE: shown ++---------------------------------------------------+ +| Task [Hide right pane] [Layouts v] | ++--------------------------+------------------------+ +| Agent | Plan | ++--------------------------+------------------------+ + +PLAN MODE: hidden ++---------------------------------------------------+ +| Task [Show right pane] [Layouts v] | ++---------------------------------------------------+ +| Agent fills the released width | ++---------------------------------------------------+ +Show restores Plan with its tabs and split state. + +PREVIEW MODE: shown ++---------------------------------------------------+ +| Task [Hide right pane] [Layouts v] | ++--------------------------+------------------------+ +| Agent | Browser | ++--------------------------+------------------------+ +Hide -> Agent fills width. Show -> the same Browser. + +DEFAULT: shown VS CODE: shown ++---------------+-----------+ +---------------+-----------+ +| Agent | Files | | Agent | VS Code | +| | Changes | | | | +| +-----------+ +---------------+-----------+ +| | Terminal | ++---------------+-----------+ +Default toggles the whole right stack. VS Code toggles VS Code. + +CUSTOM: three side-by-side regions ++---------------+-----------+---------------+ +| Agent | Plan | Browser | ++---------------+-----------+---------------+ +Hide removes Browser only; the next click restores Browser. +It does not continue removing Plan. + +SINGLE REGION: no retained hidden pane ++---------------------------------------------------+ +| Task [right-pane icon disabled] [Layouts v] | ++---------------------------------------------------+ +| Agent / Files / Changes tabs in one group | ++---------------------------------------------------+ +Explanation: No separate right pane to hide. +``` + +Button labels in this drawing stand for localized tooltips and accessible names; the actual header keeps its existing icon. +The left navigation toggle remains independent. Spacing is illustrative; group identity and hide/show results are required. +Initialization and maximize retain their existing disabled states. A hidden target always takes precedence over a new hide target. + +### UI-02: Phone and tablet fallback + +```text +Phone: one active surface Narrow tablet fallback ++-------------------------+ +----------------+-----------+ +| Chat / Files / Terminal | | Chat/Plan/... | Files | +| | | | Terminal | ++-------------------------+ +----------------+-----------+ +| Existing bottom nav | Persistent header toggle hides ++-------------------------+ and restores this right stack. +``` + +Phone navigation, safe areas, and full-screen content remain unchanged. No phone toggle is added. +UI-03 maps to AC-UI-RIGHT-PANEL-VISIBILITY-001.1-.5 and .7-.10; UI-02 maps to .3, .5, and .6. + +See the [combined preview](plan.md#ascii-ui-preview). Required geometry and state transitions are described in the owning requirements. + +## Implementation sequence + +1. Task 02 was implemented against the revised contract after reading the current implementation. +2. Add behavioral RED tests for Plan/Browser target selection and restoration against the existing default-sidebar fallback. +3. Implement selection, retained-subtree restoration, metadata lifecycle, and effective control state as one vertical slice. +4. Add production-shaped round trips for nested trees, single compact groups, A/B environments, presets, and invalid metadata. +5. Extend the browser scenarios and inspect rendered desktop, touch-tablet, and phone compositions against UI-03/UI-02. +6. Update public copy and record exact outcomes. This work order is complete after the checks below passed. + +## Regression matrix and traceability + +- `.1`, `.2`, `.8`, `.9`: Plan, Browser, VS Code, and Default hide/show identity, reclaimed width, selected tabs and focus. +- `.2`, `.8`, `.9`: custom three-region and nested right subtree; active Agent and mixed center tabs stay intact. +- `.3`, `.7`, `.8`: compact one-group and vertical-only arrangements; disabled controls; active Agent in the rightmost region. +- `.5`, `.9`, `.10`: hidden reload, A-hidden/B-visible round trips, changed remaining layout, and already-reopened hidden panel deduplication. +- `.7`, `.10`: maximize/exit/reload, repeated activation, old operation callback after environment switch, malformed or obsolete metadata. +- `.5`, `.10`: hide Plan then apply Preview; custom layout application and Reset; no hidden Plan resurrection. +- `.4`, `.6`: keyboard/focus, localized explanations, 44px touch bounds at 1280px and 900px, phone navigation, no horizontal overflow. + +Implement pure selection and snapshot cases in proposed `lib/state/dockview-right-pane.test.ts`. +Extend existing store, serializer, persistence, adapter, and component tests named in the verification command. +Extend `e2e/tests/layout/right-panel-visibility.spec.ts` for built-in/custom round trips and browser state transitions. +Use `tabletTestPage` for coarse-pointer scenarios and the mobile-chrome project for phone coverage. +Use real captured trees and `session:` Agent identities; mocked `id: right` fixtures alone are insufficient. + +## Verification + +```bash +# Repository root; install once if the workspace dependencies are absent. +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run lib/state/dockview-right-pane.test.ts lib/state/dockview-right-panel-visibility.test.ts lib/state/dockview-env-switch-action.test.ts lib/state/layout-manager/serializer.test.ts lib/local-storage.test.ts components/task/dockview-layout-restore.test.ts hooks/use-task-right-panels-toggle.test.ts components/task/task-right-panels-toggle.test.tsx components/task/mobile/session-tablet-layout.test.tsx lib/state/dockview-preset-persistence.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm e2e:run --host --project chromium tests/layout/right-panel-visibility.spec.ts tests/layout/pane-persistence-tablet.spec.ts tests/layout/compact-desktop-responsive.spec.ts) +(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/layout/mobile-right-panel-visibility.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Desktop and mobile were run separately through the managed runner with repository causal waits and isolated fixtures. +The focused browser evidence is recorded below; the broader retained matrix remains explicitly identified as coverage beyond this turn. + +## Files likely touched + +New focused helper and tests: + +- `apps/web/lib/state/dockview-right-pane.ts` +- `apps/web/lib/state/dockview-right-pane.test.ts` + +Existing implementation boundaries: + +- `apps/web/lib/state/dockview-store.ts` +- `apps/web/lib/state/dockview-env-switch.ts` +- `apps/web/lib/state/layout-manager/serializer.ts` +- `apps/web/lib/state/layout-manager/types.ts` +- `apps/web/lib/state/dockview-layout-health.ts` +- `apps/web/lib/local-storage.ts` +- `apps/web/components/task/dockview-desktop-layout.tsx` +- `apps/web/components/task/dockview-layout-restore.ts` +- `apps/web/components/task/dockview-layout-setup.ts` +- `apps/web/hooks/use-task-right-panels-toggle.ts` +- `apps/web/components/task/task-right-panels-toggle.tsx` +- Existing tests and E2E files listed in Verification. +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,pseudo}/task.json` +- `docs/public/tasks-and-workflows.md` +- This work order and `plan.md` for statuses and exact results. + +Keep tablet production changes limited to regressions required by the new shared control state. +Use existing locale generation scripts for Traditional Chinese and pseudo catalogs. + +## Dependencies and inputs + +Task 01 implementation and review fixup are present at `03fe74e955a869a30819e6d9b3f59c83bb1c3c45`. +Read the revised [requirements](../../specs/ui/requirements/right-panel-visibility.md) and +[system design](../../specs/ui/system-design/right-panel-visibility.md). +Read `apps/web/AGENTS.md`, `/tdd`, `/mobile-parity`, and `/e2e` before implementation. +Use the existing `LayoutColumn.tree`, serializer, environment restore, and disabled-tooltip patterns. + +## Risks + +Tree/flat-group disagreement, overwritten remaining edits, stale environment metadata, and duplicate panel identities are primary risks. +Preserve existing runtime eligibility checks for sessions, reviews, and plugin panels during restoration. + +## Parallelism + +`sequential`. No agent is launched by this package update. The user will delegate implementation. + +## Results + +Implemented the contextual right-pane contract. The live serializer now records root orientation and the +rightmost eligible workbench subtree. Hide stores a versioned environment-scoped descriptor; Show reinserts +that exact tree, preserves remaining edits, and reconciles panels that were reopened elsewhere. Presets, +custom layouts, Reset, environment switches, and maximize restore paths invalidate or carry the descriptor +according to the contract. A single eligible region remains disabled without fabricating a default sidebar. +Phone navigation remains unchanged, and tablet uses its existing responsive owner. + +Validation passed: + +- Focused work-order unit command: 10 files, 130 tests passed. +- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet` passed. +- Managed Chromium command: 12 tests passed, including Default, Plan, Preview, compact single-region, + maximize/exit, keyboard focus, and tablet persistence. +- Managed mobile-Chromium command: 1 test passed for phone navigation parity. +- The managed E2E builds passed with existing Vite chunk-size and dynamic-import warnings only. +- Public-document tests and validation, specification catalog validation, specification lint, and + `git diff --check` passed. + +The broader browser matrix still includes resize handoffs, the 1280-pixel coarse-pointer case, all four +sidebar combinations, archived-task restoration, browser-level mixed-center fixtures, and the wider-to-phone +handoff. Those are retained coverage boundaries and are not claimed as executed by this implementation step. + +## Review remediation and current verification (2026-09-14) + +The review of PR head `8b98810227f1aa5e3058d32461122eb747b86b00` identified three correctness gaps. They are +fixed in the current worktree: + +- Recovery metadata now validates IDs, active selections, parameter values, finite geometry, tree/flat-group + consistency, and layout-wide panel, group, and column uniqueness before a restore can reach Dockview. +- Programmatic layout applies retain the live serialized Dockview state and perform one bounded rollback when + an apply mutates the grid and then fails. The toggle keeps its recovery descriptor and flags, and does not + persist a partial result. +- Restoring a non-pinned Plan, Browser, VS Code, or custom pane reserves its captured width, clamps it to + available space, and distributes the remaining width according to the current live proportions. +- Duplicate-panel filtering retains one-child split wrappers, so nested alternating axes and split sizes survive + subtree pruning. Generated group IDs skip IDs already present in the live layout. + +Current checks for this remediation: + +- Exact work-order unit command: 10 files, 135 tests passed. +- Additional affected unit suite: 8 files, 79 tests passed. +- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet` passed. +- Managed Chromium command: 12 tests passed across right-pane visibility, tablet persistence, and compact + desktop scenarios. +- Managed mobile-Chromium command: 1 phone navigation test passed. +- E2E Vite builds passed with the existing chunk-size, deprecated-option, and ineffective-dynamic-import + warnings. +- The previous PR snapshot's failed E2E shards 2/14 and 6/14 were not attributed to these findings. The focused + local runs above pass; any current CI failure will be handled from its current check output. +- `git diff --check` passed after the remediation edits. + +## PR fixup verification (2026-09-14) + +The first current-head PR run for `2bcaa1faf28f55fc2cfbdac122e791cdced628f0` reached 44 passed, 9 failed, +and 0 pending checks before the repository PR helper's 45-minute deadline. The failed E2E shards were unrelated +to this change: shard 2 reported two narrow-tab-strip assertions, shard 6 reported one fork pull-request +comparison assertion, and shard 14 reported one Plan table pixel-tolerance assertion plus flaky Agent restart +and mobile file-viewer cases. The E2E and frontend aggregate failures depended on those leaf results. + +The frontend leaf job also exposed one compatibility failure caused by the new transactional wrapper: an existing +`dockview-store.test.ts` mock omitted `api.toJSON()`. The wrapper now guards reduced test doubles while retaining +production rollback behavior. The targeted follow-up suite passed 11 files and 157 tests, and targeted ESLint and +typecheck passed. + +The PR documentation coverage job exited without evaluator output. Replaying the evaluator locally against the +current PR event and against its trusted validator revision returned `covered`, so the status requires a fresh +workflow result after the fixup push. Aggregate checks will be re-evaluated from the new head. + +## Follow-up PR fixup verification (2026-09-14) + +After the reduced-test-double compatibility fix was pushed as `8e866cf518d07f8255ecbc927f90d33eb7be8edb`, +`scripts/pr-await 3661 --deadline-min 60` waited 18 minutes and reached the terminal exact-head snapshot: +47 passed, 6 failed, and 0 pending. The PR was `MERGEABLE / BLOCKED`, with no unresolved review threads. +The base branch advanced during the run, so the result is not a current-base merge-readiness claim. + +The failed leaf E2E jobs were outside the contextual right-pane change: + +- Shard 2 failed both existing narrow-tab-strip assertions, one for equal row widths and one for zero overflow. +- Shard 6 failed the existing fork pull-request comparison assertion because the unavailable-target notice remained. +- Shard 14 failed the existing Plan table pixel assertion, receiving `59.15625` for an expected resize delta of `60`. + Its LSP capacity-release and mobile file-viewer failures were transient attempts of unrelated tests. +- The E2E aggregate and report-merge failures followed the failed leaf shards. +- The trusted PR documentation status job again exited without evaluator output. Local replay of the current and + trusted evaluators returned `covered`. + +The three relevant no-retry reproductions were run locally after the PR report: the Plan table test reproduced the +existing `59.15625` pixel delta failure, while the LSP capacity-release test passed one test in 48.8 seconds and the +mobile file-viewer test passed one test in 13.1 seconds. No additional source fix was justified by these unrelated +failures. The contextual right-pane unit, browser, typecheck, lint, localization, documentation, specification, +and diff checks remain passed as recorded above. + +## Manual resize restoration follow-up (2026-09-14) + +A real Browser divider drag reproduced a 100-pixel width reset after hide/show. +Preview inherited Files and Changes tabs from Default. Capture then classified +that content pane as the pinned right sidebar and restored its default width. + +Capture now retains the flexible identity of Plan, Browser, and VS Code panes +when they contain tool tabs. Canonical default right groups remain pinned, +including when they contain a Browser tab. Reordering tool tabs does not change +the content pane identity. This implements the existing width preservation +requirement; toggle placement and phone navigation do not change. + +Validation: + +- Before the fix: Browser drag/toggle E2E failed with a 100-pixel width difference; + three serializer cases failed because content panes were classified as `right`. +- After the fix: 130 tests passed across 10 affected unit files. +- The full right-panel visibility Chromium file passed all 10 tests without retries. + The new Default, Plan, and Preview cases drag the real divider and compare widths + within two pixels across three hide/show cycles. +- Web typecheck and focused ESLint passed. The serializer suite passed all 12 + tests after test organization cleanup. The phone navigation regression passed + without retries (1 mobile-Chromium test). diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 62901c29fe..2119bbd942 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -96,7 +96,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also | Source | Use it for | Important behavior | | ---------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | **Repo** | A configured, discovered, or new local repository | Select a named branch policy or a raw base branch for each repository row. A policy creates a fresh branch from its saved base and uses its branch template. Each editable local row offers **Refresh repositories** and **Create new repository**. Creation initializes `main` with one empty initial commit in a parent folder you choose. Add more rows for a multi-repository task. | + | **Repo** | A configured, discovered, or new local repository | Select a named branch policy or a raw base branch for each repository row. A policy creates a fresh branch from its saved base and uses its branch template. Each editable local row offers **Refresh repositories** and **Create new repository**. Creation initializes `main` with one empty initial commit in a parent folder you choose. Add more rows for a multi-repository task. | | **Remote** | A remote repository | Search configured GitHub, GitLab, or Azure DevOps repositories, or paste a supported URL. A pasted URL stays editable until you press Enter; then select the branch. Anonymous, credential-free reads include public GitHub repository branches, pull requests, and issues, plus public `gitlab.com` branch discovery. Private resources and authenticated browse/write features require valid provider credentials. | | **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. | @@ -499,19 +499,19 @@ You can add, reorder, edit, and delete steps. Deleting a step that still contain New steps allow manual moves by default. **Show in command panel** also defaults on. WIP is unlimited and auto-archive is off until configured. -| Setting | Effect | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Start step** | Where a task is created when no agent starts with it. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. This setting places tasks; it never starts agents, which is **Auto-start agent** below. | -| **Agent profile and session handling** | The combined selector can choose a profile, the task's initial conversation, or a conversation from an earlier direct-profile step. Its lifecycle settings control how this step starts and ends. The fixed profile override and original-session options are mutually exclusive. | -| **Override original session options** | Keeps the original conversation tab while applying model and ACP configuration rules for the task's starting agent family. The options editor appears below WIP settings only when this is checked. | -| **Auto-start agent** | Starts an agent whenever a task enters the step. | -| **Plan mode** | Enables plan mode when the task enters the step. | -| **Reset agent context** | Starts with fresh conversation context on entry. It is disabled when the step has a profile override because the destination step's session start setting controls whether that switch reuses or creates a conversation. | -| **Allow manual move** | Allows dragging a task into this step. Treat it as workflow UX, not as a security or approval boundary. | -| **Show in command panel** | Includes tasks in this step in the default, empty-search **Cmd+K** task list. Typed task search currently searches every step and can also return archived tasks, regardless of this setting. | -| **Auto-archive** | Archives inactive tasks after the configured number of hours. Enabling it starts at 24 hours; the minimum is 1. | -| **WIP limit** | Maximum admitted active, non-archived, non-ephemeral tasks in the step. `0` means unlimited. Overflow remains visible as queued cards; manual moves into a full step succeed and queue there. | -| **Pull from** | Optional one-hop feeder step. When capacity opens or eligible work arrives in the feeder, Kandev promotes queued work from the destination first, then the feeder. Direct moves and automatic transitions queue in the destination without using the feeder. A full feeder rejects new overflow creation. | +| Setting | Effect | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Start step** | Where a task is created when no agent starts with it. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. This setting places tasks; it never starts agents, which is **Auto-start agent** below. | +| **Agent profile and session handling** | The combined selector can choose a profile, the task's initial conversation, or a conversation from an earlier direct-profile step. Its lifecycle settings control how this step starts and ends. The fixed profile override and original-session options are mutually exclusive. | +| **Override original session options** | Keeps the original conversation tab while applying model and ACP configuration rules for the task's starting agent family. The options editor appears below WIP settings only when this is checked. | +| **Auto-start agent** | Starts an agent whenever a task enters the step. | +| **Plan mode** | Enables plan mode when the task enters the step. | +| **Reset agent context** | Starts with fresh conversation context on entry. It is disabled when the step has a profile override because the destination step's session start setting controls whether that switch reuses or creates a conversation. | +| **Allow manual move** | Allows dragging a task into this step. Treat it as workflow UX, not as a security or approval boundary. | +| **Show in command panel** | Includes tasks in this step in the default, empty-search **Cmd+K** task list. Typed task search currently searches every step and can also return archived tasks, regardless of this setting. | +| **Auto-archive** | Archives inactive tasks after the configured number of hours. Enabling it starts at 24 hours; the minimum is 1. | +| **WIP limit** | Maximum admitted active, non-archived, non-ephemeral tasks in the step. `0` means unlimited. Overflow remains visible as queued cards; manual moves into a full step succeed and queue there. | +| **Pull from** | Optional one-hop feeder step. When capacity opens or eligible work arrives in the feeder, Kandev promotes queued work from the destination first, then the feeder. Direct moves and automatic transitions queue in the destination without using the feeder. A full feeder rejects new overflow creation. | For a profile change, configure two independent settings in the combined selector: @@ -707,6 +707,26 @@ it can be run. Agents use `create_task_plan_kandev`, `get_task_plan_kandev`, `update_task_plan_kandev`, and `delete_task_plan_kandev`. Human edits are therefore visible to the next agent that reads the plan. A plan records intent; verify that code and review still match it. +## Arrange task panels + +On desktop and tablet, use the right-panel button in the task header to hide or +restore the rightmost workbench pane. In the Default layout, this pane contains +**Files**, **Changes**, and **Terminal**. In Plan Mode it contains **Plan**; in +Preview Mode it contains **Browser**; and in VS Code mode it contains the editor. +Custom layouts follow their current rightmost split. The button stays beside +**Layouts**, and the conversation keeps the released width while the pane is +hidden. Kandev restores the same pane, tabs, and internal split arrangement in +the current task environment on the current device. + +If the workbench has one region, the button is disabled because there is no +separate right pane to hide. Kandev does not create a default sidebar in this +state. Selecting a preset, applying a custom layout, or resetting the layout +clears the previous hidden-pane target. + +On phones, use the bottom navigation to open **Chat**, **Files**, or +**Terminal** as a full-screen surface. Phone navigation keeps its existing +layout and does not change the wider task-panel choice. + Revision history is not an immutable record of every autosave. Consecutive writes from the same author name and author kind coalesce into the latest revision for five minutes by default. Operators can set `KANDEV_PLAN_COALESCE_WINDOW_MS`; `0` disables coalescing, while an invalid or negative value falls back to five minutes. ## Office documents, labels, and blockers @@ -735,11 +755,11 @@ Archive records the task as archived and removes it from active views immediatel | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Local | Attempts to stop the agent runtime; leaves the local folder, files, and branch untouched. | | Git worktree | Attempts to remove the Kandev-owned worktree directory. It keeps the local task branch and leaves any existing remote branch untouched. Shared or borrowed worktrees can remain until their last active user is gone. | -| Local Docker | Attempts to stop and remove the container; the host repository remains. | -| Kubernetes | Deletes only the recorded Pod and Kandev-managed PVC after exact UID and ownership checks. An existing claim is retained. | -| Remote Docker | Runtime create and stop are not implemented. This executor is in progress and cannot currently start a task, so it has no supported archive-cleanup flow. | -| Sprites | Attempts to destroy the sandbox; if cleanup succeeds, uncommitted sandbox work is lost. | -| SSH | Attempts to stop the remote session runtime, but the remote task directory remains. Audit and remove retained task directories manually after confirming that no session needs them. | +| Local Docker | Attempts to stop and remove the container; the host repository remains. | +| Kubernetes | Deletes only the recorded Pod and Kandev-managed PVC after exact UID and ownership checks. An existing claim is retained. | +| Remote Docker | Runtime create and stop are not implemented. This executor is in progress and cannot currently start a task, so it has no supported archive-cleanup flow. | +| Sprites | Attempts to destroy the sandbox; if cleanup succeeds, uncommitted sandbox work is lost. | +| SSH | Attempts to stop the remote session runtime, but the remote task directory remains. Audit and remove retained task directories manually after confirming that no session needs them. | The archive confirmation is enabled by default at **Settings → General → Task Actions → Archive Confirmation** under **Confirm before archiving tasks**. If a parent has children, **Also archive _N_ subtasks** is unchecked by default; without it, the children remain active. Task MCP archive/delete operations affect only the selected task and do not offer the cascade checkbox. MCP delete also does not reparent direct children the way the UI's non-cascade delete does; use the UI rather than task MCP to delete a parent that still has children. diff --git a/docs/specs/ui/requirements/right-panel-visibility.md b/docs/specs/ui/requirements/right-panel-visibility.md new file mode 100644 index 0000000000..4590471704 --- /dev/null +++ b/docs/specs/ui/requirements/right-panel-visibility.md @@ -0,0 +1,79 @@ +--- +status: active +system: ui +created: 2026-09-13 +owners: + - kandev +--- + +# Right panel visibility requirements + +## Overview + +Users need to reclaim task workspace width without losing the control that restores the right panels. +UI owns this reusable layout interaction. Task and terminal lifecycle remain outside this contract. +The related [layout profiles](task-layout-profiles.md) contract owns reusable arrangements, rather than this visibility control. + +## Terminology + +- **Right pane:** The rightmost side-by-side region in the current workbench arrangement. + It can contain one tab group or a subtree of stacked groups. Default contains Files/Changes above Terminal. + Plan Mode contains Plan; Preview Mode contains Browser; VS Code contains the editor. +- **Hidden pane:** The exact region removed by the most recent Hide action in the current environment and layout context. +- **Tablet fallback:** The two-column task view used between 768 and 1023 CSS pixels with a coarse pointer. + +## Requirements + +### REQ-UI-RIGHT-PANEL-VISIBILITY-001: Recoverable right panel visibility + +**Intent:** Users can hide and show right panels independently of the left navigation sidebar. + +#### Acceptance criteria + +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.1:** On desktop and tablet task pages, a persistent header button shall hide visible right panels and show hidden right panels. + The button shall remain reachable in both states. Its position shall remain stable next to the layout controls. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.2:** Hiding the right panels shall release their width to the remaining workbench. + Toggling shall preserve left-sidebar state, the active task and session, chat content, and unrelated center panels. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.3:** The control shall work in both tablet compositions and compact desktop mode. + When only one workbench region exists and no hidden pane is retained, the control shall be disabled with an explanation. + It shall neither hide the remaining conversation nor create a standard sidebar. + Responsive defaults shall not change merely because the control exists. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.4:** The button shall expose its state and next action to assistive technology. + Pointer, touch, Enter, and Space shall activate it. New icon buttons shall measure 28 pixels on fine pointers and at least 44 pixels on coarse pointers. + Focus shall remain on the persistent button after activation. Labels and tooltips shall be localized. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.5:** Each layout shall retain visibility through its existing device-local restoration scope. + Reloading the same context shall reflect the restored visibility in the button. + Viewport changes shall not copy phone or tablet fallback state over the desktop layout. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.6:** Below 768 CSS pixels, Files and Terminal shall remain accessible through existing full-screen panel navigation. + Returning to Chat shall reclaim the content surface without altering desktop panel visibility. + Navigation and the new wider-view control shall not cause horizontal page overflow. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.7:** Before the active layout is ready, the header control shall be disabled rather than mutate another layout or session. + During a layout transition, repeated activation shall not cause duplicate panels or lose the active session. + +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.8:** Hide shall select the rightmost side-by-side region from the current visual arrangement. + Plan Mode shall toggle Plan, Preview Mode shall toggle Browser, and VS Code shall toggle its editor. + Default shall toggle the stacked Files/Changes and Terminal region together. + Custom layouts shall follow their current split order, independent of panel names, preset names, or legacy right-group IDs. + Other regions shall keep their panels and selections. The control shall not hide a region containing the active Agent conversation. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.9:** Show shall restore the hidden region with its panel identities, parameters, selected tabs, and internal split arrangement. + Its width shall return within current viewport limits. Edits to the remaining layout shall remain intact. + Show shall not substitute Files/Changes/Terminal for a hidden Plan, Browser, editor, or custom region. + The next click after Hide shall restore that region, rather than hide another region further left. +- **AC-UI-RIGHT-PANEL-VISIBILITY-001.10:** Hidden-pane recovery shall remain scoped to its task environment and current layout context across reload and task switches. + An explicit preset selection, custom-layout application, or Reset Layout shall discard the previous hidden target for that environment. + Closing a pane normally shall not create a hidden-pane recovery target. + Stale or invalid recovery data shall not overwrite live panels, recreate unavailable panels, duplicate panel IDs, or reset the remaining layout. + +## Compatibility and exclusions + +The 2026-09-14 user correction supersedes standard-right-column reconstruction and compact-mode sidebar creation. +The persistent button and responsive compositions remain in place. +Exact restoration of the hidden region is now required, including custom groups. +It does not change saved default profiles, breakpoint thresholds, terminal process lifecycle, or left-sidebar persistence. +No new server setting, keyboard shortcut, runtime release toggle, or phone sidebar is required. +Existing saved layouts without hidden-pane data remain valid. Missing data does not authorize a fabricated sidebar. + +## Design and delivery + +- [System design](../system-design/right-panel-visibility.md) +- [Implementation plan](../../../plans/right-panel-visibility/plan.md) diff --git a/docs/specs/ui/system-design/right-panel-visibility.md b/docs/specs/ui/system-design/right-panel-visibility.md new file mode 100644 index 0000000000..bd3016a4c9 --- /dev/null +++ b/docs/specs/ui/system-design/right-panel-visibility.md @@ -0,0 +1,124 @@ +--- +status: current +system: ui +created: 2026-09-13 +requirements: + - REQ-UI-RIGHT-PANEL-VISIBILITY-001 +owners: + - kandev +--- + +# Right panel visibility system design + +## Purpose and current baseline + +The persistent header control now toggles the actual rightmost region of the active workbench. +The user clarified this behavior on 2026-09-14 with Plan Mode and Preview Mode examples. +The prior implementation at `03fe74e955a869a30819e6d9b3f59c83bb1c3c45` still rebuilds `defaultLayout()` on Show. +That reconstruction is superseded by exact hidden-pane restoration. + +UI owns this layout interaction. Reuse [layout profiles](task-layout-profiles.md) and the device-local layout exception in +[ADR 0041](../../../decisions/0041-backend-owned-portable-user-settings.md). +This is a local extension of that ownership boundary. The requirement and this design retain the rationale without another ADR. + +## Requirement mapping + +| Criteria | Design section | +| ------------------------------------------- | ---------------------------- | +| AC-UI-RIGHT-PANEL-VISIBILITY-001.1, .4, .7 | Control and readiness | +| AC-UI-RIGHT-PANEL-VISIBILITY-001.2, .3, .8 | Geometric target selection | +| AC-UI-RIGHT-PANEL-VISIBILITY-001.5, .9, .10 | Hide, restore, and lifecycle | +| AC-UI-RIGHT-PANEL-VISIBILITY-001.6 | Tablet and phone | + +## Geometric target selection + +A pane is the outermost right side-by-side workbench region. It can contain a leaf group or an entire split subtree. +Default therefore hides Files/Changes and Terminal together. Plan, Preview, and VS Code hide their current right-hand regions. +For three side-by-side regions, hide only the rightmost region. Do not remove all regions with matching panel IDs. +For nested layouts, preserve the selected region's complete tree, including its stacked and nested groups. +Do not flatten a retained `LayoutColumn.tree` into `groups`; serialization gives the tree precedence. + +Use live grid ordering and structure, as represented by `fromDockviewApi`, rather than `isRightColumn`, preset names, or component IDs. +The serializer capture must preserve the actual outer split direction. When the outer split is vertical, there is no separate right-hand region. +Do not treat the last member of a vertically stacked layout as rightmost. +Resolve the outer horizontal split from the live Dockview grid, then take its final child. +The existing split tree and serializer are the starting point, not a new independent geometry model. + +Eligibility requires another workbench region to remain. Exclude the application navigation sidebar from this count. +Reject a target containing the active `session:` Agent panel or the active `chat` placeholder. +Do not select a region to its left as a substitute when the actual rightmost region is ineligible. +A single compact group is ineligible until the user creates a separate region through normal layout actions. + +## Control and readiness + +Keep `TaskRightPanelsToggle` before `LayoutPresetSelector` in `task-top-bar.tsx`. +`useTaskRightPanelsToggle` remains the responsive adapter. It must expose effective state from the selected owner. +The desktop state has three cases: hideable visible target, retained hidden target, or unavailable target. +A retained hidden target takes precedence over selecting another visible target. + +Use localized next-action labels such as `Hide right pane` and `Show right pane`, with `aria-expanded` matching effective visibility. +For an unavailable target, retain a disabled control with an explanation such as `No separate right pane to hide`. +Use the existing focusable disabled-tooltip wrapper and maximized-state explanation. +Disable during initialization, restoration, and maximize. Preserve 28px fine-pointer and 44px coarse-pointer hit areas. +The next user action must never create Files/Changes/Terminal merely because no hidden pane exists. + +## Hide, restore, and lifecycle + +Add a focused helper module, proposed `lib/state/dockview-right-pane.ts`, for selection, snapshot validation, and reinsertion. +Keep active environment state in `useDockviewStore`; avoid growing the visibility actions into another large layout subsystem. +A hidden-pane descriptor contains the captured subtree, panel/group identities and parameters, selected tabs, width, +its placement anchor, and enough layout identity to reject a stale descriptor. It contains no transcript or terminal output. +The descriptor is one reversible operation, not an undo history or a saved profile. + +On Hide, capture the eligible region and its insertion anchor before removing it. +Preserve chat scroll and the remaining layout's group selections. Save the collapsed layout and descriptor together. +Do not terminate agents or user shells when hiding their UI. Block stale callbacks using environment and operation identity before any mutation. + +On Show, merge the retained subtree into the current live arrangement at its anchored right-side position. +Do not restore an old whole-workbench snapshot over edits made while the pane was hidden. +Reconcile panel IDs already reopened elsewhere: keep their live instances, omit duplicates from the retained subtree, +and remove empty leaves while preserving the remaining tree and its active-tab fallbacks. +Use existing availability and session reconciliation for removed plugin panels, obsolete sessions, reviews, and ephemeral panels. +When no eligible hidden panels remain, clear the descriptor without creating a fallback region. +Clear recovery state only after successful application. Retain recoverable state after an application failure. +Restore captured geometry within current safety limits; do not apply Default-only pinned widths to Plan or Browser merely because they are rightmost. + +### Environment persistence + +Use the existing environment layout storage scope, which currently uses `sessionStorage` through `getEnvLayout` and `setEnvLayout`. +Persist optional versioned `kandevHiddenRightPane` metadata with the existing serialized environment layout record. +This keeps the collapsed layout and recovery descriptor in one write, without a second storage key or backend preference. +The metadata field is proposed new code. Strip it before passing the Dockview payload to `api.fromJSON`. +Legacy records without this field remain valid. Validate metadata separately from the existing grid health check. +Invalid metadata must not discard an otherwise valid visible layout. + +Centralize record composition so immediate toggle saves, debounced saves, outgoing-environment saves, and unload saves preserve current recovery state. +Read it on initial mount and every environment restore path, including fast restores and maximize restoration. +An A/B task switch saves and loads each environment's own descriptor rather than copying the current descriptor to the next environment. +Do not put environment-specific recovery data in global fallback layouts or portable saved layout profiles. +Ordinary Save current layout captures the visible arrangement only, without hidden metadata. + +Explicit preset application, custom layout application, and Reset Layout clear the current environment's descriptor before applying the new layout. +This prevents a hidden Plan from reappearing after the user selects Preview Mode. +Normal panel close does not populate this descriptor. Maximize does not capture its temporary overlay as a hidden right pane. +Preserve metadata when saving an unchanged regular layout beneath a maximize overlay. + +## Tablet and phone + +The 768-1023px coarse-pointer fallback remains `SessionTabletLayout`, using its existing session-scoped right-column state. +Its outer right region is Files/Terminal; preserve its saved two-panel sizing and active content on hide/show. +Its left Chat/Plan/Changes tab surface is not a separate right pane. +Larger tablets use Dockview, so Plan and Preview select their live geometric target exactly as on desktop. +No breakpoint or cross-store synchronization is added. + +Phone composition remains `SessionMobileLayout` with `SessionMobileBottomNav`. +Files and Terminal keep full-screen destinations with panel-owned scrolling and existing dynamic viewport/safe-area behavior. +Do not add a right-pane toggle or let phone navigation write wider-layout recovery state. + +## Validation and delivery + +Use real capture/serialization round trips with `session:` Agent identities, rather than mocked column labels alone. +Cover exact pane identity, nested geometry, no fabricated sidebar, duplicate reconciliation, reload, environment switches, +preset invalidation, malformed recovery metadata, maximize guards, and tablet/phone parity. +The [new work order](../../../plans/right-panel-visibility/task-02-contextual-right-pane.md) owns exact checks. +[Task 01](../../../plans/right-panel-visibility/task-01-persistent-toggle.md) is historical implementation evidence, not authority for superseded reconstruction.