diff options
| author | Adam <[email protected]> | 2026-03-09 07:36:39 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-09 07:36:39 -0500 |
| commit | c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e (patch) | |
| tree | a30482cedb38dc24cad70e24ad717817065620d6 /packages/app/src/pages/session | |
| parent | f27ef595f65aa719be3f8d08665d683e95083ed3 (diff) | |
| download | opencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.tar.gz opencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.zip | |
revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745)
Diffstat (limited to 'packages/app/src/pages/session')
7 files changed, 402 insertions, 992 deletions
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 18a02993b..93ea3d465 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -140,7 +140,7 @@ export function SessionComposerRegion(props: { <div classList={{ "w-full px-3 pointer-events-auto": true, - "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > <Show when={props.state.questionRequest()} keyed> diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 07df4305f..77643789d 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) { ) return ( - <Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict"> + <Tabs.Content value={props.tab} class="mt-3 relative h-full"> <ScrollView - class="h-full min-h-0 flex-1" + class="h-full" viewportRef={(el: HTMLDivElement) => { scroll = el restoreScroll() diff --git a/packages/app/src/pages/session/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts deleted file mode 100644 index 4a9b894e2..000000000 --- a/packages/app/src/pages/session/history-window.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { historyLoadMode, historyRevealTop } from "./history-window" - -describe("historyLoadMode", () => { - test("reveals cached turns before fetching", () => { - expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal") - }) - - test("fetches older history when cache is already revealed", () => { - expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch") - }) - - test("does nothing while history is unavailable or loading", () => { - expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop") - expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop") - }) -}) - -describe("historyRevealTop", () => { - test("pins the viewport to the top when older turns were revealed there", () => { - expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( - -1400, - ) - }) - - test("keeps the latest turns pinned when the viewport was underfilled", () => { - expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0) - }) - - test("keeps the current anchor when the user was not at the top", () => { - expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( - -200, - ) - }) -}) diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts deleted file mode 100644 index e3ef20f13..000000000 --- a/packages/app/src/pages/session/history-window.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { UserMessage } from "@opencode-ai/sdk/v2" -import { createEffect, createMemo, on } from "solid-js" -import { createStore } from "solid-js/store" -import { same } from "@/utils/same" - -export const emptyUserMessages: UserMessage[] = [] - -export type SessionHistoryWindowInput = { - sessionID: () => string | undefined - messagesReady: () => boolean - visibleUserMessages: () => UserMessage[] - historyMore: () => boolean - historyLoading: () => boolean - loadMore: (sessionID: string) => Promise<void> - userScrolled: () => boolean - scroller: () => HTMLDivElement | undefined -} - -type Snap = { - top: number - height: number - gap: number - max: number -} - -export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => { - if (input.start > 0) return "reveal" - if (!input.more || input.loading) return "noop" - return "fetch" -} - -export const historyRevealTop = ( - mark: { top: number; height: number; gap: number; max: number }, - next: { clientHeight: number; height: number }, - threshold = 16, -) => { - const delta = next.height - mark.height - if (delta <= 0) return mark.top - if (mark.max <= 0) return mark.top - if (mark.gap > threshold) return mark.top - - const max = next.height - next.clientHeight - if (max <= 0) return 0 - return Math.max(-max, Math.min(0, mark.top - delta)) -} - -const snap = (el: HTMLDivElement | undefined): Snap | undefined => { - if (!el) return - const max = el.scrollHeight - el.clientHeight - return { - top: el.scrollTop, - height: el.scrollHeight, - gap: max + el.scrollTop, - max, - } -} - -const clamp = (el: HTMLDivElement, top: number) => { - const max = el.scrollHeight - el.clientHeight - if (max <= 0) return 0 - return Math.max(-max, Math.min(0, top)) -} - -const revealThreshold = 16 - -const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => { - const el = input.scroller() - if (!el || !mark) return - el.scrollTop = clamp( - el, - historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold), - ) -} - -const preserve = (input: SessionHistoryWindowInput, fn: () => void) => { - const el = input.scroller() - if (!el) { - fn() - return - } - const top = el.scrollTop - fn() - el.scrollTop = top -} - -/** - * Maintains the rendered history window for a session timeline. - * - * It keeps initial paint bounded to recent turns, reveals cached turns in - * small batches while scrolling upward, and prefetches older history near top. - */ -export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { - const turnInit = 10 - const turnBatch = 8 - const turnScrollThreshold = 200 - const turnPrefetchBuffer = 16 - const prefetchCooldownMs = 400 - const prefetchNoGrowthLimit = 2 - - const [state, setState] = createStore({ - turnID: undefined as string | undefined, - turnStart: 0, - prefetchUntil: 0, - prefetchNoGrowth: 0, - }) - - const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 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 - }) - - const setTurnStart = (start: number) => { - const id = input.sessionID() - const next = start > 0 ? start : 0 - if (!id) { - setState({ turnID: undefined, turnStart: next }) - return - } - setState({ turnID: id, turnStart: next }) - } - - const renderedUserMessages = createMemo( - () => { - const msgs = input.visibleUserMessages() - const start = turnStart() - if (start <= 0) return msgs - return msgs.slice(start) - }, - emptyUserMessages, - { - equals: same, - }, - ) - - const backfillTurns = () => { - const start = turnStart() - if (start <= 0) return - - const next = start - turnBatch - const nextStart = next > 0 ? next : 0 - - preserve(input, () => setTurnStart(nextStart)) - } - - /** Button path: reveal cached turns first, then fetch older history. */ - const loadAndReveal = async () => { - const id = input.sessionID() - if (!id) return - - const start = turnStart() - const mode = historyLoadMode({ - start, - more: input.historyMore(), - loading: input.historyLoading(), - }) - - if (mode === "reveal") { - const mark = snap(input.scroller()) - setTurnStart(0) - reveal(input, mark) - return - } - - if (mode === "noop") return - - const beforeVisible = input.visibleUserMessages().length - const mark = snap(input.scroller()) - - await input.loadMore(id) - if (input.sessionID() !== id) return - - const afterVisible = input.visibleUserMessages().length - const growth = afterVisible - beforeVisible - if (growth <= 0) return - if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) - - reveal(input, mark) - } - - /** Scroll/prefetch path: fetch older history from server. */ - const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { - const id = input.sessionID() - if (!id) return - if (!input.historyMore() || input.historyLoading()) return - - if (opts?.prefetch) { - const now = Date.now() - if (state.prefetchUntil > now) return - if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return - setState("prefetchUntil", now + prefetchCooldownMs) - } - - const start = turnStart() - const beforeVisible = input.visibleUserMessages().length - const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length - - await input.loadMore(id) - if (input.sessionID() !== id) return - - const afterVisible = input.visibleUserMessages().length - const growth = afterVisible - beforeVisible - - if (opts?.prefetch) { - setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) - } else if (growth > 0 && state.prefetchNoGrowth) { - setState("prefetchNoGrowth", 0) - } - - if (growth <= 0) return - if (turnStart() !== start) return - - const revealMore = !opts?.prefetch - const currentRendered = renderedUserMessages().length - const base = Math.max(beforeRendered, currentRendered) - const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base - const nextStart = Math.max(0, afterVisible - target) - preserve(input, () => setTurnStart(nextStart)) - } - - const onScrollerScroll = () => { - if (!input.userScrolled()) return - const el = input.scroller() - if (!el) return - if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return - - const start = turnStart() - if (start > 0) { - if (start <= turnPrefetchBuffer) { - void fetchOlderMessages({ prefetch: true }) - } - backfillTurns() - return - } - - void fetchOlderMessages() - } - - createEffect( - on( - input.sessionID, - () => { - setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => [input.sessionID(), input.messagesReady()] as const, - ([id, ready]) => { - if (!id || !ready) return - setTurnStart(initialTurnStart(input.visibleUserMessages().length)) - }, - { defer: true }, - ), - ) - - return { - turnStart, - setTurnStart, - renderedUserMessages, - loadAndReveal, - onScrollerScroll, - } -} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index e93ca11a3..ce6a01378 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,31 +1,27 @@ -import { - For, - Index, - createEffect, - createMemo, - createSignal, - on, - onCleanup, - Show, - startTransition, - type JSX, -} from "solid-js" -import { createStore } from "solid-js/store" -import { useParams } from "@solidjs/router" +import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { SessionContextUsage } from "@/components/session-context-usage" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" +import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { SessionTimelineHeader } from "@/pages/session/session-timeline-header" type MessageComment = { path: string @@ -37,9 +33,7 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] - -const isDefaultSessionTitle = (title?: string) => - !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) +const idle = { type: "idle" as const } const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { @@ -116,8 +110,6 @@ function createTimelineStaging(input: TimelineStageInput) { completedSession: "", count: 0, }) - const [readySession, setReadySession] = createSignal("") - let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -142,46 +134,23 @@ function createTimelineStaging(input: TimelineStageInput) { cancelAnimationFrame(frame) frame = undefined } - const scheduleReady = (sessionKey: string) => { - if (input.sessionKey() !== sessionKey) return - if (readySession() === sessionKey) return - setReadySession(sessionKey) - } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { - const switched = active !== sessionKey - if (switched) { - active = sessionKey - setReadySession("") - } - - const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey - const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey - - if (staging && !switched && shouldStage && frame !== undefined) return - cancel() - - if (shouldStage) setReadySession("") + const shouldStage = + isWindowed && + total > input.config.init && + state.completedSession !== sessionKey && + state.activeSession !== sessionKey if (!shouldStage) { - setState({ - activeSession: "", - completedSession: isWindowed ? sessionKey : state.completedSession, - count: total, - }) - if (total <= 0) { - setReadySession("") - return - } - if (readySession() !== sessionKey) scheduleReady(sessionKey) + setState({ activeSession: "", count: total }) return } let count = Math.min(total, input.config.init) - if (staging) count = Math.min(total, Math.max(count, state.count)) setState({ activeSession: sessionKey, count }) const step = () => { @@ -191,11 +160,10 @@ function createTimelineStaging(input: TimelineStageInput) { } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) - startTransition(() => setState("count", count)) + setState("count", count) if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined - scheduleReady(sessionKey) return } frame = requestAnimationFrame(step) @@ -209,12 +177,9 @@ function createTimelineStaging(input: TimelineStageInput) { const key = input.sessionKey() return state.activeSession === key && state.completedSession !== key }) - const ready = createMemo(() => readySession() === input.sessionKey()) - onCleanup(() => { - cancel() - }) - return { messages: stagedUserMessages, isStaging, ready } + onCleanup(cancel) + return { messages: stagedUserMessages, isStaging } } export function MessageTimeline(props: { @@ -231,7 +196,6 @@ export function MessageTimeline(props: { onScrollSpyScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void - onPreserveScrollAnchor: (target: HTMLElement) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -246,19 +210,14 @@ export function MessageTimeline(props: { let touchGesture: number | undefined const params = useParams() + const navigate = useNavigate() + const sdk = useSDK() const sync = useSync() const settings = useSettings() + const dialog = useDialog() const language = useLanguage() - const trigger = (target: EventTarget | null) => { - const next = - target instanceof Element - ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]') - : undefined - if (!(next instanceof HTMLElement)) return - return next - } - + const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { @@ -271,20 +230,28 @@ export function MessageTimeline(props: { (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) - const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle") + const sessionStatus = createMemo(() => { + const id = sessionID() + if (!id) return idle + return sync.data.session_status[id] ?? idle + }) const activeMessageID = createMemo(() => { - const messages = sessionMessages() - const message = pending() - if (message?.parentID) { - const result = Binary.search(messages, message.parentID, (item) => item.id) - const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID) - if (parent?.role === "user") return parent.id + const parentID = pending()?.parentID + if (parentID) { + const messages = sessionMessages() + const result = Binary.search(messages, parentID, (message) => message.id) + const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) + if (message && message.role === "user") return message.id } - if (sessionStatus() === "idle") return undefined - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id + const status = sessionStatus() + if (status.type !== "idle") { + const messages = sessionMessages() + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id + } } + return undefined }) const info = createMemo(() => { @@ -292,19 +259,9 @@ export function MessageTimeline(props: { if (!id) return return sync.session.get(id) }) - const titleValue = createMemo(() => { - const title = info()?.title - if (!title) return - if (isDefaultSessionTitle(title)) return language.t("command.session.new") - return title - }) - const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title)) - const headerTitle = createMemo( - () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined), - ) - const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0)) + const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(headerTitle() || parentID())) + const showHeader = createMemo(() => !!(titleValue() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, @@ -312,7 +269,212 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) - const rendered = createMemo(() => staging.messages().map((message) => message.id)) + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + createEffect( + on( + sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!sessionID()) return + setTitle({ editing: true, draft: titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (titleValue() ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID: id, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === id) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set<string>([sessionID]) + + const byParent = new Map<string, string[]>() + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + <Dialog title={language.t("session.delete.title")} fit> + <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> + <div class="flex flex-col gap-1"> + <span class="text-14-regular text-text-strong"> + {language.t("session.delete.confirm", { name: name() })} + </span> + </div> + <div class="flex justify-end gap-2"> + <Button variant="ghost" size="large" onClick={() => dialog.close()}> + {language.t("common.cancel")} + </Button> + <Button variant="primary" size="large" onClick={handleDelete}> + {language.t("session.delete.button")} + </Button> + </div> + </div> + </Dialog> + ) + } return ( <Show @@ -336,18 +498,7 @@ export function MessageTimeline(props: { <Icon name="arrow-down-to-line" /> </button> </div> - <SessionTimelineHeader - centered={props.centered} - showHeader={showHeader} - sessionKey={sessionKey} - sessionID={sessionID} - parentID={parentID} - titleValue={titleValue} - headerTitle={headerTitle} - placeholderTitle={placeholderTitle} - /> <ScrollView - reverse viewportRef={props.setScrollRef} onWheel={(e) => { const root = e.currentTarget @@ -381,18 +532,9 @@ export function MessageTimeline(props: { touchGesture = undefined }} onPointerDown={(e) => { - const next = trigger(e.target) - if (next) props.onPreserveScrollAnchor(next) - if (e.target !== e.currentTarget) return props.onMarkScrollGesture(e.currentTarget) }} - onKeyDown={(e) => { - if (e.key !== "Enter" && e.key !== " ") return - const next = trigger(e.target) - if (!next) return - props.onPreserveScrollAnchor(next) - }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() @@ -401,24 +543,134 @@ export function MessageTimeline(props: { props.onMarkScrollGesture(e.currentTarget) if (props.isDesktop) props.onScrollSpyScroll() }} - onClick={(e) => { - props.onAutoScrollInteraction(e) - }} + onClick={props.onAutoScrollInteraction} class="relative min-w-0 w-full h-full" style={{ - "--session-title-height": showHeader() ? "72px" : "0px", + "--session-title-height": showHeader() ? "40px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > - <div> + <div ref={props.setContentRef} class="min-w-0 w-full"> + <Show when={showHeader()}> + <div + data-session-title + classList={{ + "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, + "w-full": true, + "pb-4": true, + "pl-2 pr-3 md:pl-4 md:pr-3": true, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, + }} + > + <div class="h-12 w-full flex items-center justify-between gap-2"> + <div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> + <Show when={parentID()}> + <IconButton + tabIndex={-1} + icon="arrow-left" + variant="ghost" + onClick={navigateParent} + aria-label={language.t("common.goBack")} + /> + </Show> + <Show when={titleValue() || title.editing}> + <Show + when={title.editing} + fallback={ + <h1 + class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" + onDblClick={openTitleEditor} + > + {titleValue()} + </h1> + } + > + <InlineInput + ref={(el) => { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + </Show> + </Show> + </div> + <Show when={sessionID()}> + {(id) => ( + <div class="shrink-0 flex items-center gap-3"> + <SessionContextUsage placement="bottom" /> + <DropdownMenu + gutter={4} + placement="bottom-end" + open={title.menuOpen} + onOpenChange={(open) => setTitle("menuOpen", open)} + > + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="size-6 rounded-md data-[expanded]:bg-surface-base-active" + aria-label={language.t("common.moreOptions")} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content + style={{ "min-width": "104px" }} + onCloseAutoFocus={(event) => { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + <DropdownMenu.Item + onSelect={() => { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item onSelect={() => void archiveSession(id())}> + <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Separator /> + <DropdownMenu.Item + onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)} + > + <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + )} + </Show> + </div> + </div> + </Show> + <div - ref={props.setContentRef} role="log" - class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]" - style={{ "padding-top": "var(--session-title-height)" }} + class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" classList={{ "w-full": true, - "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "mt-0.5": props.centered, "mt-0": !props.centered, }} @@ -440,15 +692,6 @@ export function MessageTimeline(props: { </Show> <For each={rendered()}> {(messageID) => { - // Capture at creation time: animate only messages added after the - // timeline finishes its initial backfill staging, plus the first - // turn while a brand new session is still using its default title. - const isNew = - staging.ready() || - (defaultTitle() && - sessionStatus() !== "idle" && - props.renderedUserMessages.length === 1 && - messageID === props.renderedUserMessages[0]?.id) const active = createMemo(() => activeMessageID() === messageID) const queued = createMemo(() => { if (active()) return false @@ -457,10 +700,7 @@ export function MessageTimeline(props: { return false }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => { - if (a.length !== b.length) return false - return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment) - }, + equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), }) const commentCount = createMemo(() => comments().length) return ( @@ -473,7 +713,7 @@ export function MessageTimeline(props: { }} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-[500px] 2xl:max-w-[700px]": props.centered, + "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} > <Show when={commentCount() > 0}> @@ -517,7 +757,7 @@ export function MessageTimeline(props: { messageID={messageID} active={active()} queued={queued()} - animate={isNew || active()} + status={active() ? sessionStatus() : undefined} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx deleted file mode 100644 index 32412f0a7..000000000 --- a/packages/app/src/pages/session/session-timeline-header.tsx +++ /dev/null @@ -1,522 +0,0 @@ -import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" -import { Button } from "@opencode-ai/ui/button" -import { useReducedMotion } from "@opencode-ai/ui/hooks" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Dialog } from "@opencode-ai/ui/dialog" -import { InlineInput } from "@opencode-ai/ui/inline-input" -import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" -import { showToast } from "@opencode-ai/ui/toast" -import { errorMessage } from "@/pages/layout/helpers" -import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" - -export function SessionTimelineHeader(props: { - centered: boolean - showHeader: () => boolean - sessionKey: () => string - sessionID: () => string | undefined - parentID: () => string | undefined - titleValue: () => string | undefined - headerTitle: () => string | undefined - placeholderTitle: () => boolean -}) { - const navigate = useNavigate() - const params = useParams() - const sdk = useSDK() - const sync = useSync() - const dialog = useDialog() - const language = useLanguage() - const reduce = useReducedMotion() - - const [title, setTitle] = createStore({ - draft: "", - editing: false, - saving: false, - menuOpen: false, - pendingRename: false, - }) - const [headerText, setHeaderText] = createStore({ - session: props.sessionKey(), - value: props.headerTitle(), - prev: undefined as string | undefined, - muted: props.placeholderTitle(), - prevMuted: false, - }) - let headerAnim: AnimationPlaybackControls | undefined - let enterAnim: AnimationPlaybackControls | undefined - let leaveAnim: AnimationPlaybackControls | undefined - let titleRef: HTMLInputElement | undefined - let headerRef: HTMLDivElement | undefined - let enterRef: HTMLSpanElement | undefined - let leaveRef: HTMLSpanElement | undefined - - const clearHeaderAnim = () => { - headerAnim?.stop() - headerAnim = undefined - } - - const animateHeader = () => { - const el = headerRef - if (!el) return - - clearHeaderAnim() - if (!headerText.muted || reduce()) { - el.style.opacity = "1" - return - } - - headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 }) - headerAnim.finished.then(() => { - if (headerRef !== el) return - clearFadeStyles(el) - }) - } - - const clearTitleAnims = () => { - enterAnim?.stop() - enterAnim = undefined - leaveAnim?.stop() - leaveAnim = undefined - } - - const settleTitleEnter = () => { - if (enterRef) clearFadeStyles(enterRef) - } - - const hideLeave = () => { - if (!leaveRef) return - leaveRef.style.opacity = "0" - leaveRef.style.filter = "" - leaveRef.style.transform = "" - } - - const animateEnterSpan = () => { - if (!enterRef) return - if (reduce()) { - settleTitleEnter() - return - } - enterAnim = animate( - enterRef, - { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] }, - FAST_SPRING, - ) - enterAnim.finished.then(() => settleTitleEnter()) - } - - const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => { - clearTitleAnims() - setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) - setHeaderText({ value: nextTitle, muted: nextMuted }) - - if (reduce()) { - setHeaderText({ prev: undefined, prevMuted: false }) - hideLeave() - settleTitleEnter() - return - } - - if (leaveRef) { - leaveAnim = animate( - leaveRef, - { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] }, - FAST_SPRING, - ) - leaveAnim.finished.then(() => { - setHeaderText({ prev: undefined, prevMuted: false }) - hideLeave() - }) - } - - animateEnterSpan() - } - - const fadeInTitle = (nextTitle: string, nextMuted: boolean) => { - clearTitleAnims() - setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) - animateEnterSpan() - } - - const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => { - clearTitleAnims() - setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) - settleTitleEnter() - } - - createEffect( - on(props.showHeader, (show, prev) => { - if (!show) { - clearHeaderAnim() - return - } - if (show === prev) return - animateHeader() - }), - ) - - createEffect( - on( - () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const, - ([nextSession, nextTitle, nextMuted]) => { - if (nextSession !== headerText.session) { - setHeaderText("session", nextSession) - if (nextTitle && nextMuted) { - fadeInTitle(nextTitle, nextMuted) - return - } - snapTitle(nextTitle, nextMuted) - return - } - if (nextTitle === headerText.value && nextMuted === headerText.muted) return - if (!nextTitle) { - snapTitle(undefined, false) - return - } - if (!headerText.value) { - fadeInTitle(nextTitle, nextMuted) - return - } - if (title.saving || title.editing) { - snapTitle(nextTitle, nextMuted) - return - } - crossfadeTitle(nextTitle, nextMuted) - }, - ), - ) - - onCleanup(() => { - clearHeaderAnim() - clearTitleAnims() - }) - - const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) - - createEffect( - on( - props.sessionKey, - () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), - { defer: true }, - ), - ) - - const openTitleEditor = () => { - if (!props.sessionID()) return - setTitle({ editing: true, draft: props.titleValue() ?? "" }) - requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() - }) - } - - const closeTitleEditor = () => { - if (title.saving) return - setTitle({ editing: false, saving: false }) - } - - const saveTitleEditor = async () => { - const id = props.sessionID() - if (!id) return - if (title.saving) return - - const next = title.draft.trim() - if (!next || next === (props.titleValue() ?? "")) { - setTitle({ editing: false, saving: false }) - return - } - - setTitle("saving", true) - await sdk.client.session - .update({ sessionID: id, title: next }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((session) => session.id === id) - if (index !== -1) draft.session[index].title = next - }), - ) - setTitle({ editing: false, saving: false }) - }) - .catch((err) => { - setTitle("saving", false) - showToast({ - title: language.t("common.requestFailed"), - description: toastError(err), - }) - }) - } - - const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return - if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) - return - } - if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) - return - } - navigate(`/${params.dir}/session`) - } - - const archiveSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return - - const sessions = sync.data.session ?? [] - const index = sessions.findIndex((item) => item.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - await sdk.client.session - .update({ sessionID, time: { archived: Date.now() } }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((item) => item.id === sessionID) - if (index !== -1) draft.session.splice(index, 1) - }), - ) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: toastError(err), - }) - }) - } - - const deleteSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return false - - const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived) - const index = sessions.findIndex((item) => item.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - const result = await sdk.client.session - .delete({ sessionID }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: toastError(err), - }) - return false - }) - - if (!result) return false - - sync.set( - produce((draft) => { - const removed = new Set<string>([sessionID]) - const byParent = new Map<string, string[]>() - - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((item) => !removed.has(item.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - const navigateParent = () => { - const id = props.parentID() - if (!id) return - navigate(`/${params.dir}/session/${id}`) - } - - function DialogDeleteSession(input: { sessionID: string }) { - const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new")) - - const handleDelete = async () => { - await deleteSession(input.sessionID) - dialog.close() - } - - return ( - <Dialog title={language.t("session.delete.title")} fit> - <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> - <div class="flex flex-col gap-1"> - <span class="text-14-regular text-text-strong"> - {language.t("session.delete.confirm", { name: name() })} - </span> - </div> - <div class="flex justify-end gap-2"> - <Button variant="ghost" size="large" onClick={() => dialog.close()}> - {language.t("common.cancel")} - </Button> - <Button variant="primary" size="large" onClick={handleDelete}> - {language.t("session.delete.button")} - </Button> - </div> - </div> - </Dialog> - ) - } - - return ( - <Show when={props.showHeader()}> - <div - data-session-title - ref={(el) => { - headerRef = el - el.style.opacity = "0" - }} - class="pointer-events-none absolute inset-x-0 top-0 z-30" - > - <div - classList={{ - "bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true, - "w-full": true, - "pb-10": true, - "px-4 md:px-5": true, - "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered, - }} - > - <div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2"> - <div class="flex items-center gap-1 min-w-0 flex-1"> - <Show when={props.parentID()}> - <div> - <IconButton - tabIndex={-1} - icon="arrow-left" - variant="ghost" - onClick={navigateParent} - aria-label={language.t("common.goBack")} - /> - </div> - </Show> - <Show when={!!headerText.value || title.editing}> - <Show - when={title.editing} - fallback={ - <h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}> - <span class="grid min-w-0" style={{ overflow: "clip" }}> - <span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate"> - <span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span> - </span> - <span - ref={leaveRef} - class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none" - style={{ opacity: "0" }} - > - <span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span> - </span> - </span> - </h1> - } - > - <InlineInput - ref={(el) => { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> - </Show> - </Show> - </div> - <Show when={props.sessionID()}> - {(id) => ( - <div class="shrink-0 flex items-center gap-3"> - <SessionContextUsage placement="bottom" /> - <DropdownMenu - gutter={4} - placement="bottom-end" - open={title.menuOpen} - onOpenChange={(open) => setTitle("menuOpen", open)} - > - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="size-6 rounded-md data-[expanded]:bg-surface-base-active" - aria-label={language.t("common.moreOptions")} - /> - <DropdownMenu.Portal> - <DropdownMenu.Content - style={{ "min-width": "104px" }} - onCloseAutoFocus={(event) => { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - <DropdownMenu.Item - onSelect={() => { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Item onSelect={() => void archiveSession(id())}> - <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Separator /> - <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}> - <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - </div> - )} - </Show> - </div> - </div> - </div> - </Show> - ) -} 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 278a1ba6e..20e88a3ea 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,5 +1,6 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" -import { createEffect, createMemo, onCleanup, onMount } from "solid-js" +import { useLocation, useNavigate } from "@solidjs/router" +import { createEffect, createMemo, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" export { messageIdFromHash } from "./message-id-from-hash" @@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: { setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; snapToBottom: () => void } + autoScroll: { pause: () => void; forceScrollToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void @@ -26,13 +27,18 @@ export const useSessionHashScroll = (input: { const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" + const location = useLocation() + const navigate = useNavigate() + const clearMessageHash = () => { - if (!window.location.hash) return - window.history.replaceState(null, "", window.location.pathname + window.location.search) + if (!location.hash) return + navigate(location.pathname + location.search, { replace: true }) } const updateHash = (id: string) => { - window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`) + navigate(location.pathname + location.search + `#${input.anchor(id)}`, { + replace: true, + }) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -41,15 +47,15 @@ export const useSessionHashScroll = (input: { 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 + 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 scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + console.log({ message, behavior }) if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 @@ -97,9 +103,9 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { - const hash = window.location.hash.slice(1) + const hash = location.hash.slice(1) if (!hash) { - input.autoScroll.snapToBottom() + input.autoScroll.forceScrollToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) return @@ -123,26 +129,13 @@ export const useSessionHashScroll = (input: { return } - input.autoScroll.snapToBottom() + input.autoScroll.forceScrollToBottom() 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(() => { + location.hash if (!input.sessionID() || !input.messagesReady()) return requestAnimationFrame(() => applyHash("auto")) }) @@ -166,6 +159,7 @@ export const useSessionHashScroll = (input: { } } + if (!targetId) targetId = messageIdFromHash(location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return @@ -177,6 +171,12 @@ export const useSessionHashScroll = (input: { requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } + }) + return { clearMessageHash, scrollToMessage, |
