import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" import { same } from "@opencode-ai/util/array" import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" import { GrowBox } from "./grow-box" import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { TextReveal } from "./text-reveal" import { list } from "./text-utils" import { SessionRetry } from "./session-retry" import { Tooltip } from "./tooltip" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } function unwrap(message: string) { const text = message.replace(/^Error:\s*/, "").trim() const parse = (value: string) => { try { return JSON.parse(value) as unknown } catch { return undefined } } const read = (value: string) => { const first = parse(value) if (typeof first !== "string") return first return parse(first.trim()) } let json = read(text) if (json === undefined) { const start = text.indexOf("{") const end = text.lastIndexOf("}") if (start !== -1 && end > start) { json = read(text.slice(start, end + 1)) } } if (!record(json)) return message const err = record(json.error) ? json.error : undefined if (err) { const type = typeof err.type === "string" ? err.type : undefined const msg = typeof err.message === "string" ? err.message : undefined if (type && msg) return `${type}: ${msg}` if (msg) return msg if (type) return type const code = typeof err.code === "string" ? err.code : undefined if (code) return code } const msg = typeof json.message === "string" ? json.message : undefined if (msg) return msg const reason = typeof json.error === "string" ? json.error : undefined if (reason) return reason return message } const hidden = new Set(["todowrite", "todoread"]) const emptyMessages: MessageType[] = [] const emptyAssistant: AssistantMessage[] = [] const emptyDiffs: FileDiff[] = [] const idle: SessionStatus = { type: "idle" as const } const handoffHoldMs = 120 function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { if (hidden.has(part.tool)) return if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return return "visible" as const } if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined if (part.type === "reasoning") { if (showReasoningSummaries && part.text?.trim()) return "visible" as const return } if (PART_MAPPING[part.type]) return "visible" as const return } function clean(value: string) { return value .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") .replace(/[*_~]+/g, "") .trim() } function heading(text: string) { const markdown = text.replace(/\r\n?/g, "\n") const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) if (html?.[1]) { const value = clean(html[1].replace(/<[^>]+>/g, " ")) if (value) return value } const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m) if (atx?.[1]) { const value = clean(atx[1]) if (value) return value } const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m) if (setext?.[1]) { const value = clean(setext[1]) if (value) return value } const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m) if (strong?.[1]) { const value = clean(strong[1]) if (value) return value } } export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string animate?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean active?: boolean queued?: boolean status?: SessionStatus onUserInteracted?: () => void classes?: { root?: string content?: string container?: string } }>, ) { const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const emptyParts: PartType[] = [] const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) const messageIndex = createMemo(() => { const messages = allMessages() ?? emptyMessages const result = Binary.search(messages, props.messageID, (m) => m.id) const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID) if (index < 0) return -1 const msg = messages[index] if (!msg || msg.role !== "user") return -1 return index }) const message = createMemo(() => { const index = messageIndex() if (index < 0) return undefined const messages = allMessages() ?? emptyMessages const msg = messages[index] if (!msg || msg.role !== "user") return undefined return msg }) const active = createMemo(() => props.active ?? false) const queued = createMemo(() => props.queued ?? false) const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts return list(data.store.part?.[msg.id], emptyParts) }) const compaction = createMemo(() => parts().find((part) => part.type === "compaction")) const diffs = createMemo(() => { const files = message()?.summary?.diffs if (!files?.length) return emptyDiffs const seen = new Set() return files .reduceRight((result, diff) => { if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff) return result }, []) .reverse() }) const edited = createMemo(() => diffs().length) const [open, setOpen] = createSignal(false) const [expanded, setExpanded] = createSignal([]) createEffect( on( open, (value, prev) => { if (!value && prev) setExpanded([]) }, { defer: true }, ), ) const assistantMessages = createMemo( () => { const msg = message() if (!msg) return emptyAssistant const messages = allMessages() ?? emptyMessages const index = messageIndex() if (index < 0) return emptyAssistant const result: AssistantMessage[] = [] for (let i = index + 1; i < messages.length; i++) { const item = messages[i] if (!item) continue if (item.role === "user") break if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) } return result }, emptyAssistant, { equals: same }, ) const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) const assistantCopyPart = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (!message) continue const parts = list(data.store.part?.[message.id], emptyParts) for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j] if (!part || part.type !== "text") continue const text = part.text?.trim() if (!text) continue return { id: part.id, text, message, } } } }) const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) if (msg === undefined || msg === null) return "" return unwrap(String(msg)) }) const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => { if (status().type === "idle") return false if (!message()) return false return active() }) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) const showDiffSummary = createMemo(() => edited() > 0 && !working()) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined const end = assistantMessages().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 undefined if (end < start) return undefined return end - start }) const assistantVisible = createMemo(() => assistantMessages().reduce((count, message) => { const parts = list(data.store.part?.[message.id], emptyParts) return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length }, 0), ) const assistantTailVisible = createMemo(() => assistantMessages() .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) .flatMap((part) => { if (partState(part, showReasoningSummaries()) !== "visible") return [] if (part.type === "text") return ["text" as const] return ["other" as const] }) .at(-1), ) const reasoningHeading = createMemo(() => assistantMessages() .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) .filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning") .map((part) => heading(part.text)) .filter((text): text is string => !!text) .at(-1), ) const thinking = createMemo(() => { if (!working() || !!error()) return false if (queued()) return false if (status().type === "retry") return false if (showReasoningSummaries()) return assistantVisible() === 0 return true }) const hasAssistant = createMemo(() => assistantMessages().length > 0) const animateEnabled = createMemo(() => props.animate !== false) const [live, setLive] = createSignal(false) const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled())) const metaOpen = createMemo(() => !working() && !!assistantCopyPart()) const duration = createMemo(() => { const ms = turnDurationMs() if (typeof ms !== "number" || ms < 0) return "" const total = Math.round(ms / 1000) if (total < 60) return `${total}s` const minutes = Math.floor(total / 60) const seconds = total % 60 return `${minutes}m ${seconds}s` }) const meta = createMemo(() => { const item = assistantCopyPart() if (!item) return "" const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : "" const model = item.message.modelID ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[ item.message.modelID ]?.name ?? item.message.modelID) : "" return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0") }) const [copied, setCopied] = createSignal(false) const [handoffHold, setHandoffHold] = createSignal(false) const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold()) const handoffOpen = createMemo(() => thinkingVisible() || metaOpen()) const lane = createMemo(() => hasAssistant() || handoffOpen()) let liveFrame: number | undefined let copiedTimer: ReturnType | undefined let handoffTimer: ReturnType | undefined const copyAssistant = async () => { const text = assistantCopyPart()?.text if (!text) return await navigator.clipboard.writeText(text) setCopied(true) if (copiedTimer !== undefined) clearTimeout(copiedTimer) copiedTimer = setTimeout(() => { copiedTimer = undefined setCopied(false) }, 2000) } createEffect( on( () => [animateEnabled(), working()] as const, ([enabled, isWorking]) => { if (liveFrame !== undefined) { cancelAnimationFrame(liveFrame) liveFrame = undefined } if (!enabled || !isWorking || live()) return liveFrame = requestAnimationFrame(() => { liveFrame = undefined setLive(true) }) }, ), ) createEffect( on( () => [thinkingOpen(), metaOpen()] as const, ([thinkingNow, metaNow]) => { if (handoffTimer !== undefined) { clearTimeout(handoffTimer) handoffTimer = undefined } if (thinkingNow) { setHandoffHold(true) return } if (metaNow) { setHandoffHold(false) return } if (!handoffHold()) return handoffTimer = setTimeout(() => { handoffTimer = undefined setHandoffHold(false) }, handoffHoldMs) }, { defer: true }, ), ) const autoScroll = createAutoScroll({ working, onUserInteracted: props.onUserInteracted, overflowAnchor: "dynamic", }) onCleanup(() => { if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) if (copiedTimer !== undefined) clearTimeout(copiedTimer) if (handoffTimer !== undefined) clearTimeout(handoffTimer) }) const turnDiffSummary = () => (
{i18n.t("ui.sessionReview.change.modified")} {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > {(diff) => { const active = createMemo(() => expanded().includes(diff.file)) const [visible, setVisible] = createSignal(false) createEffect( on( active, (value) => { if (!value) { setVisible(false) return } requestAnimationFrame(() => { if (!active()) return setVisible(true) }) }, { defer: true }, ), ) return (
{`\u202A${getDirectory(diff.file)}\u202C`} {getFilename(diff.file)}
) }}
) const divider = (label: string) => (
{label}
) return (
{(msg) => (
{(part) => (
)}
event.preventDefault()} onClick={() => void copyAssistant()} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} /> {meta()}
{divider(i18n.t("ui.message.interrupted"))} {turnDiffSummary()} {errorText()}
)}
{props.children}
) }