From 9659e3e450fbd0d67ac756aa339082fa26bea4ab Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Fri, 21 Aug 2026 22:30:46 +0200 Subject: [PATCH] Keep the thread panel host mounted across thread navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread-to-thread navigation remounted the SecondaryPanelLayout PanelGroup because the group key defaulted to resetKey (the thread id). On desktop-inline layouts that destroyed and rebuilt the realized secondary panel (diff views, metadata, browser deck) in one synchronous commit per navigate, and reset the user's panel sizes every time. Pass the documented panelGroupKey escape hatch (already used by PluginPanelRightPanelHost) so the physical host survives navigation while content identity still resets via resetKey. Scope, stated plainly: this is a desktop-inline improvement. On compact viewports the drawer already rendered outside the keyed group, and the timeline/composer remount cost on any viewport is owned by PageShell's own key={threadId} inside EmbeddedThreadChat — unchanged here. Per-thread state stays correct: drafts and scroll anchors live under that PageShell key, split layouts are keyed by thread id, and the hasPanelExpandedRef mount-collapse guard now re-arms per thread since the Panel instance survives navigation. Co-Authored-By: Claude Fable 5 --- .../secondary-panel/ThreadSecondaryPanel.tsx | 9 ++++ .../ThreadDetailSecondaryContent.test.tsx | 43 +++++++++++++++++++ .../ThreadDetailSecondaryContent.tsx | 5 +++ 3 files changed, 57 insertions(+) diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index 78cc4260a2..ae071bda39 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -6,6 +6,7 @@ import { type TransitionEvent, useCallback, useContext, + useLayoutEffect, useMemo, useRef, useState, @@ -385,6 +386,14 @@ export function ThreadSecondaryPanel({ // silently closing it again. Only a collapse from a layout this Panel // instance actually held expanded may close the persisted panel. const hasPanelExpandedRef = useRef(false); + // The panel host survives thread navigation (stable panelGroupKey on the + // thread-detail layout), so the guard must re-arm per thread: a collapse + // applied while showing the next thread must not pass on the previous + // thread's expansion. Child layout effects run before the parent group's + // setLayout effect, so this reset lands first. + useLayoutEffect(() => { + hasPanelExpandedRef.current = false; + }, [splitPanelStateId]); const handlePanelResize = useCallback( (size: number) => { if (size > 0) { diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx index 207a7af319..bfc18d5732 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx @@ -422,6 +422,49 @@ describe("ThreadDetailSecondaryContent", () => { }); }); + it("keeps the panel subtree mounted when navigating between threads", async () => { + const props = createProps(); + const { rerender } = render( + + + + + + + , + ); + + const sidePanel = await screen.findByTestId( + "inline-secondary-panel", + {}, + { timeout: 5_000 }, + ); + const panelGroup = screen.getByTestId("panel-group"); + + const nextProps = createProps(); + nextProps.timeline = { + ...nextProps.timeline, + threadId: "thread-2", + } as ThreadDetailSecondaryContentProps["timeline"]; + rerender( + + + + + + + , + ); + + // Navigation swaps content identity but must not remount the physical + // panel host: same DOM nodes for the group and the realized side panel. + expect(screen.getByTestId("panel-group")).toBe(panelGroup); + expect(screen.getByTestId("inline-secondary-panel")).toBe(sidePanel); + expect( + screen.getByTestId("thread-timeline-pane").getAttribute("data-thread-id"), + ).toBe("thread-2"); + }); + it("only requests the forks list while the secondary panel is open", () => { const props = createProps(); const { rerender } = render( diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx index 6c7551cd73..bf2683ca2e 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx @@ -125,6 +125,11 @@ function ThreadDetailSecondaryContentBody({ open={isSecondaryPanelOpen} onToggle={onToggleSecondaryPanel} onClose={threadSecondaryPanelProps.onClose} + // The physical panel host survives thread-to-thread navigation; only + // content identity (resetKey) changes. Per-thread state below is safe: + // the timeline, composer and scroll anchors live under PageShell's own + // key={threadId} inside EmbeddedThreadChat. + panelGroupKey="thread-detail" resetKey={timeline.threadId} contentKey={timeline.threadId} drawerLabel="Thread details"