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"