fix(app): restore exact timeline row
This commit is contained in:
parent
3783cda020
commit
5dfaa184b6
4 changed files with 62 additions and 22 deletions
|
|
@ -115,7 +115,7 @@ test.describe("smoke: session timeline", () => {
|
||||||
.toBeLessThanOrEqual(1)
|
.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)
|
let messages = timelineMessages(140)
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
sessions: fixture.sessions,
|
sessions: fixture.sessions,
|
||||||
|
|
@ -126,8 +126,25 @@ test.describe("smoke: session timeline", () => {
|
||||||
sessionID === fixture.targetID ? pageMessageList(messages, limit, before) : pageMessages(sessionID, limit, before),
|
sessionID === fixture.targetID ? pageMessageList(messages, limit, before) : pageMessages(sessionID, limit, before),
|
||||||
})
|
})
|
||||||
await configureSmokePage(page, fixture.directory)
|
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 waitForTimelineStable(page)
|
||||||
await pointAtTimeline(page)
|
await pointAtTimeline(page)
|
||||||
await page.mouse.wheel(0, -1_000)
|
await page.mouse.wheel(0, -1_000)
|
||||||
|
|
@ -145,8 +162,12 @@ test.describe("smoke: session timeline", () => {
|
||||||
(element) => element.scrollHeight - element.clientHeight - element.scrollTop,
|
(element) => element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||||
),
|
),
|
||||||
).toBeGreaterThan(100)
|
).toBeGreaterThan(100)
|
||||||
const anchor = await firstVisibleMessage(page)
|
const anchor = await firstVisibleTimelineRow(page)
|
||||||
expect(anchor).toBeTruthy()
|
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)]
|
messages = [...messages, ...timelineMessages(120, 140)]
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await waitForTimelineStable(page)
|
await waitForTimelineStable(page)
|
||||||
|
|
@ -158,7 +179,7 @@ test.describe("smoke: session timeline", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toBeGreaterThan(100)
|
.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 }) => {
|
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]") })
|
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) => {
|
return timelineScroller(page).evaluate((element) => {
|
||||||
const box = element.getBoundingClientRect()
|
const box = element.getBoundingClientRect()
|
||||||
return [...element.querySelectorAll<HTMLElement>("[data-message-id]")]
|
const row = [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||||
.map((message) => ({ id: message.dataset.messageId, rect: message.getBoundingClientRect() }))
|
.map((row) => ({
|
||||||
.filter((message) => message.rect.bottom > box.top && message.rect.top < box.bottom)
|
id: row.querySelector<HTMLElement>("[data-message-id]")?.dataset.messageId,
|
||||||
.sort((a, b) => a.rect.top - b.rect.top)[0]?.id
|
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 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ describe("createScrollPersistence", () => {
|
||||||
test("persists semantic scroll anchors", () => {
|
test("persists semantic scroll anchors", () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
let snapshot: Record<string, { x: number; y: number; anchor?: { id: string; offset: number } }> = {}
|
let snapshot: Record<string, { x: number; y: number; anchor?: { id: string; key?: string; offset: number } }> = {}
|
||||||
const scroll = createScrollPersistence({
|
const scroll = createScrollPersistence({
|
||||||
debounceMs: 10,
|
debounceMs: 10,
|
||||||
getSnapshot: () => snapshot,
|
getSnapshot: () => snapshot,
|
||||||
|
|
@ -77,14 +77,14 @@ describe("createScrollPersistence", () => {
|
||||||
scroll.setScroll("session", "timeline", {
|
scroll.setScroll("session", "timeline", {
|
||||||
x: 1_000,
|
x: 1_000,
|
||||||
y: 400,
|
y: 400,
|
||||||
anchor: { id: "message-1", offset: 24 },
|
anchor: { id: "message-1", key: "assistant-part:message-1:part-2", offset: 24 },
|
||||||
})
|
})
|
||||||
vi.advanceTimersByTime(10)
|
vi.advanceTimersByTime(10)
|
||||||
|
|
||||||
expect(snapshot.timeline).toEqual({
|
expect(snapshot.timeline).toEqual({
|
||||||
x: 1_000,
|
x: 1_000,
|
||||||
y: 400,
|
y: 400,
|
||||||
anchor: { id: "message-1", offset: 24 },
|
anchor: { id: "message-1", key: "assistant-part:message-1:part-2", offset: 24 },
|
||||||
})
|
})
|
||||||
scroll.dispose()
|
scroll.dispose()
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export type SessionScroll = {
|
||||||
y: number
|
y: number
|
||||||
anchor?: {
|
anchor?: {
|
||||||
id: string
|
id: string
|
||||||
|
key?: string
|
||||||
offset: number
|
offset: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +34,7 @@ export function createScrollPersistence(opts: Options) {
|
||||||
out[key] = {
|
out[key] = {
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
y: pos.y,
|
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?.x === pos.x &&
|
||||||
prev?.y === pos.y &&
|
prev?.y === pos.y &&
|
||||||
prev?.anchor?.id === pos.anchor?.id &&
|
prev?.anchor?.id === pos.anchor?.id &&
|
||||||
|
prev?.anchor?.key === pos.anchor?.key &&
|
||||||
prev?.anchor?.offset === pos.anchor?.offset
|
prev?.anchor?.offset === pos.anchor?.offset
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -82,7 +84,7 @@ export function createScrollPersistence(opts: Options) {
|
||||||
setCache(sessionKey, tab, {
|
setCache(sessionKey, tab, {
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
y: pos.y,
|
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)
|
dirty.add(sessionKey)
|
||||||
schedule(sessionKey)
|
schedule(sessionKey)
|
||||||
|
|
|
||||||
|
|
@ -1134,16 +1134,24 @@ export default function Page() {
|
||||||
if (!layout.ready() || timelineScrollSession !== sessionKey()) return
|
if (!layout.ready() || timelineScrollSession !== sessionKey()) return
|
||||||
const max = el.scrollHeight - el.clientHeight
|
const max = el.scrollHeight - el.clientHeight
|
||||||
const box = el.getBoundingClientRect()
|
const box = el.getBoundingClientRect()
|
||||||
const anchor = [...el.querySelectorAll<HTMLElement>("[data-message-id]")]
|
const anchor = [...el.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||||
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
.map((element) => ({
|
||||||
|
element,
|
||||||
|
message: element.querySelector<HTMLElement>("[data-message-id]"),
|
||||||
|
rect: element.getBoundingClientRect(),
|
||||||
|
}))
|
||||||
.filter((item) => item.rect.bottom > box.top && item.rect.top < box.bottom)
|
.filter((item) => item.rect.bottom > box.top && item.rect.top < box.bottom)
|
||||||
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
||||||
view().setScroll("timeline", {
|
view().setScroll("timeline", {
|
||||||
x: max,
|
x: max,
|
||||||
y: max <= 1 || max - el.scrollTop <= 2 ? Number.MAX_SAFE_INTEGER : el.scrollTop,
|
y: max <= 1 || max - el.scrollTop <= 2 ? Number.MAX_SAFE_INTEGER : el.scrollTop,
|
||||||
anchor:
|
anchor:
|
||||||
max > 1 && max - el.scrollTop > 2 && anchor?.element.dataset.messageId
|
max > 1 && max - el.scrollTop > 2 && anchor?.message?.dataset.messageId && anchor.element.dataset.timelineKey
|
||||||
? { id: anchor.element.dataset.messageId, offset: anchor.rect.top - box.top }
|
? {
|
||||||
|
id: anchor.message.dataset.messageId,
|
||||||
|
key: anchor.element.dataset.timelineKey,
|
||||||
|
offset: anchor.rect.top - box.top,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1564,9 +1572,11 @@ export default function Page() {
|
||||||
if (!scroller || !current()) return
|
if (!scroller || !current()) return
|
||||||
autoScroll.pause()
|
autoScroll.pause()
|
||||||
const max = scroller.scrollHeight - scroller.clientHeight
|
const max = scroller.scrollHeight - scroller.clientHeight
|
||||||
const target = saved.anchor
|
const target = saved.anchor?.key
|
||||||
? scroller.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(saved.anchor.id)}"]`)
|
? scroller.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(saved.anchor.key)}"]`)
|
||||||
: undefined
|
: saved.anchor
|
||||||
|
? scroller.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(saved.anchor.id)}"]`)
|
||||||
|
: undefined
|
||||||
if (saved.anchor && !target) {
|
if (saved.anchor && !target) {
|
||||||
revealMessage(saved.anchor.id)
|
revealMessage(saved.anchor.id)
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue