mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(app): stop hash targets that can never load and wait for the timeline before applying them (#61913)
This commit is contained in:
committed by
GitHub
Unverified
parent
b6f3294e9d
commit
24cddd1659
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user