import { AssistantMessage, Part as PartType, TextPart, 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, Part } 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" import { createAutoScroll } from "../hooks" function computeStatusFromPart(part: PartType | undefined): string | undefined { if (!part) return undefined if (part.type === "tool") { switch (part.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: return undefined } } if (part.type === "reasoning") { const text = part.text ?? "" const match = text.trimStart().match(/^\*\*(.+?)\*\*/) if (match) return `Thinking · ${match[1].trim()}` return "Thinking" } if (part.type === "text") { return "Gathering thoughts" } return undefined } function AssistantMessageItem(props: { message: AssistantMessage summary: string | undefined response: string | undefined lastTextPartId: string | undefined working: boolean }) { const data = useData() const msgParts = createMemo(() => data.store.part[props.message.id] ?? []) const lastTextPart = createMemo(() => msgParts() .filter((p) => p?.type === "text") .at(-1), ) const filteredParts = createMemo(() => { if (!props.working && !props.summary && props.response && props.lastTextPartId === lastTextPart()?.id) { return msgParts().filter((p) => p?.id !== lastTextPart()?.id) } return msgParts() }) return } export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string stepsExpanded?: boolean onStepsExpandedToggle?: () => void onUserInteracted?: () => void classes?: { root?: string content?: string container?: string } }>, ) { const data = useData() const diffComponent = useDiffComponent() const allMessages = createMemo(() => data.store.message[props.sessionID] ?? []) const userMessages = createMemo(() => allMessages() .filter((m) => m.role === "user") .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages().find((m) => m.id === props.messageID)) const isLastUserMessage = createMemo(() => message()?.id === userMessages().at(-1)?.id) const parts = createMemo(() => { const msg = message() if (!msg) return [] return data.store.part[msg.id] ?? [] }) const assistantMessages = createMemo(() => { const msg = message() if (!msg) return [] as AssistantMessage[] return allMessages().filter((m) => m.role === "assistant" && m.parentID === msg.id) as AssistantMessage[] }) const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) const lastTextPart = createMemo(() => { const msgs = assistantMessages() for (let mi = msgs.length - 1; mi >= 0; mi--) { const msgParts = data.store.part[msgs[mi].id] ?? [] for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (part?.type === "text") return part as TextPart } } return undefined }) const hasSteps = createMemo(() => { for (const m of assistantMessages()) { const msgParts = data.store.part[m.id] if (!msgParts) continue for (const p of msgParts) { if (p?.type === "tool") return true } } return false }) const permissionParts = createMemo(() => { const result: { part: ToolPart; message: AssistantMessage }[] = [] const permissions = data.store.permission?.[props.sessionID] ?? [] if (!permissions.length) return result for (const m of assistantMessages()) { const msgParts = data.store.part[m.id] ?? [] for (const p of msgParts) { if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) { result.push({ part: p as ToolPart, message: m }) } } } return result }) const shellModePart = createMemo(() => { const p = parts() if (!p.every((part) => part?.type === "text" && part?.synthetic)) return const msgs = assistantMessages() if (msgs.length !== 1) return const msgParts = data.store.part[msgs[0].id] ?? [] if (msgParts.length !== 1) return const assistantPart = msgParts[0] if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart }) const isShellMode = createMemo(() => !!shellModePart()) const rawStatus = createMemo(() => { const msgs = assistantMessages() let last: PartType | undefined let currentTask: ToolPart | undefined for (let mi = msgs.length - 1; mi >= 0; mi--) { const msgParts = data.store.part[msgs[mi].id] ?? [] for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (!part) continue if (!last) last = part if ( part.type === "tool" && part.tool === "task" && part.state && "metadata" in part.state && part.state.metadata?.sessionId && part.state.status === "running" ) { currentTask = part as ToolPart break } } if (currentTask) break } const taskSessionId = currentTask?.state && "metadata" in currentTask.state ? (currentTask.state.metadata?.sessionId as string | undefined) : undefined if (taskSessionId) { const taskMessages = data.store.message[taskSessionId] ?? [] for (let mi = taskMessages.length - 1; mi >= 0; mi--) { const msg = taskMessages[mi] if (!msg || msg.role !== "assistant") continue const msgParts = data.store.part[msg.id] ?? [] for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (part) return computeStatusFromPart(part) } } } return computeStatusFromPart(last) }) const status = createMemo( () => data.store.session_status[props.sessionID] ?? { type: "idle", }, ) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) const retry = createMemo(() => { const s = status() if (s.type !== "retry") return return s }) const summary = () => message()?.summary?.body const response = () => { const part = lastTextPart() return part?.type === "text" ? (part as TextPart).text : undefined } const hasDiffs = () => message()?.summary?.diffs?.length function duration() { const msg = message() if (!msg) return "" const completed = lastAssistantMessage()?.time.completed const from = DateTime.fromMillis(msg.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 autoScroll = createAutoScroll({ working, onUserInteracted: props.onUserInteracted, }) const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, stickyHeaderHeight: 0, retrySeconds: 0, status: rawStatus(), duration: duration(), summaryWaitTimedOut: false, }) createEffect(() => { const r = retry() if (!r) { setStore("retrySeconds", 0) return } const updateSeconds = () => { const next = r.next if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000))) } updateSeconds() const timer = setInterval(updateSeconds, 1000) onCleanup(() => clearInterval(timer)) }) 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(() => { const timer = setInterval(() => { setStore("duration", duration()) }, 1000) onCleanup(() => clearInterval(timer)) }) createEffect(() => { if (working()) { setStore("summaryWaitTimedOut", false) } }) createEffect(() => { if (permissionParts().length > 0) { autoScroll.forceScrollToBottom() } }) createEffect(() => { if (working() || !isLastUserMessage()) return const diffs = message()?.summary?.diffs if (!diffs?.length) return if (summary()) return if (store.summaryWaitTimedOut) return const timer = setTimeout(() => { setStore("summaryWaitTimedOut", true) }, 6000) onCleanup(() => clearTimeout(timer)) }) const waitingForSummary = createMemo(() => { if (!isLastUserMessage()) return false if (working()) return false const diffs = message()?.summary?.diffs if (!diffs?.length) return false if (summary()) return false return !store.summaryWaitTimedOut }) const showSummarySection = createMemo(() => { if (working()) return false return !waitingForSummary() }) let lastStatusChange = Date.now() let statusTimeout: number | undefined createEffect(() => { const newStatus = rawStatus() if (newStatus === store.status || !newStatus) return const timeSinceLastChange = Date.now() - lastStatusChange if (timeSinceLastChange >= 2500) { setStore("status", newStatus) lastStatusChange = Date.now() if (statusTimeout) { clearTimeout(statusTimeout) statusTimeout = undefined } } else { if (statusTimeout) clearTimeout(statusTimeout) statusTimeout = setTimeout(() => { setStore("status", rawStatus()) lastStatusChange = Date.now() statusTimeout = undefined }, 2500 - timeSinceLastChange) as unknown as number } }) return (
{(msg) => (
{/* Title (sticky) */}
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">

{msg().summary?.title}

{/* User Message */}
{/* Trigger (sticky) */}
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
{/* Response */} 0}>
{(assistantMessage) => ( )} {error()?.data?.message as string}
0}>
{({ part, message }) => }
{/* Summary */}
{(summary) => ( <>

Summary

)}
{(response) => ( <>

Response

)}
{(diff) => (
{getDirectory(diff.file)}‎ {getFilename(diff.file)}
)}
{error()?.data?.message as string}
)}
{props.children}
) }