From 30f6fc81e2f6cf21d57cd95e968b364403a70c31 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 21:49:12 -0500 Subject: [PATCH] fix(desktop): keep ever-active tab panes mounted so revisiting a tab doesn't layout-shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab group rendered only the active pane's content, so every tab switch unmounted and remounted the whole surface — revisiting a session tab re-measured and re-scrolled the thread from scratch, visibly shifting layout each time. Panes that have been active in a zone now stay mounted in absolutely positioned layers; the inactive ones hide via visibility (keeping their layout box, so scroll positions and measurements survive) with pointer-events disabled and aria-hidden. Mounting stays lazy — a pane first mounts when first activated — so a boot-restored tab stack still doesn't resume every session up front, and panes that leave the zone (closed / moved) unmount as before. --- .../pane-shell/tree/renderer/tree-group.tsx | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) 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)} +
+ ) + )} +
+ ) + }) )}
)}