From edf1f6faa5ad1667b9574ed7605a3a7e63c46013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 24 Feb 2026 20:33:45 +0000 Subject: [PATCH] fix: layout switching messing panels (#278) --- .../web/components/github/pr-detail-panel.tsx | 12 +- apps/web/components/task/browser-panel.tsx | 12 +- .../components/task/commit-detail-panel.tsx | 22 +- .../task/dockview-desktop-layout.tsx | 369 +++++++++++------- .../web/components/task/file-editor-panel.tsx | 12 +- apps/web/components/task/terminal-panel.tsx | 20 +- apps/web/components/task/vscode-panel.tsx | 10 +- apps/web/lib/layout/panel-portal-host.tsx | 99 +++++ apps/web/lib/layout/panel-portal-manager.ts | 168 ++++++++ 9 files changed, 553 insertions(+), 171 deletions(-) create mode 100644 apps/web/lib/layout/panel-portal-host.tsx create mode 100644 apps/web/lib/layout/panel-portal-manager.ts diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index f2e6ec6792..c928a1a91b 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect } from "react"; -import type { IDockviewPanelProps } from "dockview-react"; +import { setPanelTitle } from "@/lib/layout/panel-portal-manager"; import { IconRefresh, IconPlus, @@ -31,14 +31,18 @@ import { CommentsSection } from "./pr-comments-section"; // --- Dockview panel wrapper --- -export function PRDetailPanelComponent(props: IDockviewPanelProps) { +type PRDetailPanelProps = { + panelId: string; +}; + +export function PRDetailPanelComponent({ panelId }: PRDetailPanelProps) { const pr = useActiveTaskPR(); const sessionId = useAppStore((s) => s.tasks.activeSessionId); useEffect(() => { const title = pr ? prPanelLabel(pr.pr_number) : "Pull Request"; - if (props.api.title !== title) props.api.setTitle(title); - }, [pr, props.api]); + setPanelTitle(panelId, title); + }, [pr, panelId]); if (!pr || !sessionId) { return ( diff --git a/apps/web/components/task/browser-panel.tsx b/apps/web/components/task/browser-panel.tsx index 31d8c13dca..47d0465c72 100644 --- a/apps/web/components/task/browser-panel.tsx +++ b/apps/web/components/task/browser-panel.tsx @@ -1,7 +1,6 @@ "use client"; import { memo, useState, useEffect, useMemo } from "react"; -import type { IDockviewPanelProps } from "dockview-react"; import { IconRefresh, IconExternalLink } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; @@ -45,10 +44,13 @@ function BrowserPanelContent({ ); } -export const BrowserPanel = memo(function BrowserPanel( - props: IDockviewPanelProps<{ url?: string }>, -) { - const initialUrl = props.params.url || ""; +type BrowserPanelProps = { + panelId: string; + params: Record; +}; + +export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelProps) { + const initialUrl = (params.url as string) || ""; const [userUrl, setUserUrl] = useState(initialUrl); const [urlDraft, setUrlDraft] = useState(initialUrl); const [refreshKey, setRefreshKey] = useState(0); diff --git a/apps/web/components/task/commit-detail-panel.tsx b/apps/web/components/task/commit-detail-panel.tsx index 2cfb9d8adc..f14e7aab70 100644 --- a/apps/web/components/task/commit-detail-panel.tsx +++ b/apps/web/components/task/commit-detail-panel.tsx @@ -1,7 +1,6 @@ "use client"; import { memo, useState, useEffect, useCallback, useMemo } from "react"; -import type { IDockviewPanelProps } from "dockview-react"; import { IconLoader2 } from "@tabler/icons-react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { FileDiffViewer } from "@/components/diff"; @@ -10,10 +9,12 @@ import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useToast } from "@/components/toast-provider"; import { usePanelActions } from "@/hooks/use-panel-actions"; +import { setPanelTitle } from "@/lib/layout/panel-portal-manager"; import type { FileInfo } from "@/lib/state/store"; -type CommitDetailParams = { - commitSha: string; +type CommitDetailPanelProps = { + panelId: string; + params: Record; }; function formatRelativeTime(dateStr: string): string { @@ -40,10 +41,11 @@ function getInitials(name: string): string { .toUpperCase(); } -const CommitDetailPanel = memo(function CommitDetailPanel( - props: IDockviewPanelProps, -) { - const commitSha = props.params.commitSha; +const CommitDetailPanel = memo(function CommitDetailPanel({ + panelId, + params, +}: CommitDetailPanelProps) { + const commitSha = params.commitSha as string; const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const { commits } = useSessionCommits(activeSessionId ?? null); const { toast } = useToast(); @@ -58,7 +60,7 @@ const CommitDetailPanel = memo(function CommitDetailPanel( [commits, commitSha], ); - // Update tab title + // Update tab title via dockview API stored in portal manager useEffect(() => { if (commit) { const shortSha = commitSha.slice(0, 7); @@ -66,9 +68,9 @@ const CommitDetailPanel = memo(function CommitDetailPanel( commit.commit_message.length > 30 ? commit.commit_message.slice(0, 30) + "..." : commit.commit_message; - props.api.setTitle(`${shortSha} ${msg}`); + setPanelTitle(panelId, `${shortSha} ${msg}`); } - }, [commit, commitSha, props.api]); + }, [commit, commitSha, panelId]); // Fetch diff const fetchDiff = useCallback(async () => { diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 4bad6a788c..8b07660954 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState, memo } from "react"; +import React, { useCallback, useEffect, useRef, useState, memo } from "react"; import { DockviewReact, DockviewDefaultTab, @@ -20,7 +20,7 @@ import { useEditorKeybinds } from "@/hooks/use-editor-keybinds"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; -// Panel components +// Panel components (rendered via portals, not directly by dockview) import { TaskSessionSidebar } from "./task-session-sidebar"; import { LeftHeaderActions, RightHeaderActions } from "./dockview-header-actions"; import { DockviewWatermark } from "./dockview-watermark"; @@ -49,13 +49,113 @@ import type { DiffComment } from "@/lib/diff/types"; import type { Repository, RepositoryScript } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; +// Portal system +import { panelPortalManager, setPanelTitle } from "@/lib/layout/panel-portal-manager"; +import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host"; + // --- STORAGE KEY --- const LAYOUT_STORAGE_KEY = "dockview-layout-v1"; -// --- PANEL COMPONENTS --- -// Each panel is a standalone component wrapped for dockview +// --------------------------------------------------------------------------- +// PORTAL SLOT — generic dockview component that adopts a persistent portal +// --------------------------------------------------------------------------- -function SidebarPanel(props: IDockviewPanelProps) { +/** + * Components whose portals are tied to a specific session. + * + * When the user switches sessions, portals for these components are released + * via `panelPortalManager.releaseBySession()` so stale state (WebSocket + * connections, iframes, editor buffers) from the old session doesn't leak + * into the new one. + * + * A component belongs here if its content is bound to session-specific runtime + * state that can't be swapped by simply reading a new `activeSessionId` from + * the store: + * + * - terminal — holds a live xterm WebSocket to a shell in the session's container + * - file-editor — editing a file in the session's worktree + * - browser — iframe preview of the session's dev server URL + * - vscode — VS Code Server iframe running in the session's container + * - commit-detail — displays a commit from the session's git history + * - diff-viewer — shows file diffs from the session's working tree + * - pr-detail — PR linked to the session's task + * + * Components NOT listed here are **global** — they read `activeSessionId` + * reactively from the store and automatically reflect the current session: + * + * - sidebar — workspace/task navigation, not session-specific + * - chat — subscribes to `activeSessionId`, re-renders for new session + * - changes — reads session git status via `useSessionGitStatus(activeSessionId)` + * - files — reads the active session's file tree reactively + * - plan — reads `activeTaskId` from the store + */ +const SESSION_SCOPED_COMPONENTS = new Set([ + "terminal", + "file-editor", + "browser", + "vscode", + "commit-detail", + "diff-viewer", + "pr-detail", +]); + +/** + * Every entry in the dockview `components` map uses this wrapper. + * It renders an empty container and attaches the persistent portal element + * managed by PanelPortalManager. The actual panel content is rendered by + * PanelPortalHost outside the dockview tree. + * + * Session-scoped panels are tagged with the current session ID so they can + * be cleaned up on session switch. + */ +function PortalSlot(props: IDockviewPanelProps) { + const component = props.api.component; + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const sessionId = SESSION_SCOPED_COMPONENTS.has(component) + ? (activeSessionId ?? undefined) + : undefined; + const containerRef = usePortalSlot(props, sessionId); + return
; +} + +// --- COMPONENT MAP --- +// All panel types use the same PortalSlot wrapper — dockview only manages +// layout positioning. Actual rendering happens in PanelPortalHost below. +const components: Record> = { + sidebar: PortalSlot, + chat: PortalSlot, + "diff-viewer": PortalSlot, + "file-editor": PortalSlot, + "commit-detail": PortalSlot, + changes: PortalSlot, + files: PortalSlot, + terminal: PortalSlot, + browser: PortalSlot, + vscode: PortalSlot, + plan: PortalSlot, + "pr-detail": PortalSlot, + // Backwards compat aliases for saved layouts + "diff-files": PortalSlot, + "all-files": PortalSlot, +}; + +// --- TAB COMPONENTS --- +function PermanentTab(props: IDockviewPanelHeaderProps) { + return ; +} + +const tabComponents: Record> = { + permanentTab: PermanentTab, +}; + +// --------------------------------------------------------------------------- +// PORTAL CONTENT — the actual panel implementations rendered via portals +// --------------------------------------------------------------------------- + +// Each content component renders the real panel UI. They live permanently +// in the PanelPortalHost and survive dockview layout switches. + +function SidebarContent({ panelId }: { panelId: string }) { const workspaceId = useAppStore((state) => state.workspaces.activeId); const workflowId = useAppStore((state) => state.workflows.activeId); const workspaceName = useAppStore((state) => { @@ -63,19 +163,14 @@ function SidebarPanel(props: IDockviewPanelProps) { return ws?.name ?? "Workspace"; }); - // Keep the dockview tab title in sync with workspace name useEffect(() => { - if (props.api.title !== workspaceName) { - props.api.setTitle(workspaceName); - } - }, [props.api, workspaceName]); + setPanelTitle(panelId, workspaceName); + }, [panelId, workspaceName]); return ; } -function ChatPanel(props: IDockviewPanelProps) { - const groupId = props.api.group.id; - const isPanelFocused = useDockviewStore((s) => s.activeGroupId === groupId); +function ChatContent({ panelId }: { panelId: string }) { const sessionId = useAppStore((state) => state.tasks.activeSessionId); const { openFile } = useFileEditors(); @@ -85,8 +180,8 @@ function ChatPanel(props: IDockviewPanelProps) { }); useEffect(() => { - props.api.setTitle("Agent"); - }, [props.api]); + setPanelTitle(panelId, "Agent"); + }, [panelId]); if (isPassthrough) { return ( @@ -103,29 +198,33 @@ function ChatPanel(props: IDockviewPanelProps) { sessionId={sessionId} onOpenFile={openFile} onOpenFileAtLine={openFile} - isPanelFocused={isPanelFocused} + isPanelFocused={false} /> ); } -function DiffViewerPanelComponent( - props: IDockviewPanelProps<{ kind?: "all" | "file"; path?: string; content?: string }>, -) { +function DiffViewerContent({ + panelId, + params, +}: { + panelId: string; + params: Record; +}) { const selectedDiff = useDockviewStore((s) => s.selectedDiff); const setSelectedDiff = useDockviewStore((s) => s.setSelectedDiff); const { openFile } = useFileEditors(); - const panelKind = props.params?.kind ?? "all"; - const selectedPath = panelKind === "file" ? props.params?.path : undefined; + const panelKind = (params?.kind as string) ?? "all"; + const selectedPath = panelKind === "file" ? (params?.path as string) : undefined; const panelSelectedDiff = panelKind === "all" ? selectedDiff : null; const handleClosePanel = useCallback(() => { const dockApi = useDockviewStore.getState().api; - const panel = dockApi?.getPanel(props.api.id); + const panel = dockApi?.getPanel(panelId); if (dockApi && panel) dockApi.removePanel(panel); - }, [props.api.id]); + }, [panelId]); return ( setSelectedDiff(null)} @@ -135,7 +234,7 @@ function DiffViewerPanelComponent( ); } -function ChangesPanelWrapper(props: IDockviewPanelProps) { +function ChangesContent({ panelId }: { panelId: string }) { const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel); const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel); const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel); @@ -145,42 +244,24 @@ function ChangesPanelWrapper(props: IDockviewPanelProps) { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); const { commits } = useSessionCommits(activeSessionId ?? null); - const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0; const totalCount = fileCount + commits.length; useEffect(() => { const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes"; - if (props.api.title !== title) { - props.api.setTitle(title); - } - }, [totalCount, props.api]); - - const handleEditFile = useCallback( - (path: string) => { - openFile(path); - }, - [openFile], - ); + setPanelTitle(panelId, title); + }, [totalCount, panelId]); + const handleEditFile = useCallback((path: string) => openFile(path), [openFile]); const handleOpenDiffFile = useCallback( - (path: string) => { - addFileDiffPanel(path); - }, + (path: string) => addFileDiffPanel(path), [addFileDiffPanel], ); - const handleOpenCommitDetail = useCallback( - (sha: string) => { - addCommitDetailPanel(sha); - }, + (sha: string) => addCommitDetailPanel(sha), [addCommitDetailPanel], ); - - const handleOpenDiffAll = useCallback(() => { - addDiffViewerPanel(); - }, [addDiffViewerPanel]); - + const handleOpenDiffAll = useCallback(() => addDiffViewerPanel(), [addDiffViewerPanel]); const handleOpenReview = useCallback(() => { window.dispatchEvent(new CustomEvent("open-review-dialog")); }, []); @@ -196,75 +277,81 @@ function ChangesPanelWrapper(props: IDockviewPanelProps) { ); } -function FilesPanelWrapper() { +function FilesContent() { const { openFile } = useFileEditors(); - const handleOpenFile = useCallback( - (file: { - path: string; - name: string; - content: string; - originalContent?: string; - originalHash?: string; - isDirty?: boolean; - isBinary?: boolean; - }) => { - openFile(file.path); - }, + (file: { path: string; name: string; content: string }) => openFile(file.path), [openFile], ); - return ; } -function PlanPanelComponent() { +function PlanContent() { const taskId = useAppStore((state) => state.tasks.activeTaskId); return ; } -// --- COMPONENT MAP --- -const components: Record> = { - sidebar: SidebarPanel, - chat: ChatPanel, - "diff-viewer": DiffViewerPanelComponent, - "file-editor": FileEditorPanel, - "commit-detail": CommitDetailPanel, - changes: ChangesPanelWrapper, - files: FilesPanelWrapper, - terminal: TerminalPanel, - browser: BrowserPanel, - vscode: VscodePanel, - plan: PlanPanelComponent, - "pr-detail": PRDetailPanelComponent, - // Backwards compat aliases for saved layouts - "diff-files": ChangesPanelWrapper, - "all-files": FilesPanelWrapper, +// --------------------------------------------------------------------------- +// renderPanel — maps component names to their portal content +// --------------------------------------------------------------------------- + +/** Resolve legacy component aliases to current names. */ +const COMPONENT_ALIASES: Record = { + "diff-files": "changes", + "all-files": "files", }; -// --- TAB COMPONENTS --- -// Permanent tab — same as default but without close button -function PermanentTab(props: IDockviewPanelHeaderProps) { - return ; +function resolveComponent(component: string): string { + return COMPONENT_ALIASES[component] ?? component; } -const tabComponents: Record> = { - permanentTab: PermanentTab, -}; +function renderPanel( + panelId: string, + component: string, + params: Record, +): React.ReactNode { + const resolved = resolveComponent(component); -// --- LAYOUT RESTORATION HELPERS --- + switch (resolved) { + case "sidebar": + return ; + case "chat": + return ; + case "diff-viewer": + return ; + case "file-editor": + return ; + case "commit-detail": + return ; + case "changes": + return ; + case "files": + return ; + case "terminal": + return ; + case "browser": + return ; + case "vscode": + return ; + case "plan": + return ; + case "pr-detail": + return ; + default: + return
Unknown panel: {component}
; + } +} + +// --------------------------------------------------------------------------- +// LAYOUT RESTORATION HELPERS +// --------------------------------------------------------------------------- const VALID_COMPONENTS = new Set(Object.keys(components)); -/** - * Remove panels with unregistered component names from a serialized layout. - * Prevents dockview from crashing when a saved layout references a component - * that no longer exists (e.g. after a rename or removal). - */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function sanitizeLayout(layout: any): any { if (!layout?.panels || !layout?.grid?.root) return layout; - // Find panels with unknown components const invalidIds = new Set(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const validPanels: Record = {}; @@ -280,7 +367,6 @@ function sanitizeLayout(layout: any): any { if (invalidIds.size === 0) return layout; - // Strip invalid panel IDs from grid leaf nodes // eslint-disable-next-line @typescript-eslint/no-explicit-any function cleanNode(node: any): any { if (node.type === "leaf") { @@ -299,7 +385,7 @@ function sanitizeLayout(layout: any): any { } const cleanedRoot = cleanNode(layout.grid.root); - if (!cleanedRoot) return null; // Entire layout is empty + if (!cleanedRoot) return null; return { ...layout, @@ -308,12 +394,10 @@ function sanitizeLayout(layout: any): any { }; } -/** Try to restore layout from per-session or global localStorage. Returns true if restored. */ function tryRestoreLayout( api: DockviewReadyEvent["api"], currentSessionId: string | null, ): boolean { - // 1. Try per-session layout if (currentSessionId) { try { const sessionLayout = getSessionLayout(currentSessionId); @@ -329,9 +413,6 @@ function tryRestoreLayout( } } - // 2. Fallback to global localStorage — only when there's no session context - // (true first load). If we have a session ID but no saved layout, it's a - // new session and should get the default layout, not an old global one. if (!currentSessionId) { try { const saved = localStorage.getItem(LAYOUT_STORAGE_KEY); @@ -350,17 +431,11 @@ function tryRestoreLayout( return false; } -/** - * Track pinned column widths (sidebar, right) when the user manually resizes. - * Reads column widths from the root splitview on layout changes, - * and stores them in the dockview store's pinnedWidths map. - */ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { if (useDockviewStore.getState().isRestoringLayout) return; const sv = getRootSplitview(api); if (!sv || sv.length < 2) return; try { - // Track sidebar (index 0) const sidebarW = sv.getViewSize(0); if (sidebarW > 50) { const current = useDockviewStore.getState().pinnedWidths.get("sidebar"); @@ -368,7 +443,6 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { useDockviewStore.getState().setPinnedWidth("sidebar", sidebarW); } } - // Track right column (last index, if 3+ columns) if (sv.length >= 3) { const rightIdx = sv.length - 1; const rightW = sv.getViewSize(rightIdx); @@ -384,11 +458,9 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { } } -/** Re-add the chat panel if it gets removed (keeps center group alive). */ function setupChatPanelSafetyNet(api: DockviewReadyEvent["api"]) { api.onDidRemovePanel((panel) => { if (panel.id !== "chat") return; - // Skip during layout restore — fromJSON removes all panels before re-adding if (useDockviewStore.getState().isRestoringLayout) return; requestAnimationFrame(() => { if (api.getPanel("chat")) return; @@ -408,7 +480,6 @@ function setupChatPanelSafetyNet(api: DockviewReadyEvent["api"]) { }); } -/** Debounced layout persistence on every layout change. */ function setupLayoutPersistence( api: DockviewReadyEvent["api"], saveTimerRef: React.MutableRefObject | null>, @@ -433,7 +504,22 @@ function setupLayoutPersistence( }); } -/** Hook encapsulating the review dialog state and send-comments handler. */ +/** + * Clean up portal entries for panels that were permanently removed (user + * closed a tab), but NOT during layout restores where fromJSON temporarily + * removes all panels. + */ +function setupPortalCleanup(api: DockviewReadyEvent["api"]) { + api.onDidRemovePanel((panel) => { + if (useDockviewStore.getState().isRestoringLayout) return; + panelPortalManager.release(panel.id); + }); +} + +// --------------------------------------------------------------------------- +// useReviewDialog hook +// --------------------------------------------------------------------------- + function useReviewDialog(effectiveSessionId: string | null) { const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const { toast } = useToast(); @@ -470,7 +556,6 @@ function useReviewDialog(effectiveSessionId: string | null) { [activeTaskId, effectiveSessionId, toast], ); - // Listen for open-review-dialog events from any panel useEffect(() => { const handler = () => setReviewDialogOpen(true); window.addEventListener("open-review-dialog", handler); @@ -488,7 +573,38 @@ function useReviewDialog(effectiveSessionId: string | null) { }; } -// --- MAIN LAYOUT COMPONENT --- +// --------------------------------------------------------------------------- +// useSessionSwitchCleanup — releases session-scoped portals + triggers layout switch +// --------------------------------------------------------------------------- + +function useSessionSwitchCleanup(effectiveSessionId: string | null) { + const prevSessionRef = useRef(undefined); + useEffect(() => { + if (prevSessionRef.current === undefined) { + prevSessionRef.current = effectiveSessionId; + return; + } + if (prevSessionRef.current === effectiveSessionId) return; + + const oldSessionId = prevSessionRef.current; + prevSessionRef.current = effectiveSessionId; + + // Release session-scoped portals from the old session so stale + // terminals, editors, etc. don't leak into the new session. + if (oldSessionId) { + panelPortalManager.releaseBySession(oldSessionId); + } + + if (effectiveSessionId) { + performLayoutSwitch(oldSessionId, effectiveSessionId); + } + }, [effectiveSessionId]); +} + +// --------------------------------------------------------------------------- +// MAIN LAYOUT COMPONENT +// --------------------------------------------------------------------------- + type DockviewDesktopLayoutProps = { workspaceId: string | null; workflowId: string | null; @@ -560,32 +676,20 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ setupChatPanelSafetyNet(api); setupLayoutPersistence(api, saveTimerRef, sessionIdRef); + setupPortalCleanup(api); }, [setApi, buildDefaultLayout], ); - // Catch-all: detect session changes and trigger layout switch - const prevSessionRef = useRef(undefined); - useEffect(() => { - if (prevSessionRef.current === undefined) { - prevSessionRef.current = effectiveSessionId; - return; - } - if (prevSessionRef.current === effectiveSessionId) return; + // Release session-scoped portals + trigger layout switch on session change + useSessionSwitchCleanup(effectiveSessionId); - const oldSessionId = prevSessionRef.current; - prevSessionRef.current = effectiveSessionId; - - if (effectiveSessionId) { - performLayoutSwitch(oldSessionId, effectiveSessionId); - } - }, [effectiveSessionId]); - - // Clean up timer on unmount + // Clean up on unmount (e.g. navigating away from session page) useEffect(() => { const timerRef = saveTimerRef; return () => { if (timerRef.current) clearTimeout(timerRef.current); + panelPortalManager.releaseAll(); }; }, []); @@ -604,6 +708,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ defaultRenderer="always" className="h-full" /> + {effectiveSessionId && ( , -) { - const path = props.params.path; +type FileEditorPanelProps = { + panelId: string; + params: Record; +}; + +export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEditorPanelProps) { + const path = params.path as string; const hasFile = useDockviewStore((s) => s.openFiles.has(path)); const content = useDockviewStore((s) => s.openFiles.get(path)?.content ?? ""); diff --git a/apps/web/components/task/terminal-panel.tsx b/apps/web/components/task/terminal-panel.tsx index 34d8dbfefc..303dca9e36 100644 --- a/apps/web/components/task/terminal-panel.tsx +++ b/apps/web/components/task/terminal-panel.tsx @@ -1,23 +1,21 @@ "use client"; import { memo, useState } from "react"; -import type { IDockviewPanelProps } from "dockview-react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { PassthroughTerminal } from "./passthrough-terminal"; import { ShellTerminal } from "./shell-terminal"; import { useAppStore } from "@/components/state-provider"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; -export const TerminalPanel = memo(function TerminalPanel( - props: IDockviewPanelProps<{ - terminalId: string; - type?: "shell" | "dev-server"; - processId?: string; - }>, -) { - const terminalId = props.params.terminalId; - const type = props.params.type ?? "shell"; - const processId = props.params.processId; +type TerminalPanelProps = { + panelId: string; + params: Record; +}; + +export const TerminalPanel = memo(function TerminalPanel({ params }: TerminalPanelProps) { + const terminalId = params.terminalId as string; + const type = (params.type as string) ?? "shell"; + const processId = params.processId as string | undefined; // Capture the session ID at creation time — terminal stays connected // to its original session even when the user switches tasks diff --git a/apps/web/components/task/vscode-panel.tsx b/apps/web/components/task/vscode-panel.tsx index 9915bbdb8a..5ff214b94c 100644 --- a/apps/web/components/task/vscode-panel.tsx +++ b/apps/web/components/task/vscode-panel.tsx @@ -1,7 +1,6 @@ "use client"; import { memo, useState, useCallback, useEffect, useRef } from "react"; -import type { IDockviewPanelProps } from "dockview-react"; import { IconRefresh, IconExternalLink, @@ -236,9 +235,12 @@ function useVscodeLifecycle() { }; } -export const VscodePanel = memo(function VscodePanel( - _props: IDockviewPanelProps, // eslint-disable-line @typescript-eslint/no-unused-vars -) { +type VscodePanelProps = { + panelId: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const VscodePanel = memo(function VscodePanel({ panelId }: VscodePanelProps) { const { status, iframeUrl, refreshKey, handleStop, handleRetry, handleRefresh, handleOpenInTab } = useVscodeLifecycle(); diff --git a/apps/web/lib/layout/panel-portal-host.tsx b/apps/web/lib/layout/panel-portal-host.tsx new file mode 100644 index 0000000000..16600d75d8 --- /dev/null +++ b/apps/web/lib/layout/panel-portal-host.tsx @@ -0,0 +1,99 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useSyncExternalStore, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { panelPortalManager } from "./panel-portal-manager"; +import type { IDockviewPanelProps } from "dockview-react"; + +// --------------------------------------------------------------------------- +// PanelPortalHost — renders all persistent panel content via React portals +// --------------------------------------------------------------------------- + +/** + * A render function that receives the panel ID and dockview-compatible props. + * The host calls this to decide *what* to render inside each portal element. + */ +export type PortalRenderer = ( + panelId: string, + component: string, + params: Record, +) => ReactNode; + +type PanelPortalHostProps = { + /** Render function called for each registered panel. */ + renderPanel: PortalRenderer; +}; + +/** + * Renders panel content into persistent portal elements that live outside the + * dockview tree. Mount this as a sibling to ``. + */ +export function PanelPortalHost({ renderPanel }: PanelPortalHostProps) { + // Re-render when the set of registered panels changes. + const ids = useSyncExternalStore( + useCallback((cb) => panelPortalManager.subscribe(cb), []), + useCallback(() => panelPortalManager.ids().join(","), []), + useCallback(() => panelPortalManager.ids().join(","), []), + ); + + const panelIds = ids ? ids.split(",") : []; + + return ( + <> + {panelIds.map((panelId) => { + const entry = panelPortalManager.get(panelId); + if (!entry) return null; + return createPortal( + renderPanel(panelId, entry.component, entry.params), + entry.element, + panelId, + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// usePortalSlot — hook for dockview panel wrappers (slot components) +// --------------------------------------------------------------------------- + +/** + * Attaches the persistent portal element for `panelId` into the dockview + * panel's container on mount, and detaches it on unmount (without destroying). + * + * Also updates the stored `api` and `params` on every mount, so the portal + * content can read the latest dockview state. + * + * @param sessionId — when provided, tags the portal as session-scoped so it + * gets cleaned up on session switch via `releaseBySession()`. + */ +export function usePortalSlot( + props: IDockviewPanelProps, + sessionId?: string, +): React.RefObject { + const containerRef = useRef(null); + const panelId = props.api.id; + const component = props.api.component; + const params = props.params ?? {}; + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const entry = panelPortalManager.acquire(panelId, component, params, props.api, sessionId); + + // Reparent the portal element into this panel's DOM slot. + container.appendChild(entry.element); + + return () => { + // Detach only — do NOT release. The element stays in the manager + // and will be re-adopted when the panel remounts after fromJSON(). + if (entry.element.parentNode === container) { + container.removeChild(entry.element); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [panelId]); + + return containerRef; +} diff --git a/apps/web/lib/layout/panel-portal-manager.ts b/apps/web/lib/layout/panel-portal-manager.ts new file mode 100644 index 0000000000..99f23592c9 --- /dev/null +++ b/apps/web/lib/layout/panel-portal-manager.ts @@ -0,0 +1,168 @@ +/** + * PanelPortalManager — singleton registry for persistent dockview panel portals. + * + * When dockview calls `api.fromJSON()` during layout switches, all React panel + * components are unmounted and remounted. This destroys expensive state such as + * xterm WebSocket connections, iframes, and editor instances. + * + * The portal manager lifts panel content out of dockview's lifecycle: + * – Each panel gets a persistent `HTMLDivElement` (the "portal element") that + * lives in a hidden host outside the dockview tree. + * – Dockview panel wrappers adopt the portal element into their own DOM on mount + * and release it on unmount — without destroying it. + * – The actual React component tree is rendered into the portal element via + * `createPortal`, so component state, effects, and DOM survive layout switches. + * + * ## Session scoping + * + * Portals are either **global** or **session-scoped**: + * + * - **Global** (`sessionId` is `undefined`) — the portal persists across session + * switches. Used for panels that read `activeSessionId` reactively from the + * store and automatically show the correct data for whichever session is active. + * Examples: sidebar, chat, changes, files, plan. + * + * - **Session-scoped** (`sessionId` is set) — the portal is bound to the session + * that created it and is destroyed via `releaseBySession()` when the user + * switches away. Used for panels whose content is intrinsically tied to a + * specific session's runtime state (container processes, worktree files, etc.) + * and cannot simply re-read a store selector to switch context. + * Examples: terminal, file-editor, browser, vscode, commit-detail, diff-viewer, + * pr-detail. + * + * See `SESSION_SCOPED_COMPONENTS` in `dockview-desktop-layout.tsx` for the + * authoritative list and per-component rationale. + */ + +import type { DockviewPanelApi } from "dockview-react"; + +export type PortalEntry = { + /** The persistent DOM element that React portals into. */ + element: HTMLDivElement; + /** The dockview component name (e.g. "terminal", "chat"). */ + component: string; + /** Latest panel params from dockview. */ + params: Record; + /** Latest dockview panel API handle — updated on each remount. */ + api: DockviewPanelApi | null; + /** Session ID this portal is scoped to (undefined = global, persists across sessions). */ + sessionId?: string; +}; + +type Listener = () => void; + +class PanelPortalManager { + private entries = new Map(); + private listeners = new Set(); + + /** + * Get or create the portal entry for a panel. + * Called by the dockview slot wrapper on mount. + */ + acquire( + panelId: string, + component: string, + params: Record, + api: DockviewPanelApi, + sessionId?: string, + ): PortalEntry { + let entry = this.entries.get(panelId); + if (!entry) { + const el = document.createElement("div"); + el.style.display = "contents"; + el.dataset.portalPanel = panelId; + entry = { element: el, component, params, api, sessionId }; + this.entries.set(panelId, entry); + this.notify(); + } else { + // Panel remounted after fromJSON — update api & params + entry.api = api; + entry.params = params; + entry.component = component; + } + return entry; + } + + /** Remove a panel's portal (permanent deletion, e.g. user closes tab). */ + release(panelId: string): void { + const entry = this.entries.get(panelId); + if (!entry) return; + entry.element.remove(); + entry.api = null; + this.entries.delete(panelId); + this.notify(); + } + + /** Release all portals scoped to a specific session. */ + releaseBySession(sessionId: string): void { + const toRemove: string[] = []; + for (const [panelId, entry] of this.entries) { + if (entry.sessionId === sessionId) { + toRemove.push(panelId); + } + } + if (toRemove.length === 0) return; + for (const panelId of toRemove) { + const entry = this.entries.get(panelId)!; + entry.element.remove(); + entry.api = null; + this.entries.delete(panelId); + } + this.notify(); + } + + /** Release all portals (e.g. when the layout component unmounts entirely). */ + releaseAll(): void { + if (this.entries.size === 0) return; + for (const entry of this.entries.values()) { + entry.element.remove(); + entry.api = null; + } + this.entries.clear(); + this.notify(); + } + + /** Check if a portal exists for a panel. */ + has(panelId: string): boolean { + return this.entries.has(panelId); + } + + /** Get a specific entry. */ + get(panelId: string): PortalEntry | undefined { + return this.entries.get(panelId); + } + + /** All registered panel IDs. */ + ids(): string[] { + return Array.from(this.entries.keys()); + } + + /** Iterate all registered portals (used by the host to render). */ + getAll(): Map { + return this.entries; + } + + /** Subscribe to entry additions/removals. Returns unsubscribe fn. */ + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + for (const fn of this.listeners) fn(); + } +} + +/** App-wide singleton. */ +export const panelPortalManager = new PanelPortalManager(); + +/** + * Set the dockview tab title for a panel managed by the portal system. + * Safe to call even when the panel's api isn't available yet. + */ +export function setPanelTitle(panelId: string, title: string): void { + const entry = panelPortalManager.get(panelId); + if (entry?.api && entry.api.title !== title) { + entry.api.setTitle(title); + } +}