mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-06 06:00:28 +00:00
fix(transcript): hold the top after an immediate jump to the first message (#1302)
On long transcripts, First from the bottom loaded the oldest window but left the reader at its bottom, needing a second click. The jump wrote offset 0, then Virtua applied a size compensation computed against its previous offset (the replaced window's position), carrying the reader back down. An immediate jump to the top now holds the first live row at its top offset through the reader settlement, as key jumps hold their row, for the 30-frame window. A gesture releases it, and so does any focus move: the top landing reveals nothing, unlike in-place reveals, whose focus rule is unchanged. The session fixture now serves ascending (oldest) pages from the first records, like the native API. List-fixture tests cover a late compensation after a top jump (fails without the hold) and Tab within the first row (fails without the focus release); a session test covers First into an overlapping oldest window. On the reporting conversation, First landed at the oldest window's bottom without the fix and at its top in three runs with it. Gatekeeper review approved after its focus finding was fixed.
This commit is contained in:
parent
fc1b9e72b2
commit
30527e58dd
5 changed files with 99 additions and 6 deletions
|
|
@ -350,7 +350,11 @@ and does so even if the prompt is then rejected (the saved passage is not restor
|
|||
|
||||
In-place reveals (`alignElement`) are reader-settlement holds on the revealed
|
||||
element; key jumps hold their row the same way at offset 0, or the block their
|
||||
optional selector finds in it on that frame (a timeline marker's own block). While
|
||||
optional selector finds in it on that frame (a timeline marker's own block). An
|
||||
immediate jump to the top holds the first live row at its top offset: right after
|
||||
a window replacement Virtua can apply a size compensation computed against its
|
||||
previous offset after the jump's write, which would otherwise carry the reader back
|
||||
down (a First jump from the bottom of a long transcript). While
|
||||
a jump's row is not rendered, the hold rewrites Virtua's current estimate of its
|
||||
offset instead, since measuring the rows around a distant estimate can carry the
|
||||
row out of the rendered range; once rendered it is aligned. Both last a fixed
|
||||
|
|
|
|||
|
|
@ -533,8 +533,21 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
runBottomPinFrame()
|
||||
}
|
||||
|
||||
/** The anchor an immediate jump to the top holds, told apart from in-place reveals. */
|
||||
let topHold: { key: string; offset: number } | undefined
|
||||
|
||||
function performScrollToTop(immediate: boolean, source: ScrollReason) {
|
||||
writeScroll(source, immediate ? { type: "dom", top: 0, record: true } : { type: "native", top: 0, behavior: "smooth", record: false })
|
||||
if (!immediate) return
|
||||
// Like a key jump, the landing is held while rows are measured: right after a
|
||||
// window replacement Virtua can still apply a size compensation computed
|
||||
// against its previous offset, which would carry the reader back down.
|
||||
const handle = virtuaHandle(), first = retiredCount()
|
||||
const item = virtualItems()[first]
|
||||
if (handle && item !== undefined) {
|
||||
topHold = { key: props.getKey(item, first), offset: handle.getItemOffset(first + HEADER_ROWS) }
|
||||
readerSettlement.hold(topHold)
|
||||
}
|
||||
}
|
||||
|
||||
function performScrollToKey(key: string, source: ScrollReason) {
|
||||
|
|
@ -1052,10 +1065,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
}
|
||||
markUserScrollIntent(intent.direction, intent.distance)
|
||||
}
|
||||
// Moving focus elsewhere (Tab, assistive technology) ends an in-place reveal's hold.
|
||||
// Moving focus elsewhere (Tab, assistive technology) ends an in-place reveal's
|
||||
// hold; the top landing reveals nothing, so any focus move ends it.
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
const held = readerSettlement.held()
|
||||
if (held && !itemElements.get(held.key)?.contains(event.target as Node)) readerSettlement.cancel()
|
||||
if (held && (held === topHold || !itemElements.get(held.key)?.contains(event.target as Node))) readerSettlement.cancel()
|
||||
}
|
||||
const detachGestureRouting = attachScrollGestureRouting(element, handleWheelIntent)
|
||||
element.addEventListener("focusin", handleFocusIn)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ render(() => <Show when={visible()}><VirtualFollowList items={items} getKey={ite
|
|||
/></Show>, document.getElementById("root")!)
|
||||
;(window as any).fixture = {
|
||||
bottom: () => api?.scrollToBottom({ immediate: true }),
|
||||
top: () => api?.scrollToTop({ immediate: true }),
|
||||
follow: () => { api?.scrollToBottom({ immediate: true }); api?.setAutoScroll(true) },
|
||||
middle: () => api?.scrollToKey("row-100"),
|
||||
nearTail: () => api?.scrollToKey("row-180"),
|
||||
|
|
|
|||
|
|
@ -50,8 +50,17 @@ const client: any = {
|
|||
},
|
||||
model: { default: async () => ({ data: model }) },
|
||||
permission: { list: async () => [] },
|
||||
message: { list: async ({ cursor, limit = 200 }: { cursor?: string; limit?: number } = {}) => {
|
||||
message: { list: async ({ cursor, limit = 200, order }: { cursor?: string; limit?: number; order?: "asc" | "desc" } = {}) => {
|
||||
calls++
|
||||
// The oldest window reads forward from the first record, like the native
|
||||
// ascending page, whose boundary cursor promises no older page.
|
||||
if (order === "asc" || cursor?.startsWith("asc:")) {
|
||||
const offset = Number(cursor?.slice(4) ?? 0)
|
||||
return { data: nativeMessages.slice(offset, offset + limit), cursor: {
|
||||
previous: "asc-boundary",
|
||||
...(offset + limit < nativeMessages.length ? { next: `asc:${offset + limit}` } : {}),
|
||||
} }
|
||||
}
|
||||
const offset = Number(cursor ?? 0), messages = nativeMessages.slice().reverse()
|
||||
return { data: messages.slice(offset, offset + limit), cursor: offset + limit < messages.length ? { next: String(offset + limit) } : {} }
|
||||
} },
|
||||
|
|
|
|||
|
|
@ -2245,8 +2245,7 @@ test("a First jump during a pending send still lands on the first message", asyn
|
|||
await frames(page, 120)
|
||||
await page.locator(".message-scroll-controls").hover()
|
||||
await page.getByRole("button", { name: "Scroll to first message", exact: true }).click()
|
||||
// The fixture serves this window from its newest records; what matters is that
|
||||
// the loaded window replaces the latest one and is then entered at its top.
|
||||
// The oldest window replaces the latest one and is then entered at its top.
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().window.kind !== "latest")
|
||||
await frames(page, 60)
|
||||
const state = await page.evaluate(() => {
|
||||
|
|
@ -2259,6 +2258,72 @@ test("a First jump during a pending send still lands on the first message", asyn
|
|||
}, { history: 620 })
|
||||
})
|
||||
|
||||
// Right after a window replacement Virtua can apply a size compensation computed
|
||||
// against its previous offset, after the jump's write (long real transcripts):
|
||||
// an immediate jump to the top holds its landing while the rows settle.
|
||||
test("an immediate jump to the top holds its landing against a late compensation", async () => {
|
||||
await open(async page => {
|
||||
await page.evaluate(() => (window as any).fixture.bottom())
|
||||
await frames(page)
|
||||
await page.evaluate(() => new Promise<void>(resolve => {
|
||||
;(window as any).fixture.top()
|
||||
requestAnimationFrame(() => {
|
||||
// The stale compensation lands one frame after the jump's write.
|
||||
document.querySelector<HTMLElement>(".message-stream")!.scrollTop = 5000
|
||||
resolve()
|
||||
})
|
||||
}))
|
||||
await frames(page, 10)
|
||||
assert.equal(await page.locator(".message-stream").evaluate(el => el.scrollTop), 0)
|
||||
}, { name: "list" })
|
||||
})
|
||||
|
||||
// The top landing reveals nothing: moving focus within the first message (Tab to
|
||||
// one of its controls) scrolls to that control and ends the hold.
|
||||
test("focus moving within the first row ends the top hold", async () => {
|
||||
await open(async page => {
|
||||
await page.evaluate(() => (window as any).fixture.resizeRow("row-0", 2400))
|
||||
await page.evaluate(() => (window as any).fixture.bottom())
|
||||
await frames(page)
|
||||
await page.evaluate(() => (window as any).fixture.top())
|
||||
await frames(page, 2)
|
||||
await page.evaluate(() => {
|
||||
const content = document.querySelector<HTMLElement>('[data-virtual-follow-key="row-0"]')!.firstElementChild as HTMLElement
|
||||
content.style.position = "relative"
|
||||
const button = document.createElement("button")
|
||||
button.textContent = "Inside the first row"
|
||||
button.style.cssText = "position:absolute;top:2000px"
|
||||
content.append(button)
|
||||
button.focus()
|
||||
})
|
||||
await frames(page, 40)
|
||||
const state = await page.evaluate(() => {
|
||||
const button = document.activeElement as HTMLElement, el = document.querySelector<HTMLElement>(".message-stream")!
|
||||
const rect = button.getBoundingClientRect(), bounds = el.getBoundingClientRect()
|
||||
return { scrollTop: el.scrollTop, visible: rect.top >= bounds.top && rect.bottom <= bounds.bottom }
|
||||
})
|
||||
assert.ok(state.scrollTop > 1000 && state.visible, JSON.stringify(state))
|
||||
}, { name: "list" })
|
||||
})
|
||||
|
||||
// Below two pages the oldest and latest windows share messages: one First jump
|
||||
// from the bottom must still land on the first message, not on the bottom of
|
||||
// the oldest window where the shared rows sit.
|
||||
test("a First jump into an overlapping oldest window lands on the first message", async () => {
|
||||
await open(async page => {
|
||||
await page.locator(".message-scroll-controls").hover()
|
||||
await page.getByRole("button", { name: "Scroll to first message", exact: true }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().window.kind !== "latest")
|
||||
await frames(page, 60)
|
||||
const state = await page.evaluate(() => {
|
||||
const el = document.querySelector<HTMLElement>(".message-stream")!, bounds = el.getBoundingClientRect(), key = (window as any).fixture.snapshot().ids[0]
|
||||
const first = el.querySelector(`[data-virtual-follow-key="${key}"]`)
|
||||
return { key, scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, firstTop: first ? Math.round(first.getBoundingClientRect().top - bounds.top) : null }
|
||||
})
|
||||
assert.ok(state.scrollTop <= 2 && state.firstTop !== null && Math.abs(state.firstTop) <= 2, JSON.stringify(state))
|
||||
}, { history: 205 })
|
||||
})
|
||||
|
||||
const AT_BOTTOM = { scrollTop: 0, atBottom: true, followModeType: "following" }
|
||||
|
||||
test("an at-bottom restoration that loses follow without a gesture reports cancellation and stops restoring", async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue