diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 8ba225a4bad8..37bd9a8f01c6 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -10,7 +10,7 @@ */ import { useStore } from '@nanostores/react' -import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react' import { Codicon } from '@/components/ui/codicon' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' @@ -178,6 +178,30 @@ export function TreeGroup({ const active = paneFor(activeId) const isEmpty = node.panes.length === 0 + // KEEP-ALIVE: every pane that has been ACTIVE in this zone stays mounted — + // an inactive tab merely hides (visibility), it does not unmount. Remounting + // on every tab switch re-measured and re-scrolled the content from scratch + // (the thread visibly layout-shifted each time a session tab was revisited). + // Lazy on purpose: a pane first mounts when first activated, so a + // boot-restored tab stack doesn't resume every session up front. + const everActivePanesRef = useRef>(new Set()) + + useEffect(() => { + if (!node.minimized && !isEmpty) { + everActivePanesRef.current.add(activeId) + } + + // Prune panes that left the zone (closed / moved to another group), so a + // long-lived zone doesn't pin stale ids forever. + for (const id of everActivePanesRef.current) { + if (!node.panes.includes(id)) { + everActivePanesRef.current.delete(id) + } + } + }) + + const keptPanes = shown.filter(id => id === activeId || everActivePanesRef.current.has(id)) + // ONE header style: the app's compact pane-header. DEFAULT is contextual — // a single pane isn't a "tab", so its header auto-hides; a stack shows its // chips. EXCEPTIONS force a lone pane to keep its header (tab + close X): @@ -478,18 +502,40 @@ export function TreeGroup({ )} - {/* Body: the active pane's contributed content, or the empty zone. */} + {/* Body: the zone's pane content — every kept (ever-active) pane stays + mounted in an absolute layer; only the active one is visible. + `visibility` (not display) keeps the hidden pane's layout box, so + scroll positions and measurements survive the round-trip. */} {!node.minimized && ( -
+
{isEmpty ? (
{/* Same decode primitive as the CONNECTING boot overlay. */}
- ) : active?.render ? ( - {active.render()} ) : ( -
{t.zones.missingPane(activeId)}
+ keptPanes.map(paneId => { + const pane = paneFor(paneId) + const isActive = paneId === activeId + + return ( +
+ {pane?.render ? ( + {pane.render()} + ) : ( + isActive && ( +
+ {t.zones.missingPane(paneId)} +
+ ) + )} +
+ ) + }) )}
)}