diff --git a/packages/app/src/pages/session/use-session-hash-scroll-core.ts b/packages/app/src/pages/session/use-session-hash-scroll-core.ts new file mode 100644 index 000000000..0d0032f94 --- /dev/null +++ b/packages/app/src/pages/session/use-session-hash-scroll-core.ts @@ -0,0 +1,230 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" +import { messageIdFromHash } from "./message-id-from-hash" + +export type SessionHashScrollInput = { + sessionKey: () => string + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise + turnStart: () => number + currentMessageId: () => string | undefined + pendingMessage: () => string | undefined + setPendingMessage: (value: string | undefined) => void + setActiveMessage: (message: UserMessage | undefined) => 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 + onMessageNavigation?: (messageID: string) => void + onMessageHashCleared?: () => void +} + +type SessionHashScrollLocation = { hash: string; pathname: string; search: string } +type SessionHashScrollNavigate = (to: string, options?: { replace?: boolean }) => void + +export const createSessionHashScroll = ( + input: SessionHashScrollInput, + location: SessionHashScrollLocation, + navigate: SessionHashScrollNavigate, +) => { + const visibleUserMessages = createMemo(() => input.visibleUserMessages()) + const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) + const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) + let pendingKey = "" + let clearingHash: string | undefined + + const frames = new Set() + const queue = (fn: () => void) => { + const id = requestAnimationFrame(() => { + frames.delete(id) + fn() + }) + frames.add(id) + } + const cancel = () => { + for (const id of frames) cancelAnimationFrame(id) + frames.clear() + } + + const clearMessageHash = () => { + cancel() + input.consumePendingMessage(input.sessionKey()) + if (input.pendingMessage()) input.setPendingMessage(undefined) + if (!location.hash) return + clearingHash = location.hash + navigate(location.pathname + location.search, { replace: true }) + input.onMessageHashCleared?.() + } + + const updateHash = (id: string) => { + const hash = `#${input.anchor(id)}` + if (location.hash === hash) return + clearingHash = undefined + navigate(location.pathname + location.search + hash, { + replace: true, + }) + } + + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { + const root = input.scroller() + if (!root) return false + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const sticky = root.querySelector("[data-session-title]") + const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 + const top = Math.max(0, a.top - b.top + root.scrollTop - inset) + root.scrollTo({ top, behavior }) + return true + } + + const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => { + const el = document.getElementById(input.anchor(id)) + if (el) return scrollToElement(el, behavior) + if (left <= 0) return false + queue(() => { + seek(id, behavior, left - 1) + }) + return false + } + + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + cancel() + input.onMessageNavigation?.(message.id) + 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()) { + queue(() => { + seek(message.id, behavior) + }) + + updateHash(message.id) + return + } + + if (seek(message.id, behavior)) { + updateHash(message.id) + return + } + + updateHash(message.id) + } + + const applyHash = (behavior: ScrollBehavior) => { + const hash = location.hash.slice(1) + if (!hash) { + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + return + } + + const messageId = messageIdFromHash(hash) + if (messageId) { + input.autoScroll.pause() + const msg = messageById().get(messageId) + if (msg) { + scrollToMessage(msg, behavior) + return + } + input.onMessageNavigation?.(messageId) + return + } + + const target = document.getElementById(hash) + if (target) { + input.autoScroll.pause() + scrollToElement(target, behavior) + return + } + + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + } + + createEffect(() => { + const hash = location.hash + if (!hash) { + clearingHash = undefined + } else if (hash === clearingHash) { + return + } else { + clearingHash = undefined + } + if (!input.sessionID() || !input.messagesReady()) return + cancel() + queue(() => applyHash("auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + + visibleUserMessages() + input.turnStart() + + let targetId = input.pendingMessage() + if (!targetId) { + const key = input.sessionKey() + if (pendingKey !== key) { + pendingKey = key + const next = input.consumePendingMessage(key) + if (next) { + input.setPendingMessage(next) + targetId = next + } + } + } + + if (!targetId && !clearingHash) targetId = messageIdFromHash(location.hash) + if (!targetId) return + + const pending = input.pendingMessage() === targetId + const msg = messageById().get(targetId) + if (!msg) return + + if (pending) input.setPendingMessage(undefined) + if (input.currentMessageId() === targetId && !pending) return + + input.autoScroll.pause() + cancel() + queue(() => scrollToMessage(msg, "auto")) + }) + + createEffect(() => { + const sessionID = input.sessionID() + if (!sessionID || !input.messagesReady()) return + + visibleUserMessages() + + let targetId = input.pendingMessage() + if (!targetId && !clearingHash) targetId = messageIdFromHash(location.hash) + if (!targetId) return + if (messageById().has(targetId)) return + if (!input.historyMore() || input.historyLoading()) return + + void input.loadMore(sessionID) + }) + + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } + }) + + onCleanup(cancel) + + return { + clearMessageHash, + scrollToMessage, + applyHash, + } +} 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 5bfa9b24c..8453d7566 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 @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createSessionHashScroll } from "./use-session-hash-scroll-core" import { messageIdFromHash } from "./message-id-from-hash" describe("messageIdFromHash", () => { @@ -17,7 +19,7 @@ describe("messageIdFromHash", () => { 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() + const source = await Bun.file(new URL("./use-session-hash-scroll-core.ts", import.meta.url)).text() expect(source).toContain("onMessageHashCleared") expect(source).toContain("input.onMessageHashCleared?.()") @@ -31,26 +33,100 @@ describe("useSessionHashScroll", () => { }) test("timeline cancels bottom follow before hash or active-message navigation", async () => { - const hashSource = await Bun.file(new URL("./use-session-hash-scroll.ts", import.meta.url)).text() + const hashSource = await Bun.file(new URL("./use-session-hash-scroll-core.ts", import.meta.url)).text() const timelineSource = await Bun.file(new URL("./use-session-timeline-interaction.ts", import.meta.url)).text() const sessionSource = await Bun.file(new URL("../session.tsx", import.meta.url)).text() expect(hashSource).toContain("onMessageNavigation") expect(hashSource).toContain("input.onMessageNavigation?.(message.id)") - expect(timelineSource).toContain("type: \"target_message\"") + expect(timelineSource).toContain('type: "target_message"') expect(timelineSource).toContain("const navigateMessageByOffset") expect(timelineSource).toContain("scrollDock.cancelBottomFollowLock()") expect(sessionSource).toContain("markScrollGesture: timelineInteraction.markScrollGesture") expect(sessionSource).toContain("navigateMessageByOffset: timelineInteraction.navigateMessageByOffset") }) - test("hash navigation emits once for already rendered messages", async () => { - const source = await Bun.file(new URL("./use-session-hash-scroll.ts", import.meta.url)).text() + test("hash navigation scrolls an already rendered message without duplicate fallback navigation", async () => { + const root = document.createElement("div") + const target = document.createElement("div") + const scrollPositions: ScrollToOptions[] = [] + const navigationCalls: string[] = [] + const activeMessages: string[] = [] + const markedTargets: number[] = [] - expect(source).toContain(`if (msg) { - scrollToMessage(msg, behavior) - return - } - input.onMessageNavigation?.(messageId)`) + root.id = "session-root" + target.id = "message-msg_2" + root.append(target) + document.body.append(root) + + root.getBoundingClientRect = () => ({ + top: 0, + bottom: 400, + left: 0, + right: 400, + width: 400, + height: 400, + x: 0, + y: 0, + toJSON: () => ({}), + }) + target.getBoundingClientRect = () => ({ + top: 160, + bottom: 220, + left: 0, + right: 400, + width: 400, + height: 60, + x: 0, + y: 160, + toJSON: () => ({}), + }) + root.scrollTo = (options?: ScrollToOptions | number) => { + if (typeof options === "object") scrollPositions.push(options) + } + + const dispose = createRoot((dispose) => { + const [currentMessageId, setCurrentMessageId] = createSignal() + + const hashScroll = createSessionHashScroll( + { + sessionKey: () => "ses_1:/repo", + sessionID: () => "ses_1", + messagesReady: () => true, + visibleUserMessages: () => [{ id: "msg_1" }, { id: "msg_2" }] as any, + historyMore: () => false, + historyLoading: () => false, + loadMore: async () => undefined, + turnStart: () => 0, + currentMessageId, + pendingMessage: () => undefined, + setPendingMessage: () => undefined, + setActiveMessage: (message) => { + activeMessages.push(message?.id ?? "") + setCurrentMessageId(message?.id) + }, + markHashTarget: (index) => markedTargets.push(index), + autoScroll: { pause: () => undefined, forceScrollToBottom: () => undefined }, + scroller: () => root, + anchor: (id) => `message-${id}`, + scheduleScrollState: () => undefined, + consumePendingMessage: () => undefined, + onMessageNavigation: (messageID) => navigationCalls.push(messageID), + }, + { hash: "#message-msg_2", pathname: "/session/ses_1", search: "" }, + () => undefined, + ) + + hashScroll.applyHash("auto") + return dispose + }) + + expect(scrollPositions).toEqual([{ top: 160, behavior: "auto" }]) + expect(activeMessages).toEqual(["msg_2"]) + expect(markedTargets).toEqual([1]) + expect(navigationCalls).toEqual(["msg_2"]) + + dispose() + root.remove() }) }) 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 5260965e8..230256f1a 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,225 +1,9 @@ -import type { UserMessage } from "@opencode-ai/sdk/v2" import { useLocation, useNavigate } from "@solidjs/router" -import { createEffect, createMemo, onCleanup, onMount } from "solid-js" -import { messageIdFromHash } from "./message-id-from-hash" - -export const useSessionHashScroll = (input: { - sessionKey: () => string - sessionID: () => string | undefined - messagesReady: () => boolean - visibleUserMessages: () => UserMessage[] - historyMore: () => boolean - historyLoading: () => boolean - loadMore: (sessionID: string) => Promise - turnStart: () => number - currentMessageId: () => string | undefined - pendingMessage: () => string | undefined - setPendingMessage: (value: string | undefined) => void - setActiveMessage: (message: UserMessage | undefined) => 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 - onMessageNavigation?: (messageID: string) => void - onMessageHashCleared?: () => void -}) => { - const visibleUserMessages = createMemo(() => input.visibleUserMessages()) - const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) - const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) - let pendingKey = "" - let clearingHash: string | undefined +import { createSessionHashScroll, type SessionHashScrollInput } from "./use-session-hash-scroll-core" +export const useSessionHashScroll = (input: SessionHashScrollInput) => { const location = useLocation() const navigate = useNavigate() - const frames = new Set() - const queue = (fn: () => void) => { - const id = requestAnimationFrame(() => { - frames.delete(id) - fn() - }) - frames.add(id) - } - const cancel = () => { - for (const id of frames) cancelAnimationFrame(id) - frames.clear() - } - - const clearMessageHash = () => { - cancel() - input.consumePendingMessage(input.sessionKey()) - if (input.pendingMessage()) input.setPendingMessage(undefined) - if (!location.hash) return - clearingHash = location.hash - navigate(location.pathname + location.search, { replace: true }) - input.onMessageHashCleared?.() - } - - const updateHash = (id: string) => { - const hash = `#${input.anchor(id)}` - if (location.hash === hash) return - clearingHash = undefined - navigate(location.pathname + location.search + hash, { - replace: true, - }) - } - - const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { - const root = input.scroller() - if (!root) return false - - const a = el.getBoundingClientRect() - const b = root.getBoundingClientRect() - const sticky = root.querySelector("[data-session-title]") - const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 - const top = Math.max(0, a.top - b.top + root.scrollTop - inset) - root.scrollTo({ top, behavior }) - return true - } - - const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => { - const el = document.getElementById(input.anchor(id)) - if (el) return scrollToElement(el, behavior) - if (left <= 0) return false - queue(() => { - seek(id, behavior, left - 1) - }) - return false - } - - const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - cancel() - input.onMessageNavigation?.(message.id) - 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()) { - queue(() => { - seek(message.id, behavior) - }) - - updateHash(message.id) - return - } - - if (seek(message.id, behavior)) { - updateHash(message.id) - return - } - - updateHash(message.id) - } - - const applyHash = (behavior: ScrollBehavior) => { - const hash = location.hash.slice(1) - if (!hash) { - input.autoScroll.forceScrollToBottom() - const el = input.scroller() - if (el) input.scheduleScrollState(el) - return - } - - const messageId = messageIdFromHash(hash) - if (messageId) { - input.autoScroll.pause() - const msg = messageById().get(messageId) - if (msg) { - scrollToMessage(msg, behavior) - return - } - input.onMessageNavigation?.(messageId) - return - } - - const target = document.getElementById(hash) - if (target) { - input.autoScroll.pause() - scrollToElement(target, behavior) - return - } - - input.autoScroll.forceScrollToBottom() - const el = input.scroller() - if (el) input.scheduleScrollState(el) - } - - createEffect(() => { - const hash = location.hash - if (!hash) { - clearingHash = undefined - } else if (hash === clearingHash) { - return - } else { - clearingHash = undefined - } - if (!input.sessionID() || !input.messagesReady()) return - cancel() - queue(() => applyHash("auto")) - }) - - createEffect(() => { - if (!input.sessionID() || !input.messagesReady()) return - - visibleUserMessages() - input.turnStart() - - let targetId = input.pendingMessage() - if (!targetId) { - const key = input.sessionKey() - if (pendingKey !== key) { - pendingKey = key - const next = input.consumePendingMessage(key) - if (next) { - input.setPendingMessage(next) - targetId = next - } - } - } - - if (!targetId && !clearingHash) targetId = messageIdFromHash(location.hash) - if (!targetId) return - - const pending = input.pendingMessage() === targetId - const msg = messageById().get(targetId) - if (!msg) return - - if (pending) input.setPendingMessage(undefined) - if (input.currentMessageId() === targetId && !pending) return - - input.autoScroll.pause() - cancel() - queue(() => scrollToMessage(msg, "auto")) - }) - - createEffect(() => { - const sessionID = input.sessionID() - if (!sessionID || !input.messagesReady()) return - - visibleUserMessages() - - let targetId = input.pendingMessage() - if (!targetId && !clearingHash) targetId = messageIdFromHash(location.hash) - if (!targetId) return - if (messageById().has(targetId)) return - if (!input.historyMore() || input.historyLoading()) return - - void input.loadMore(sessionID) - }) - - onMount(() => { - if (typeof window !== "undefined" && "scrollRestoration" in window.history) { - window.history.scrollRestoration = "manual" - } - }) - - onCleanup(cancel) - - return { - clearMessageHash, - scrollToMessage, - applyHash, - } + return createSessionHashScroll(input, location, navigate) }