import { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2/client" import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" import { Message } from "./message-part" import { Markdown } from "./markdown" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Card } from "./card" import { Dynamic } from "solid-js/web" import { Button } from "./button" import { Spinner } from "./spinner" import { createStore } from "solid-js/store" import { DateTime, DurationUnit, Interval } from "luxon" export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string classes?: { root?: string content?: string container?: string } }>, ) { const data = useData() const diffComponent = useDiffComponent() const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : [])) const userMessages = createMemo(() => messages() .filter((m) => m.role === "user") .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) if (!message()) return null const status = createMemo( () => data.store.session_status[props.sessionID] ?? { type: "idle", }, ) const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined const assistantMessages = createMemo(() => { return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] }) const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) const parts = createMemo(() => data.store.part[message()!.id]) const lastTextPart = createMemo(() => assistantMessageParts() .filter((p) => p?.type === "text") ?.at(-1), ) const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) const currentTask = createMemo( () => assistantParts().findLast( (p) => p && p.type === "tool" && p.tool === "task" && p.state && "metadata" in p.state && p.state.metadata && p.state.metadata.sessionId && p.state.status === "running", ) as ToolPart, ) const resolvedParts = createMemo(() => { let resolved = assistantParts() const task = currentTask() if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() } return resolved }) const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) const rawStatus = createMemo(() => { const last = lastPart() if (!last) return undefined if (last.type === "tool") { switch (last.tool) { case "task": return "Delegating work" case "todowrite": case "todoread": return "Planning next steps" case "read": return "Gathering context" case "list": case "grep": case "glob": return "Searching the codebase" case "webfetch": return "Searching the web" case "edit": case "write": return "Making edits" case "bash": return "Running commands" default: break } } else if (last.type === "reasoning") { const text = last.text ?? "" const match = text.trimStart().match(/^\*\*(.+?)\*\*/) if (match) return `Thinking · ${match[1].trim()}` return "Thinking" } else if (last.type === "text") { return "Gathering thoughts" } return undefined }) function duration() { const completed = lastAssistantMessage()?.time.completed const from = DateTime.fromMillis(message()!.time.created) const to = completed ? DateTime.fromMillis(completed) : DateTime.now() const interval = Interval.fromDateTimes(from, to) const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] return interval.toDuration(unit).normalize().toHuman({ notation: "compact", unitDisplay: "narrow", compactDisplay: "short", showZeros: false, }) } const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, status: rawStatus(), stepsExpanded: true, duration: duration(), lastStatusChange: Date.now(), statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return setStore("scrollY", scrollRef.scrollTop) if (store.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { setStore("userScrolled", true) } } function handleInteraction() { if (working()) { setStore("userScrolled", true) } } function scrollToBottom() { if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return setStore("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { setStore("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { setStore("userScrolled", false) } }) createResizeObserver( () => store.stickyTitleRef, ({ height }) => { const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 setStore("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( () => store.stickyTriggerRef, ({ height }) => { const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 setStore("stickyHeaderHeight", titleHeight + height + 8) }, ) createEffect(() => { lastPart() scrollToBottom() }) createEffect(() => { const timer = setInterval(() => { setStore("duration", duration()) }, 1000) onCleanup(() => clearInterval(timer)) }) createEffect(() => { const newStatus = rawStatus() if (newStatus === store.status || !newStatus) return const timeSinceLastChange = Date.now() - store.lastStatusChange if (timeSinceLastChange >= 2500) { setStore("status", newStatus) setStore("lastStatusChange", Date.now()) if (store.statusTimeout) { clearTimeout(store.statusTimeout) setStore("statusTimeout", undefined) } } else { if (store.statusTimeout) clearTimeout(store.statusTimeout) setStore( "statusTimeout", setTimeout(() => { setStore("status", rawStatus()) setStore("lastStatusChange", Date.now()) setStore("statusTimeout", undefined) }, 2500 - timeSinceLastChange) as unknown as number, ) } }) createEffect((prev) => { const isWorking = working() if (prev && !isWorking && !store.userScrolled) { setStore("stepsExpanded", false) } return isWorking }, working()) return (