Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions packages/app/src/pages/session/use-session-hash-scroll-core.ts
Original file line number Diff line number Diff line change
@@ -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<void>
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<number>()
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)
Comment thread
Astro-Han marked this conversation as resolved.

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,
}
}
96 changes: 86 additions & 10 deletions packages/app/src/pages/session/use-session-hash-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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?.()")
Expand All @@ -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<string | undefined>()

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"])
Comment thread
Astro-Han marked this conversation as resolved.

dispose()
root.remove()
})
})
Loading
Loading