import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, 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" type MessageComment = { path: string comment: string selection?: { startLine: number endLine: number } } const emptyMessages: MessageType[] = [] const idle = { type: "idle" as const } const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { if (part.type !== "text" || !(part as TextPart).synthetic) return [] const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) if (!next) return [] return [ { path: next.path, comment: next.comment, selection: next.selection ? { startLine: next.selection.startLine, endLine: next.selection.endLine, } : undefined, }, ] }) const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined const nested = current?.closest("[data-scrollable]") if (!nested || nested === root) return root if (!(nested instanceof HTMLElement)) return root return nested } const markBoundaryGesture = (input: { root: HTMLDivElement target: EventTarget | null delta: number onMarkScrollGesture: (target?: EventTarget | null) => void }) => { const target = boundaryTarget(input.root, input.target) if (target === input.root) { input.onMarkScrollGesture(input.root) return } if ( shouldMarkBoundaryGesture({ delta: input.delta, scrollTop: target.scrollTop, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, }) ) { input.onMarkScrollGesture(input.root) } } type StageConfig = { init: number batch: number } type TimelineStageInput = { sessionKey: () => string turnStart: () => number messages: () => UserMessage[] config: StageConfig } /** * Defer-mounts small timeline windows so revealing older turns does not * block first paint with a large DOM mount. * * Once staging completes for a session it never re-stages — backfill and * new messages render immediately. */ function createTimelineStaging(input: TimelineStageInput) { const [state, setState] = createStore({ activeSession: "", completedSession: "", count: 0, }) const stagedCount = createMemo(() => { const total = input.messages().length if (input.turnStart() <= 0) return total if (state.completedSession === input.sessionKey()) return total const init = Math.min(total, input.config.init) if (state.count <= init) return init if (state.count >= total) return total return state.count }) const stagedUserMessages = createMemo(() => { const list = input.messages() const count = stagedCount() if (count >= list.length) return list return list.slice(Math.max(0, list.length - count)) }) let frame: number | undefined const cancel = () => { if (frame === undefined) return cancelAnimationFrame(frame) frame = undefined } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { cancel() const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey && state.activeSession !== sessionKey if (!shouldStage) { setState({ activeSession: "", count: total }) return } let count = Math.min(total, input.config.init) setState({ activeSession: sessionKey, count }) const step = () => { if (input.sessionKey() !== sessionKey) { frame = undefined return } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) startTransition(() => setState("count", count)) if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined return } frame = requestAnimationFrame(step) } frame = requestAnimationFrame(step) }, ), ) const isStaging = createMemo(() => { const key = input.sessionKey() return state.activeSession === key && state.completedSession !== key }) onCleanup(cancel) return { messages: stagedUserMessages, isStaging } } export function MessageTimeline(props: { mobileChanges: boolean mobileFallback: JSX.Element scroll: { overflow: boolean; bottom: boolean } onResumeScroll: () => void setScrollRef: (el: HTMLDivElement | undefined) => void onScheduleScrollState: (el: HTMLDivElement) => void onAutoScrollHandleScroll: () => void onMarkScrollGesture: (target?: EventTarget | null) => void hasScrollGesture: () => boolean isDesktop: boolean onScrollSpyScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number historyMore: boolean historyLoading: boolean onLoadEarlier: () => void renderedUserMessages: UserMessage[] anchor: (id: string) => string onRegisterMessage: (el: HTMLDivElement, id: string) => void onUnregisterMessage: (id: string) => void }) { 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 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(() => { const id = sessionID() if (!id) return emptyMessages return sync.data.message[id] ?? emptyMessages }) const pending = createMemo(() => sessionMessages().findLast( (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) const sessionStatus = createMemo(() => { const id = sessionID() if (!id) return idle return sync.data.session_status[id] ?? idle }) const activeMessageID = createMemo(() => { 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 } 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(() => { const id = sessionID() if (!id) return return sync.session.get(id) }) const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) const showHeader = createMemo(() => !!(titleValue() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, turnStart: () => props.turnStart, messages: () => props.renderedUserMessages, config: stageCfg, }) 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([sessionID]) const byParent = new Map() 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 (
{language.t("session.delete.confirm", { name: name() })}
) } return ( {props.mobileFallback}} >
{ const root = e.currentTarget const delta = normalizeWheelDelta({ deltaY: e.deltaY, deltaMode: e.deltaMode, rootHeight: root.clientHeight, }) if (!delta) return markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) }} onTouchStart={(e) => { touchGesture = e.touches[0]?.clientY }} onTouchMove={(e) => { const next = e.touches[0]?.clientY const prev = touchGesture touchGesture = next if (next === undefined || prev === undefined) return const delta = prev - next if (!delta) return const root = e.currentTarget markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) }} onTouchEnd={() => { touchGesture = undefined }} onTouchCancel={() => { touchGesture = undefined }} onPointerDown={(e) => { if (e.target !== e.currentTarget) return props.onMarkScrollGesture(e.currentTarget) }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() if (!props.hasScrollGesture()) return props.onAutoScrollHandleScroll() props.onMarkScrollGesture(e.currentTarget) if (props.isDesktop) props.onScrollSpyScroll() }} onClick={props.onAutoScrollInteraction} class="relative min-w-0 w-full h-full" style={{ "--session-title-height": showHeader() ? "40px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} >
{titleValue()} } > { 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} />
{(id) => (
setTitle("menuOpen", open)} > { if (!title.pendingRename) return event.preventDefault() setTitle("pendingRename", false) openTitleEditor() }} > { setTitle("pendingRename", true) setTitle("menuOpen", false) }} > {language.t("common.rename")} void archiveSession(id())}> {language.t("common.archive")} dialog.show(() => )} > {language.t("common.delete")}
)}
0 || props.historyMore}>
{(messageID) => { const active = createMemo(() => activeMessageID() === messageID) const queued = createMemo(() => { if (active()) return false const activeID = activeMessageID() if (activeID) return messageID > activeID return false }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), }) const commentCount = createMemo(() => comments().length) return (
{ props.onRegisterMessage(el, messageID) onCleanup(() => props.onUnregisterMessage(messageID)) }} classList={{ "min-w-0 w-full max-w-full": true, "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} > 0}>
{(commentAccessor: () => MessageComment) => { const comment = createMemo(() => commentAccessor()) return (
{getFilename(comment().path)} {(selection) => ( {selection().startLine === selection().endLine ? `:${selection().startLine}` : `:${selection().startLine}-${selection().endLine}`} )}
{comment().comment}
) }}
) }}
) }