import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" 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 { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { TextField } from "@opencode-ai/ui/text-field" 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 { Popover as KobaltePopover } from "@kobalte/core/popover" 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 { useSessionKey } from "@/pages/session/session-layout" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { makeTimer } from "@solid-primitives/timer" type MessageComment = { path: string comment: string selection?: { startLine: number endLine: number } } const emptyMessages: MessageType[] = [] const idle = { type: "idle" as const } type UserActions = { fork?: (input: { sessionID: string; messageID: string }) => Promise | void revert?: (input: { sessionID: string; messageID: string }) => Promise | void } 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) 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 actions?: UserActions scroll: { overflow: boolean; bottom: boolean; jump: boolean } onResumeScroll: () => void setScrollRef: (el: HTMLDivElement | undefined) => void onScheduleScrollState: (el: HTMLDivElement) => void onAutoScrollHandleScroll: () => void onMarkScrollGesture: (target?: EventTarget | null) => void hasScrollGesture: () => boolean onUserScroll: () => 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 }) { let touchGesture: number | undefined const navigate = useNavigate() const globalSDK = useGlobalSDK() const sdk = useSDK() const sync = useSync() const settings = useSettings() const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() const platform = usePlatform() const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.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 working = createMemo(() => !!pending() || sessionStatus().type !== "idle") const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const [timeoutDone, setTimeoutDone] = createSignal(true) const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => { if (working()) return "showing" if (prev === "showing" || !timeoutDone()) return "hiding" return "hidden" }) createEffect(() => { if (workingStatus() !== "hiding") return setTimeoutDone(false) makeTimer(() => setTimeoutDone(true), 260, setTimeout) }) 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 titleLabel = createMemo(() => sessionTitle(titleValue())) const shareUrl = createMemo(() => info()?.share?.url) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") 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, menuOpen: false, pendingRename: false, pendingShare: false, }) let titleRef: HTMLInputElement | undefined const [share, setShare] = createStore({ open: false, dismiss: null as "escape" | "outside" | null, }) let more: HTMLButtonElement | undefined const viewShare = () => { const url = shareUrl() if (!url) return platform.openLink(url) } 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") } const shareMutation = useMutation(() => ({ mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }), onError: (err) => { console.error("Failed to share session", err) }, })) const unshareMutation = useMutation(() => ({ mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }), onError: (err) => { console.error("Failed to unshare session", err) }, })) const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => sdk.client.session.update({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync.set( produce((draft) => { const index = draft.session.findIndex((s) => s.id === input.id) if (index !== -1) draft.session[index].title = input.title }), ) setTitle("editing", false) }, onError: (err) => { showToast({ title: language.t("common.requestFailed"), description: errorMessage(err), }) }, })) const shareSession = () => { const id = sessionID() if (!id || shareMutation.isPending) return if (!shareEnabled()) return shareMutation.mutate(id) } const unshareSession = () => { const id = sessionID() if (!id || unshareMutation.isPending) return if (!shareEnabled()) return unshareMutation.mutate(id) } createEffect( on( sessionKey, () => setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false, pendingShare: false, }), { defer: true }, ), ) const openTitleEditor = () => { if (!sessionID()) return setTitle({ editing: true, draft: titleLabel() ?? "" }) requestAnimationFrame(() => { titleRef?.focus() titleRef?.select() }) } const closeTitleEditor = () => { if (titleMutation.isPending) return setTitle("editing", false) } const saveTitleEditor = () => { const id = sessionID() if (!id) return if (titleMutation.isPending) return const next = title.draft.trim() if (!next || next === (titleLabel() ?? "")) { setTitle("editing", false) return } titleMutation.mutate({ id, title: next }) } 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( () => sessionTitle(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.onUserScroll() props.onAutoScrollHandleScroll() props.onMarkScrollGesture(e.currentTarget) }} 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", }} >
{titleLabel()} } > { titleRef = el }} value={title.draft} disabled={titleMutation.isPending} 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} />
{(id) => (
{ setTitle("menuOpen", open) if (open) return }} > { more = el }} /> { if (title.pendingRename) { event.preventDefault() setTitle("pendingRename", false) openTitleEditor() return } if (title.pendingShare) { event.preventDefault() requestAnimationFrame(() => { setShare({ open: true, dismiss: null }) setTitle("pendingShare", false) }) } }} > { setTitle("pendingRename", true) setTitle("menuOpen", false) }} > {language.t("common.rename")} { setTitle({ pendingShare: true, menuOpen: false }) }} > {language.t("session.share.action.share")} void archiveSession(id())}> {language.t("common.archive")} dialog.show(() => )} > {language.t("common.delete")} more} placement="bottom-end" gutter={4} modal={false} onOpenChange={(open) => { if (open) setShare("dismiss", null) setShare("open", open) }} > { setShare({ dismiss: "escape", open: false }) event.preventDefault() event.stopPropagation() }} onPointerDownOutside={() => { setShare({ dismiss: "outside", open: false }) }} onFocusOutside={() => { setShare({ dismiss: "outside", open: false }) }} onCloseAutoFocus={(event) => { if (share.dismiss === "outside") event.preventDefault() setShare("dismiss", null) }} >
{language.t("session.share.popover.title")}
{shareUrl() ? language.t("session.share.popover.description.shared") : language.t("session.share.popover.description.unshared")}
{shareMutation.isPending ? language.t("session.share.action.publishing") : language.t("session.share.action.publish")} } >
)}
0 || props.historyMore}>
{(messageID) => { const active = createMemo(() => activeMessageID() === messageID) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { equals: (a, b) => a.length === b.length && a.every( (c, i) => c.path === b[i].path && c.comment === b[i].comment && c.selection?.startLine === b[i].selection?.startLine && c.selection?.endLine === b[i].selection?.endLine, ), }) const commentCount = createMemo(() => comments().length) return (
0}>
{(commentAccessor: () => MessageComment) => { const comment = createMemo(() => commentAccessor()) return ( {(c) => (
{getFilename(c().path)} {(selection) => ( {selection().startLine === selection().endLine ? `:${selection().startLine}` : `:${selection().startLine}-${selection().endLine}`} )}
{c().comment}
)}
) }}
) }}
) }