diff --git a/packages/app/src/context/global-sdk-event-queue.test.ts b/packages/app/src/context/global-sdk-event-queue.test.ts new file mode 100644 index 000000000..cba0f3929 --- /dev/null +++ b/packages/app/src/context/global-sdk-event-queue.test.ts @@ -0,0 +1,122 @@ +import type { Event, SessionStatus } from "@opencode-ai/sdk/v2/client" +import { describe, expect, test } from "bun:test" +import { coalesceQueuedEvents, type QueuedGlobalEvent } from "./global-sdk-event-queue" + +const directory = "/repo" + +const delta = (partID: string, value: string, messageID = "msg_1", field = "text"): Event => ({ + type: "message.part.delta", + properties: { sessionID: "ses_1", messageID, partID, field, delta: value }, +}) + +const updated = (partID: string, messageID = "msg_1"): Event => + ({ + type: "message.part.updated", + properties: { + sessionID: "ses_1", + time: 1, + part: { id: partID, sessionID: "ses_1", messageID, type: "text", text: "full" }, + }, + }) as Event + +const status = (sessionID = "ses_1", type: "busy" | "idle" | "retry" = "busy"): Event => ({ + type: "session.status", + properties: { + sessionID, + status: type === "retry" ? ({ type, attempt: 1, message: "retry", next: 1 } satisfies SessionStatus) : { type }, + }, +}) + +const queued = (...events: Event[]): QueuedGlobalEvent[] => events.map((payload) => ({ directory, payload })) +const queuedIn = (directory: string, ...events: Event[]): QueuedGlobalEvent[] => + events.map((payload) => ({ directory, payload })) +const eventTypes = (events: QueuedGlobalEvent[]) => events.map((event) => event.payload.type) +const deltas = (events: QueuedGlobalEvent[]) => + events + .filter((event) => event.payload.type === "message.part.delta") + .map((event) => (event.payload as Extract).properties.delta) + +describe("global SDK event queue coalescing", () => { + test("combines only contiguous deltas for the same part", () => { + const events = coalesceQueuedEvents(queued(delta("prt_1", "a"), delta("prt_1", "b"))) + + expect(events).toHaveLength(1) + expect(deltas(events)).toEqual(["ab"]) + }) + + test("does not merge same-part deltas across another part delta", () => { + const events = coalesceQueuedEvents(queued(delta("prt_1", "a"), delta("prt_2", "x"), delta("prt_1", "b"))) + + expect(deltas(events)).toEqual(["a", "x", "b"]) + }) + + test("does not merge deltas across non-delta barriers", () => { + const events = coalesceQueuedEvents(queued(delta("prt_1", "a"), status(), delta("prt_1", "b"))) + + expect(eventTypes(events)).toEqual(["message.part.delta", "session.status", "message.part.delta"]) + expect(deltas(events)).toEqual(["a", "b"]) + }) + + test("does not merge same-part deltas for different fields", () => { + const events = coalesceQueuedEvents( + queued(delta("prt_1", "a", "msg_1", "text"), delta("prt_1", "b", "msg_1", "metadata")), + ) + + expect(deltas(events)).toEqual(["a", "b"]) + }) + + test("drops stale deltas before a full part update but keeps later deltas", () => { + const events = coalesceQueuedEvents(queued(delta("prt_1", "stale"), updated("prt_1"), delta("prt_1", "fresh"))) + + expect(eventTypes(events)).toEqual(["message.part.updated", "message.part.delta"]) + expect(deltas(events)).toEqual(["fresh"]) + }) + + test("keeps only the full update and later delta for delta-update-delta ordering", () => { + const events = coalesceQueuedEvents(queued(delta("prt_1", "before"), updated("prt_1"), delta("prt_1", "after"))) + + expect(eventTypes(events)).toEqual(["message.part.updated", "message.part.delta"]) + expect(deltas(events)).toEqual(["after"]) + }) + + test("handles multiple full updates for the same part without resurrecting stale deltas", () => { + const events = coalesceQueuedEvents(queued(delta("prt_1", "stale"), updated("prt_1"), updated("prt_1"))) + + expect(eventTypes(events)).toEqual(["message.part.updated", "message.part.updated"]) + expect(deltas(events)).toEqual([]) + }) + + test("keeps replaceable event indexes correct after stale delta removal", () => { + const events = coalesceQueuedEvents( + queued(delta("prt_1", "stale"), status("ses_1", "busy"), updated("prt_1"), status("ses_1", "idle")), + ) + + expect(eventTypes(events)).toEqual(["session.status", "message.part.updated"]) + expect(events[0].payload).toEqual(status("ses_1", "idle")) + }) + + test("keeps delta merging and stale pruning isolated by directory", () => { + const events = coalesceQueuedEvents([ + ...queuedIn("/repo-a", delta("prt_1", "a")), + ...queuedIn("/repo-b", delta("prt_1", "b")), + ...queuedIn("/repo-a", delta("prt_1", "c")), + ...queuedIn("/repo-a", updated("prt_1")), + ...queuedIn("/repo-b", delta("prt_1", "d")), + ...queuedIn("/repo-b", updated("prt_1")), + ]) + + expect(eventTypes(events)).toEqual(["message.part.updated", "message.part.updated"]) + expect(deltas(events)).toEqual([]) + expect(events.map((event) => event.directory)).toEqual(["/repo-a", "/repo-b"]) + }) + + test("does not collide composite keys when directories contain separators", () => { + const events = coalesceQueuedEvents([ + ...queuedIn("/repo:msg_1", delta("prt_1", "a", "prt_2")), + ...queuedIn("/repo", delta("msg_1:prt_1", "b", "prt_2")), + ]) + + expect(deltas(events)).toEqual(["a", "b"]) + expect(events.map((event) => event.directory)).toEqual(["/repo:msg_1", "/repo"]) + }) +}) diff --git a/packages/app/src/context/global-sdk-event-queue.ts b/packages/app/src/context/global-sdk-event-queue.ts new file mode 100644 index 000000000..92360a10a --- /dev/null +++ b/packages/app/src/context/global-sdk-event-queue.ts @@ -0,0 +1,96 @@ +import type { Event } from "@opencode-ai/sdk/v2/client" + +export type QueuedGlobalEvent = { directory: string; payload: Event } + +const deltaKey = (event: QueuedGlobalEvent) => { + if (event.payload.type !== "message.part.delta") return + const props = event.payload.properties + return JSON.stringify([event.directory, props.messageID, props.partID, props.field]) +} + +const partKey = (event: QueuedGlobalEvent) => { + if (event.payload.type === "message.part.delta") { + const props = event.payload.properties + return JSON.stringify([event.directory, props.messageID, props.partID]) + } + if (event.payload.type === "message.part.updated") { + const part = event.payload.properties.part + return JSON.stringify([event.directory, part.messageID, part.id]) + } +} + +const replaceableKey = (event: QueuedGlobalEvent) => { + if (event.payload.type === "session.status") + return JSON.stringify(["session.status", event.directory, event.payload.properties.sessionID]) + if (event.payload.type === "lsp.updated") return JSON.stringify(["lsp.updated", event.directory]) +} + +const appendDelta = (event: QueuedGlobalEvent, delta: string): QueuedGlobalEvent => { + if (event.payload.type !== "message.part.delta") return event + return { + ...event, + payload: { + ...event.payload, + properties: { + ...event.payload.properties, + delta: event.payload.properties.delta + delta, + }, + }, + } +} + +export function coalesceQueuedEvents(events: QueuedGlobalEvent[]): QueuedGlobalEvent[] { + const result: (QueuedGlobalEvent | undefined)[] = [] + const replaceable = new Map() + const deltaIndexesByPart = new Map>() + const pushEvent = (event: QueuedGlobalEvent) => { + const index = result.length + result.push(event) + + const key = partKey(event) + if (event.payload.type === "message.part.delta" && key) { + const indexes = deltaIndexesByPart.get(key) ?? new Set() + indexes.add(index) + deltaIndexesByPart.set(key, indexes) + } + + return index + } + + for (const event of events) { + const replaceKey = replaceableKey(event) + if (replaceKey) { + const index = replaceable.get(replaceKey) + if (index !== undefined) { + result[index] = event + continue + } + replaceable.set(replaceKey, pushEvent(event)) + continue + } + + if (event.payload.type === "message.part.updated") { + const updatedKey = partKey(event) + if (updatedKey) { + for (const index of deltaIndexesByPart.get(updatedKey) ?? []) { + result[index] = undefined + } + deltaIndexesByPart.delete(updatedKey) + } + pushEvent(event) + continue + } + + if (event.payload.type === "message.part.delta") { + const last = result[result.length - 1] + if (last?.payload.type === "message.part.delta" && deltaKey(last) === deltaKey(event)) { + result[result.length - 1] = appendDelta(last, event.payload.properties.delta) + continue + } + } + + pushEvent(event) + } + + return result.filter((event): event is QueuedGlobalEvent => event !== undefined) +} diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 5935e3819..256f03bdb 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { batch, onCleanup, onMount } from "solid-js" import z from "zod" import { createSdkForServer } from "@/utils/server" +import { coalesceQueuedEvents, type QueuedGlobalEvent } from "./global-sdk-event-queue" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { useServer } from "./server" @@ -44,50 +45,29 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo [key: string]: Event }>() - type Queued = { directory: string; payload: Event } const FLUSH_FRAME_MS = 16 const STREAM_YIELD_MS = 8 const RECONNECT_DELAY_MS = 250 - let queue: Queued[] = [] - let buffer: Queued[] = [] - const coalesced = new Map() - const staleDeltas = new Set() + let queue: QueuedGlobalEvent[] = [] + let buffer: QueuedGlobalEvent[] = [] let timer: ReturnType | undefined let last = 0 - const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` - - const key = (directory: string, payload: Event) => { - if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}` - if (payload.type === "lsp.updated") return `lsp.updated:${directory}` - if (payload.type === "message.part.updated") { - const part = payload.properties.part - return `message.part.updated:${directory}:${part.messageID}:${part.id}` - } - } - const flush = () => { if (timer) clearTimeout(timer) timer = undefined if (queue.length === 0) return - const events = queue - const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined + const events = coalesceQueuedEvents(queue) queue = buffer buffer = events queue.length = 0 - coalesced.clear() - staleDeltas.clear() last = Date.now() batch(() => { for (const event of events) { - if (skip && event.payload.type === "message.part.delta") { - const props = event.payload.properties - if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue - } emitter.emit(event.directory, event.payload) } }) @@ -160,19 +140,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (payload.type === "sync") { continue } - const k = key(directory, payload) - if (k) { - const i = coalesced.get(k) - if (i !== undefined) { - queue[i] = { directory, payload } - if (payload.type === "message.part.updated") { - const part = payload.properties.part - staleDeltas.add(deltaKey(directory, part.messageID, part.id)) - } - continue - } - coalesced.set(k, queue.length) - } queue.push({ directory, payload }) schedule() diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts index 7f3389baa..48d0e1969 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.test.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -14,3 +14,19 @@ describe("messageIdFromHash", () => { expect(messageIdFromHash("#review-panel")).toBeUndefined() }) }) + +describe("useSessionHashScroll", () => { + test("clearing a message hash notifies the timeline to leave hash history mode", async () => { + const source = await Bun.file(new URL("./use-session-hash-scroll.ts", import.meta.url)).text() + + expect(source).toContain("onMessageHashCleared") + expect(source).toContain("input.onMessageHashCleared?.()") + }) + + test("timeline wires hash clearing to guarded latest-window recovery", async () => { + const source = await Bun.file(new URL("./use-session-timeline-interaction.ts", import.meta.url)).text() + + expect(source).toContain("onMessageHashCleared") + expect(source).toContain("historyWindow.clearHashTarget()") + }) +}) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 873f03a9d..9d799f36c 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -16,12 +16,13 @@ export const useSessionHashScroll = (input: { pendingMessage: () => string | undefined setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void - setTurnStart: (value: number) => void + markHashTarget: (index: number) => void autoScroll: { pause: () => void; forceScrollToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void consumePendingMessage: (key: string) => string | undefined + onMessageHashCleared?: () => void }) => { const visibleUserMessages = createMemo(() => input.visibleUserMessages()) const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) @@ -52,6 +53,7 @@ export const useSessionHashScroll = (input: { if (!location.hash) return clearingHash = location.hash navigate(location.pathname + location.search, { replace: true }) + input.onMessageHashCleared?.() } const updateHash = (id: string) => { @@ -91,9 +93,8 @@ export const useSessionHashScroll = (input: { if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 + if (index !== -1) input.markHashTarget(index) if (index !== -1 && index < input.turnStart()) { - input.setTurnStart(index) - queue(() => { seek(message.id, behavior) }) diff --git a/packages/app/src/pages/session/use-session-history-window.test.ts b/packages/app/src/pages/session/use-session-history-window.test.ts index 88bc5e7c5..aa8b92c3d 100644 --- a/packages/app/src/pages/session/use-session-history-window.test.ts +++ b/packages/app/src/pages/session/use-session-history-window.test.ts @@ -1,7 +1,11 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { createSessionHistoryWindow } from "./use-session-history-window" +import { createRoot, createSignal } from "solid-js" +import { + createSessionHistoryWindow, + resolveClearedHashTarget, + resolveHistoryTurnStart, +} from "./use-session-history-window" const userMessage = (id: number) => ({ @@ -10,6 +14,43 @@ const userMessage = (id: number) => time: { created: Date.now() }, }) as UserMessage +const userMessages = (count: number) => Array.from({ length: count }, (_, index) => userMessage(index)) +const ids = (start: number, end: number) => Array.from({ length: end - start }, (_, index) => `msg_${start + index}`) + +const createHarness = (input: { count: number; userScrolled?: boolean; atBottom?: boolean; historyMore?: boolean }) => { + const [state, setState] = createSignal({ + count: input.count, + userScrolled: input.userScrolled ?? false, + atBottom: input.atBottom ?? !(input.userScrolled ?? false), + historyMore: input.historyMore ?? false, + }) + const history = createSessionHistoryWindow({ + sessionID: () => "ses_1", + messagesReady: () => true, + loaded: () => state().count, + visibleUserMessages: () => userMessages(state().count), + historyMore: () => state().historyMore, + historyLoading: () => false, + loadMore: async () => undefined, + userScrolled: () => state().userScrolled, + isAtBottom: () => state().atBottom, + scroller: () => undefined, + }) + + return { + history, + setCount: (count: number) => { + setState((prev) => ({ ...prev, count })) + }, + setUserScrolled: (value: boolean) => { + setState((prev) => ({ ...prev, userScrolled: value })) + }, + setAtBottom: (value: boolean) => { + setState((prev) => ({ ...prev, atBottom: value })) + }, + } +} + describe("session history window extraction", () => { test("renders only the last ten messages for long sessions", () => { createRoot((dispose) => { @@ -23,6 +64,7 @@ describe("session history window extraction", () => { historyLoading: () => false, loadMore: async () => undefined, userScrolled: () => false, + isAtBottom: () => true, scroller: () => undefined, }) @@ -46,14 +88,146 @@ describe("session history window extraction", () => { historyLoading: () => false, loadMore: async () => undefined, userScrolled: () => false, + isAtBottom: () => true, scroller: () => undefined, }) expect(history.turnStart()).toBe(0) - expect(history.renderedUserMessages().map((message) => message.id)).toEqual( - messages.map((message) => message.id), - ) + expect(history.renderedUserMessages().map((message) => message.id)).toEqual(messages.map((message) => message.id)) + dispose() + }) + }) + + test("bottom mode keeps same-session rendered turns bounded as new turns append", () => { + expect(resolveHistoryTurnStart({ mode: "bottom", storedTurnStart: 0, length: 25, userScrolled: false })).toBe(15) + }) + + test("bottom mode preserves storedTurnStart when user has scrolled inside rendered range", () => { + expect(resolveHistoryTurnStart({ mode: "bottom", storedTurnStart: 15, length: 26, userScrolled: true })).toBe(15) + }) + + test("expanded history stays expanded when new turns append", () => { + expect(resolveHistoryTurnStart({ mode: "reading", storedTurnStart: 0, length: 26, userScrolled: false })).toBe(0) + }) + + test("hash mode keeps the target rendered across appends", () => { + expect(resolveHistoryTurnStart({ mode: "hash", storedTurnStart: 4, length: 31, userScrolled: false })).toBe(4) + }) + + test("hash mode is entered even when the target is already inside the rendered bottom window", () => { + createRoot((dispose) => { + const state = createHarness({ count: 30 }) + + state.history.markHashTarget(24) + state.setCount(40) + + expect(state.history.mode()).toBe("hash") + expect(state.history.renderedUserMessages().map((message) => message.id)).toContain("msg_24") + dispose() + }) + }) + + test("jump to latest returns to bottom mode and latest bounded window", () => { + createRoot((dispose) => { + const state = createHarness({ count: 30 }) + + state.history.expandForReading(0) + state.history.resumeLatestWindow() + + expect(state.history.mode()).toBe("bottom") + expect(state.history.turnStart()).toBe(20) + expect(state.history.renderedUserMessages().map((message) => message.id)).toEqual(ids(20, 30)) + dispose() + }) + }) + + test("manual scroll back to bottom returns to bottom mode and bounds later appends", () => { + createRoot((dispose) => { + const state = createHarness({ count: 30 }) + + state.history.expandForReading(0) + expect(state.history.mode()).toBe("reading") + + state.setUserScrolled(false) + state.history.returnToLatestIfFollowing() + state.setCount(40) + state.history.returnToLatestIfFollowing() + + expect(state.history.mode()).toBe("bottom") + expect(resolveHistoryTurnStart({ mode: "bottom", storedTurnStart: 20, length: 40, userScrolled: false })).toBe(30) dispose() }) }) + + test("reading mode returns to latest only when the viewport is actually at bottom", () => { + createRoot((dispose) => { + const state = createHarness({ count: 30, userScrolled: true, atBottom: false }) + + state.history.expandForReading(0) + state.setUserScrolled(false) + state.setAtBottom(false) + state.history.returnToLatestIfFollowing() + + expect(state.history.mode()).toBe("reading") + + state.setAtBottom(true) + state.setCount(40) + state.history.returnToLatestIfFollowing() + + expect(state.history.mode()).toBe("bottom") + expect(resolveHistoryTurnStart({ mode: "bottom", storedTurnStart: 20, length: 40, userScrolled: false })).toBe(30) + dispose() + }) + }) + + test("loadAndReveal does not collapse reading window before the viewport is at bottom", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const state = createHarness({ count: 30, userScrolled: false, atBottom: false, historyMore: true }) + + void state.history.loadAndReveal().then(() => { + state.setCount(40) + + expect(state.history.mode()).toBe("reading") + expect( + resolveHistoryTurnStart({ mode: "reading", storedTurnStart: 0, length: 40, userScrolled: false }), + ).toBe(0) + dispose() + resolve() + }) + }) + }) + }) + + test("cleared hash target returns to bottom mode before later appends", () => { + createRoot((dispose) => { + const state = createHarness({ count: 30 }) + + state.history.markHashTarget(12) + expect(state.history.mode()).toBe("hash") + + state.setUserScrolled(false) + state.history.clearHashTarget() + state.setCount(40) + state.history.returnToLatestIfFollowing() + + expect(state.history.mode()).toBe("bottom") + expect(resolveHistoryTurnStart({ mode: "bottom", storedTurnStart: 20, length: 40, userScrolled: false })).toBe(30) + dispose() + }) + }) + + test("cleared hash target away from bottom becomes reading without losing the current window", () => { + expect(resolveClearedHashTarget({ atBottom: false, currentTurnStart: 12, length: 40 })).toEqual({ + mode: "reading", + turnStart: 12, + }) + }) + + test("cleared hash target at bottom returns to the latest bounded window", () => { + expect(resolveClearedHashTarget({ atBottom: true, currentTurnStart: 12, length: 40 })).toEqual({ + mode: "bottom", + turnStart: 30, + }) + }) }) diff --git a/packages/app/src/pages/session/use-session-history-window.ts b/packages/app/src/pages/session/use-session-history-window.ts index bf2591684..52c257897 100644 --- a/packages/app/src/pages/session/use-session-history-window.ts +++ b/packages/app/src/pages/session/use-session-history-window.ts @@ -13,9 +13,47 @@ export type SessionHistoryWindowInput = { historyLoading: () => boolean loadMore: (sessionID: string) => Promise userScrolled: () => boolean + isAtBottom: () => boolean scroller: () => HTMLDivElement | undefined } +type HistoryWindowMode = "bottom" | "reading" | "hash" + +export function resolveHistoryTurnStart(input: { + mode: HistoryWindowMode + storedTurnStart: number + length: number + userScrolled: boolean + initialWindow?: number +}) { + const initialWindow = input.initialWindow ?? 10 + const initial = input.length > initialWindow ? input.length - initialWindow : 0 + if (input.length <= 0) return 0 + // bottom follows the newest turns unless the user has moved away from the latest viewport. + if (input.mode === "bottom") return input.userScrolled ? Math.min(input.storedTurnStart, initial) : initial + // reading keeps the user's current window stable while new turns append. + // hash uses the same stable-window behavior so the target message stays mounted. + if (input.storedTurnStart <= 0) return 0 + if (input.storedTurnStart >= input.length) return initial + return input.storedTurnStart +} + +export function resolveClearedHashTarget(input: { + atBottom: boolean + currentTurnStart: number + length: number + initialWindow?: number +}) { + const initialWindow = input.initialWindow ?? 10 + if (input.atBottom) { + return { + mode: "bottom" as HistoryWindowMode, + turnStart: input.length > initialWindow ? input.length - initialWindow : 0, + } + } + return { mode: "reading" as HistoryWindowMode, turnStart: input.currentTurnStart > 0 ? input.currentTurnStart : 0 } +} + /** * Maintains the rendered history window for a session timeline. * @@ -35,29 +73,64 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { turnStart: 0, prefetchUntil: 0, prefetchNoGrowth: 0, + mode: "bottom" as HistoryWindowMode, }) const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) + let latestTurnID: string | undefined + let latestTurnStart = 0 const turnStart = createMemo(() => { const id = input.sessionID() const len = input.visibleUserMessages().length if (!id || len <= 0) return 0 if (state.turnID !== id) return initialTurnStart(len) - if (state.turnStart <= 0) return 0 - if (state.turnStart >= len) return initialTurnStart(len) - return state.turnStart + return resolveHistoryTurnStart({ + mode: state.mode, + storedTurnStart: state.turnStart, + length: len, + userScrolled: input.userScrolled(), + initialWindow: turnInit, + }) }) - const setTurnStart = (start: number) => { + const setTurnStart = (start: number, opts?: { mode?: HistoryWindowMode }) => { const id = input.sessionID() const next = start > 0 ? start : 0 + const mode = opts?.mode ?? state.mode if (!id) { - setState({ turnID: undefined, turnStart: next }) + latestTurnID = undefined + latestTurnStart = next + setState({ turnID: undefined, turnStart: next, mode }) return } - setState({ turnID: id, turnStart: next }) + latestTurnID = id + latestTurnStart = next + setState({ turnID: id, turnStart: next, mode }) + } + + const expandForReading = (start: number) => setTurnStart(start, { mode: "reading" }) + const expandForHash = (start: number) => setTurnStart(start, { mode: "hash" }) + const markHashTarget = (index: number) => { + const current = turnStart() + expandForHash(index < current ? index : current) + } + const resumeLatestWindow = () => + setTurnStart(initialTurnStart(input.visibleUserMessages().length), { mode: "bottom" }) + const returnToLatestIfFollowing = () => { + if (!input.isAtBottom()) return + resumeLatestWindow() + } + const clearHashTarget = () => { + const next = resolveClearedHashTarget({ + atBottom: input.isAtBottom(), + currentTurnStart: latestTurnID === input.sessionID() ? latestTurnStart : turnStart(), + length: input.visibleUserMessages().length, + initialWindow: turnInit, + }) + setTurnStart(next.turnStart, { mode: next.mode }) } + const mode = () => state.mode const renderedUserMessages = createMemo( () => { @@ -95,7 +168,7 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { const next = start - turnBatch const nextStart = next > 0 ? next : 0 - preserveScroll(() => setTurnStart(nextStart)) + preserveScroll(() => expandForReading(nextStart)) } /** Button path: reveal all cached turns, fetch older history, reveal one batch. */ @@ -107,7 +180,7 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { const beforeVisible = input.visibleUserMessages().length let loaded = input.loaded() - if (start > 0) setTurnStart(0) + if (start > 0) expandForReading(0) if (!input.historyMore() || input.historyLoading()) return @@ -137,7 +210,7 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { if (turnStart() !== 0) return const target = Math.min(afterVisible, beforeVisible + turnBatch) - setTurnStart(Math.max(0, afterVisible - target)) + expandForReading(Math.max(0, afterVisible - target)) } /** Scroll/prefetch path: fetch older history from server. */ @@ -189,7 +262,7 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { if (opts?.prefetch) { const current = turnStart() - preserveScroll(() => setTurnStart(current + growth)) + preserveScroll(() => expandForReading(current + growth)) return } @@ -198,7 +271,7 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { const currentRendered = renderedUserMessages().length const base = Math.max(beforeRendered, currentRendered) const target = Math.min(afterVisible, base + turnBatch) - preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target))) + preserveScroll(() => expandForReading(Math.max(0, afterVisible - target))) } const onScrollerScroll = () => { @@ -223,7 +296,7 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { on( input.sessionID, () => { - setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) + setState({ prefetchUntil: 0, prefetchNoGrowth: 0, mode: "bottom" }) }, { defer: true }, ), @@ -231,10 +304,26 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { createEffect( on( - () => [input.sessionID(), input.messagesReady()] as const, - ([id, ready]) => { + () => + [ + input.sessionID(), + input.messagesReady(), + input.visibleUserMessages().length, + input.userScrolled(), + input.isAtBottom(), + ] as const, + ([id, ready, len, userScrolled]) => { if (!id || !ready) return - setTurnStart(initialTurnStart(input.visibleUserMessages().length)) + if (userScrolled && state.mode === "bottom") { + setState("mode", "reading") + return + } + if (!userScrolled && state.mode === "reading") { + returnToLatestIfFollowing() + return + } + if (state.mode === "hash") return + returnToLatestIfFollowing() }, { defer: true }, ), @@ -243,6 +332,13 @@ export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { return { turnStart, setTurnStart, + expandForReading, + expandForHash, + markHashTarget, + resumeLatestWindow, + returnToLatestIfFollowing, + clearHashTarget, + mode, renderedUserMessages, loadAndReveal, onScrollerScroll, diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.ts b/packages/app/src/pages/session/use-session-timeline-interaction.ts index cf7b50279..db84bec73 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -63,9 +63,15 @@ export function createSessionTimelineInteraction(input: { historyLoading: input.historyLoading, loadMore: input.loadMore, userScrolled: autoScroll.userScrolled, + isAtBottom: () => scrollDock.scroll.bottom, scroller: scrollDock.scroller, }) + const resumeLatest = () => { + historyWindow.resumeLatestWindow() + resumeScroll() + } + historyBackfill = createSessionHistoryBackfill({ routeSessionID: input.routeSessionID, sessionID: input.sessionID, @@ -91,12 +97,13 @@ export function createSessionTimelineInteraction(input: { pendingMessage: activeMessage.pendingMessage, setPendingMessage: activeMessage.setPendingMessage, setActiveMessage: activeMessage.setActiveMessage, - setTurnStart: historyWindow.setTurnStart, + markHashTarget: historyWindow.markHashTarget, autoScroll, scroller: scrollDock.scroller, anchor, scheduleScrollState: scrollDock.scheduleScrollState, consumePendingMessage: input.consumePendingMessage, + onMessageHashCleared: () => historyWindow.clearHashTarget(), }) clearMessageHash = hashScroll.clearMessageHash activeMessage.setScrollToMessage(hashScroll.scrollToMessage) @@ -106,7 +113,7 @@ export function createSessionTimelineInteraction(input: { autoScroll, anchor, historyWindow, - resumeScroll, + resumeScroll: resumeLatest, scheduleScrollState: scrollDock.scheduleScrollState, scrollDock, setScrollRef: scrollDock.setScrollRef,