mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-27 16:25:50 +00:00
186 lines
5.6 KiB
TypeScript
186 lines
5.6 KiB
TypeScript
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
|
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
|
import { messageIdFromHash } from "./message-id-from-hash"
|
|
|
|
export { messageIdFromHash } from "./message-id-from-hash"
|
|
|
|
export const useSessionHashScroll = (input: {
|
|
sessionKey: () => string
|
|
sessionID: () => string | undefined
|
|
messagesReady: () => boolean
|
|
visibleUserMessages: () => UserMessage[]
|
|
turnStart: () => number
|
|
currentMessageId: () => string | undefined
|
|
pendingMessage: () => string | undefined
|
|
setPendingMessage: (value: string | undefined) => void
|
|
setActiveMessage: (message: UserMessage | undefined) => void
|
|
setTurnStart: (value: number) => void
|
|
autoScroll: { pause: () => void; snapToBottom: () => void }
|
|
scroller: () => HTMLDivElement | undefined
|
|
anchor: (id: string) => string
|
|
scheduleScrollState: (el: HTMLDivElement) => void
|
|
consumePendingMessage: (key: string) => string | undefined
|
|
}) => {
|
|
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 = ""
|
|
|
|
const clearMessageHash = () => {
|
|
if (!window.location.hash) return
|
|
window.history.replaceState(null, "", window.location.pathname + window.location.search)
|
|
}
|
|
|
|
const updateHash = (id: string) => {
|
|
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
|
|
}
|
|
|
|
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
|
const root = input.scroller()
|
|
if (!root) return false
|
|
|
|
const a = el.getBoundingClientRect()
|
|
const b = root.getBoundingClientRect()
|
|
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
|
const inset = Number.isNaN(title) ? 0 : title
|
|
// With column-reverse, scrollTop is negative — don't clamp to 0
|
|
const top = a.top - b.top + root.scrollTop - inset
|
|
root.scrollTo({ top, behavior })
|
|
return true
|
|
}
|
|
|
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
|
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
|
|
|
const index = messageIndex().get(message.id) ?? -1
|
|
if (index !== -1 && index < input.turnStart()) {
|
|
input.setTurnStart(index)
|
|
|
|
requestAnimationFrame(() => {
|
|
const el = document.getElementById(input.anchor(message.id))
|
|
if (!el) {
|
|
requestAnimationFrame(() => {
|
|
const next = document.getElementById(input.anchor(message.id))
|
|
if (!next) return
|
|
scrollToElement(next, behavior)
|
|
})
|
|
return
|
|
}
|
|
scrollToElement(el, behavior)
|
|
})
|
|
|
|
updateHash(message.id)
|
|
return
|
|
}
|
|
|
|
const el = document.getElementById(input.anchor(message.id))
|
|
if (!el) {
|
|
updateHash(message.id)
|
|
requestAnimationFrame(() => {
|
|
const next = document.getElementById(input.anchor(message.id))
|
|
if (!next) return
|
|
if (!scrollToElement(next, behavior)) return
|
|
})
|
|
return
|
|
}
|
|
if (scrollToElement(el, behavior)) {
|
|
updateHash(message.id)
|
|
return
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
const next = document.getElementById(input.anchor(message.id))
|
|
if (!next) return
|
|
if (!scrollToElement(next, behavior)) return
|
|
})
|
|
updateHash(message.id)
|
|
}
|
|
|
|
const applyHash = (behavior: ScrollBehavior) => {
|
|
const hash = window.location.hash.slice(1)
|
|
if (!hash) {
|
|
input.autoScroll.snapToBottom()
|
|
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
|
|
}
|
|
return
|
|
}
|
|
|
|
const target = document.getElementById(hash)
|
|
if (target) {
|
|
input.autoScroll.pause()
|
|
scrollToElement(target, behavior)
|
|
return
|
|
}
|
|
|
|
input.autoScroll.snapToBottom()
|
|
const el = input.scroller()
|
|
if (el) input.scheduleScrollState(el)
|
|
}
|
|
|
|
onMount(() => {
|
|
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
|
window.history.scrollRestoration = "manual"
|
|
}
|
|
|
|
const handler = () => {
|
|
if (!input.sessionID() || !input.messagesReady()) return
|
|
requestAnimationFrame(() => applyHash("auto"))
|
|
}
|
|
|
|
window.addEventListener("hashchange", handler)
|
|
onCleanup(() => window.removeEventListener("hashchange", handler))
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (!input.sessionID() || !input.messagesReady()) return
|
|
requestAnimationFrame(() => 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) return
|
|
if (input.currentMessageId() === targetId) return
|
|
|
|
const msg = messageById().get(targetId)
|
|
if (!msg) return
|
|
|
|
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
|
|
input.autoScroll.pause()
|
|
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
|
})
|
|
|
|
return {
|
|
clearMessageHash,
|
|
scrollToMessage,
|
|
applyHash,
|
|
}
|
|
}
|