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 { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, For, Match, on, 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
responsePartId: string | undefined
hideResponsePart: boolean
}) {
const data = useData()
const msgParts = createMemo(() => data.store.part[props.message.id] ?? [])
const lastTextPart = createMemo(() => {
const parts = msgParts()
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (part?.type === "text") return part as TextPart
}
return undefined
})
const filteredParts = createMemo(() => {
const parts = msgParts()
if (!props.hideResponsePart) return parts
const responsePartId = props.responsePartId
if (!responsePartId) return parts
if (responsePartId !== lastTextPart()?.id) return parts
return parts.filter((part) => part?.id !== responsePartId)
})
return
}
export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
lastUserMessageID?: 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 messageIndex = createMemo(() => {
const messages = allMessages()
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (!result.found) return -1
const msg = messages[result.index]
if (msg.role !== "user") return -1
return result.index
})
const message = createMemo(() => {
const index = messageIndex()
if (index < 0) return undefined
const msg = allMessages()[index]
if (!msg || msg.role !== "user") return undefined
return msg
})
const lastUserMessageID = createMemo(() => {
if (props.lastUserMessageID) return props.lastUserMessageID
const messages = allMessages()
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg?.role === "user") return msg.id
}
return undefined
})
const isLastUserMessage = createMemo(() => props.messageID === lastUserMessageID())
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[]
const messages = allMessages()
const index = messageIndex()
if (index < 0) return [] as AssistantMessage[]
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
})
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 permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? [])
const permissionCount = createMemo(() => permissions().length)
const nextPermission = createMemo(() => permissions()[0])
const permissionParts = createMemo(() => {
if (props.stepsExpanded) return [] as { part: ToolPart; message: AssistantMessage }[]
const next = nextPermission()
if (!next) return [] as { part: ToolPart; message: AssistantMessage }[]
for (const message of assistantMessages()) {
const parts = data.store.part[message.id] ?? []
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.callID === next.callID) return [{ part: tool, message }]
}
}
return [] as { part: ToolPart; message: AssistantMessage }[]
})
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 = createMemo(() => message()?.summary?.body)
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
const hideResponsePart = createMemo(() => !working() && !summary() && !!responsePartId())
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(
on(permissionCount, (count, prev) => {
if (!count) return
if (prev !== undefined && count <= prev) return
autoScroll.forceScrollToBottom()
}),
)
createEffect(() => {
if (working() || !isLastUserMessage()) return
const diffs = message()?.summary?.diffs
if (!diffs?.length) return
if (summary()) return
if (store.summaryWaitTimedOut) return
// If session was already completed before we started viewing it,
// show the response immediately without waiting for summary
const completed = lastAssistantMessage()?.time.completed
if (completed && Date.now() - completed > 2000) {
setStore("summaryWaitTimedOut", true)
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}
)
}