import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type Accessor, type JSX, } from "solid-js" import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { Accordion } from "@opencode-ai/ui/accordion" import { Button } from "@opencode-ai/ui/button" import { Card } from "@opencode-ai/ui/card" import { ContextToolGroup, Message, MessageDivider, Part as MessagePart, partDefaultOpen, type UserActions, } from "@opencode-ai/ui/message-part" import { DiffChanges } from "@opencode-ai/ui/diff-changes" 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 { SessionRetry } from "@opencode-ai/ui/session-retry" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { TextField } from "@opencode-ai/ui/text-field" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import type { AssistantMessage, Message as MessageType, Part as PartType, ToolPart, UserMessage, } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/core/util/binary" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { normalize } from "@opencode-ai/ui/session-diff" import { useFileComponent } from "@opencode-ai/ui/context/file" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" import { useServerSDK } from "@/context/server-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 { makeTimer } from "@solid-primitives/timer" import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] const idle = { type: "idle" as const } type FramedTimelineRow = Exclude type TimelineRowByTag = Extract function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) { if (a === b) return true if (!a || !b) return false if (a.length !== b.length) return false return a.every((key, index) => key === b[index]) } const timelineCacheLimit = 16 const timelineFallbackItemSize = 60 const timelineCache = new Map() function readTimelineCache(id: string, keys: readonly string[]) { const entry = timelineCache.get(id) if (!entry) return if (sameKeys(entry.keys, keys)) return entry.cache timelineCache.delete(id) } function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) { if (!handle || keys.length === 0) return timelineCache.delete(id) timelineCache.set(id, { keys: keys.slice(), cache: handle.cache }) while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!) } function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { if (!previous?.length) return rows const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) return rows.map((row) => { const existing = byKey.get(TimelineRow.key(row)) if (!existing) return row return TimelineRow.equals(existing, row) ? existing : row }) } const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return const metadata = "metadata" in part.state ? part.state.metadata : undefined if (metadata?.sessionId !== sessionID) return const value = part.state.input?.description if (typeof value === "string" && value) return value } const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900))) 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) } } function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) { const language = useLanguage() return (
) } function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { const language = useLanguage() const maxFiles = 10 const [state, setState] = createStore({ showAll: false, expanded: [] as string[], }) const showAll = () => state.showAll const expanded = () => state.expanded const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles)) const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles))) return (
{props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")}{" "} {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")} 0}> setState("showAll", !showAll())}> {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
setState("expanded", Array.isArray(value) ? value : value ? [value] : [])} > {(diff) => { const opened = createMemo(() => expanded().includes(diff.file)) return (
{`\u202A${getDirectory(diff.file)}\u202C`} {getFilename(diff.file)}
) }}
0}>
setState("showAll", true)}> {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
) } function TimelineDiffView(props: { diff: SummaryDiff }) { const fileComponent = useFileComponent() const view = normalize(props.diff) return (
) } export function MessageTimeline(props: { 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 onHistoryScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void shouldAnchorBottom: () => boolean centered: boolean setContentRef: (el: HTMLDivElement) => void historyShift: boolean userMessages: UserMessage[] anchor: (id: string) => string setRevealMessage?: (fn: (id: string) => void) => void }) { let touchGesture: number | undefined const navigate = useNavigate() const serverSDK = useServerSDK() const sdk = useSDK() const sync = useSync() const settings = useSettings() const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() const platform = usePlatform() let virtualizer: VirtualizerHandle | undefined const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { const id = sessionID() if (!id) return emptyMessages return sync.data.message[id] ?? emptyMessages }) const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const))) const assistantMessagesByParent = createMemo(() => { const result = new Map() for (const message of sessionMessages()) { if (message.role !== "assistant") continue const messages = result.get(message.parentID) if (messages) { messages.push(message) continue } result.set(message.parentID, [message]) } return result }) 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(() => 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 parent = createMemo(() => { const id = parentID() if (!id) return return sync.session.get(id) }) const parentMessages = createMemo(() => { const id = parentID() if (!id) return emptyMessages return sync.data.message[id] ?? emptyMessages }) const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts const childTaskDescription = createMemo(() => { const id = sessionID() if (!id) return return parentMessages() .flatMap((message) => getMsgParts(message.id)) .map((part) => taskDescription(part, id)) .findLast((value): value is string => !!value) }) const childTitle = createMemo(() => { if (!parentID()) return titleLabel() ?? "" if (childTaskDescription()) return childTaskDescription() const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "") if (value) return value return language.t("command.session.new") }) const showHeader = createMemo(() => !!(titleValue() || parentID())) const messageRowMemos = createMemo( mapArray( () => props.userMessages, (userMessage, indexAccessor) => { return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { const rows = Timeline.constructMessageRows( userMessage, getMsgParts, assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, indexAccessor(), settings.general.showReasoningSummaries(), sessionStatus().type, activeMessageID() === userMessage.id, ) return reuseTimelineRows(previous, rows) }) }, ), ) const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { const rows = messageRowMemos().flatMap((memo) => memo()) if (rows.length === 0) return rows return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()]) }) const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys }) const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys())) const messageRowIndex = createMemo(() => { const result = new Map() timelineRows().forEach((row, index) => { if (!("userMessageID" in row)) return if (result.has(row.userMessageID)) return result.set(row.userMessageID, index) }) return result }) const lastAssistantGroupKey = createMemo(() => { const result = new Map() timelineRows().forEach((row) => { if (row._tag !== "AssistantPart") return result.set(row.userMessageID, row.group.key) }) return result }) const keepMounted = createMemo(() => { const id = activeMessageID() if (!id) return const rows = timelineRows() const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id) if (index < 0) return return [index] }) const activeAssistantMessages = createMemo(() => { const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id if (!id) return emptyAssistantMessages return assistantMessagesByParent().get(id) ?? emptyAssistantMessages }) const activeAssistantContentVersion = createMemo(() => activeAssistantMessages() .flatMap((message) => [ `${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`, ...getMsgParts(message.id).map((part) => { if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}` if (part.type === "tool") { const metadata = "metadata" in part.state ? part.state.metadata : undefined const output = "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0 const metadataOutput = metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string" ? metadata.output.length : 0 return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}` } return `${part.id}:${part.type}` }), ]) .join("|"), ) createEffect( on( () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const, () => { if (!virtualizer) return if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return const keys = timelineRowKeys() if (keys.length === 0) return virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) scheduleMeasuredBottomAnchor() }, { defer: true }, ), ) createEffect(() => { props.setRevealMessage?.((id) => { const index = messageRowIndex().get(id) if (index === undefined) return virtualizer?.scrollToIndex(index, { align: "center" }) }) }) let cacheSessionKey = sessionKey() let cacheRowKeys = timelineRowKeys() let virtualizerSessionKey = cacheSessionKey let virtualizerRowKeys = cacheRowKeys let bottomAnchorSessionKey = "" const maybeAnchorBottom = () => { const key = sessionKey() if (bottomAnchorSessionKey === key) return if (!virtualizer) return const keys = timelineRowKeys() if (keys.length === 0) return bottomAnchorSessionKey = key if (!props.shouldAnchorBottom()) return virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) } createEffect( on( () => [sessionKey(), timelineRowKeys()] as const, (next, prev) => { if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer) cacheSessionKey = next[0] cacheRowKeys = next[1] if (virtualizer) { virtualizerSessionKey = cacheSessionKey virtualizerRowKeys = cacheRowKeys maybeAnchorBottom() } }, { defer: true }, ), ) onCleanup(() => { writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) props.setRevealMessage?.(() => {}) }) 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, }) const [bar, setBar] = createStore({ ms: pace(640), }) const [toolOpen, setToolOpen] = createStore>({}) let more: HTMLButtonElement | undefined let head: HTMLDivElement | undefined let listRoot: HTMLDivElement | undefined let listFrame: number | undefined let contentFrame: number | undefined let bottomAnchorFrame: number | undefined let bottomAnchorFrames = 0 let measuredBottomAnchored = true const [scrollRoot, setScrollRoot] = createSignal() const updateTitleMetrics = () => { if (!head || head.clientWidth <= 0) return setBar("ms", pace(head.clientWidth)) } createResizeObserver(() => head, updateTitleMetrics) const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4 const measureTimeline = () => { virtualizer?.measure() anchorMeasuredBottom() } function anchorMeasuredBottom() { if (!listRoot) return false if (!measuredBottomAnchored) return false listRoot.scrollTop = listRoot.scrollHeight return true } function scheduleMeasuredBottomAnchor() { // Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for // "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual // height, so keep the scroll container bottom-locked for a few frames while measurement settles. bottomAnchorFrames = 90 if (bottomAnchorFrame !== undefined) return const tick = () => { bottomAnchorFrame = undefined if (!anchorMeasuredBottom()) { bottomAnchorFrames = 0 return } bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1 if (bottomAnchorFrames <= 0) return bottomAnchorFrame = requestAnimationFrame(tick) } bottomAnchorFrame = requestAnimationFrame(tick) } const bindContentRoot = (root: HTMLDivElement) => { const child = root.firstElementChild props.setContentRef(child instanceof HTMLDivElement ? child : root) } const scheduleContentRoot = (root: HTMLDivElement) => { if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) contentFrame = requestAnimationFrame(() => { contentFrame = undefined if (listRoot !== root) return bindContentRoot(root) }) } const connectListRoot = (root: HTMLDivElement) => { if (listRoot !== root) return if (!root.isConnected || !root.ownerDocument.defaultView) { listFrame = requestAnimationFrame(() => { listFrame = undefined connectListRoot(root) }) return } props.setScrollRef(root) measuredBottomAnchored = isMeasuredBottom(root) setScrollRoot(root) scheduleContentRoot(root) } const bindListRoot = (root: HTMLDivElement) => { if (root === listRoot) return if (listFrame !== undefined) cancelAnimationFrame(listFrame) if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) listRoot = root setScrollRoot(undefined) connectListRoot(root) } const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { const root = event.currentTarget const delta = normalizeWheelDelta({ deltaY: event.deltaY, deltaMode: event.deltaMode, rootHeight: root.clientHeight, }) if (!delta) return markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) } const handleListTouchStart = (event: TouchEvent) => { touchGesture = event.touches[0]?.clientY } const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => { const next = event.touches[0]?.clientY const prev = touchGesture touchGesture = next if (next === undefined || prev === undefined) return const delta = prev - next if (!delta) return markBoundaryGesture({ root: event.currentTarget, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture, }) } const handleListTouchEnd = () => { touchGesture = undefined } const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { if (event.target !== event.currentTarget) return props.onMarkScrollGesture(event.currentTarget) } const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { measuredBottomAnchored = isMeasuredBottom(event.currentTarget) props.onScheduleScrollState(event.currentTarget) props.onHistoryScroll() if (!props.hasScrollGesture()) return props.onUserScroll() props.onAutoScrollHandleScroll() props.onMarkScrollGesture(event.currentTarget) } onCleanup(() => { if (listFrame !== undefined) cancelAnimationFrame(listFrame) if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) setScrollRoot(undefined) props.setScrollRef(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) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }), onError: (err) => { console.error("Failed to share session", err) }, })) const unshareMutation = useMutation(() => ({ mutationFn: (id: string) => serverSDK.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 }, ), ) createEffect( on( () => [parentID(), childTaskDescription()] as const, ([id, description]) => { if (!id || description) return if (sync.data.message[id] !== undefined) return void sync.session.sync(id) }, { defer: true }, ), ) const openTitleEditor = () => { if (!sessionID() || parentID()) 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() })}
) } const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID const turnDurationMs = (userMessageID: string) => { const message = messageByID().get(userMessageID) if (!message || message.role !== "user") return const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce( (max, item) => { const completed = item.time.completed if (typeof completed !== "number") return max if (max === undefined) return completed return Math.max(max, completed) }, undefined, ) if (typeof end !== "number") return if (end < message.time.created) return return end - message.time.created } const assistantCopyPartID = (userMessageID: string) => { if (workingTurn(userMessageID)) return null const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (!message) continue const parts = getMsgParts(message.id) for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j] if (!part || part.type !== "text" || !part.text?.trim()) continue return part.id } } } const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) const renderAssistantPartGroup = (row: Accessor) => { if (row().group.type === "context") { const parts = createMemo(() => { const group = row().group if (group.type !== "context") return emptyTools return group.refs .map((ref) => getMsgPart(ref.messageID, ref.partID)) .filter((part): part is ToolPart => part?.type === "tool") }) return ( ) } const message = createMemo(() => { const group = row().group if (group.type !== "part") return return messageByID().get(group.ref.messageID) }) const part = createMemo(() => { const group = row().group if (group.type !== "part") return return getMsgPart(group.ref.messageID, group.ref.partID) }) const defaultOpen = createMemo(() => { const item = part() if (!item) return return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) }) return ( {(message) => ( {(part) => ( setToolOpen(part().id, open)} deferToolContent={false} virtualizeDiff={false} /> )} )} ) } function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { const anchor = () => { const row = input.row() return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) } const previousUserMessage = () => { const row = input.row() return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage } const previousAssistantPart = () => { const row = input.row() return row._tag === "AssistantPart" && row.previousAssistantPart } return (
{input.children}
) } const renderTimelineRow = (row: Accessor) => { switch (row()._tag) { case "CommentStrip": { const commentStripRow = row as Accessor> const comments = createMemo(() => getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), ) return (
{(comment) => (
{getFilename(comment().path)} {(selection) => ( {selection().startLine === selection().endLine ? `:${selection().startLine}` : `:${selection().startLine}-${selection().endLine}`} )}
{comment().comment}
)}
) } case "UserMessage": { const userMessageRow = row as Accessor> const message = createMemo(() => { const m = messageByID().get(userMessageRow().userMessageID) if (m?.role === "user") return m }) return ( {(message) => (
)}
) } case "TurnDivider": { const turnDividerRow = row as Accessor> return (
) } case "AssistantPart": { const assistantPartRow = row as Accessor> return (
{renderAssistantPartGroup(assistantPartRow)}
) } case "Thinking": { const thinkingRow = row as Accessor> return (
) } case "Retry": { const retryRow = row as Accessor> return (
) } case "DiffSummary": { const diffSummaryRow = row as Accessor> return (
) } case "Error": { const errorRow = row as Accessor> return (
{errorRow().text}
) } case "BottomSpacer": return