diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index b89939c845..19c5d773d0 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -115,7 +115,7 @@ test.describe("smoke: session timeline", () => { .toBeLessThanOrEqual(1) }) - test("restores the persisted timeline position after reload", async ({ page }) => { + test("restores the persisted timeline position across tabs and reload", async ({ page }) => { let messages = timelineMessages(140) await mockOpenCodeServer(page, { sessions: fixture.sessions, @@ -126,8 +126,25 @@ test.describe("smoke: session timeline", () => { sessionID === fixture.targetID ? pageMessageList(messages, limit, before) : pageMessages(sessionID, limit, before), }) await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) - await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await waitForTimelineStable(page) await pointAtTimeline(page) await page.mouse.wheel(0, -1_000) @@ -145,8 +162,12 @@ test.describe("smoke: session timeline", () => { (element) => element.scrollHeight - element.clientHeight - element.scrollTop, ), ).toBeGreaterThan(100) - const anchor = await firstVisibleMessage(page) - expect(anchor).toBeTruthy() + const anchor = await firstVisibleTimelineRow(page) + expect(anchor?.id).toBeTruthy() + expect(anchor?.key).toBeTruthy() + await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await expect.poll(() => firstVisibleTimelineRow(page)).toEqual(anchor) messages = [...messages, ...timelineMessages(120, 140)] await page.reload() await waitForTimelineStable(page) @@ -158,7 +179,7 @@ test.describe("smoke: session timeline", () => { ), ) .toBeGreaterThan(100) - await expect.poll(() => firstVisibleMessage(page)).toBe(anchor) + await expect.poll(() => firstVisibleTimelineRow(page)).toEqual(anchor) }) test("paints cached session tabs at the latest message", async ({ page }) => { @@ -603,13 +624,20 @@ function timelineScroller(page: Page) { return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) } -function firstVisibleMessage(page: Page) { +function firstVisibleTimelineRow(page: Page) { return timelineScroller(page).evaluate((element) => { const box = element.getBoundingClientRect() - return [...element.querySelectorAll("[data-message-id]")] - .map((message) => ({ id: message.dataset.messageId, rect: message.getBoundingClientRect() })) - .filter((message) => message.rect.bottom > box.top && message.rect.top < box.bottom) - .sort((a, b) => a.rect.top - b.rect.top)[0]?.id + const row = [...element.querySelectorAll("[data-timeline-key]")] + .map((row) => ({ + id: row.querySelector("[data-message-id]")?.dataset.messageId, + key: row.dataset.timelineKey, + offset: Math.round(row.getBoundingClientRect().top - box.top), + rect: row.getBoundingClientRect(), + })) + .filter((row) => row.rect.bottom > box.top && row.rect.top < box.bottom) + .sort((a, b) => a.rect.top - b.rect.top)[0] + if (!row) return + return { id: row.id, key: row.key, offset: row.offset } }) } diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts index 2c91adbc3a..0e33b90673 100644 --- a/packages/app/src/context/layout-scroll.test.ts +++ b/packages/app/src/context/layout-scroll.test.ts @@ -65,7 +65,7 @@ describe("createScrollPersistence", () => { test("persists semantic scroll anchors", () => { vi.useFakeTimers() try { - let snapshot: Record = {} + let snapshot: Record = {} const scroll = createScrollPersistence({ debounceMs: 10, getSnapshot: () => snapshot, @@ -77,14 +77,14 @@ describe("createScrollPersistence", () => { scroll.setScroll("session", "timeline", { x: 1_000, y: 400, - anchor: { id: "message-1", offset: 24 }, + anchor: { id: "message-1", key: "assistant-part:message-1:part-2", offset: 24 }, }) vi.advanceTimersByTime(10) expect(snapshot.timeline).toEqual({ x: 1_000, y: 400, - anchor: { id: "message-1", offset: 24 }, + anchor: { id: "message-1", key: "assistant-part:message-1:part-2", offset: 24 }, }) scroll.dispose() } finally { diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts index 3a1faeeb32..3f95704a43 100644 --- a/packages/app/src/context/layout-scroll.ts +++ b/packages/app/src/context/layout-scroll.ts @@ -5,6 +5,7 @@ export type SessionScroll = { y: number anchor?: { id: string + key?: string offset: number } } @@ -33,7 +34,7 @@ export function createScrollPersistence(opts: Options) { out[key] = { x: pos.x, y: pos.y, - anchor: pos.anchor ? { id: pos.anchor.id, offset: pos.anchor.offset } : undefined, + anchor: pos.anchor ? { id: pos.anchor.id, key: pos.anchor.key, offset: pos.anchor.offset } : undefined, } } @@ -75,6 +76,7 @@ export function createScrollPersistence(opts: Options) { prev?.x === pos.x && prev?.y === pos.y && prev?.anchor?.id === pos.anchor?.id && + prev?.anchor?.key === pos.anchor?.key && prev?.anchor?.offset === pos.anchor?.offset ) return @@ -82,7 +84,7 @@ export function createScrollPersistence(opts: Options) { setCache(sessionKey, tab, { x: pos.x, y: pos.y, - anchor: pos.anchor ? { id: pos.anchor.id, offset: pos.anchor.offset } : undefined, + anchor: pos.anchor ? { id: pos.anchor.id, key: pos.anchor.key, offset: pos.anchor.offset } : undefined, }) dirty.add(sessionKey) schedule(sessionKey) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e43f561af5..e79b68adc8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1134,16 +1134,24 @@ export default function Page() { if (!layout.ready() || timelineScrollSession !== sessionKey()) return const max = el.scrollHeight - el.clientHeight const box = el.getBoundingClientRect() - const anchor = [...el.querySelectorAll("[data-message-id]")] - .map((element) => ({ element, rect: element.getBoundingClientRect() })) + const anchor = [...el.querySelectorAll("[data-timeline-key]")] + .map((element) => ({ + element, + message: element.querySelector("[data-message-id]"), + rect: element.getBoundingClientRect(), + })) .filter((item) => item.rect.bottom > box.top && item.rect.top < box.bottom) .sort((a, b) => a.rect.top - b.rect.top)[0] view().setScroll("timeline", { x: max, y: max <= 1 || max - el.scrollTop <= 2 ? Number.MAX_SAFE_INTEGER : el.scrollTop, anchor: - max > 1 && max - el.scrollTop > 2 && anchor?.element.dataset.messageId - ? { id: anchor.element.dataset.messageId, offset: anchor.rect.top - box.top } + max > 1 && max - el.scrollTop > 2 && anchor?.message?.dataset.messageId && anchor.element.dataset.timelineKey + ? { + id: anchor.message.dataset.messageId, + key: anchor.element.dataset.timelineKey, + offset: anchor.rect.top - box.top, + } : undefined, }) } @@ -1564,9 +1572,11 @@ export default function Page() { if (!scroller || !current()) return autoScroll.pause() const max = scroller.scrollHeight - scroller.clientHeight - const target = saved.anchor - ? scroller.querySelector(`[data-message-id="${CSS.escape(saved.anchor.id)}"]`) - : undefined + const target = saved.anchor?.key + ? scroller.querySelector(`[data-timeline-key="${CSS.escape(saved.anchor.key)}"]`) + : saved.anchor + ? scroller.querySelector(`[data-message-id="${CSS.escape(saved.anchor.id)}"]`) + : undefined if (saved.anchor && !target) { revealMessage(saved.anchor.id) return false