fix(app): stop hash targets that can never load and wait for the timeline before applying them (#61913)

This commit is contained in:
Luke Parker
2026-11-04 22:02:39 -06:00
committed by GitHub
Unverified
parent b6f3294e9d
commit 24cddd1659
9 changed files with 680 additions and 249 deletions
@@ -8,10 +8,12 @@ import {
historyMessages,
messageUpdated,
partUpdated,
session,
sessionID,
setupTimeline,
status,
textPart,
userID,
userMessage,
validateTimelineMessages,
} from "../utils/timeline"
@@ -528,7 +530,54 @@ test.describe("timeline history", () => {
await page.getByRole("button", { name: "Jump to latest" }).click()
await expect.poll(hash).toBe("")
await expect(page.getByText("Latest answer", { exact: true })).toBeInViewport()
const latest = page.getByText("Latest answer", { exact: true })
await expect(latest).toBeInViewport()
// Browser Back off a page anchor returns to the latest message, as Back off a prompt does.
await page.evaluate(() => (location.hash = "#notes"))
await expect.poll(hash).toBe("#notes")
await timelineScroller(page).hover()
await page.mouse.wheel(0, -5_000)
await expect(latest).not.toBeInViewport()
await page.evaluate(() => history.back())
await expect.poll(hash).toBe("")
await expect(latest).toBeInViewport()
})
test("a hash for a prompt the revert hides, or one that does not exist, clears without paging history", async ({
page,
}) => {
const messages = validateTimelineMessages([
...historyMessages(30),
userMessage(),
assistantMessage([textPart("prt_hash_missing_tail", "Reverted answer")]),
])
const older: string[] = []
const hash = () => page.evaluate(() => location.hash)
await setupTimeline(page, {
sessions: [session({ revert: { messageID: userID } })],
message: (_, id) => messages.find((message) => message.id === id),
pageMessages: (_, limit, before) => {
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
},
onMessages: (request) => {
if (request.before && request.phase === "start") older.push(request.before)
},
})
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const loaded = [...older]
// The revert hides this prompt, and older pages hold only older messages, so neither link can ever load.
await page.evaluate((id) => (location.hash = `#message-${id}`), userID)
await expect.poll(hash).toBe("")
await page.evaluate(() => (location.hash = "#message-msg_9999_missing"))
await expect.poll(hash).toBe("")
expect(older).toEqual(loaded)
})
})
+5 -5
View File
@@ -126,7 +126,11 @@ function SessionScreenContent(props: {
widths: useLayout().panelWidth,
})
const timeline = createSessionTimelineInteraction(session)
const mobileView = attachment.mobile.current
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
const timeline = createSessionTimelineInteraction(session, conversationVisible)
const timelineSearch = createTimelineSearchController({
sessionID: session.identity.sessionID,
@@ -218,16 +222,12 @@ function SessionScreenContent(props: {
if (screen.side.open()) setStore("sideMounted", true)
})
const mobileView = attachment.mobile.current
const mobileEntry = createMemo(() => {
const key = mobileView()
return key === "session" ? undefined : mobile.find(key)
})
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
// Narrow-screen views that ask to stay mounted once shown, e.g. a terminal that keeps its shell's screen.
createEffect(() => {
const entry = mobileEntry()
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import { messageIdFromHash } from "../message-id-from-hash"
import {
CLEAR_CURSOR_EVENT,
CLEAR_WITHOUT_HASH_EVENT,
createTimelineCursorState,
CURSOR_SEEK_LIMIT,
cursorMessageID,
@@ -13,21 +12,59 @@ import {
SCROLL_MOVED_EVENT,
SESSION_CHANGED_EVENT,
transitionTimelineCursor,
VIEW_READY_EVENT,
VIEW_UNREADY_EVENT,
type TimelineCursorEvent,
type TimelineCursorState,
} from "./cursor-machine"
const jump = (messageID: string): TimelineCursorEvent => ({ type: "message.jump", messageID, behavior: "auto" })
// Events are built the way the driver builds them from `location.hash`, without its `#`.
const changed = (hash: string, viewReady = true): TimelineCursorEvent => ({
type: "hash.changed",
hash: hash.slice(1),
viewReady,
})
// Built the way the driver builds it from `location.hash`.
const hash = (value: string, initial = false, reveal = false): TimelineCursorEvent => ({
const applied = (state: TimelineCursorState, hash: string, reveal = false): TimelineCursorEvent => ({
type: "hash.applied",
hash: value.slice(1),
messageID: messageIdFromHash(value.slice(1)),
initial,
attempt: state.attempt,
hash: hash.slice(1),
reveal,
})
const jump = (messageID: string, hash = "", viewReady = true): TimelineCursorEvent => ({
type: "message.jump",
messageID,
behavior: "auto",
hash: hash.slice(1),
viewReady,
})
const clear = (hash: string): TimelineCursorEvent =>
hash ? { type: "clear", hash: hash.slice(1) } : CLEAR_WITHOUT_HASH_EVENT
type TargetSynced = Extract<TimelineCursorEvent, { type: "target.synced" }>
const synced = (
state: TimelineCursorState,
input: Partial<Omit<TargetSynced, "type" | "messageID">> = {},
): TargetSynced => {
const messageID = pendingCursorMessageID(state)!
return {
type: "target.synced",
messageID,
loaded: false,
viewReady: true,
syncing: false,
revertMessageID: undefined,
oldestMessageID: "msg_0040",
history: "seekable",
hash: `message-${messageID}`,
...input,
}
}
function replay(events: TimelineCursorEvent[], from = createTimelineCursorState()) {
return events.reduce((state, event) => transitionTimelineCursor(state, event).state, from)
}
@@ -39,25 +76,194 @@ function expectIgnored(state: TimelineCursorState, event: TimelineCursorEvent) {
expect(next.effects).toEqual([])
}
// The session opened on `hash` once its timeline is on screen.
const open = (hash: string, reveal = false) => {
const scheduled = replay([VIEW_READY_EVENT])
return transitionTimelineCursor(scheduled, applied(scheduled, hash, reveal))
}
// The route hash changed after the session opened.
const navigate = (state: TimelineCursorState, hash: string) => {
const scheduled = replay([changed(hash)], state)
return transitionTimelineCursor(scheduled, applied(scheduled, hash))
}
const idle = () => open("").state
const landed = (state: TimelineCursorState) => replay([{ type: "seek.landed", attempt: state.attempt }], state)
describe("cursor machine", () => {
test("jumping back to the same prompt after scrolling away makes it fresh again", () => {
const scrolled = replay([jump("msg_5"), SCROLL_MOVED_EVENT])
expect(freshCursorMessageID(scrolled)).toBeUndefined()
expect(cursorMessageID(scrolled)).toBe("msg_5")
test("a hash target the revert hides stops at once and returns an opened session to the end", () => {
const pending = open("#message-msg_0050").state
const hidden = transitionTimelineCursor(pending, synced(pending, { revertMessageID: "msg_0048" }))
const again = transitionTimelineCursor(scrolled, jump("msg_5"))
expect(freshCursorMessageID(again.state)).toBe("msg_5")
expect(again.effects).toEqual([
{ type: "follow.detach" },
{ type: "hash.set", messageID: "msg_5" },
{ type: "seek.start", attempt: again.state.attempt, messageID: "msg_5", behavior: "auto" },
expect(hidden.state).toEqual({ status: "none", attempt: pending.attempt + 1, echo: true })
expect(hidden.effects).toEqual([{ type: "follow.end" }, { type: "hash.clear" }])
// The router's repeat of the cleared hash is consumed without scrolling again.
const echoed = transitionTimelineCursor(hidden.state, changed(""))
expect(echoed.state).toEqual({ status: "none", attempt: hidden.state.attempt })
expect(echoed.effects).toEqual([])
})
test("a hash target at or after the oldest loaded message stops once the transcript has synced", () => {
const pending = open("#message-msg_0045").state
expectIgnored(pending, synced(pending, { syncing: true }))
expect(transitionTimelineCursor(pending, synced(pending)).effects).toEqual([
{ type: "follow.end" },
{ type: "hash.clear" },
])
// A later link keeps the reader where they were.
const linked = navigate(idle(), "#message-zzz").state
expect(transitionTimelineCursor(linked, synced(linked)).effects).toEqual([{ type: "hash.clear" }])
})
test("a hash target older than the loaded messages pages until it loads, history runs out or a page fails", () => {
const pending = open("#message-msg_0010").state
const seek = transitionTimelineCursor(pending, synced(pending))
expect(seek.state).toBe(pending)
expect(seek.effects).toEqual([{ type: "history.seek" }])
expectIgnored(pending, synced(pending, { history: "loading" }))
// An older page that passes the target without it ends the search.
expect(transitionTimelineCursor(pending, synced(pending, { oldestMessageID: "msg_0005" })).state.status).toBe(
"none",
)
const exhausted = transitionTimelineCursor(pending, synced(pending, { history: "exhausted" }))
expect(exhausted.state.status).toBe("none")
expect(exhausted.effects).toEqual([{ type: "follow.end" }, { type: "hash.clear" }])
// A failed page keeps the hash for a reload.
const failed = transitionTimelineCursor(pending, synced(pending, { history: "failed" }))
expect(failed.state).toEqual({ status: "none", attempt: pending.attempt + 1 })
expect(failed.effects).toEqual([{ type: "follow.end" }])
// A hash that no longer names the target is left alone.
expect(transitionTimelineCursor(pending, synced(pending, { history: "exhausted", hash: "" })).effects).toEqual([
{ type: "follow.end" },
])
})
test("jumping back onto the prompt the hash already names writes nothing, so a later link still navigates", () => {
const first = transitionTimelineCursor(idle(), jump("msg_5"))
expect(first.effects).toContainEqual({ type: "hash.set", messageID: "msg_5" })
const scrolled = replay([changed("#message-msg_5"), SCROLL_MOVED_EVENT], first.state)
expect(freshCursorMessageID(scrolled)).toBeUndefined()
expect(cursorMessageID(scrolled)).toBe("msg_5")
const again = transitionTimelineCursor(scrolled, jump("msg_5", "#message-msg_5"))
expect(freshCursorMessageID(again.state)).toBe("msg_5")
expect(again.state).toMatchObject({ echo: false })
expect(again.effects).toEqual([
{ type: "follow.detach" },
{ type: "seek.start", attempt: again.state.attempt, messageID: "msg_5", behavior: "auto" },
])
const linked = navigate(replay([SCROLL_MOVED_EVENT], again.state), "#message-msg_5")
expect(pendingCursorMessageID(linked.state)).toBe("msg_5")
})
test("a hash waits for the timeline to be on screen, and a hidden timeline drops the frame", () => {
const hidden = replay([changed("#message-msg_5", false)])
expect(hidden).toEqual(createTimelineCursorState())
const shown = transitionTimelineCursor(hidden, VIEW_READY_EVENT)
expect(shown.state).toEqual({ status: "applying", initial: true, scheduled: true, attempt: 1 })
expect(shown.effects).toEqual([{ type: "apply.schedule", attempt: 1 }])
const away = transitionTimelineCursor(shown.state, VIEW_UNREADY_EVENT)
expect(away.state).toEqual({ status: "applying", initial: true, scheduled: false, attempt: 2 })
expect(away.effects).toEqual([{ type: "apply.cancel" }])
expectIgnored(away.state, applied(shown.state, "#message-msg_5"))
})
test("a loaded target seeks only once the timeline is on screen", () => {
const pending = open("#message-msg_5").state
expectIgnored(pending, synced(pending, { loaded: true, viewReady: false }))
const loaded = transitionTimelineCursor(pending, synced(pending, { loaded: true }))
expect(loaded.state).toEqual({
status: "selected",
messageID: "msg_5",
fresh: true,
attempt: pending.attempt + 1,
seek: { behavior: "auto", retries: 0 },
})
expect(loaded.effects).toEqual([
{ type: "follow.detach" },
{ type: "seek.schedule", attempt: loaded.state.attempt, messageID: "msg_5", behavior: "auto" },
])
})
test("hiding the timeline pauses a seek, and showing it again starts the seek over", () => {
const seeking = replay([jump("msg_5")], idle())
const exhausted = Array.from({ length: CURSOR_SEEK_LIMIT }).reduce<TimelineCursorState>(
(state) => replay([{ type: "seek.missing", attempt: seeking.attempt }], state),
seeking,
)
const paused = transitionTimelineCursor(exhausted, VIEW_UNREADY_EVENT)
expect(paused.state).toMatchObject({ attempt: seeking.attempt + 1, seek: { retries: 0, paused: true } })
expect(paused.effects).toEqual([{ type: "seek.cancel" }])
expectIgnored(paused.state, { type: "seek.missing", attempt: paused.state.attempt })
expectIgnored(paused.state, VIEW_UNREADY_EVENT)
const resumed = transitionTimelineCursor(paused.state, VIEW_READY_EVENT)
expect(resumed.state).toMatchObject({ attempt: paused.state.attempt + 1, seek: { behavior: "auto", retries: 0 } })
expect(resumed.effects).toEqual([
{ type: "seek.schedule", attempt: resumed.state.attempt, messageID: "msg_5", behavior: "auto" },
])
// A jump while the timeline is hidden waits the same way.
const hiddenJump = transitionTimelineCursor(replay([VIEW_UNREADY_EVENT], idle()), jump("msg_3", "", false))
expect(hiddenJump.effects).toEqual([{ type: "follow.detach" }, { type: "hash.set", messageID: "msg_3" }])
expect(hasFreshCursorOrSeek(hiddenJump.state)).toBe(true)
})
test("a session switch, clear or jump drops the apply frame already scheduled", () => {
const scheduled = replay([VIEW_READY_EVENT])
const late = applied(scheduled, "#message-msg_a")
const switched = transitionTimelineCursor(scheduled, SESSION_CHANGED_EVENT)
expect(switched.state).toEqual({ status: "applying", initial: true, scheduled: false, attempt: 2 })
expect(switched.effects).toEqual([{ type: "apply.cancel" }])
expectIgnored(switched.state, late)
const cleared = transitionTimelineCursor(scheduled, clear("#message-msg_a"))
expect(cleared.effects).toEqual([{ type: "apply.cancel" }, { type: "hash.clear" }])
expectIgnored(cleared.state, late)
const jumped = transitionTimelineCursor(scheduled, jump("msg_b", "#message-msg_a"))
expect(jumped.effects[0]).toEqual({ type: "apply.cancel" })
expectIgnored(jumped.state, late)
})
test("Browser Back off an anchor returns to the end, while the cursor's own clear does not scroll again", () => {
const anchored = navigate(idle(), "#section")
expect(anchored.effects).toEqual([{ type: "anchor.scroll", id: "section" }])
const back = navigate(anchored.state, "")
expect(back.state).toMatchObject({ status: "none" })
expect(back.effects).toEqual([{ type: "follow.end" }])
const selected = landed(replay([jump("msg_5"), changed("#message-msg_5")], idle()))
const cleared = transitionTimelineCursor(selected, clear("#message-msg_5"))
expect(cleared.state).toEqual({ status: "none", attempt: selected.attempt + 1, echo: true })
expect(cleared.effects).toEqual([{ type: "hash.clear" }])
expect(transitionTimelineCursor(cleared.state, changed("")).effects).toEqual([])
// Back off a prompt returns to the end too.
expect(navigate(selected, "").effects).toEqual([{ type: "follow.end" }])
})
test("a Find or tool reveal makes the cursor stale and cancels its seek", () => {
const seeking = replay([jump("msg_10")])
const seeking = replay([jump("msg_10")], idle())
const moved = transitionTimelineCursor(seeking, SCROLL_MOVED_EVENT)
expect(moved.state).toEqual({
@@ -73,82 +279,73 @@ describe("cursor machine", () => {
})
test("undo or redo while pinned does not leave a cursor behind", () => {
expectIgnored(createTimelineCursorState(), { type: "message.select", messageID: "msg_2", following: true })
expectIgnored(idle(), { type: "message.select", messageID: "msg_2", following: true })
expectIgnored(createTimelineCursorState(), { type: "message.select", messageID: undefined, following: false })
const selected = replay([{ type: "message.select", messageID: "msg_2", following: false }])
const selected = replay([{ type: "message.select", messageID: "msg_2", following: false }], idle())
expect(freshCursorMessageID(selected)).toBe("msg_2")
expect(replay([{ type: "message.select", messageID: "msg_3", following: true }], selected).status).toBe("none")
})
test("clearing its own hash does not scroll again, while Browser Back off a prompt returns to the end", () => {
const selected = landed(replay([jump("msg_5")]))
const cleared = transitionTimelineCursor(selected, CLEAR_CURSOR_EVENT)
expect(cleared.state.status).toBe("none")
expect(cleared.effects).toEqual([{ type: "hash.clear" }])
// The router echoes the cleared hash; a smooth return to the end must keep its course.
expectIgnored(cleared.state, hash(""))
const back = transitionTimelineCursor(selected, hash(""))
expect(back.state.status).toBe("none")
expect(back.effects).toEqual([{ type: "follow.end" }])
})
test("the router's echo of the cursor's own hash does not seek again, once", () => {
const seeking = replay([jump("msg_5")])
const seeking = replay([jump("msg_5")], idle())
for (const state of [seeking, replay([SCROLL_MOVED_EVENT], seeking)]) {
const echoed = transitionTimelineCursor(state, hash("#message-msg_5"))
const echoed = transitionTimelineCursor(state, changed("#message-msg_5"))
expect(echoed.state).toMatchObject({ status: "selected", messageID: "msg_5", echo: false })
expect(echoed.state.attempt).toBe(state.attempt)
expect(echoed.effects).toEqual([])
// Only the write echoes; the same hash applied again is a real navigation.
expect(transitionTimelineCursor(echoed.state, hash("#message-msg_5")).state.status).toBe("pending")
expect(navigate(echoed.state, "#message-msg_5").state.status).toBe("pending")
}
})
test("a link to the prompt undo, redo or rewind selected still navigates to it", () => {
const undone = replay([{ type: "message.select", messageID: "msg_3", following: false }])
const linked = transitionTimelineCursor(undone, hash("#message-msg_3"))
const undone = replay([{ type: "message.select", messageID: "msg_3", following: false }], idle())
const linked = navigate(undone, "#message-msg_3")
expect(pendingCursorMessageID(linked.state)).toBe("msg_3")
expect(linked.effects).toEqual([{ type: "follow.detach" }])
const loaded = transitionTimelineCursor(linked.state, { type: "target.loaded", messageID: "msg_3" })
expect(loaded.effects).toEqual([
{ type: "follow.detach" },
{ type: "seek.schedule", attempt: loaded.state.attempt, messageID: "msg_3", behavior: "auto" },
])
expectIgnored(linked.state, changed("#message-msg_3"))
})
test("a hash target waits until its prompt loads, then seeks to it", () => {
const pending = transitionTimelineCursor(createTimelineCursorState(), hash("#message-msg_1", true))
test("a hash target ignores reconciliation for another target", () => {
const pending = open("#message-msg_1").state
expect(pendingCursorMessageID(pending.state)).toBe("msg_1")
expect(pending.effects).toEqual([{ type: "follow.detach" }])
expectIgnored(pending.state, { type: "target.loaded", messageID: "msg_2" })
const loaded = transitionTimelineCursor(pending.state, { type: "target.loaded", messageID: "msg_1" })
expect(freshCursorMessageID(loaded.state)).toBe("msg_1")
expect(loaded.effects).toEqual([
{ type: "follow.detach" },
{ type: "seek.schedule", attempt: loaded.state.attempt, messageID: "msg_1", behavior: "auto" },
])
expect(pendingCursorMessageID(pending)).toBe("msg_1")
expectIgnored(pending, { ...synced(pending, { loaded: true }), messageID: "msg_2" })
expectIgnored(idle(), synced(pending, { loaded: true }))
})
test("a session switch drops the previous session's target and seek", () => {
const pending = replay([hash("#message-msg_a", true)])
expect(replay([SESSION_CHANGED_EVENT], pending)).toEqual({ status: "none", attempt: pending.attempt + 1 })
const pending = open("#message-msg_a").state
expect(replay([SESSION_CHANGED_EVENT], pending)).toEqual({
status: "applying",
initial: true,
scheduled: false,
attempt: pending.attempt + 1,
})
expectIgnored(createTimelineCursorState(), SESSION_CHANGED_EVENT)
const seeking = replay([jump("msg_a")])
const seeking = replay([jump("msg_a")], idle())
const switched = transitionTimelineCursor(seeking, SESSION_CHANGED_EVENT)
expect(switched.effects).toEqual([{ type: "seek.cancel" }])
expectIgnored(switched.state, { type: "seek.missing", attempt: seeking.attempt })
})
test("a hash change while switching sessions stays an initial apply", () => {
const scheduled = replay([changed("#message-msg_a")], replay([VIEW_READY_EVENT]))
expect(scheduled).toMatchObject({ status: "applying", initial: true, scheduled: true })
expect(transitionTimelineCursor(scheduled, applied(scheduled, "#message-msg_a")).state).toMatchObject({
status: "pending",
endOnMiss: true,
})
})
test("a seek retries for a bounded number of frames", () => {
const seeking = replay([jump("msg_5")])
const seeking = replay([jump("msg_5")], idle())
const missing: TimelineCursorEvent = { type: "seek.missing", attempt: seeking.attempt }
const retried = Array.from({ length: CURSOR_SEEK_LIMIT }).reduce<TimelineCursorState>((state) => {
@@ -174,28 +371,28 @@ describe("cursor machine", () => {
})
test("the first apply of an empty hash goes to the end unless the route reveals a tool", () => {
expect(transitionTimelineCursor(createTimelineCursorState(), hash("", true)).effects).toEqual([
{ type: "follow.end" },
])
expectIgnored(createTimelineCursorState(), hash("", true, true))
expect(transitionTimelineCursor(createTimelineCursorState(), hash("#section")).effects).toEqual([
{ type: "anchor.scroll", id: "section" },
])
expect(open("").effects).toEqual([{ type: "follow.end" }])
expect(open("", true).effects).toEqual([])
expect(open("#message-msg_1", true).state).toMatchObject({ status: "pending", endOnMiss: false })
})
test("a newer prompt clears the selection but keeps a pending target", () => {
expect(replay([jump("msg_1"), LATEST_ADVANCED_EVENT]).status).toBe("none")
expectIgnored(replay([hash("#message-msg_1")]), LATEST_ADVANCED_EVENT)
expect(replay([jump("msg_1"), LATEST_ADVANCED_EVENT], idle()).status).toBe("none")
expectIgnored(open("#message-msg_1").state, LATEST_ADVANCED_EVENT)
})
test("steady scrolling and disposal of an idle cursor keep the same state", () => {
const stale = replay([jump("msg_1"), SCROLL_MOVED_EVENT])
const stale = replay([jump("msg_1"), SCROLL_MOVED_EVENT], idle())
expectIgnored(createTimelineCursorState(), SCROLL_MOVED_EVENT)
expectIgnored(idle(), SCROLL_MOVED_EVENT)
expectIgnored(stale, SCROLL_MOVED_EVENT)
expectIgnored(createTimelineCursorState(), DISPOSED_CURSOR_EVENT)
expect(transitionTimelineCursor(replay([jump("msg_1")]), DISPOSED_CURSOR_EVENT).effects).toEqual([
expectIgnored(idle(), DISPOSED_CURSOR_EVENT)
expectIgnored(idle(), CLEAR_WITHOUT_HASH_EVENT)
expect(transitionTimelineCursor(replay([jump("msg_1")], idle()), DISPOSED_CURSOR_EVENT).effects).toEqual([
{ type: "seek.cancel" },
])
expect(transitionTimelineCursor(replay([VIEW_READY_EVENT]), DISPOSED_CURSOR_EVENT).effects).toEqual([
{ type: "apply.cancel" },
])
})
})
@@ -1,16 +1,40 @@
// Frames a seek waits for the target prompt's virtualized row to mount.
export const CURSOR_SEEK_LIMIT = 4
import { messageIdFromHash } from "../message-id-from-hash"
import type { HistorySeekStatus } from "./history-machine"
// Frames a seek waits for the target prompt's virtualized row to mount once the timeline is on screen.
export const CURSOR_SEEK_LIMIT = 12
export type CursorSeek = {
readonly behavior: ScrollBehavior
readonly retries: number
/** The timeline is not on screen, so no frame probes it; showing it again starts the seek over. */
readonly paused?: boolean
}
// `attempt` advances on every change of target or seek, so a seek result from an earlier one is ignored.
// `attempt` advances on every change of target, apply or seek, so a frame or result from an earlier one is ignored.
export type TimelineCursorState =
| { readonly status: "none"; readonly attempt: number }
/** A `#message-<id>` target that is not loaded yet; history pages toward it. */
| { readonly status: "pending"; readonly messageID: string; readonly attempt: number }
/**
* The route hash waits to apply: until the session's messages and the timeline are on screen, then for one frame
* (`scheduled`) while the virtualizer registers its navigation. `initial`: the hash opened the session.
*/
| {
readonly status: "applying"
readonly initial: boolean
readonly scheduled: boolean
readonly attempt: number
}
/** No prompt is chosen. `echo` after the cursor cleared the hash, until the router repeats the empty hash once. */
| { readonly status: "none"; readonly attempt: number; readonly echo?: boolean }
/**
* A `#message-<id>` target that is not loaded yet; history pages toward it while it can still load. `endOnMiss`: a
* target that never loads returns the view to the end, because the hash opened the session.
*/
| {
readonly status: "pending"
readonly messageID: string
readonly endOnMiss: boolean
readonly attempt: number
}
/**
* The active prompt. `fresh` until the reader scrolls, after which navigation steps from the prompt in view instead.
* `seek` while the view is still bringing the prompt in. `echo` after a jump wrote the prompt's hash, until the router
@@ -27,32 +51,51 @@ export type TimelineCursorState =
export type TimelineCursorEvent =
| { readonly type: "session.changed" }
/**
* The route hash, applied once the session's messages are ready (`initial`) and on every later change. `messageID` is
* the `#message-<id>` target. `reveal`: the route opens the session on a tool call instead.
*/
/** The session's messages and the timeline are on screen (`view.ready`), or no longer are. */
| { readonly type: "view.ready" }
| { readonly type: "view.unready" }
/** The route hash changed. `hash` is `location.hash` without its `#`. */
| { readonly type: "hash.changed"; readonly hash: string; readonly viewReady: boolean }
/** The frame an apply waited for. `reveal`: the route opens the session on a tool call instead. */
| { readonly type: "hash.applied"; readonly attempt: number; readonly hash: string; readonly reveal: boolean }
/** What decides whether a pending target can still load: the transcript, the revert boundary and history paging. */
| {
readonly type: "hash.applied"
readonly type: "target.synced"
readonly messageID: string
readonly loaded: boolean
readonly viewReady: boolean
/** The transcript's first sync is still under way, so the loaded messages may not be the newest page yet. */
readonly syncing: boolean
readonly revertMessageID: string | undefined
readonly oldestMessageID: string | undefined
readonly history: HistorySeekStatus
readonly hash: string
readonly messageID: string | undefined
readonly initial: boolean
readonly reveal: boolean
}
| { readonly type: "target.loaded"; readonly messageID: string }
/** Previous or next message. */
| { readonly type: "message.jump"; readonly messageID: string; readonly behavior: ScrollBehavior }
/** Previous or next message. `hash` is the route hash before the jump. */
| {
readonly type: "message.jump"
readonly messageID: string
readonly behavior: ScrollBehavior
readonly hash: string
readonly viewReady: boolean
}
/** Undo, redo and rewind name the prompt navigation continues from. */
| { readonly type: "message.select"; readonly messageID: string | undefined; readonly following: boolean }
/** The reader scrolled the timeline, or Find or a tool reveal moved it. */
| { readonly type: "scroll.moved" }
/** Following resumed, or the reader jumped to the latest message. */
| { readonly type: "clear" }
/** Following resumed, or the reader jumped to the latest message. `hash` is the route hash. */
| { readonly type: "clear"; readonly hash: string }
| { readonly type: "latest.advanced" }
| { readonly type: "seek.landed"; readonly attempt: number }
| { readonly type: "seek.missing"; readonly attempt: number }
| { readonly type: "disposed" }
export type TimelineCursorEffect =
/** Apply the route hash on the next frame. */
| { readonly type: "apply.schedule"; readonly attempt: number }
| { readonly type: "apply.cancel" }
/** Load the next older page toward a pending target. */
| { readonly type: "history.seek" }
| { readonly type: "follow.detach" }
/** Follow the newest content again and scroll to it. */
| { readonly type: "follow.end" }
@@ -80,23 +123,33 @@ export type TimelineCursorTransition = {
readonly effects: readonly TimelineCursorEffect[]
}
type Pending = Extract<TimelineCursorState, { readonly status: "pending" }>
type Selected = Extract<TimelineCursorState, { readonly status: "selected" }>
// Shared events for the frequent dispatches, so scrolling allocates nothing.
export const SCROLL_MOVED_EVENT: TimelineCursorEvent = Object.freeze({ type: "scroll.moved" })
export const CLEAR_CURSOR_EVENT: TimelineCursorEvent = Object.freeze({ type: "clear" })
export const CLEAR_WITHOUT_HASH_EVENT: TimelineCursorEvent = Object.freeze({ type: "clear", hash: "" })
export const LATEST_ADVANCED_EVENT: TimelineCursorEvent = Object.freeze({ type: "latest.advanced" })
export const SESSION_CHANGED_EVENT: TimelineCursorEvent = Object.freeze({ type: "session.changed" })
export const VIEW_READY_EVENT: TimelineCursorEvent = Object.freeze({ type: "view.ready" })
export const VIEW_UNREADY_EVENT: TimelineCursorEvent = Object.freeze({ type: "view.unready" })
export const DISPOSED_CURSOR_EVENT: TimelineCursorEvent = Object.freeze({ type: "disposed" })
const NO_EFFECTS: readonly TimelineCursorEffect[] = Object.freeze([])
const SEEK_CANCEL: readonly TimelineCursorEffect[] = Object.freeze([{ type: "seek.cancel" }])
const APPLY_CANCEL: readonly TimelineCursorEffect[] = Object.freeze([{ type: "apply.cancel" }])
const HISTORY_SEEK: readonly TimelineCursorEffect[] = Object.freeze([{ type: "history.seek" }])
const FOLLOW_END: TimelineCursorEffect = Object.freeze({ type: "follow.end" })
const FOLLOW_DETACH: TimelineCursorEffect = Object.freeze({ type: "follow.detach" })
@@ -104,43 +157,98 @@ const FOLLOW_DETACH: TimelineCursorEffect = Object.freeze({ type: "follow.detach
const HASH_CLEAR: TimelineCursorEffect = Object.freeze({ type: "hash.clear" })
export function createTimelineCursorState(): TimelineCursorState {
return { status: "none", attempt: 0 }
return { status: "applying", initial: true, scheduled: false, attempt: 0 }
}
export function transitionTimelineCursor(
state: TimelineCursorState,
event: TimelineCursorEvent,
): TimelineCursorTransition {
if (event.type === "session.changed" || event.type === "disposed") return clear(state)
if (event.type === "session.changed") {
if (state.status === "applying" && state.initial && !state.scheduled) return unchanged(state)
return changed(
{ status: "applying", initial: true, scheduled: false, attempt: state.attempt + 1 },
cancelWork(state),
)
}
if (event.type === "disposed") {
if (state.status === "none") return unchanged(state)
return changed({ status: "none", attempt: state.attempt + 1 }, cancelWork(state))
}
if (event.type === "view.ready") {
if (state.status === "applying" && !state.scheduled) return apply(state, state.initial, true)
if (state.status !== "selected" || !state.seek?.paused) return unchanged(state)
const attempt = state.attempt + 1
const behavior = state.seek.behavior
return changed({ ...state, attempt, seek: { behavior, retries: 0 } }, [
{ type: "seek.schedule", attempt, messageID: state.messageID, behavior },
])
}
if (event.type === "view.unready") {
if (state.status === "applying" && state.scheduled)
return changed({ ...state, scheduled: false, attempt: state.attempt + 1 }, APPLY_CANCEL)
if (state.status !== "selected" || !state.seek || state.seek.paused) return unchanged(state)
return changed(
{ ...state, attempt: state.attempt + 1, seek: { behavior: state.seek.behavior, retries: 0, paused: true } },
SEEK_CANCEL,
)
}
if (event.type === "hash.changed") return hashChanged(state, event)
if (event.type === "hash.applied") return hashApplied(state, event)
if (event.type === "target.loaded") {
if (state.status !== "pending" || state.messageID !== event.messageID) return unchanged(state)
if (event.type === "target.synced") return targetSynced(state, event)
// The route already carries this hash, so nothing echoes.
return seek(state, event.messageID, "auto", [FOLLOW_DETACH], { now: false, echo: false })
}
if (event.type === "message.jump") {
// Jumping back onto the prompt the route already names writes nothing, so nothing echoes.
const write = messageIdFromHash(event.hash) !== event.messageID
const attempt = state.attempt + 1
const seek = { type: "seek.start", attempt, messageID: event.messageID, behavior: event.behavior } as const
if (event.type === "message.jump")
return seek(
state,
event.messageID,
event.behavior,
[...cancelSeek(state), FOLLOW_DETACH, { type: "hash.set", messageID: event.messageID }],
{ now: true, echo: true },
return changed(
{
status: "selected",
messageID: event.messageID,
fresh: true,
attempt,
seek: event.viewReady
? { behavior: event.behavior, retries: 0 }
: { behavior: event.behavior, retries: 0, paused: true },
echo: write,
},
[
...cancelWork(state),
FOLLOW_DETACH,
...(write ? [{ type: "hash.set", messageID: event.messageID } as const] : NO_EFFECTS),
...(event.viewReady ? [seek] : NO_EFFECTS),
],
)
}
if (event.type === "message.select") {
// A pinned view shows the newest prompt; a remembered one would only send navigation elsewhere.
if (!event.messageID || event.following) return clear(state)
if (!event.messageID || event.following) {
if (state.status === "applying" || state.status === "none") return unchanged(state)
return changed({ status: "none", attempt: state.attempt + 1 }, cancelWork(state))
}
if (state.status === "selected" && state.messageID === event.messageID && state.fresh && !state.seek)
return unchanged(state)
return changed(
{ status: "selected", messageID: event.messageID, fresh: true, attempt: state.attempt + 1 },
cancelSeek(state),
cancelWork(state),
)
}
@@ -149,19 +257,28 @@ export function transitionTimelineCursor(
return changed(
{ status: "selected", messageID: state.messageID, fresh: false, attempt: state.attempt + 1, echo: state.echo },
cancelSeek(state),
cancelWork(state),
)
}
if (event.type === "clear") {
const next = clear(state)
if (state.status === "none" && !event.hash) return unchanged(state)
return changed(next.state, [...next.effects, HASH_CLEAR])
// The router repeats the cleared hash once; ignoring it keeps a smooth return to the end on course.
if (event.hash)
return changed({ status: "none", attempt: state.attempt + 1, echo: true }, [...cancelWork(state), HASH_CLEAR])
return changed({ status: "none", attempt: state.attempt + 1 }, cancelWork(state))
}
if (event.type === "latest.advanced") return state.status === "selected" ? clear(state) : unchanged(state)
if (event.type === "latest.advanced") {
if (state.status !== "selected") return unchanged(state)
if (state.status !== "selected" || !state.seek || state.attempt !== event.attempt) return unchanged(state)
return changed({ status: "none", attempt: state.attempt + 1 }, cancelWork(state))
}
if (state.status !== "selected" || !state.seek || state.seek.paused || state.attempt !== event.attempt)
return unchanged(state)
if (event.type === "seek.landed" || state.seek.retries >= CURSOR_SEEK_LIMIT) return changed(settled(state))
@@ -180,61 +297,109 @@ export function cursorMessageID(state: TimelineCursorState) {
return state.status === "selected" ? state.messageID : undefined
}
/** A target history pages toward until it loads. */
/** A target history pages toward until it loads or can no longer load. */
export function pendingCursorMessageID(state: TimelineCursorState) {
return state.status === "pending" ? state.messageID : undefined
}
/** Only a fresh cursor or a running seek cares about the reader's scroll, so steady scrolling skips the rest. */
export function hasFreshCursorOrSeek(state: TimelineCursorState) {
return state.status === "selected" && (state.fresh || !!state.seek)
return state.status === "selected" && (state.fresh || (!!state.seek && !state.seek.paused))
}
function hashChanged(
state: TimelineCursorState,
event: Extract<TimelineCursorEvent, { type: "hash.changed" }>,
): TimelineCursorTransition {
const messageID = messageIdFromHash(event.hash)
// The router's repeat of a hash the cursor wrote or cleared, consumed once: a later link to the same place still
// navigates.
if (state.status === "selected" && state.echo && messageID === state.messageID)
return changed({ ...state, echo: false })
if (state.status === "none" && state.echo && !event.hash) return changed({ status: "none", attempt: state.attempt })
if (state.status === "pending" && messageID === state.messageID) return unchanged(state)
if (state.status === "applying" && !state.scheduled && !event.viewReady) return unchanged(state)
// A session switch that also changes the hash stays an initial apply.
return apply(state, state.status === "applying" && state.initial, event.viewReady)
}
function hashApplied(
state: TimelineCursorState,
event: Extract<TimelineCursorEvent, { type: "hash.applied" }>,
): TimelineCursorTransition {
if (event.messageID) {
// The hash this cursor wrote, repeated by the router: consumed once, so a later link to the prompt still navigates.
if (state.status === "selected" && state.echo && state.messageID === event.messageID)
return changed({ ...state, echo: false })
if (state.status !== "applying" || !state.scheduled || state.attempt !== event.attempt) return unchanged(state)
const attempt = state.attempt + 1
const messageID = messageIdFromHash(event.hash)
if (state.status === "pending" && state.messageID === event.messageID) return unchanged(state)
return changed({ status: "pending", messageID: event.messageID, attempt: state.attempt + 1 }, [
...cancelSeek(state),
if (messageID)
return changed({ status: "pending", messageID, endOnMiss: state.initial && !event.reveal, attempt }, [
FOLLOW_DETACH,
])
}
if (event.hash) {
const next = clear(state)
if (event.hash) return changed({ status: "none", attempt }, [{ type: "anchor.scroll", id: event.hash }])
return changed(next.state, [...next.effects, { type: "anchor.scroll", id: event.hash }])
}
// A hash this cursor cleared itself leaves it at `none`, so the echo does not undo a smooth return to the end.
if (state.status === "none" && !event.initial) return unchanged(state)
const next = clear(state)
if (event.reveal) return next
return changed(next.state, [...next.effects, FOLLOW_END])
return changed({ status: "none", attempt }, event.reveal ? NO_EFFECTS : [FOLLOW_END])
}
function seek(
function targetSynced(
state: TimelineCursorState,
messageID: string,
behavior: ScrollBehavior,
effects: readonly TimelineCursorEffect[],
options: { now: boolean; echo: boolean },
event: Extract<TimelineCursorEvent, { type: "target.synced" }>,
): TimelineCursorTransition {
if (state.status !== "pending" || state.messageID !== event.messageID) return unchanged(state)
if (event.loaded) {
// Nothing can mount the prompt's row until the timeline is on screen.
if (!event.viewReady) return unchanged(state)
const attempt = state.attempt + 1
// The route already carries this hash, so nothing echoes.
return changed(
{ status: "selected", messageID: state.messageID, fresh: true, attempt, seek: { behavior: "auto", retries: 0 } },
[FOLLOW_DETACH, { type: "seek.schedule", attempt, messageID: state.messageID, behavior: "auto" }],
)
}
// Message IDs ascend and older pages hold only older messages, so a target the revert hides, or one at or after the
// oldest loaded message, never loads.
if (event.revertMessageID !== undefined && state.messageID >= event.revertMessageID) return missing(state, event.hash)
if (event.syncing) return unchanged(state)
if (event.oldestMessageID !== undefined && state.messageID >= event.oldestMessageID) return missing(state, event.hash)
if (event.history === "seekable") return changed(state, HISTORY_SEEK)
if (event.history === "loading") return unchanged(state)
// A failed page keeps the hash, so a reload tries the target again.
if (event.history === "failed")
return changed({ status: "none", attempt: state.attempt + 1 }, state.endOnMiss ? [FOLLOW_END] : NO_EFFECTS)
return missing(state, event.hash)
}
// A target that cannot load: the hash that still names it is cleared, and the router's repeat ignored once.
function missing(state: Pending, hash: string): TimelineCursorTransition {
const clear = messageIdFromHash(hash) === state.messageID
const effects = [...(state.endOnMiss ? [FOLLOW_END] : NO_EFFECTS), ...(clear ? [HASH_CLEAR] : NO_EFFECTS)]
if (clear) return changed({ status: "none", attempt: state.attempt + 1, echo: true }, effects)
return changed({ status: "none", attempt: state.attempt + 1 }, effects)
}
function apply(state: TimelineCursorState, initial: boolean, schedule: boolean): TimelineCursorTransition {
const attempt = state.attempt + 1
return changed(
{ status: "selected", messageID, fresh: true, attempt, seek: { behavior, retries: 0 }, echo: options.echo },
[...effects, { type: options.now ? "seek.start" : "seek.schedule", attempt, messageID, behavior }],
)
return changed({ status: "applying", initial, scheduled: schedule, attempt }, [
...cancelWork(state),
...(schedule ? [{ type: "apply.schedule", attempt } as const] : NO_EFFECTS),
])
}
function settled(state: Selected): TimelineCursorState {
@@ -247,14 +412,13 @@ function settled(state: Selected): TimelineCursorState {
}
}
function clear(state: TimelineCursorState): TimelineCursorTransition {
if (state.status === "none") return unchanged(state)
// Releases the frame the state being left owns.
function cancelWork(state: TimelineCursorState) {
if (state.status === "applying" && state.scheduled) return APPLY_CANCEL
return changed({ status: "none", attempt: state.attempt + 1 }, cancelSeek(state))
}
if (state.status === "selected" && state.seek && !state.seek.paused) return SEEK_CANCEL
function cancelSeek(state: TimelineCursorState) {
return state.status === "selected" && state.seek ? SEEK_CANCEL : NO_EFFECTS
return NO_EFFECTS
}
function changed(
+77 -74
View File
@@ -1,40 +1,45 @@
import type { SessionMessageUser } from "@opencode/client/promise"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createSignal, on, onCleanup, onMount, untrack, type Accessor } from "solid-js"
import { messageIdFromHash } from "../message-id-from-hash"
import {
CLEAR_CURSOR_EVENT,
CLEAR_WITHOUT_HASH_EVENT,
createTimelineCursorState,
DISPOSED_CURSOR_EVENT,
LATEST_ADVANCED_EVENT,
pendingCursorMessageID,
SESSION_CHANGED_EVENT,
transitionTimelineCursor,
VIEW_READY_EVENT,
VIEW_UNREADY_EVENT,
type TimelineCursorEffect,
type TimelineCursorEvent,
} from "./cursor-machine"
import type { HistorySeekStatus } from "./history-machine"
/** The active prompt, the route's `#message-<id>` target, and the scroll that brings either into view. */
export function createTimelineCursor(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
ready: Accessor<boolean>
/** The session's messages are ready and its timeline is mounted and on screen. */
viewReady: Accessor<boolean>
following: Accessor<boolean>
visibleUserMessages: Accessor<SessionMessageUser[]>
/** The transcript's first sync is still under way. */
syncing: Accessor<boolean>
revertMessageID: Accessor<string | undefined>
oldestMessageID: Accessor<string | undefined>
historyStatus: Accessor<HistorySeekStatus>
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
revealMessage: (id: string) => void
detach: () => void
toEnd: () => void
/** Whether history can page toward a pending target now; read tracked, so paging resumes after each page. */
historySeekable: Accessor<boolean>
seekHistory: () => void
}) {
const location = useLocation<{ reveal?: string }>()
const navigate = useNavigate()
const [cursorState, setCursorState] = createSignal(createTimelineCursorState())
let seekFrame: number | undefined
let applyFrame: { id: number; initial: boolean } | undefined
let applyFrame: number | undefined
function dispatch(event: TimelineCursorEvent) {
untrack(() => {
@@ -51,19 +56,12 @@ export function createTimelineCursor(input: {
if (effect.type === "follow.end") return input.toEnd()
if (effect.type === "hash.set") {
const hash = `#${input.anchor(effect.messageID)}`
if (effect.type === "history.seek") return input.seekHistory()
if (location.hash !== hash) navigate(location.pathname + location.search + hash, { replace: true })
if (effect.type === "hash.set")
return navigate(location.pathname + location.search + `#${input.anchor(effect.messageID)}`, { replace: true })
return
}
if (effect.type === "hash.clear") {
if (location.hash) navigate(location.pathname + location.search, { replace: true })
return
}
if (effect.type === "hash.clear") return navigate(location.pathname + location.search, { replace: true })
if (effect.type === "anchor.scroll") {
const target = document.getElementById(effect.id)
@@ -75,7 +73,27 @@ export function createTimelineCursor(input: {
return
}
cancelSeek()
if (effect.type === "apply.schedule" || effect.type === "apply.cancel") {
if (applyFrame !== undefined) cancelAnimationFrame(applyFrame)
applyFrame = undefined
if (effect.type === "apply.cancel") return
// A frame later, once the virtualizer has registered its navigation.
applyFrame = requestAnimationFrame(() => {
applyFrame = undefined
dispatch({
type: "hash.applied",
attempt: effect.attempt,
hash: location.hash.slice(1),
reveal: !!location.state?.reveal,
})
})
return
}
if (seekFrame !== undefined) cancelAnimationFrame(seekFrame)
seekFrame = undefined
if (effect.type === "seek.cancel") return
@@ -113,90 +131,75 @@ export function createTimelineCursor(input: {
return true
}
function cancelSeek() {
if (seekFrame !== undefined) cancelAnimationFrame(seekFrame)
seekFrame = undefined
}
function clear() {
const hash = location.hash.slice(1)
// Applies the hash a frame later, once the virtualizer has registered its navigation. A session switch that also
// changes the hash stays an initial apply.
function scheduleApply(initial: boolean) {
const merged = initial || (applyFrame?.initial ?? false)
if (applyFrame) cancelAnimationFrame(applyFrame.id)
applyFrame = {
initial: merged,
id: requestAnimationFrame(() => {
applyFrame = undefined
const hash = location.hash.slice(1)
dispatch({
type: "hash.applied",
hash,
messageID: messageIdFromHash(hash),
initial: merged,
reveal: !!location.state?.reveal,
})
}),
}
dispatch(hash ? { type: "clear", hash } : CLEAR_WITHOUT_HASH_EVENT)
}
// Runs first on a session switch, so nothing below sees the previous session's cursor.
createEffect(on(input.sessionKey, () => dispatch(SESSION_CHANGED_EVENT), { defer: true }))
createEffect(
on(
() => (input.sessionID() && input.ready() ? input.sessionKey() : undefined),
(key) => {
if (key) scheduleApply(true)
},
),
)
createEffect(() => {
input.sessionKey()
dispatch(input.viewReady() ? VIEW_READY_EVENT : VIEW_UNREADY_EVENT)
})
createEffect(
on(
() => location.hash,
() => {
if (untrack(input.sessionID) && untrack(input.ready)) scheduleApply(false)
},
(hash) => dispatch({ type: "hash.changed", hash: hash.slice(1), viewReady: untrack(input.viewReady) }),
{ defer: true },
),
)
// Subscribes to the transcript and history only while a target is pending.
createEffect(() => {
const id = pendingCursorMessageID(cursorState())
if (!id || !input.sessionID() || !input.ready()) return
if (input.visibleUserMessages().some((message) => message.id === id))
return dispatch({ type: "target.loaded", messageID: id })
if (input.historySeekable()) input.seekHistory()
if (!id) return
dispatch({
type: "target.synced",
messageID: id,
loaded: input.visibleUserMessages().some((message) => message.id === id),
viewReady: input.viewReady(),
syncing: input.syncing(),
revertMessageID: input.revertMessageID(),
oldestMessageID: input.oldestMessageID(),
history: input.historyStatus(),
hash: untrack(() => location.hash.slice(1)),
})
})
// Not deferred: a deferred `on` passes no previous input to its first change.
createEffect(
on(
() => input.visibleUserMessages().at(-1)?.id,
(lastID, previousID) => {
if (lastID && previousID && lastID > previousID) dispatch(LATEST_ADVANCED_EVENT)
},
{ defer: true },
),
)
// A new session starts out following; only the reader's own return to the end clears the route's hash.
createEffect(
on(
input.following,
(following) => {
if (following) dispatch(CLEAR_CURSOR_EVENT)
},
{ defer: true },
),
on([input.sessionKey, input.following], ([key, following], previous) => {
if (following && previous?.[0] === key) clear()
}),
)
onMount(() => {
if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual"
})
onCleanup(() => {
if (applyFrame) cancelAnimationFrame(applyFrame.id)
dispatch(DISPOSED_CURSOR_EVENT)
})
onCleanup(() => dispatch(DISPOSED_CURSOR_EVENT))
return { state: cursorState, dispatch }
return {
state: cursorState,
dispatch,
clear,
jump: (messageID: string, behavior: ScrollBehavior) =>
dispatch({
type: "message.jump",
messageID,
behavior,
hash: location.hash.slice(1),
viewReady: untrack(input.viewReady),
}),
}
}
@@ -6,6 +6,7 @@ import {
createTimelineHistoryState,
DISPOSED_HISTORY_EVENT,
FILL_REQUESTED_EVENT,
historySeekStatus,
SCROLL_TOP_EVENT,
SEEK_REQUESTED_EVENT,
transitionTimelineHistory,
@@ -290,5 +291,14 @@ describe("history machine", () => {
expect([idle, loading, failed].map(canRequestScrollPage)).toEqual([true, false, false])
expect([idle, loading, failed].map(canRequestFillProbe)).toEqual([true, false, false])
expect([idle, loading, failed].map(canSeekHistory)).toEqual([true, false, false])
const exhausted = replay([sync({ more: false })]).state
expect([createTimelineHistoryState(), idle, loading, failed, exhausted].map(historySeekStatus)).toEqual([
"loading",
"seekable",
"loading",
"failed",
"exhausted",
])
})
})
@@ -185,6 +185,17 @@ export function canSeekHistory(state: TimelineHistoryState) {
return state.status === "idle"
}
export type HistorySeekStatus = "loading" | "seekable" | "exhausted" | "failed"
/** How paging toward a target that is not loaded yet stands: it can start, is under way, ran out or failed. */
export function historySeekStatus(state: TimelineHistoryState): HistorySeekStatus {
if (state.status === "idle") return "seekable"
if (state.status === "inactive" || state.status === "loading") return "loading"
return state.status
}
function synced(
state: TimelineHistoryState,
event: Extract<TimelineHistoryEvent, { type: "sync" }>,
@@ -1,28 +1,22 @@
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocation } from "@solidjs/router"
import { createEffect, createMemo, createSignal, on, onCleanup, untrack } from "solid-js"
import { createEffect, createMemo, createSignal, on, onCleanup, untrack, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { frameLoop } from "@opencode/ui/motion"
import type { SessionModel } from "../model"
import { createTimelineCursor } from "./cursor"
import {
CLEAR_CURSOR_EVENT,
cursorMessageID,
freshCursorMessageID,
hasFreshCursorOrSeek,
SCROLL_MOVED_EVENT,
} from "./cursor-machine"
import { cursorMessageID, freshCursorMessageID, hasFreshCursorOrSeek, SCROLL_MOVED_EVENT } from "./cursor-machine"
import { createTimelineFollow } from "./follow"
import {
canRequestFillProbe,
canRequestScrollPage,
canSeekHistory,
createTimelineHistoryState,
DISPOSED_HISTORY_EVENT,
FILL_REQUESTED_EVENT,
HISTORY_LOAD_TIMEOUT_MS,
HISTORY_RETRY_COOLDOWN_MS,
HISTORY_SCROLL_THRESHOLD,
historySeekStatus,
SCROLL_TOP_EVENT,
SEEK_REQUESTED_EVENT,
transitionTimelineHistory,
@@ -31,7 +25,8 @@ import {
} from "./history-machine"
import { createTimelineModel } from "./model"
export function createSessionTimelineInteraction(session: SessionModel) {
/** `visible`: the conversation is on screen, not behind another narrow-screen view. */
export function createSessionTimelineInteraction(session: SessionModel, visible: Accessor<boolean>) {
const location = useLocation<{ reveal?: string }>()
const timeline = createTimelineModel({ session })
@@ -159,10 +154,13 @@ export function createSessionTimelineInteraction(session: SessionModel) {
const cursor = createTimelineCursor({
sessionKey: session.identity.sessionKey,
sessionID: () => session.identity.params.id,
ready: timeline.ready,
viewReady: createMemo(() => !!session.identity.params.id && timeline.ready() && !!state.refs.scroller && visible()),
following: follow.following,
visibleUserMessages,
syncing: () => timeline.resource.loading,
revertMessageID: session.data.revertMessageID,
oldestMessageID: oldestMessage,
historyStatus: createMemo(() => historySeekStatus(historyState())),
scroller: () => scroller,
anchor,
revealMessage: (id) => revealMessage(id),
@@ -173,14 +171,13 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (scroller) scheduleScrollState(scroller)
},
historySeekable: () => canSeekHistory(historyState()),
seekHistory: () => dispatchHistory(SEEK_REQUESTED_EVENT),
})
let resumeFrame: (() => void) | undefined
const resume = (behavior?: ScrollBehavior) => {
cursor.dispatch(CLEAR_CURSOR_EVENT)
cursor.clear()
const attempt = follow.beginReturn()
// Scrolls on the next frame, before it paints. A submit resumes from its key handler right after changing the
// DOM, where reading the scroll range would force a layout that the frame then repeats. Input that stops
@@ -215,7 +212,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
return
}
cursor.dispatch({ type: "message.jump", messageID: messages[target].id, behavior: "auto" })
cursor.jump(messages[target].id, "auto")
}
// A gesture inside a nested scrollable region scrolls that region, not the timeline.