From 80fe0bff60566789682c22c3402e6db47ea8b06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 26 Feb 2026 11:26:30 +0000 Subject: [PATCH] feat: improve layout manager (#290) --- apps/web/app/s/[sessionId]/page.tsx | 10 +++- .../components/task-create-dialog-helpers.ts | 5 +- .../task/chat/use-chat-panel-state.ts | 12 +++-- .../task/dockview-desktop-layout.tsx | 9 ++-- .../mobile/session-task-switcher-sheet.tsx | 32 ++++-------- .../web/components/task/sessions-dropdown.tsx | 10 ++-- apps/web/components/task/task-layout.tsx | 3 ++ .../web/components/task/task-page-content.tsx | 8 +++ .../components/task/task-session-sidebar.tsx | 11 ++-- .../domains/kanban/use-kanban-navigation.ts | 23 +-------- apps/web/lib/links.ts | 11 +++- apps/web/lib/state/dockview-store.ts | 51 +++++++++++++------ apps/web/lib/state/layout-manager/index.ts | 8 ++- apps/web/lib/state/layout-manager/intent.ts | 30 ++++++++++- 14 files changed, 134 insertions(+), 89 deletions(-) diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 93d7c2a497..6e647e8c48 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -298,9 +298,16 @@ function extractInitialScripts( return initialState?.repositoryScripts?.itemsByRepositoryId?.[repoId] ?? []; } -export default async function SessionPage({ params }: { params: Promise<{ sessionId: string }> }) { +export default async function SessionPage({ + params, + searchParams, +}: { + params: Promise<{ sessionId: string }>; + searchParams: Promise<{ layout?: string }>; +}) { let fetchedData: FetchedSessionData | null = null; const defaultLayouts = await readLayoutDefaults(); + const { layout: initialLayout } = await searchParams; try { const { sessionId: paramSessionId } = await params; @@ -331,6 +338,7 @@ export default async function SessionPage({ params }: { params: Promise<{ sessio initialScripts={extractInitialScripts(initialState, task)} initialTerminals={initialTerminals} defaultLayouts={defaultLayouts} + initialLayout={initialLayout} /> ); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 8a7909f1dc..c2f5dac687 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -6,9 +6,9 @@ import type { StepType } from "@/components/task-create-dialog-types"; import { selectPreferredBranch } from "@/lib/utils"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; -import { useDockviewStore } from "@/lib/state/dockview-store"; import { useContextFilesStore } from "@/lib/state/context-files-store"; import { linkToSession } from "@/lib/links"; +import { INTENT_PLAN } from "@/lib/state/layout-manager"; import { createTask } from "@/lib/api"; type CreateTaskParams = Parameters[0]; @@ -76,10 +76,9 @@ export function activatePlanMode({ router, }: ActivatePlanModeArgs) { setActiveDocument(sessionId, { type: "plan", taskId }); - useDockviewStore.getState().setLayoutIntent({ preset: "plan" }); setPlanMode(sessionId, true); useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" }); - router.push(linkToSession(sessionId)); + router.push(linkToSession(sessionId, INTENT_PLAN)); } export type BuildCreatePayloadArgs = { diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 36f49733c5..5e66d4a357 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -396,8 +396,12 @@ export function useChatPanelState({ [planModeAvailable, rawHandlePlanModeChange, togglePlanLayout, planLayoutVisible], ); - // Auto-disable plan mode if agent doesn't support MCP (e.g. started from create dialog) + // Auto-disable plan mode if agent doesn't support MCP (e.g. started from create dialog). + // Only clear state — do NOT call applyBuiltInPreset("default") because the layout + // may have just been set via URL intent (?layout=plan) and we don't want to overwrite it. const hasAgentProfile = Boolean(sessionState.session?.agent_profile_id); + const setPlanMode = useAppStore((s) => s.setPlanMode); + const removeCtxFile = useContextFilesStore((s) => s.removeFile); const hasAutoDisabled = useRef(false); useEffect(() => { if ( @@ -408,7 +412,8 @@ export function useChatPanelState({ !hasAutoDisabled.current ) { hasAutoDisabled.current = true; - rawHandlePlanModeChange(false); + setPlanMode(resolvedSessionId, false); + removeCtxFile(resolvedSessionId, PLAN_CONTEXT_PATH); } if (!planModeEnabled) hasAutoDisabled.current = false; }, [ @@ -416,7 +421,8 @@ export function useChatPanelState({ hasAgentProfile, planModeAvailable, resolvedSessionId, - rawHandlePlanModeChange, + setPlanMode, + removeCtxFile, ]); const contextFilesState = useContextFiles(resolvedSessionId); diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 47ea8d9dcf..23d30fd997 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -627,11 +627,13 @@ type DockviewDesktopLayoutProps = { repository?: Repository | null; initialScripts?: RepositoryScript[]; initialTerminals?: Terminal[]; + initialLayout?: string | null; }; export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ sessionId, repository, + initialLayout, }: DockviewDesktopLayoutProps) { const setApi = useDockviewStore((s) => s.setApi); const buildDefaultLayout = useDockviewStore((s) => s.buildDefaultLayout); @@ -672,9 +674,10 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ setApi(api); const currentSessionId = sessionIdRef.current; - const restored = tryRestoreLayout(api, currentSessionId); + // If a layout intent was passed via URL, skip saved layout restoration + const restored = !initialLayout && tryRestoreLayout(api, currentSessionId); if (!restored) { - buildDefaultLayout(api); + buildDefaultLayout(api, initialLayout ?? undefined); } useDockviewStore.setState({ currentLayoutSessionId: currentSessionId }); @@ -693,7 +696,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ setupLayoutPersistence(api, saveTimerRef, sessionIdRef); setupPortalCleanup(api); }, - [setApi, buildDefaultLayout], + [setApi, buildDefaultLayout, initialLayout], ); // Release session-scoped portals + trigger layout switch on session change diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index d07dba762a..4dd3a9982f 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -8,7 +8,7 @@ import { TaskSwitcher } from "../task-switcher"; import { WorkspaceSwitcher } from "../workspace-switcher"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { linkToSession } from "@/lib/links"; +import { replaceSessionUrl } from "@/lib/links"; import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api"; import { useTasks } from "@/hooks/use-tasks"; import { useTaskActions } from "@/hooks/use-task-actions"; @@ -173,19 +173,11 @@ type SheetNavOptions = { ) => Promise>; setActiveSession: (taskId: string, sessionId: string) => void; setActiveTask: (taskId: string) => void; - updateUrl: (sessionId: string) => void; onOpenChange: (open: boolean) => void; }; async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { - const { - store, - loadTaskSessionsForTask, - setActiveSession, - setActiveTask, - updateUrl, - onOpenChange, - } = opts; + const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts; store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } })); try { const workflowsResponse = await listWorkflows(newWorkspaceId, { cache: "no-store" }); @@ -220,7 +212,7 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { const mostRecentSession = sortByUpdatedAtDesc(sessions)[0]; if (mostRecentSession) { setActiveSession(mostRecentTask.id, mostRecentSession.id); - updateUrl(mostRecentSession.id); + replaceSessionUrl(mostRecentSession.id); } else { setActiveTask(mostRecentTask.id); } @@ -233,7 +225,7 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { } function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { - const { workspaceId, store, setActiveSession, setActiveTask, updateUrl, onOpenChange } = opts; + const { workspaceId, store, setActiveSession, setActiveTask, onOpenChange } = opts; const handleWorkspaceChange = useCallback( async (newWorkspaceId: string) => { @@ -278,11 +270,11 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { setActiveTask(task.id); if (meta?.taskSessionId) { setActiveSession(task.id, meta.taskSessionId); - updateUrl(meta.taskSessionId); + replaceSessionUrl(meta.taskSessionId); } onOpenChange(false); }, - [store, setActiveTask, setActiveSession, updateUrl, onOpenChange], + [store, setActiveTask, setActiveSession, onOpenChange], ); return { handleWorkspaceChange, handleTaskCreated }; @@ -296,18 +288,13 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea const { deleteTaskById, archiveTaskById } = useTaskActions(); const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store }); - const updateUrl = useCallback((sessionId: string) => { - if (typeof window === "undefined") return; - window.history.replaceState({}, "", linkToSession(sessionId)); - }, []); - const handleSelectTask = useCallback( (taskId: string) => { const kanbanTasks = store.getState().kanban.tasks; const task = kanbanTasks.find((t) => t.id === taskId); if (task?.primarySessionId) { setActiveSession(taskId, task.primarySessionId); - updateUrl(task.primarySessionId); + replaceSessionUrl(task.primarySessionId); loadTaskSessionsForTask(taskId); onOpenChange(false); return; @@ -320,11 +307,11 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea return; } setActiveSession(taskId, sessionId); - updateUrl(sessionId); + replaceSessionUrl(sessionId); onOpenChange(false); }); }, - [loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl, store, onOpenChange], + [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange], ); const handleArchiveTask = useCallback( @@ -358,7 +345,6 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea loadTaskSessionsForTask, setActiveSession, setActiveTask, - updateUrl, onOpenChange, }); diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx index 67b3f29008..447bda4f97 100644 --- a/apps/web/components/task/sessions-dropdown.tsx +++ b/apps/web/components/task/sessions-dropdown.tsx @@ -13,7 +13,7 @@ import { Badge } from "@kandev/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { TaskCreateDialog } from "../task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { linkToSession } from "@/lib/links"; +import { replaceSessionUrl } from "@/lib/links"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { performLayoutSwitch } from "@/lib/state/dockview-store"; import type { TaskSession, TaskSessionState } from "@/lib/types/http"; @@ -110,20 +110,16 @@ function useRunningSessionsClock(sessions: TaskSession[]) { function useSessionSelectionHandlers(taskId: string | null) { const setActiveSession = useAppStore((state) => state.setActiveSession); const appStore = useAppStoreApi(); - const updateUrl = useCallback((sessionId: string) => { - if (typeof window === "undefined") return; - window.history.replaceState({}, "", linkToSession(sessionId)); - }, []); const handleSelectSession = useCallback( (sessionId: string, close: () => void) => { if (!taskId) return; const oldSessionId = appStore.getState().tasks.activeSessionId; setActiveSession(taskId, sessionId); performLayoutSwitch(oldSessionId, sessionId); - updateUrl(sessionId); + replaceSessionUrl(sessionId); close(); }, - [appStore, setActiveSession, taskId, updateUrl], + [appStore, setActiveSession, taskId], ); return { handleSelectSession }; } diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index 26a5a40269..d804475318 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -35,6 +35,7 @@ type TaskLayoutProps = { remoteCreatedAt?: string | null; remoteCheckedAt?: string | null; remoteStatusError?: string | null; + initialLayout?: string | null; }; export const TaskLayout = memo(function TaskLayout({ @@ -55,6 +56,7 @@ export const TaskLayout = memo(function TaskLayout({ remoteCreatedAt, remoteCheckedAt, remoteStatusError, + initialLayout, }: TaskLayoutProps) { const { isMobile, isTablet } = useResponsiveBreakpoint(); @@ -101,6 +103,7 @@ export const TaskLayout = memo(function TaskLayout({ repository={repository} initialScripts={initialScripts} initialTerminals={initialTerminals} + initialLayout={initialLayout} /> ); }); diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index aec0a00ddf..9be58732f8 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -35,6 +35,7 @@ type TaskPageContentProps = { initialScripts?: RepositoryScript[]; initialTerminals?: Terminal[]; defaultLayouts?: Record; + initialLayout?: string | null; }; function resolveEffectiveTask( @@ -192,6 +193,7 @@ type TaskPageInnerProps = { initialScripts: RepositoryScript[]; initialTerminals?: Terminal[]; defaultLayouts: Record; + initialLayout?: string | null; }; type RemoteExecutorStatus = { @@ -273,6 +275,7 @@ function buildTaskLayoutProps(params: { defaultLayouts: Record; merged: ReturnType; remote: ReturnType; + initialLayout?: string | null; }) { const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params; return { @@ -283,6 +286,7 @@ function buildTaskLayoutProps(params: { initialScripts, initialTerminals, defaultLayouts: params.defaultLayouts, + initialLayout: params.initialLayout, taskTitle: taskProps.taskTitle, baseBranch: taskProps.baseBranch, worktreeBranch: params.merged.worktreeBranch, @@ -342,6 +346,7 @@ function TaskPageInner({ initialScripts, initialTerminals, defaultLayouts, + initialLayout, }: TaskPageInnerProps) { const taskProps = resolveTaskProps(task, repository); const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null); @@ -374,6 +379,7 @@ function TaskPageInner({ defaultLayouts, merged, remote, + initialLayout, }); return ( @@ -493,6 +499,7 @@ export function TaskPageContent({ initialScripts = [], initialTerminals, defaultLayouts = {}, + initialLayout, }: TaskPageContentProps) { const [isMounted, setIsMounted] = useState(false); const [showDebugOverlay, setShowDebugOverlay] = useState(false); @@ -537,6 +544,7 @@ export function TaskPageContent({ initialScripts={initialScripts} initialTerminals={initialTerminals} defaultLayouts={defaultLayouts} + initialLayout={initialLayout} /> ); } diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 0380c5cc29..3264fc8a79 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -10,7 +10,7 @@ import { PanelRoot, PanelBody } from "./panel-primitives"; import { IconPlus } from "@tabler/icons-react"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { linkToSession } from "@/lib/links"; +import { replaceSessionUrl } from "@/lib/links"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { useTaskActions } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; @@ -229,18 +229,13 @@ function useSidebarActions(store: ReturnType) { useLayoutSwitch: true, }); - const updateUrl = useCallback((sessionId: string) => { - if (typeof window === "undefined") return; - window.history.replaceState({}, "", linkToSession(sessionId)); - }, []); - const switchToSession = useCallback( (taskId: string, sessionId: string, oldSessionId: string | null | undefined) => { setActiveSession(taskId, sessionId); performLayoutSwitch(oldSessionId ?? null, sessionId); - updateUrl(sessionId); + replaceSessionUrl(sessionId); }, - [setActiveSession, updateUrl], + [setActiveSession], ); const handleSelectTask = useCallback( diff --git a/apps/web/hooks/domains/kanban/use-kanban-navigation.ts b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts index 56f29ac9aa..aaad4941ad 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-navigation.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts @@ -4,9 +4,8 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; import { getWebSocketClient } from "@/lib/ws/connection"; import { linkToSession } from "@/lib/links"; +import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager"; import { useAppStore } from "@/components/state-provider"; -import { useDockviewStore } from "@/lib/state/dockview-store"; -import { CENTER_GROUP, RIGHT_TOP_GROUP } from "@/lib/state/layout-manager"; import type { Task } from "@/components/kanban-card"; type NavigationOptions = { @@ -55,23 +54,6 @@ async function preparePRSession(taskId: string): Promise { } } -function setPRReviewLayoutIntent(): void { - useDockviewStore.getState().setLayoutIntent({ - panels: [ - { - id: "pr-detail", - component: "pr-detail", - title: "Pull Request", - targetGroup: "center", - }, - ], - activePanels: { - [CENTER_GROUP]: "pr-detail", - [RIGHT_TOP_GROUP]: "changes", - }, - }); -} - export function useKanbanNavigation({ enablePreviewOnClick, isMobile, @@ -102,8 +84,7 @@ export function useKanbanNavigation({ if (taskPRs[task.id]) { const sessionId = await preparePRSession(task.id); if (sessionId) { - setPRReviewLayoutIntent(); - router.push(linkToSession(sessionId)); + router.push(linkToSession(sessionId, INTENT_PR_REVIEW)); return; } } diff --git a/apps/web/lib/links.ts b/apps/web/lib/links.ts index acd0c64c3c..23dbcbe3e2 100644 --- a/apps/web/lib/links.ts +++ b/apps/web/lib/links.ts @@ -1,5 +1,12 @@ -export function linkToSession(sessionId: string): string { - return `/s/${sessionId}`; +export function linkToSession(sessionId: string, layout?: string): string { + const base = `/s/${sessionId}`; + return layout ? `${base}?layout=${encodeURIComponent(layout)}` : base; +} + +/** Replace the browser URL to reflect the active session (no navigation). */ +export function replaceSessionUrl(sessionId: string): void { + if (typeof window === "undefined") return; + window.history.replaceState({}, "", linkToSession(sessionId)); } export function linkToTasks(workspaceId?: string): string { diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 2ea5e13b8d..05f3037c34 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -15,8 +15,12 @@ import { defaultLayout, mergeCurrentPanelsIntoPreset, } from "./layout-manager"; -import type { BuiltInPreset, LayoutState, LayoutGroupIds, LayoutIntent } from "./layout-manager"; -import { injectIntentPanels, applyActivePanelOverrides } from "./layout-manager"; +import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager"; +import { + injectIntentPanels, + applyActivePanelOverrides, + resolveNamedIntent, +} from "./layout-manager"; import { buildFileStateActions } from "./dockview-file-state"; import { buildPanelActions, buildExtraPanelActions } from "./dockview-panel-actions"; @@ -73,7 +77,7 @@ type DockviewStore = { updateFileState: (path: string, updates: Partial) => void; removeFileState: (path: string) => void; clearFileStates: () => void; - buildDefaultLayout: (api: DockviewApi) => void; + buildDefaultLayout: (api: DockviewApi, intentName?: string) => void; resetLayout: () => void; addChatPanel: () => void; addChangesPanel: (groupId?: string) => void; @@ -113,8 +117,6 @@ type DockviewStore = { setPinnedWidth: (columnId: string, width: number) => void; userDefaultLayout: LayoutState | null; setUserDefaultLayout: (layout: LayoutState | null) => void; - layoutIntent: LayoutIntent | null; - setLayoutIntent: (intent: LayoutIntent | null) => void; activeFilePath: string | null; pendingChatScrollTop: number | null; setPendingChatScrollTop: (value: number | null) => void; @@ -374,21 +376,27 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { }; } -function performBuildDefault(api: DockviewApi, set: StoreSet, get: StoreGet): void { - const { userDefaultLayout, layoutIntent } = get(); +function performBuildDefault( + api: DockviewApi, + set: StoreSet, + get: StoreGet, + intentName?: string, +): void { + const { userDefaultLayout } = get(); + const intent = intentName ? resolveNamedIntent(intentName) : null; const freshPinned = new Map(); - set({ isRestoringLayout: true, pinnedWidths: freshPinned, layoutIntent: null }); + set({ isRestoringLayout: true, pinnedWidths: freshPinned }); - const basePreset = layoutIntent?.preset as BuiltInPreset | undefined; + const basePreset = intent?.preset as BuiltInPreset | undefined; let state = basePreset ? getPresetLayout(basePreset) : (userDefaultLayout ?? getPresetLayout("default")); - if (layoutIntent?.panels?.length) { - state = injectIntentPanels(state, layoutIntent.panels); + if (intent?.panels?.length) { + state = injectIntentPanels(state, intent.panels); } - if (layoutIntent?.activePanels) { - state = applyActivePanelOverrides(state, layoutIntent.activePanels); + if (intent?.activePanels) { + state = applyActivePanelOverrides(state, intent.activePanels); } const ids = applyLayout(api, state, freshPinned); @@ -441,8 +449,6 @@ export const useDockviewStore = create((set, get) => ({ }, userDefaultLayout: null, setUserDefaultLayout: (layout) => set({ userDefaultLayout: layout }), - layoutIntent: null, - setLayoutIntent: (intent) => set({ layoutIntent: intent }), ...buildVisibilityActions(set, get), ...buildPresetActions(set, get), isRestoringLayout: false, @@ -455,6 +461,19 @@ export const useDockviewStore = create((set, get) => ({ switchSessionLayout: (oldSessionId, newSessionId) => { const { api, currentLayoutSessionId } = get(); if (!api || currentLayoutSessionId === newSessionId) return; + // When the first session becomes active (oldSessionId is null and no prior + // layout session), onReady already built the correct layout (possibly with + // an intent). Just adopt the current layout for the new session without + // rebuilding, which would lose the intent-based layout. + if (!oldSessionId && !currentLayoutSessionId) { + set({ currentLayoutSessionId: newSessionId }); + try { + setSessionLayout(newSessionId, api.toJSON()); + } catch { + /* ignore */ + } + return; + } const safeWidth = api.width; const safeHeight = api.height; set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId }); @@ -472,7 +491,7 @@ export const useDockviewStore = create((set, get) => ({ set({ isRestoringLayout: false }); } }, - buildDefaultLayout: (api) => performBuildDefault(api, set, get), + buildDefaultLayout: (api, intentName) => performBuildDefault(api, set, get, intentName), resetLayout: () => { const { api } = get(); if (api) get().buildDefaultLayout(api); diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts index e0e0191d32..4ce29ccfb8 100644 --- a/apps/web/lib/state/layout-manager/index.ts +++ b/apps/web/lib/state/layout-manager/index.ts @@ -46,4 +46,10 @@ export type { LayoutGroupIds } from "./applier"; export { mergeCurrentPanelsIntoPreset } from "./merger"; // Intent -export { injectIntentPanels, applyActivePanelOverrides } from "./intent"; +export { + INTENT_PLAN, + INTENT_PR_REVIEW, + injectIntentPanels, + applyActivePanelOverrides, + resolveNamedIntent, +} from "./intent"; diff --git a/apps/web/lib/state/layout-manager/intent.ts b/apps/web/lib/state/layout-manager/intent.ts index 09c43331a1..a200ce3af5 100644 --- a/apps/web/lib/state/layout-manager/intent.ts +++ b/apps/web/lib/state/layout-manager/intent.ts @@ -1,6 +1,34 @@ -import type { LayoutState, LayoutIntentPanel, LayoutPanel } from "./types"; +import type { LayoutState, LayoutIntent, LayoutIntentPanel, LayoutPanel } from "./types"; import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP } from "./constants"; +/** Well-known intent name constants (used as URL ?layout= values). */ +export const INTENT_PLAN = "plan"; +export const INTENT_PR_REVIEW = "pr-review"; + +/** Registry of named layout intents, keyed by URL param value. */ +const NAMED_INTENTS: Record = { + [INTENT_PLAN]: { preset: "plan" }, + [INTENT_PR_REVIEW]: { + panels: [ + { + id: "pr-detail", + component: "pr-detail", + title: "Pull Request", + targetGroup: "center", + }, + ], + activePanels: { + [CENTER_GROUP]: "pr-detail", + [RIGHT_TOP_GROUP]: "changes", + }, + }, +}; + +/** Resolve a named intent string (from URL param) to a LayoutIntent, or null if unknown. */ +export function resolveNamedIntent(name: string): LayoutIntent | null { + return NAMED_INTENTS[name] ?? null; +} + /** Map short aliases to well-known group IDs. */ const GROUP_ALIASES: Record = { center: CENTER_GROUP,