import { AssistantMessage } from "@opencode-ai/sdk/v2" 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, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" 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 { MessageProgress } from "./message-progress" import { Collapsible } from "./collapsible" import { Dynamic } from "solid-js/web" // Track animation state per message ID - persists across re-renders // "empty" = first saw with no value (should animate when value arrives) // "animating" = currently animating (keep returning true) // "done" = already animated or first saw with value (never animate) const titleAnimationState = new Map() const summaryAnimationState = new Map() export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string classes?: { root?: string content?: string container?: string } }>, ) { const data = useData() const diffComponent = useDiffComponent() const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined)) 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 lastUserMessage = createMemo(() => { return userMessages()?.at(-1) }) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) const status = createMemo( () => data.store.session_status[props.sessionID] ?? { type: "idle", }, ) const working = createMemo(() => status()?.type !== "idle") return (
{(msg) => { const [detailsExpanded, setDetailsExpanded] = createSignal(false) // Animation logic: only animate if we witness the value transition from empty to non-empty // Track in module-level Maps keyed by message ID so it persists across re-renders // Initialize animation state for current message (reactive - runs when msg().id changes) createEffect(() => { const id = msg().id if (!titleAnimationState.has(id)) { titleAnimationState.set(id, msg().summary?.title ? "done" : "empty") } if (!summaryAnimationState.has(id)) { const assistantMsgs = messages()?.filter( (m) => m.role === "assistant" && m.parentID == id, ) as AssistantMessage[] const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id]) const lastText = parts?.filter((p) => p?.type === "text")?.at(-1) const summaryValue = msg().summary?.body ?? lastText?.text summaryAnimationState.set(id, summaryValue ? "done" : "empty") } // When message changes or component unmounts, mark any "animating" states as "done" onCleanup(() => { if (titleAnimationState.get(id) === "animating") { titleAnimationState.set(id, "done") } if (summaryAnimationState.get(id) === "animating") { summaryAnimationState.set(id, "done") } }) }) const assistantMessages = createMemo(() => { return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[] }) 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[msg().id]) const lastTextPart = createMemo(() => assistantMessageParts() .filter((p) => p?.type === "text") ?.at(-1), ) const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool")) const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working()) const initialCompleted = !(msg().id === lastUserMessage()?.id && working()) const [completed, setCompleted] = createSignal(initialCompleted) const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text) const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) // Should animate: state is "empty" AND value now exists, or state is "animating" // Transition: empty -> animating -> done (done happens on cleanup) const animateTitle = createMemo(() => { const id = msg().id const state = titleAnimationState.get(id) const title = msg().summary?.title if (state === "animating") { return true } if (state === "empty" && title) { titleAnimationState.set(id, "animating") return true } return false }) const animateSummary = createMemo(() => { const id = msg().id const state = summaryAnimationState.get(id) const value = summary() if (state === "animating") { return true } if (state === "empty" && value) { summaryAnimationState.set(id, "animating") return true } return false }) createEffect(() => { const done = !messageWorking() setTimeout(() => setCompleted(done), 1200) }) return (
{/* Title */}
} >

{msg().summary?.title}

{/* Summary */}

Summary Response

{(summary) => ( )}
{(diff) => (
{getDirectory(diff.file)}‎ {getFilename(diff.file)}
)}
{error()?.data?.message as string} {/* Response */}
Hide details Show details
{(assistantMessage) => { const parts = createMemo(() => data.store.part[assistantMessage.id]) const last = createMemo(() => parts() .filter((p) => p?.type === "text") .at(-1), ) if (lastTextPartShown() && lastTextPart()?.id === last()?.id) { return ( p?.id !== last()?.id)} sanitize={sanitizer()} /> ) } return }} {error()?.data?.message as string}
) }}
{props.children}
) }