import { For, createEffect, createMemo, on, onCleanup, onMount, Show, 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 { 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 type { UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" 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 { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" 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) } } 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 onAutoScrollInteraction: (event: MouseEvent) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number onRenderEarlier: () => void historyMore: boolean historyLoading: boolean onLoadEarlier: () => void renderedUserMessages: UserMessage[] anchor: (id: string) => string onRegisterMessage: (el: HTMLDivElement, id: string) => void onUnregisterMessage: (id: string) => void onFirstTurnMount?: () => void lastUserMessageID?: string }) { let touchGesture: number | undefined const params = useParams() const navigate = useNavigate() const sdk = useSDK() const sync = useSync() const dialog = useDialog() const language = useLanguage() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) 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 [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) 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 overflow-y-auto session-scroller" style={{ "--session-title-height": showHeader() ? "40px" : "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}>
{(message) => { if (import.meta.env.DEV && props.onFirstTurnMount) { onMount(() => props.onFirstTurnMount?.()) } return (
{ props.onRegisterMessage(el, message.id) onCleanup(() => props.onUnregisterMessage(message.id)) }} classList={{ "min-w-0 w-full max-w-full": true, "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} >
) }}
) }