From 51625f7709fdddb95ff6abaad0b84e79ab53a3f0 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 29 Oct 2026 16:23:18 -0400 Subject: [PATCH] fix(app): keep inbox scroll position when opening a session (#60356) --- packages/app/e2e/regression/tabs.spec.ts | 41 +++++++++++++++++++++++ packages/app/e2e/utils/mock-api.ts | 6 ++++ packages/app/e2e/utils/mock-server.ts | 1 + packages/app/src/shell/titlebar/inbox.tsx | 14 ++++---- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index cddaca53d54..f477cf5756a 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -4,6 +4,7 @@ import { NO_PROVIDER, REMOTE_SERVER, SERVER, + T0, draftHref, expectPath, holdRoute, @@ -1356,3 +1357,43 @@ async function emptySidePanel(page: Page) { return { beta, alphaTab: page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`) } } + +test("opening a session from the inbox keeps the list's scroll position and rows", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 480 }) + + const sessions = Array.from({ length: 24 }, (_, index) => ({ + id: `ses_inbox_${String(index).padStart(2, "0")}`, + title: `Inbox session ${index}`, + time: { created: T0, updated: T0 + index, idle: T0 + index }, + })) + + await mockWorkspace(page, { + name: "Inbox", + sessions, + seed: { + settings: { appearance: { tabLayout: "inbox" } }, + tabs: [], + // Every seeded session finished a run after the inbox was first used, so Recent lists them all. + storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true, since: 0 } }, + }, + }) + await page.goto("/") + + const list = page.locator('[data-slot="sidebar-inbox-scroll"]') + const items = list.locator('[data-slot="sidebar-inbox-item"]') + const target = items.filter({ hasText: "Inbox session 0" }) + await expect(items).toHaveCount(sessions.length) + await list.evaluate((element) => element.scrollTo(0, element.scrollHeight)) + await expect(target).toBeInViewport({ ratio: 1 }) + const offset = await list.evaluate((element) => element.scrollTop) + expect(offset).toBeGreaterThan(0) + const row = await target.elementHandle() + + await target.click() + await expectPath(page, sessionHref("ses_inbox_00")) + await expect(target).toHaveAttribute("data-state", "active") + // Opening a session loads it into the client, which updates every section. + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + expect(await target.evaluate((element, before) => element === before, row)).toBe(true) + expect(await list.evaluate((element) => element.scrollTop)).toBe(offset) +}) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 1f236218f81..0b87d797987 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -321,6 +321,12 @@ const Group = HttpApiGroup.make("mock") success: NoContent, }), ) + .add( + HttpApiEndpoint.post("sessionView", "/api/session/:sessionID/view", { + params: SessionParams, + success: NoContent, + }), + ) .add( HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", { params: SessionParams, diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 0ccbbcdf5c0..926a684b73f 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1315,6 +1315,7 @@ function mockHandlers( }), sessionRemove: () => noContent, sessionShell: () => noContent, + sessionView: () => noContent, sessionForm: (ctx) => Effect.succeed({ data: resolve(config.forms ?? []).filter( diff --git a/packages/app/src/shell/titlebar/inbox.tsx b/packages/app/src/shell/titlebar/inbox.tsx index a67e6dbf384..850ec4f5f21 100644 --- a/packages/app/src/shell/titlebar/inbox.tsx +++ b/packages/app/src/shell/titlebar/inbox.tsx @@ -103,8 +103,10 @@ export function SidebarInbox() { ) } - const rows = (list: InboxEntry[]) => ( - entry.key)}> + // Takes an accessor so only the For tracks the section. Reading it here would recreate the For, remounting every + // row on any session change and resetting the list's scroll. + const rows = (list: () => InboxEntry[]) => ( + entry.key)}> {(key) => ( {(entry) => ( @@ -133,7 +135,7 @@ export function SidebarInbox() { 0}>
- {rows(sections().needs)} + {rows(() => sections().needs)}
0}> - {rows(sections().inbox)} + {rows(() => sections().inbox)}
0}>
- {rows(sections().archived)} + {rows(() => sections().archived)}
@@ -176,7 +178,7 @@ export function SidebarInbox() { />
{/* A collapsed shelf still shows the session that is open. */} - {rows(sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))} + {rows(() => sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))}