+
You
-
-
- {(value) => Agent: {value()}}
- {(value) => Model: {value()}}
-
+ Assistant
@@ -267,94 +227,61 @@ export default function MessageItem(props: MessageItemProps) {
/>
)}
-
-
-
0}>
-
-
- {(attachment) => {
- const name = getAttachmentName(attachment)
- const isImage = isImageAttachment(attachment)
- return (
-
-
-
-
- }>
-
-
-
{name}
-
-
-
-

-
-
-
- )
- }}
-
-
-
-
-
- {(usage) => (
-
-
- Input
- {formatTokenTotal(usage().input)}
-
-
- Output
- {formatTokenTotal(usage().output)}
-
-
- Reasoning
- {formatTokenTotal(usage().reasoning)}
-
-
- Cache Read
- {formatTokenTotal(usage().cacheRead)}
-
-
- Cache Write
- {formatTokenTotal(usage().cacheWrite)}
-
-
- Cost
- {formatCostValue(usage().cost)}
-
+
0}>
+
+
+ {(attachment) => {
+ const name = getAttachmentName(attachment)
+ const isImage = isImageAttachment(attachment)
+ return (
+
+
+
+
+ }>
+
+
+
{name}
+
+
+
+

+
+
+
+ )
+ }}
+
- )}
-
-
+
+
+
+ ● Sending...
+
+
-
- ● Sending...
-
-
-
-
- ⚠ Message failed to send
-
+
+ ⚠ Message failed to send
+
+
)
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 22354617..749a420a 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -33,6 +33,38 @@ export default function MessagePart(props: MessagePartProps) {
return ""
}
+ function reasoningSegmentHasText(segment: unknown): boolean {
+ if (typeof segment === "string") {
+ return segment.trim().length > 0
+ }
+ if (segment && typeof segment === "object") {
+ const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
+ if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
+ return true
+ }
+ if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
+ return true
+ }
+ if (Array.isArray(candidate.content)) {
+ return candidate.content.some((entry) => reasoningSegmentHasText(entry))
+ }
+ }
+ return false
+ }
+
+ const hasReasoningContent = () => {
+ if (props.part?.type !== "reasoning") {
+ return false
+ }
+ if (reasoningSegmentHasText((props.part as any).text)) {
+ return true
+ }
+ if (Array.isArray((props.part as any).content)) {
+ return (props.part as any).content.some((entry: unknown) => reasoningSegmentHasText(entry))
+ }
+ return false
+ }
+
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
@@ -83,23 +115,7 @@ export default function MessagePart(props: MessagePartProps) {
-
-
-
-
-
+
)
}
diff --git a/packages/ui/src/components/message-stream-v2.tsx b/packages/ui/src/components/message-stream-v2.tsx
index d9bd3277..4513bf3a 100644
--- a/packages/ui/src/components/message-stream-v2.tsx
+++ b/packages/ui/src/components/message-stream-v2.tsx
@@ -1,4 +1,4 @@
-import { For, Show, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
+import { For, Match, Show, Switch, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import Kbd from "./kbd"
@@ -7,7 +7,7 @@ import { getSessionInfo, sessions, setActiveParentSession, setActiveSession } fr
import { showCommandPalette } from "../stores/command-palette"
import { messageStoreBus } from "../stores/message-v2/bus"
import type { MessageRecord } from "../stores/message-v2/types"
-import { buildRecordDisplayData, clearRecordDisplayCacheForInstance, type ToolCallPart } from "../stores/message-v2/record-display-cache"
+import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import { useConfig } from "../stores/preferences"
import { sseManager } from "../lib/sse-manager"
import { formatTokenTotal } from "../lib/formatters"
@@ -19,9 +19,11 @@ const SCROLL_SCOPE = "session"
const TOOL_ICON = "🔧"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
-const messageItemCache = new Map
()
+const messageItemCache = new Map()
const toolItemCache = new Map()
+type ToolCallPart = Extract
+
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
@@ -111,11 +113,11 @@ interface MessageStreamV2Props {
onFork?: (messageId?: string) => void
}
-interface MessageDisplayItem {
- type: "message"
+interface ContentDisplayItem {
+ type: "content"
+ key: string
record: MessageRecord
- combinedParts: ClientPart[]
- orderedParts: ClientPart[]
+ parts: ClientPart[]
messageInfo?: MessageInfo
isQueued: boolean
}
@@ -130,27 +132,30 @@ interface ToolDisplayItem {
partVersion: number
}
-interface MessageDisplayBlock {
- record: MessageRecord
- messageItem: MessageDisplayItem | null
- toolItems: ToolDisplayItem[]
+interface StepDisplayItem {
+ type: "step-start" | "step-finish"
+ key: string
+ part: ClientPart
+ messageInfo?: MessageInfo
}
-function hasRenderableContent(record: MessageRecord, combinedParts: ClientPart[], info?: MessageInfo): boolean {
- if (record.role !== "assistant" && record.role !== "user") {
- return false
- }
- if (record.role !== "assistant" || combinedParts.length > 0) {
- return true
- }
- if (info && info.role === "assistant" && info.error) {
- return true
- }
- return record.status === "error"
+type ReasoningDisplayItem = {
+ type: "reasoning"
+ key: string
+ part: ClientPart
+ messageInfo?: MessageInfo
+}
+
+type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem
+
+interface MessageDisplayBlock {
+ record: MessageRecord
+ items: MessageBlockItem[]
}
export default function MessageStreamV2(props: MessageStreamV2Props) {
const { preferences } = useConfig()
+ const showUsagePreference = () => preferences().showUsageMetrics ?? true
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
const messageRecords = createMemo(() =>
@@ -217,9 +222,42 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
return -1
})
+ function reasoningHasRenderableContent(part: ClientPart): boolean {
+ if (!part || part.type !== "reasoning") {
+ return false
+ }
+ const checkSegment = (segment: unknown): boolean => {
+ if (typeof segment === "string") {
+ return segment.trim().length > 0
+ }
+ if (segment && typeof segment === "object") {
+ const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
+ if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
+ return true
+ }
+ if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
+ return true
+ }
+ if (Array.isArray(candidate.content)) {
+ return candidate.content.some((entry) => checkSegment(entry))
+ }
+ }
+ return false
+ }
+
+ if (checkSegment((part as any).text)) {
+ return true
+ }
+ if (Array.isArray((part as any).content)) {
+ return (part as any).content.some((entry: unknown) => checkSegment(entry))
+ }
+ return false
+ }
+
const displayBlocks = createMemo(() => {
const infoMap = messageInfoMap()
const showThinking = preferences().showThinkingBlocks
+ const showUsageMetrics = showUsagePreference()
const revert = revertTarget()
const instanceId = props.instanceId
const blocks: MessageDisplayBlock[] = []
@@ -234,71 +272,99 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
break
}
- const { orderedParts, textAndReasoningParts, toolParts } = buildRecordDisplayData(instanceId, record, showThinking)
+ const { orderedParts } = buildRecordDisplayData(instanceId, record)
const messageInfo = infoMap.get(record.id)
- const recordCacheKey = makeInstanceCacheKey(instanceId, record.id)
const recordIndex = indexMap.get(record.id) ?? 0
const isQueued = record.role === "user" && (assistantIndex === -1 || recordIndex > assistantIndex)
- let messageItem: MessageDisplayItem | null = null
- if (hasRenderableContent(record, textAndReasoningParts, messageInfo)) {
- let cached = messageItemCache.get(recordCacheKey)
+ const items: MessageBlockItem[] = []
+ let segmentIndex = 0
+ let pendingParts: ClientPart[] = []
+
+ const flushContent = () => {
+ if (pendingParts.length === 0) return
+ const segmentKey = makeInstanceCacheKey(instanceId, `${record.id}:segment:${segmentIndex}`)
+ segmentIndex += 1
+ let cached = messageItemCache.get(segmentKey)
if (!cached) {
cached = {
- type: "message",
+ type: "content",
+ key: segmentKey,
record,
- combinedParts: textAndReasoningParts,
- orderedParts,
+ parts: pendingParts.slice(),
messageInfo,
isQueued,
}
- messageItemCache.set(recordCacheKey, cached)
+ messageItemCache.set(segmentKey, cached)
} else {
cached.record = record
- cached.combinedParts = textAndReasoningParts
- cached.orderedParts = orderedParts
+ cached.parts = pendingParts.slice()
cached.messageInfo = messageInfo
cached.isQueued = isQueued
}
- messageItem = cached
- usedMessageKeys.add(recordCacheKey)
+ items.push(cached)
+ usedMessageKeys.add(segmentKey)
+ pendingParts = []
}
- const toolItems: ToolDisplayItem[] = []
- toolParts.forEach((toolPart, toolIndex) => {
- const partVersion = typeof toolPart.version === "number" ? toolPart.version : 0
- const messageVersion = record.revision
- const key = `${record.id}:${toolPart.id ?? toolIndex}`
- const cacheKey = makeInstanceCacheKey(instanceId, key)
- let toolItem = toolItemCache.get(cacheKey)
- if (!toolItem) {
- toolItem = {
- type: "tool",
- key,
- toolPart,
- messageInfo,
- messageId: record.id,
- messageVersion,
- partVersion,
+ orderedParts.forEach((part, partIndex) => {
+ if (part.type === "tool") {
+ flushContent()
+ const partVersion = typeof part.version === "number" ? part.version : 0
+ const messageVersion = record.revision
+ const key = `${record.id}:${part.id ?? partIndex}`
+ const cacheKey = makeInstanceCacheKey(instanceId, key)
+ let toolItem = toolItemCache.get(cacheKey)
+ if (!toolItem) {
+ toolItem = {
+ type: "tool",
+ key,
+ toolPart: part as ToolCallPart,
+ messageInfo,
+ messageId: record.id,
+ messageVersion,
+ partVersion,
+ }
+ toolItemCache.set(cacheKey, toolItem)
+ } else {
+ toolItem.key = key
+ toolItem.toolPart = part as ToolCallPart
+ toolItem.messageInfo = messageInfo
+ toolItem.messageId = record.id
+ toolItem.messageVersion = messageVersion
+ toolItem.partVersion = partVersion
}
- toolItemCache.set(cacheKey, toolItem)
- } else {
- toolItem.key = key
- toolItem.toolPart = toolPart
- toolItem.messageInfo = messageInfo
- toolItem.messageId = record.id
- toolItem.messageVersion = messageVersion
- toolItem.partVersion = partVersion
+ items.push(toolItem)
+ usedToolKeys.add(cacheKey)
+ return
}
- toolItems.push(toolItem)
- usedToolKeys.add(cacheKey)
+
+ if (part.type === "step-start" || part.type === "step-finish") {
+ flushContent()
+ const key = makeInstanceCacheKey(instanceId, `${record.id}:${part.id ?? partIndex}:${part.type}`)
+ items.push({ type: part.type, key, part, messageInfo })
+ return
+ }
+
+ if (part.type === "reasoning") {
+ flushContent()
+ if (showThinking && reasoningHasRenderableContent(part)) {
+ const key = makeInstanceCacheKey(instanceId, `${record.id}:${part.id ?? partIndex}:reasoning`)
+ items.push({ type: "reasoning", key, part, messageInfo })
+ }
+ return
+ }
+
+ pendingParts.push(part)
})
- if (!messageItem && toolItems.length === 0) {
+ flushContent()
+
+ if (items.length === 0) {
continue
}
- blocks.push({ record, messageItem, toolItems })
+ blocks.push({ record, items })
}
for (const key of messageItemCache.keys()) {
@@ -322,10 +388,18 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
return `${revisionValue}:empty`
}
const lastBlock = blocks[blocks.length - 1]
- const lastTool = lastBlock.toolItems[lastBlock.toolItems.length - 1]
- const tailSignature = lastTool
- ? `tool:${lastTool.key}:${lastTool.partVersion}`
- : `msg:${lastBlock.record.id}:${lastBlock.record.revision}`
+ const lastItem = lastBlock.items[lastBlock.items.length - 1]
+ let tailSignature: string
+ if (!lastItem) {
+ tailSignature = `msg:${lastBlock.record.id}:${lastBlock.record.revision}`
+ } else if (lastItem.type === "tool") {
+ tailSignature = `tool:${lastItem.key}:${lastItem.partVersion}`
+ } else if (lastItem.type === "content") {
+ tailSignature = `content:${lastItem.key}:${lastBlock.record.revision}`
+ } else {
+ const version = typeof lastItem.part.version === "number" ? lastItem.part.version : 0
+ tailSignature = `step:${lastItem.key}:${version}`
+ }
return `${revisionValue}:${tailSignature}`
})
@@ -527,68 +601,99 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
{(block) => (
-
- {(message) => (
-
- )}
-
+
+ {(item) => (
+
+
+
+
+
+ {(() => {
+ const toolItem = item as ToolDisplayItem
+ const toolState = toolItem.toolPart.state as ToolState | undefined
+ const hasToolState =
+ Boolean(toolState) &&
+ (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
+ const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
+ const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
+ const handleGoToTaskSession = (event: MouseEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ if (!taskLocation) return
+ navigateToTaskSession(taskLocation)
+}
-
- {(item) => {
- const toolState = item.toolPart.state as ToolState | undefined
- const hasToolState =
- Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
- const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
- const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
- const handleGoToTaskSession = (event: MouseEvent) => {
- event.preventDefault()
- event.stopPropagation()
- if (!taskLocation) return
- navigateToTaskSession(taskLocation)
- }
- return (
-
-
-
+
+
+
+ )
+ })()}
+
+
+
+
+
+
+
+
+
-
- )
- }}
+
+
+ )}
)}
@@ -626,3 +731,206 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {