Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dfaa184b6 | |||
| 3783cda020 | |||
| f7b287987b |
@@ -222,7 +222,10 @@ function turn(index: number): Message[] {
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
export const timelineMessages = (count: number, start = 0) =>
|
||||
Array.from({ length: count }, (_, index) => turn(start + index)).flat()
|
||||
|
||||
const targetMessages = timelineMessages(72)
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
@@ -301,6 +304,10 @@ export const fixture = {
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
return pageMessageList(messages, limit, before)
|
||||
}
|
||||
|
||||
export function pageMessageList(messages: Message[], limit: number, before?: string) {
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||
import { fixture, pageMessageList, pageMessages, timelineMessages } from "./session-timeline.fixture"
|
||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
@@ -115,6 +115,73 @@ test.describe("smoke: session timeline", () => {
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("restores the persisted timeline position across tabs and reload", async ({ page }) => {
|
||||
let messages = timelineMessages(140)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID, limit, before) =>
|
||||
sessionID === fixture.targetID ? pageMessageList(messages, limit, before) : pageMessages(sessionID, limit, before),
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
server: "http://127.0.0.1:4096",
|
||||
dirBase64,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
|
||||
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await waitForTimelineStable(page)
|
||||
await pointAtTimeline(page)
|
||||
await page.mouse.wheel(0, -1_000)
|
||||
await expect
|
||||
.poll(() =>
|
||||
timelineScroller(page).evaluate(
|
||||
(element) => element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThan(100)
|
||||
await page.waitForTimeout(500)
|
||||
expect(await timelineScroller(page).evaluate((element) => element.scrollTop)).toBeGreaterThan(100)
|
||||
expect(
|
||||
await timelineScroller(page).evaluate(
|
||||
(element) => element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||
),
|
||||
).toBeGreaterThan(100)
|
||||
const anchor = await firstVisibleTimelineRow(page)
|
||||
expect(anchor?.id).toBeTruthy()
|
||||
expect(anchor?.key).toBeTruthy()
|
||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await expect.poll(() => firstVisibleTimelineRow(page)).toEqual(anchor)
|
||||
messages = [...messages, ...timelineMessages(120, 140)]
|
||||
await page.reload()
|
||||
await waitForTimelineStable(page)
|
||||
await expect.poll(() => timelineScroller(page).evaluate((element) => element.scrollTop)).toBeGreaterThan(100)
|
||||
await expect
|
||||
.poll(() =>
|
||||
timelineScroller(page).evaluate(
|
||||
(element) => element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThan(100)
|
||||
await expect.poll(() => firstVisibleTimelineRow(page)).toEqual(anchor)
|
||||
})
|
||||
|
||||
test("paints cached session tabs at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
@@ -557,6 +624,23 @@ function timelineScroller(page: Page) {
|
||||
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
}
|
||||
|
||||
function firstVisibleTimelineRow(page: Page) {
|
||||
return timelineScroller(page).evaluate((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const row = [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
.map((row) => ({
|
||||
id: row.querySelector<HTMLElement>("[data-message-id]")?.dataset.messageId,
|
||||
key: row.dataset.timelineKey,
|
||||
offset: Math.round(row.getBoundingClientRect().top - box.top),
|
||||
rect: row.getBoundingClientRect(),
|
||||
}))
|
||||
.filter((row) => row.rect.bottom > box.top && row.rect.top < box.bottom)
|
||||
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
||||
if (!row) return
|
||||
return { id: row.id, key: row.key, offset: row.offset }
|
||||
})
|
||||
}
|
||||
|
||||
async function pointAtTimeline(page: Page) {
|
||||
const box = await timelineScroller(page).boundingBox()
|
||||
if (!box) throw new Error("Timeline scroller is not visible")
|
||||
|
||||
@@ -61,4 +61,34 @@ describe("createScrollPersistence", () => {
|
||||
expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
|
||||
scroll.dispose()
|
||||
})
|
||||
|
||||
test("persists semantic scroll anchors", () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let snapshot: Record<string, { x: number; y: number; anchor?: { id: string; key?: string; offset: number } }> = {}
|
||||
const scroll = createScrollPersistence({
|
||||
debounceMs: 10,
|
||||
getSnapshot: () => snapshot,
|
||||
onFlush: (_sessionKey, next) => {
|
||||
snapshot = next
|
||||
},
|
||||
})
|
||||
|
||||
scroll.setScroll("session", "timeline", {
|
||||
x: 1_000,
|
||||
y: 400,
|
||||
anchor: { id: "message-1", key: "assistant-part:message-1:part-2", offset: 24 },
|
||||
})
|
||||
vi.advanceTimersByTime(10)
|
||||
|
||||
expect(snapshot.timeline).toEqual({
|
||||
x: 1_000,
|
||||
y: 400,
|
||||
anchor: { id: "message-1", key: "assistant-part:message-1:part-2", offset: 24 },
|
||||
})
|
||||
scroll.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,11 @@ import { createStore, produce } from "solid-js/store"
|
||||
export type SessionScroll = {
|
||||
x: number
|
||||
y: number
|
||||
anchor?: {
|
||||
id: string
|
||||
key?: string
|
||||
offset: number
|
||||
}
|
||||
}
|
||||
|
||||
type ScrollMap = Record<string, SessionScroll>
|
||||
@@ -26,7 +31,11 @@ export function createScrollPersistence(opts: Options) {
|
||||
for (const key of Object.keys(input)) {
|
||||
const pos = input[key]
|
||||
if (!pos) continue
|
||||
out[key] = { x: pos.x, y: pos.y }
|
||||
out[key] = {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
anchor: pos.anchor ? { id: pos.anchor.id, key: pos.anchor.key, offset: pos.anchor.offset } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
@@ -63,9 +72,20 @@ export function createScrollPersistence(opts: Options) {
|
||||
seed(sessionKey)
|
||||
|
||||
const prev = cache[sessionKey]?.[tab]
|
||||
if (prev?.x === pos.x && prev?.y === pos.y) return
|
||||
if (
|
||||
prev?.x === pos.x &&
|
||||
prev?.y === pos.y &&
|
||||
prev?.anchor?.id === pos.anchor?.id &&
|
||||
prev?.anchor?.key === pos.anchor?.key &&
|
||||
prev?.anchor?.offset === pos.anchor?.offset
|
||||
)
|
||||
return
|
||||
|
||||
setCache(sessionKey, tab, { x: pos.x, y: pos.y })
|
||||
setCache(sessionKey, tab, {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
anchor: pos.anchor ? { id: pos.anchor.id, key: pos.anchor.key, offset: pos.anchor.offset } : undefined,
|
||||
})
|
||||
dirty.add(sessionKey)
|
||||
schedule(sessionKey)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useComments } from "@/context/comments"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import type { SessionScroll } from "@/context/layout-scroll"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
@@ -1076,6 +1077,15 @@ export default function Page() {
|
||||
working: () => true,
|
||||
overflowAnchor: "none",
|
||||
})
|
||||
const timelineScroll = () => view().scroll("timeline")
|
||||
const hasTimelineScroll = createMemo(() => layout.ready() && !!timelineScroll())
|
||||
let timelineScrollSession = ""
|
||||
let timelineRestoreGeneration = 0
|
||||
const timelineScrollTop = () => {
|
||||
const y = timelineScroll()?.y
|
||||
if (y === Number.MAX_SAFE_INTEGER) return
|
||||
return y
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
@@ -1116,9 +1126,41 @@ export default function Page() {
|
||||
if (!target) return
|
||||
|
||||
updateScrollState(target)
|
||||
persistTimelineScroll(target)
|
||||
})
|
||||
}
|
||||
|
||||
const persistTimelineScroll = (el: HTMLDivElement) => {
|
||||
if (!layout.ready() || timelineScrollSession !== sessionKey()) return
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
const box = el.getBoundingClientRect()
|
||||
const anchor = [...el.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
.map((element) => ({
|
||||
element,
|
||||
message: element.querySelector<HTMLElement>("[data-message-id]"),
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter((item) => item.rect.bottom > box.top && item.rect.top < box.bottom)
|
||||
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
||||
view().setScroll("timeline", {
|
||||
x: max,
|
||||
y: max <= 1 || max - el.scrollTop <= 2 ? Number.MAX_SAFE_INTEGER : el.scrollTop,
|
||||
anchor:
|
||||
max > 1 && max - el.scrollTop > 2 && anchor?.message?.dataset.messageId && anchor.element.dataset.timelineKey
|
||||
? {
|
||||
id: anchor.message.dataset.messageId,
|
||||
key: anchor.element.dataset.timelineKey,
|
||||
offset: anchor.rect.top - box.top,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const cancelTimelineScrollRestore = () => {
|
||||
timelineRestoreGeneration += 1
|
||||
timelineScrollSession = sessionKey()
|
||||
}
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.resume()
|
||||
@@ -1516,6 +1558,78 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
|
||||
const restoreTimelineScroll = (saved: SessionScroll) => {
|
||||
const id = params.id
|
||||
const owner = sessionOwnership.capture()
|
||||
const key = sessionKey()
|
||||
const generation = ++timelineRestoreGeneration
|
||||
if (!id) return
|
||||
|
||||
const current = () => owner.current() && timelineRestoreGeneration === generation
|
||||
|
||||
const apply = () =>
|
||||
owner.run(() => {
|
||||
if (!scroller || !current()) return
|
||||
autoScroll.pause()
|
||||
const max = scroller.scrollHeight - scroller.clientHeight
|
||||
const target = saved.anchor?.key
|
||||
? scroller.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(saved.anchor.key)}"]`)
|
||||
: saved.anchor
|
||||
? scroller.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(saved.anchor.id)}"]`)
|
||||
: undefined
|
||||
if (saved.anchor && !target) {
|
||||
revealMessage(saved.anchor.id)
|
||||
return false
|
||||
}
|
||||
const top = target
|
||||
? scroller.scrollTop + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - saved.anchor!.offset
|
||||
: max < saved.y + 100 && !historyMore() && saved.x > 0
|
||||
? (saved.y / saved.x) * max
|
||||
: saved.y
|
||||
const stable = Math.abs(scroller.scrollTop - top) < 1
|
||||
scroller.scrollTop = top
|
||||
scheduleScrollState(scroller)
|
||||
return stable
|
||||
})
|
||||
apply()
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
while (current()) {
|
||||
const found = !saved.anchor || visibleUserMessages().some((message) => message.id === saved.anchor?.id)
|
||||
const tall = !!scroller && scroller.scrollHeight - scroller.clientHeight >= saved.y + 100
|
||||
if ((found && tall) || !historyMore()) break
|
||||
const before = timeline.messages().length
|
||||
await sync().session.history.loadMore(id)
|
||||
if (!current() || timeline.messages().length <= before) break
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
|
||||
apply()
|
||||
}
|
||||
} catch (error) {
|
||||
if (current()) {
|
||||
timelineScrollSession = key
|
||||
console.error("[session] failed to restore timeline scroll", error)
|
||||
}
|
||||
return
|
||||
}
|
||||
apply()
|
||||
let frames = 0
|
||||
let stable = 0
|
||||
const settle = () => {
|
||||
if (!current()) return
|
||||
stable = apply() ? stable + 1 : 0
|
||||
frames += 1
|
||||
if (stable >= 10 || frames >= 180) {
|
||||
timelineScrollSession = key
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(settle)
|
||||
}
|
||||
requestAnimationFrame(settle)
|
||||
}
|
||||
void load()
|
||||
}
|
||||
|
||||
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
@@ -1538,6 +1652,18 @@ export default function Page() {
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
revealMessage: (id) => revealMessage(id),
|
||||
scrollReady: layout.ready,
|
||||
hasSavedScroll: hasTimelineScroll,
|
||||
restoreScroll: () => {
|
||||
const saved = timelineScroll()
|
||||
const el = scroller
|
||||
if (!saved || saved.y === Number.MAX_SAFE_INTEGER || !el) return false
|
||||
restoreTimelineScroll(saved)
|
||||
return true
|
||||
},
|
||||
onApplyScroll: () => {
|
||||
timelineScrollSession = sessionKey()
|
||||
},
|
||||
scheduleScrollState,
|
||||
consumePendingMessage: layout.pendingMessage.consume,
|
||||
})
|
||||
@@ -1734,6 +1860,7 @@ export default function Page() {
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onCancelScrollRestore={cancelTimelineScrollRestore}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
@@ -1741,8 +1868,13 @@ export default function Page() {
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
!location.hash &&
|
||||
!store.messageId &&
|
||||
!ui.pendingMessage &&
|
||||
!autoScroll.userScrolled() &&
|
||||
timelineScrollTop() === undefined
|
||||
}
|
||||
initialScrollTop={timelineScrollTop}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
|
||||
@@ -235,6 +235,7 @@ export function MessageTimeline(props: {
|
||||
onResumeScroll: () => void
|
||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||
onCancelScrollRestore: () => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: () => boolean
|
||||
@@ -242,6 +243,7 @@ export function MessageTimeline(props: {
|
||||
onHistoryScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
shouldAnchorBottom: () => boolean
|
||||
initialScrollTop: () => number | undefined
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
@@ -400,7 +402,7 @@ export function MessageTimeline(props: {
|
||||
return timelineRows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : (props.initialScrollTop() ?? 0)),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
@@ -552,6 +554,7 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
props.onCancelScrollRestore()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
const root = event.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
@@ -564,6 +567,7 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
props.onCancelScrollRestore()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
touchGesture = event.touches[0]?.clientY
|
||||
}
|
||||
@@ -590,11 +594,17 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
props.onCancelScrollRestore()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
if (event.target !== event.currentTarget) return
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent) => {
|
||||
if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) return
|
||||
props.onCancelScrollRestore()
|
||||
}
|
||||
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
@@ -1277,6 +1287,7 @@ export function MessageTimeline(props: {
|
||||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
|
||||
@@ -19,12 +19,17 @@ export const useSessionHashScroll = (input: {
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
revealMessage?: (id: string) => void
|
||||
scrollReady: () => boolean
|
||||
hasSavedScroll: () => boolean
|
||||
restoreScroll: () => boolean
|
||||
onApplyScroll: () => void
|
||||
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])))
|
||||
let pendingKey = ""
|
||||
let restoredKey = ""
|
||||
let clearing = false
|
||||
|
||||
const location = useLocation()
|
||||
@@ -99,14 +104,24 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const key = input.sessionKey()
|
||||
const initial = restoredKey !== key
|
||||
const hash = location.hash.slice(1)
|
||||
if (!hash) {
|
||||
if (initial && input.restoreScroll()) {
|
||||
restoredKey = key
|
||||
return
|
||||
}
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
if (input.scrollReady()) input.onApplyScroll()
|
||||
return
|
||||
}
|
||||
|
||||
restoredKey = key
|
||||
input.onApplyScroll()
|
||||
|
||||
const messageId = messageIdFromHash(hash)
|
||||
if (messageId) {
|
||||
input.autoScroll.pause()
|
||||
@@ -132,6 +147,8 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
createEffect(() => {
|
||||
const hash = location.hash
|
||||
input.scrollReady()
|
||||
input.hasSavedScroll()
|
||||
if (!hash) clearing = false
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
cancel()
|
||||
|
||||
Reference in New Issue
Block a user