refine thinking cards and message layout

This commit is contained in:
Shantur Rathore
2025-11-27 10:24:41 +00:00
parent 6a9a442948
commit 755695a35a
5 changed files with 638 additions and 306 deletions

View File

@@ -1,9 +1,7 @@
import { For, Show, createMemo } from "solid-js"
import { For, Show } from "solid-js"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { formatTokenTotal } from "../lib/formatters"
import { preferences } from "../stores/preferences"
import MessagePart from "./message-part"
interface MessageItemProps {
@@ -20,7 +18,6 @@ interface MessageItemProps {
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.record.role === "user"
const showUsageMetrics = () => preferences().showUsageMetrics ?? true
const timestamp = () => {
const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt
const date = new Date(createdTime)
@@ -140,49 +137,15 @@ export default function MessageItem(props: MessageItemProps) {
}
}
if (!isUser() && !hasContent()) {
return null
}
const containerClass = () =>
isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const statChipClass =
"inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]"
const statLabelClass = "uppercase text-[9px] tracking-wide text-[var(--text-muted)]"
const statValueClass = "font-semibold text-[var(--text-primary)]"
const usageStats = createMemo(() => {
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
return null
}
if (!showUsageMetrics()) {
return null
}
const tokens = info.tokens
const input = tokens.input ?? 0
const output = tokens.output ?? 0
const reasoning = tokens.reasoning ?? 0
if (input === 0 && output === 0 && reasoning === 0) {
return null
}
return {
input,
output,
reasoning,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
}
})
const formatCostValue = (value: number) => {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
const agentIdentifier = () => {
if (isUser()) return ""
const info = props.messageInfo
@@ -199,21 +162,18 @@ export default function MessageItem(props: MessageItemProps) {
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
return (
<div class={containerClass()}>
<div class="flex justify-between items-center gap-2.5 pb-0.5">
<div class={`flex justify-between items-center gap-2.5 ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="flex flex-col">
<Show when={isUser()}>
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
</Show>
<Show when={!isUser()}>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
</div>
<span class="font-semibold text-xs text-[var(--message-assistant-border)]">Assistant</span>
</Show>
</div>
<div class="flex items-center gap-2">
@@ -267,94 +227,61 @@ export default function MessageItem(props: MessageItemProps) {
/>
)}
</For>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={usageStats()}>
{(usage) => (
<div class="mt-3 flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]">
<div class={statChipClass}>
<span class={statLabelClass}>Input</span>
<span class={statValueClass}>{formatTokenTotal(usage().input)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Output</span>
<span class={statValueClass}>{formatTokenTotal(usage().output)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Reasoning</span>
<span class={statValueClass}>{formatTokenTotal(usage().reasoning)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Read</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheRead)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Write</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheWrite)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cost</span>
<span class={statValueClass}>{formatCostValue(usage().cost)}</span>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
)}
</Show>
<Show when={props.record.status === "sending"}>
</Show>
<Show when={props.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
</div>
)
}

View File

@@ -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) {
<Match when={partType() === "reasoning"}>
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
<div class="message-reasoning">
<div class="reasoning-container">
<div class="reasoning-header" onClick={handleReasoningClick}>
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
<span class="reasoning-label">Reasoning</span>
</div>
<Show when={isReasoningExpanded()}>
<div class={`${textContainerClass()} mt-2`}>
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</div>
</Show>
</div>
</div>
</Show>
</Match>
</Switch>
)
}

View File

@@ -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<string, MessageDisplayItem>()
const messageItemCache = new Map<string, ContentDisplayItem>()
const toolItemCache = new Map<string, ToolDisplayItem>()
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
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<MessageDisplayBlock[]>(() => {
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) {
<For each={displayBlocks()}>
{(block) => (
<div class="message-stream-block" data-message-id={block.record.id}>
<Show when={block.messageItem} keyed>
{(message) => (
<MessageItem
record={message.record}
messageInfo={message.messageInfo}
combinedParts={message.combinedParts}
orderedParts={message.orderedParts}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={message.isQueued}
onRevert={props.onRevert}
onFork={props.onFork}
/>
)}
</Show>
<For each={block.items}>
{(item) => (
<Switch>
<Match when={item.type === "content"}>
<MessageItem
record={(item as ContentDisplayItem).record}
messageInfo={(item as ContentDisplayItem).messageInfo}
combinedParts={(item as ContentDisplayItem).parts}
orderedParts={(item as ContentDisplayItem).parts}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={(item as ContentDisplayItem).isQueued}
onRevert={props.onRevert}
onFork={props.onFork}
/>
</Match>
<Match when={item.type === "tool"}>
{(() => {
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)
}
<For each={block.toolItems}>
{(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 (
<div class="tool-call-message" data-key={item.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>Tool Call</span>
<span class="tool-name">{item.toolPart.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={item.toolPart}
toolCallId={item.key}
messageId={item.messageId}
messageVersion={item.messageVersion}
partVersion={item.partVersion}
return (
<div class="tool-call-message" data-key={toolItem.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>Tool Call</span>
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.key}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)
})()}
</Match>
<Match when={item.type === "step-start"}>
<StepCard
kind="start"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta
/>
</Match>
<Match when={item.type === "step-finish"}>
<StepCard
kind="finish"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={showUsagePreference()}
/>
</Match>
<Match when={item.type === "reasoning"}>
<ReasoningCard
part={(item as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)
}}
</Match>
</Switch>
)}
</For>
</div>
)}
@@ -626,3 +731,206 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
</div>
)
}
interface StepCardProps {
kind: "start" | "finish"
part: ClientPart
messageInfo?: MessageInfo
showAgentMeta?: boolean
showUsage?: boolean
}
function StepCard(props: StepCardProps) {
const snapshot = () => {
const value = (props.part as { snapshot?: string }).snapshot
return typeof value === "string" ? value : ""
}
const reason = () => {
if (props.kind !== "finish") return ""
const value = (props.part as { reason?: string }).reason
return typeof value === "string" ? value : ""
}
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const agentIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
return info.mode || ""
}
const modelIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const usageStats = () => {
if (props.kind !== "finish" || !props.showUsage) {
return null
}
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
return null
}
const tokens = info.tokens
const input = tokens.input ?? 0
const output = tokens.output ?? 0
const reasoningTokens = tokens.reasoning ?? 0
if (input === 0 && output === 0 && reasoningTokens === 0) {
return null
}
return {
input,
output,
reasoning: reasoningTokens,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
}
}
return (
<div class={`message-step-card ${props.kind === "start" ? "message-step-start" : "message-step-finish"}`}>
<div class="message-step-heading">
<div class="message-step-title">
<div class="message-step-title-left">
<Show when={props.kind === "start" && props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
</span>
</Show>
<span>{props.kind === "start" ? "Step started" : "Step finished"}</span>
</div>
<span class="message-step-time">{timestamp()}</span>
</div>
<Show when={props.kind === "finish" && reason()}>{(value) => <span class="message-step-reason">{value()}</span>}</Show>
</div>
<Show when={usageStats()}>
{(usage) => (
<div class="mt-3 flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]">
<div class="inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]">
<span class="uppercase text-[9px] tracking-wide text-[var(--text-muted)]">Input</span>
<span class="font-semibold text-[var(--text-primary)]">{formatTokenTotal(usage().input)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]">
<span class="uppercase text-[9px] tracking-wide text-[var(--text-muted)]">Output</span>
<span class="font-semibold text-[var(--text-primary)]">{formatTokenTotal(usage().output)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]">
<span class="uppercase text-[9px] tracking-wide text-[var(--text-muted)]">Reasoning</span>
<span class="font-semibold text-[var(--text-primary)]">{formatTokenTotal(usage().reasoning)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]">
<span class="uppercase text-[9px] tracking-wide text-[var(--text-muted)]">Cache Read</span>
<span class="font-semibold text-[var(--text-primary)]">{formatTokenTotal(usage().cacheRead)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]">
<span class="uppercase text-[9px] tracking-wide text-[var(--text-muted)]">Cache Write</span>
<span class="font-semibold text-[var(--text-primary)]">{formatTokenTotal(usage().cacheWrite)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]">
<span class="uppercase text-[9px] tracking-wide text-[var(--text-muted)]">Cost</span>
<span class="font-semibold text-[var(--text-primary)]">{formatCostValue(usage().cost)}</span>
</div>
</div>
)}
</Show>
<Show when={props.kind === "finish" && !props.showUsage}>
<div class="message-step-finish-spacer" aria-hidden="true" />
</Show>
</div>
)
}
function formatCostValue(value: number) {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
interface ReasoningCardProps {
part: ClientPart
messageInfo?: MessageInfo
instanceId: string
sessionId: string
}
function ReasoningCard(props: ReasoningCardProps) {
const [expanded, setExpanded] = createSignal(false)
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const reasoningText = () => {
const part = props.part as any
if (!part) return ""
const stringifySegment = (segment: unknown): string => {
if (typeof segment === "string") {
return segment
}
if (segment && typeof segment === "object") {
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
const pieces: string[] = []
if (typeof obj.text === "string") {
pieces.push(obj.text)
}
if (typeof obj.value === "string") {
pieces.push(obj.value)
}
if (Array.isArray(obj.content)) {
pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
}
return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
}
return ""
}
const textValue = stringifySegment(part.text)
if (textValue.trim().length > 0) {
return textValue
}
if (Array.isArray(part.content)) {
return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
}
return ""
}
const toggle = () => setExpanded((prev) => !prev)
return (
<div class="message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]">
<div class="reasoning-card-header">
<div class="flex flex-col">
<span class="font-semibold text-xs text-[var(--message-assistant-border)]">Thinking</span>
</div>
<div class="reasoning-card-header-right">
<button type="button" class="reasoning-card-toggle" onClick={toggle} aria-expanded={expanded()} aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}>
{expanded() ? "Collapse" : "Expand"}
</button>
<span class="reasoning-card-time">{timestamp()}</span>
</div>
</div>
<Show when={expanded()}>
<div class="reasoning-card-body">
<pre class="reasoning-card-text">{reasoningText() || ""}</pre>
</div>
</Show>
</div>
)
}

View File

@@ -1,13 +1,8 @@
import type { ClientPart } from "../../types/message"
import { partHasRenderableText } from "../../types/message"
import type { MessageRecord } from "./types"
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
export interface RecordDisplayData {
orderedParts: ClientPart[]
textAndReasoningParts: ClientPart[]
toolParts: ToolCallPart[]
}
interface RecordDisplayCacheEntry {
@@ -17,47 +12,26 @@ interface RecordDisplayCacheEntry {
const recordDisplayCache = new Map<string, RecordDisplayCacheEntry>()
function makeCacheKey(instanceId: string, messageId: string, showThinking: boolean) {
return `${instanceId}:${messageId}:${showThinking ? 1 : 0}`
function makeCacheKey(instanceId: string, messageId: string) {
return `${instanceId}:${messageId}`
}
function isToolPart(part: ClientPart): part is ToolCallPart {
return part.type === "tool"
}
export function buildRecordDisplayData(instanceId: string, record: MessageRecord, showThinking: boolean): RecordDisplayData {
const cacheKey = makeCacheKey(instanceId, record.id, showThinking)
export function buildRecordDisplayData(instanceId: string, record: MessageRecord): RecordDisplayData {
const cacheKey = makeCacheKey(instanceId, record.id)
const cached = recordDisplayCache.get(cacheKey)
if (cached && cached.revision === record.revision) {
return cached.data
}
const orderedParts: ClientPart[] = []
const textAndReasoningParts: ClientPart[] = []
const toolParts: ToolCallPart[] = []
for (const partId of record.partIds) {
const entry = record.parts[partId]
if (!entry?.data) continue
const part = entry.data
orderedParts.push(part)
if (isToolPart(part)) {
toolParts.push(part)
continue
}
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
textAndReasoningParts.push(part)
continue
}
if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
textAndReasoningParts.push(part)
}
orderedParts.push(entry.data)
}
const data = { orderedParts, textAndReasoningParts, toolParts }
const data: RecordDisplayData = { orderedParts }
recordDisplayCache.set(cacheKey, { revision: record.revision, data })
return data
}

View File

@@ -6,8 +6,28 @@
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
margin-top: 0;
margin-bottom: 0;
}
.message-item-base:not(.assistant-message) {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.message-step-start {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
margin-top: 0.5rem;
}
.message-step-finish {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
margin-bottom: 0.5rem;
}
.message-queued-badge {
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
background-color: var(--accent-primary);
@@ -101,3 +121,90 @@
.reasoning-label {
font-weight: var(--font-weight-medium);
}
.message-step-card {
@apply flex flex-col gap-2 px-3 py-2;
color: inherit;
border-radius: 0;
}
.message-step-start {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
}
.message-step-finish {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
}
.message-step-heading {
@apply flex flex-wrap items-center gap-2 text-xs;
color: var(--text-muted);
}
.message-step-title {
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
@apply flex items-center justify-between w-full;
}
.message-step-title-left {
@apply flex items-center gap-2 text-[var(--text-muted)];
}
.message-step-title-left span:last-child {
@apply font-medium text-[11px];
}
.message-step-time {
@apply text-[11px] text-[var(--text-muted)] font-normal ml-auto;
}
.message-step-meta-inline {
@apply flex flex-wrap gap-2 text-[11px];
color: var(--message-assistant-border);
}
.message-step-reason {
@apply text-[11px] font-medium;
color: var(--text-muted);
}
.message-step-finish-spacer {
@apply mt-4;
}
.reasoning-card-header {
@apply flex items-center justify-between;
}
.reasoning-card-header-right {
@apply flex items-center gap-2;
}
.reasoning-card-toggle {
@apply inline-flex items-center justify-center rounded border border-[var(--border-base)] text-[var(--text-muted)] bg-transparent px-3 py-0.5 text-xs font-semibold;
}
.reasoning-card-toggle:hover {
color: var(--accent-primary);
border-color: var(--accent-primary);
}
.reasoning-card-time {
@apply text-[11px] text-[var(--text-muted)];
}
.reasoning-card-body {
@apply pt-1;
}
.reasoning-card-text {
@apply whitespace-pre-wrap break-words leading-[1.1] text-[var(--text-muted)] text-sm;
font-family: inherit;
background: transparent;
border: none;
}