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">
{/* 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}
)
}