diff --git a/apps/desktop/src/renderer/src/components/tab-content.tsx b/apps/desktop/src/renderer/src/components/tab-content.tsx
index 90e37b9e7..6113382f0 100644
--- a/apps/desktop/src/renderer/src/components/tab-content.tsx
+++ b/apps/desktop/src/renderer/src/components/tab-content.tsx
@@ -3,6 +3,7 @@ import { RouterProvider } from "react-router-dom";
import { useActiveGroup } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
+import { useTabScrollRestore } from "@/hooks/use-tab-scroll-restore";
import type { Tab } from "@/stores/tab-store";
/**
@@ -15,6 +16,28 @@ function TabRouterInner({ tab }: { tab: Tab }) {
return null;
}
+/**
+ * Wraps a tab's subtree so its scroll position survives the round trip
+ * through ``. Lives inside Activity so the hook's
+ * effects cycle with the tab's visibility — see `useTabScrollRestore` for
+ * the mechanism. `display: contents` keeps the wrapper transparent to
+ * the surrounding flex layout.
+ */
+function TabScrollRestoreWrapper({
+ tabPath,
+ children,
+}: {
+ tabPath: string;
+ children: React.ReactNode;
+}) {
+ const ref = useTabScrollRestore(tabPath);
+ return (
+
+ {children}
+
+ );
+}
+
/**
* Renders the active workspace's tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
@@ -44,10 +67,12 @@ export function TabContent() {
key={tab.id}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
>
-
-
-
-
+
+
+
+
+
+
))}
>
diff --git a/apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.test.tsx b/apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.test.tsx
new file mode 100644
index 000000000..3f0f25bf7
--- /dev/null
+++ b/apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.test.tsx
@@ -0,0 +1,116 @@
+import { Activity } from "react";
+import { describe, expect, it } from "vitest";
+import { fireEvent, render } from "@testing-library/react";
+import { useTabScrollRestore } from "./use-tab-scroll-restore";
+
+function Harness({ path }: { path: string }) {
+ const ref = useTabScrollRestore(path);
+ return (
+
+ );
+}
+
+function App({ visible, path }: { visible: boolean; path: string }) {
+ return (
+
+
+
+ );
+}
+
+function setScroll(el: HTMLElement, top: number) {
+ el.scrollTop = top;
+ fireEvent.scroll(el);
+}
+
+describe("useTabScrollRestore", () => {
+ it("restores scroll position when a tab cycles through hidden -> visible", () => {
+ const { rerender, getByTestId } = render(
+ ,
+ );
+ const scroller = getByTestId("scroller") as HTMLElement;
+
+ setScroll(scroller, 500);
+ expect(scroller.scrollTop).toBe(500);
+
+ // Simulate Activity hiding the subtree: layout would drop the offset.
+ rerender();
+ scroller.scrollTop = 0;
+
+ rerender();
+ expect(scroller.scrollTop).toBe(500);
+ });
+
+ it("restores multiple named scroll roots independently", () => {
+ const { rerender, getByTestId } = render(
+ ,
+ );
+ const main = getByTestId("scroller") as HTMLElement;
+ const aside = getByTestId("aside") as HTMLElement;
+
+ setScroll(main, 300);
+ setScroll(aside, 150);
+
+ rerender();
+ main.scrollTop = 0;
+ aside.scrollTop = 0;
+
+ rerender();
+ expect(main.scrollTop).toBe(300);
+ expect(aside.scrollTop).toBe(150);
+ });
+
+ it("ignores scroll on elements without the data-tab-scroll-root marker", () => {
+ const { rerender, getByTestId } = render(
+ ,
+ );
+ const unmarked = getByTestId("unmarked") as HTMLElement;
+
+ setScroll(unmarked, 250);
+
+ rerender();
+ unmarked.scrollTop = 0;
+
+ rerender();
+ expect(unmarked.scrollTop).toBe(0);
+ });
+
+ it("drops saved offsets when the tab path changes (intra-tab navigation)", () => {
+ const { rerender, getByTestId } = render(
+ ,
+ );
+ const scroller = getByTestId("scroller") as HTMLElement;
+
+ setScroll(scroller, 500);
+
+ // Navigating within the tab swaps the active route — same marker key,
+ // different page. We should NOT restore the prior page's offset.
+ rerender();
+ scroller.scrollTop = 0;
+
+ rerender();
+ rerender();
+ expect(scroller.scrollTop).toBe(0);
+ });
+});
diff --git a/apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.ts b/apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.ts
new file mode 100644
index 000000000..cfb3a1a37
--- /dev/null
+++ b/apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.ts
@@ -0,0 +1,70 @@
+import { useEffect, useLayoutEffect, useRef } from "react";
+
+/**
+ * Persist a tab's scroll positions across visibility transitions.
+ *
+ * Tabs render under ``, which keeps React
+ * state but loses DOM scrollTop — the subtree is taken out of layout while
+ * hidden and rejoins with scrollTop=0. This hook records every marked
+ * container's `scrollTop` while the tab is visible (continuously, via a
+ * capture-phase scroll listener) and restores them in a `useLayoutEffect`
+ * the next time the tab becomes visible, before the browser paints.
+ *
+ * Mark scroll containers in views with `data-tab-scroll-root`. The
+ * attribute value is the cache key — defaults to `"main"` for unnamed
+ * roots. Most pages have a single scroll container, so a bare attribute
+ * is enough; named keys are only needed when a page has multiple
+ * independently scrollable regions whose positions must all be restored.
+ *
+ * When the tab's path changes (intra-tab navigation), the saved offsets
+ * are dropped — the new route's container shares the same marker key but
+ * is a different page, and restoring the old offset would land the user
+ * somewhere arbitrary on the new page.
+ */
+export function useTabScrollRestore(tabPath: string) {
+ const containerRef = useRef(null);
+ const savedRef = useRef