import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, onMount, Show, 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 { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" 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 "@/utils/toast" 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 { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { makeTimer } from "@solid-primitives/timer" import { scheduleConnectedMeasure } from "./measure" import { createTimelineProjection } from "./projection" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] const idle = { type: "idle" as const } type FramedTimelineRow = Exclude type TimelineRowByTag = Extract const timelineFallbackItemSize = 60 const timelineCache = new Map< string, { measurements: VirtualItem[]; toolOpen: Record } >() 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 userMessages: UserMessage[] anchor: (id: string) => string setRevealMessage?: (fn: (id: string) => void) => void setScrollToEnd?: (fn: () => void) => void setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => 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 ownerSessionKey = sessionKey() const cached = timelineCache.get(ownerSessionKey) const initialMeasurements = cached?.measurements const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom() const platform = usePlatform() const [listRoot, setListRoot] = createSignal() const sessionID = createMemo(() => params.id) const sessionStatus = createMemo(() => { const id = sessionID() if (!id) return idle return sync().data.session_status[id] ?? idle }) const working = createMemo(() => sessionStatus().type !== "idle") const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) 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 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 getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) 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 projection = createTimelineProjection({ messages: sessionMessages, userMessages: () => props.userMessages, parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, }) const activeMessageID = projection.activeMessageID const assistantMessagesByParent = projection.assistantMessagesByParent const lastAssistantGroupKey = projection.lastAssistantGroupKey const messageByID = projection.messageByID const messageLastRowIndex = projection.messageLastRowIndex const messageRowIndex = projection.messageRowIndex const timelineRowByKey = projection.rowByKey const timelineRows = projection.rows let prependAnchor: { key: string; offset: number } | undefined let prependAnchorFrame: number | undefined let prependLoading = false const clearPrependAnchor = () => { prependLoading = false prependAnchor = undefined if (prependAnchorFrame === undefined) return cancelAnimationFrame(prependAnchorFrame) prependAnchorFrame = undefined } const capturePrependAnchor = () => { prependLoading = true updatePrependAnchor() } const updatePrependAnchor = () => { const root = listRoot() if (!root) return const view = root.getBoundingClientRect() const anchor = [...root.querySelectorAll("[data-timeline-key]")] .map((element) => ({ element, rect: element.getBoundingClientRect() })) .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom) .sort((a, b) => a.rect.top - b.rect.top)[0] if (!anchor) return if (!anchor.element.dataset.timelineKey) return prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top } } const restorePrependAnchor = (done: boolean) => { if (done) prependLoading = false applyPrependAnchor() } const applyPrependAnchor = () => { const root = listRoot() if (!root || !prependAnchor) return if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) let frames = 0 let stable = 0 const apply = () => { prependAnchorFrame = undefined const anchor = prependAnchor if (!anchor) return const element = root.querySelector(`[data-timeline-key="${CSS.escape(anchor.key)}"]`) const delta = element ? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset : undefined if (delta !== undefined && Math.abs(delta) > 0.5) { root.scrollTop += delta stable = 0 } else { stable += 1 } frames += 1 if (stable >= 30 || frames >= 180) { if (!prependLoading) prependAnchor = undefined return } prependAnchorFrame = requestAnimationFrame(apply) } prependAnchorFrame = requestAnimationFrame(apply) } const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20) let resizePinnedIndexes: number[] = [] let resizePinFrame: number | undefined let virtualContent: HTMLDivElement | undefined const virtualizer = createVirtualizer({ get count() { return timelineRows().length }, getScrollElement: () => listRoot() ?? null, initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), initialMeasurementsCache: initialMeasurements, estimateSize: () => timelineFallbackItemSize, scrollToFn: (offset, options, instance) => { // Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height. if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px` elementScroll(offset, options, instance) }, get getItemKey() { const rows = timelineRows() return (index: number) => { const row = rows[index] // ResizeObserver can report a removed element after its row has left the projection. if (!row) return `removed:${index}` return TimelineRow.key(row) } }, anchorTo: "end", followOnAppend: true, scrollEndThreshold: 80, get scrollMargin() { return showHeader() ? 64 : 0 }, overscan: 50, paddingEnd: 64, rangeExtractor: (range) => { const id = activeMessageID() const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1 const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() }) return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b) }, }) const resizeItem = virtualizer.resizeItem virtualizer.resizeItem = (index, size) => { const item = virtualizer.measurementsCache[index] const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined const root = listRoot() if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) { const view = root.getBoundingClientRect() resizePinnedIndexes = [...root.querySelectorAll("[data-index]")] .filter((element) => { const rect = element.getBoundingClientRect() return rect.bottom > view.top && rect.top < view.bottom }) .map((element) => Number(element.dataset.index)) if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) resizePinFrame = requestAnimationFrame(() => { resizePinFrame = requestAnimationFrame(() => { resizePinFrame = undefined resizePinnedIndexes = [] }) }) } resizeItem(index, size) } virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => item.end <= instance.getLogicalScrollOffset() const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string)) createEffect(() => { props.setRevealMessage?.((id) => { const index = messageRowIndex().get(id) if (index === undefined) return virtualizer.scrollToIndex(index, { align: "center" }) }) props.setScrollToEnd?.(() => virtualizer.scrollToEnd()) props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor }) }) let overscanFrame: number | undefined onMount(() => { overscanFrame = requestAnimationFrame(() => { if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() overscanFrame = requestAnimationFrame(() => { overscanFrame = undefined if (renderOverscan() < 20) setRenderOverscan(20) if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() }) }) }) let bottomAnchorSessionKey = "" let bottomAnchorFrame: number | undefined const maybeAnchorBottom = () => { const key = sessionKey() if (bottomAnchorSessionKey === key) return if (timelineRows().length === 0) return bottomAnchorSessionKey = key if (!props.shouldAnchorBottom()) return if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) clearPrependAnchor() if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) bottomAnchorFrame = requestAnimationFrame(() => { bottomAnchorFrame = undefined if (sessionKey() !== key) return virtualizer.scrollToEnd() }) } let measuredSessionKey = sessionKey() createEffect(() => { const key = sessionKey() timelineRows().length if (measuredSessionKey !== key) { measuredSessionKey = key virtualizer.measure() } maybeAnchorBottom() }) onCleanup(() => { clearPrependAnchor() timelineCache.delete(ownerSessionKey) timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } }) while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!) if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame) props.setRevealMessage?.(() => {}) props.setScrollToEnd?.(() => {}) props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} }) }) 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), }) let more: HTMLButtonElement | undefined let head: HTMLDivElement | undefined const updateTitleMetrics = () => { if (!head || head.clientWidth <= 0) return setBar("ms", pace(head.clientWidth)) } createResizeObserver(() => head, updateTitleMetrics) const bindListRoot = (root: HTMLDivElement) => { if (root === listRoot()) return setListRoot(root) props.setScrollRef(root) } const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { if (!prependLoading) clearPrependAnchor() 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) => { if (!prependLoading) clearPrependAnchor() 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 (!prependLoading) clearPrependAnchor() if (event.target !== event.currentTarget) return props.onMarkScrollGesture(event.currentTarget) } const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { if (prependLoading) updatePrependAnchor() props.onScheduleScrollState(event.currentTarget) props.onHistoryScroll() if (!props.hasScrollGesture()) return props.onUserScroll() props.onAutoScrollHandleScroll() props.onMarkScrollGesture(event.currentTarget) } onCleanup(() => { 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) }), ) sync().session.evict(sessionID) navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] }) }) .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 const removed = new Set([sessionID]) const byParent = new Map() for (const item of sync().data.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) } } navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) sync().set( produce((draft) => { draft.session = draft.session.filter((s) => !removed.has(s.id)) }), ) for (const id of removed) { sync().session.evict(id) } notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] }) 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 renderAssistantPartGroup = (row: Accessor, onSizeChange?: () => void) => { 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 virtualizeDiff={false} onContentRendered={onSizeChange} /> )} )} ) } function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { const anchor = () => { const row = input.row() return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) } const previousAssistantPart = () => { const row = input.row() return row._tag === "AssistantPart" && row.previousAssistantPart } return (
{input.children}
) } const renderTimelineRow = (row: Accessor, onSizeChange?: () => void) => { switch (row()._tag) { case "TurnGap": return