fix: layout switching messing panels (#278)

This commit is contained in:
Carlos Florêncio
2026-02-24 20:33:45 +00:00
committed by GitHub
parent a94b8c2c32
commit edf1f6faa5
9 changed files with 553 additions and 171 deletions
@@ -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 (
+7 -5
View File
@@ -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<string, unknown>;
};
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);
@@ -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<string, unknown>;
};
function formatRelativeTime(dateStr: string): string {
@@ -40,10 +41,11 @@ function getInitials(name: string): string {
.toUpperCase();
}
const CommitDetailPanel = memo(function CommitDetailPanel(
props: IDockviewPanelProps<CommitDetailParams>,
) {
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 () => {
@@ -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 <div ref={containerRef} className="h-full w-full overflow-hidden" />;
}
// --- COMPONENT MAP ---
// All panel types use the same PortalSlot wrapper — dockview only manages
// layout positioning. Actual rendering happens in PanelPortalHost below.
const components: Record<string, React.FunctionComponent<IDockviewPanelProps>> = {
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 <DockviewDefaultTab {...props} hideClose />;
}
const tabComponents: Record<string, React.FunctionComponent<IDockviewPanelHeaderProps>> = {
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 <TaskSessionSidebar workspaceId={workspaceId} workflowId={workflowId} />;
}
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<string, unknown>;
}) {
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 (
<TaskChangesPanel
mode={panelKind}
mode={panelKind as "all" | "file"}
filePath={selectedPath}
selectedDiff={panelSelectedDiff}
onClearSelected={() => 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 <FilesPanel onOpenFile={handleOpenFile} />;
}
function PlanPanelComponent() {
function PlanContent() {
const taskId = useAppStore((state) => state.tasks.activeTaskId);
return <TaskPlanPanel taskId={taskId} visible />;
}
// --- COMPONENT MAP ---
const components: Record<string, React.FunctionComponent<IDockviewPanelProps>> = {
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<string, string> = {
"diff-files": "changes",
"all-files": "files",
};
// --- TAB COMPONENTS ---
// Permanent tab — same as default but without close button
function PermanentTab(props: IDockviewPanelHeaderProps) {
return <DockviewDefaultTab {...props} hideClose />;
function resolveComponent(component: string): string {
return COMPONENT_ALIASES[component] ?? component;
}
const tabComponents: Record<string, React.FunctionComponent<IDockviewPanelHeaderProps>> = {
permanentTab: PermanentTab,
};
function renderPanel(
panelId: string,
component: string,
params: Record<string, unknown>,
): React.ReactNode {
const resolved = resolveComponent(component);
// --- LAYOUT RESTORATION HELPERS ---
switch (resolved) {
case "sidebar":
return <SidebarContent panelId={panelId} />;
case "chat":
return <ChatContent panelId={panelId} />;
case "diff-viewer":
return <DiffViewerContent panelId={panelId} params={params} />;
case "file-editor":
return <FileEditorPanel panelId={panelId} params={params} />;
case "commit-detail":
return <CommitDetailPanel panelId={panelId} params={params} />;
case "changes":
return <ChangesContent panelId={panelId} />;
case "files":
return <FilesContent />;
case "terminal":
return <TerminalPanel panelId={panelId} params={params} />;
case "browser":
return <BrowserPanel panelId={panelId} params={params} />;
case "vscode":
return <VscodePanel panelId={panelId} />;
case "plan":
return <PlanContent />;
case "pr-detail":
return <PRDetailPanelComponent panelId={panelId} />;
default:
return <div className="p-4 text-muted-foreground">Unknown panel: {component}</div>;
}
}
// ---------------------------------------------------------------------------
// 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<string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validPanels: Record<string, any> = {};
@@ -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<ReturnType<typeof setTimeout> | 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<string | null | undefined>(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<string | null | undefined>(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"
/>
<PanelPortalHost renderPanel={renderPanel} />
{effectiveSessionId && (
<ReviewDialog
open={review.reviewDialogOpen}
@@ -1,7 +1,6 @@
"use client";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { IDockviewPanelProps } from "dockview-react";
import { PanelRoot, PanelBody } from "./panel-primitives";
import { FileEditorContent } from "./file-editor-content";
import { FileImageViewer } from "./file-image-viewer";
@@ -79,10 +78,13 @@ function useFileLoader(
}, [hasFile, activeSessionId, path, setFileState]);
}
export const FileEditorPanel = memo(function FileEditorPanel(
props: IDockviewPanelProps<{ path: string }>,
) {
const path = props.params.path;
type FileEditorPanelProps = {
panelId: string;
params: Record<string, unknown>;
};
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 ?? "");
+9 -11
View File
@@ -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<string, unknown>;
};
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
+6 -4
View File
@@ -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();
+99
View File
@@ -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<string, unknown>,
) => 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 `<DockviewReact>`.
*/
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<HTMLDivElement | null> {
const containerRef = useRef<HTMLDivElement | null>(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;
}
+168
View File
@@ -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<string, unknown>;
/** 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<string, PortalEntry>();
private listeners = new Set<Listener>();
/**
* Get or create the portal entry for a panel.
* Called by the dockview slot wrapper on mount.
*/
acquire(
panelId: string,
component: string,
params: Record<string, unknown>,
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<string, PortalEntry> {
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);
}
}