mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 00:23:33 +00:00
fix(app): paint cached session tabs at the latest message
This commit is contained in:
parent
77848d3a55
commit
7a45ff89f9
3 changed files with 145 additions and 6 deletions
|
|
@ -104,6 +104,93 @@ test.describe("smoke: session timeline", () => {
|
|||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("paints cached session tabs at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
})
|
||||
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 page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
const ids = new Set(destination)
|
||||
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
|
||||
let running = true
|
||||
const sample = () => {
|
||||
if (!running) return
|
||||
setTimeout(() => {
|
||||
if (!running) return
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (root) {
|
||||
const view = root.getBoundingClientRect()
|
||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.map((element) => element.dataset.messageId!)
|
||||
.filter((id) => ids.has(id))
|
||||
const bottom = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
;(window as Window & { __sessionTabPaint?: { samples: typeof samples; stop: () => void } }).__sessionTabPaint = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ destination, last },
|
||||
)
|
||||
|
||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await page.waitForFunction(() =>
|
||||
(window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }).__sessionTabPaint?.samples.some(
|
||||
(sample) => sample.ids.length > 0,
|
||||
),
|
||||
)
|
||||
const first = await page.evaluate(() => {
|
||||
const probe = (window as Window & {
|
||||
__sessionTabPaint?: { samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>; stop: () => void }
|
||||
}).__sessionTabPaint!
|
||||
probe.stop()
|
||||
return probe.samples.find((sample) => sample.ids.length > 0)
|
||||
})
|
||||
expect(first?.last).toBe(true)
|
||||
expect(Math.abs(first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
|
|
@ -501,6 +588,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
|||
await expectSessionTitle(page, expectedTitle)
|
||||
}
|
||||
|
||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
async function expectSessionReady(page: Page) {
|
||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1165,6 +1165,16 @@ export default function Page() {
|
|||
working: () => true,
|
||||
overflowAnchor: "none",
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id, previous) => {
|
||||
if (!id || !previous || id === previous) return
|
||||
if (location.hash || store.messageId || ui.pendingMessage) return
|
||||
autoScroll.resume()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
let scrollStateTarget: HTMLDivElement | undefined
|
||||
|
|
@ -1763,8 +1773,9 @@ export default function Page() {
|
|||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
|
|
@ -1799,7 +1810,8 @@ export default function Page() {
|
|||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { createStore, produce } from "solid-js/store"
|
|||
import { Dynamic } from "solid-js/web"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll } from "@tanstack/solid-virtual"
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
|
|
@ -82,6 +82,10 @@ type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
|
|||
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
||||
|
||||
const timelineFallbackItemSize = 60
|
||||
const timelineCache = new Map<
|
||||
string,
|
||||
{ measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }
|
||||
>()
|
||||
|
||||
function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
|
|
@ -274,6 +278,8 @@ export function MessageTimeline(props: {
|
|||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const ownerSessionKey = sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const platform = usePlatform()
|
||||
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
|
|
@ -467,13 +473,20 @@ export function MessageTimeline(props: {
|
|||
const rows = messageRowMemos().flatMap((memo) => memo())
|
||||
return reuseTimelineRows(previous, rows)
|
||||
})
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const initialMeasurements = cached?.measurements
|
||||
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length ? 6 : 50)
|
||||
const prepareScrollOverscan = () => {
|
||||
if (renderOverscan() < 50) setRenderOverscan(50)
|
||||
}
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return timelineRows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
// Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height.
|
||||
|
|
@ -497,7 +510,8 @@ export function MessageTimeline(props: {
|
|||
rangeExtractor: (range) => {
|
||||
const id = activeMessageID()
|
||||
const active = id ? timelineRows().findLastIndex((row) => "userMessageID" in row && row.userMessageID === id) : -1
|
||||
return [...new Set([...defaultRangeExtractor(range), ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
|
||||
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
|
||||
return [...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
|
||||
|
|
@ -534,6 +548,18 @@ export function MessageTimeline(props: {
|
|||
props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const expand = () => {
|
||||
const next = Math.min(50, renderOverscan() + 8)
|
||||
setRenderOverscan(next)
|
||||
if (next < 50) requestAnimationFrame(() => setTimeout(expand, 0))
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
if (renderOverscan() < 50) setTimeout(expand, 0)
|
||||
})
|
||||
})
|
||||
|
||||
let bottomAnchorSessionKey = ""
|
||||
let bottomAnchorFrame: number | undefined
|
||||
|
||||
|
|
@ -565,6 +591,9 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
|
||||
onCleanup(() => {
|
||||
timelineCache.delete(ownerSessionKey)
|
||||
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
props.setRevealMessage?.(() => {})
|
||||
props.setScrollToEnd?.(() => {})
|
||||
|
|
@ -605,6 +634,7 @@ export function MessageTimeline(props: {
|
|||
|
||||
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
prepareScrollOverscan()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
const root = event.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
|
|
@ -617,6 +647,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
prepareScrollOverscan()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
touchGesture = event.touches[0]?.clientY
|
||||
}
|
||||
|
|
@ -643,6 +674,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
prepareScrollOverscan()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
if (event.target !== event.currentTarget) return
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue