import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import MessageItem from "./message-item" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { ClientPart, MessageInfo } from "../types/message" import { partHasRenderableText } from "../types/message" import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache" import type { MessageRecord } from "../stores/message-v2/types" import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { setActiveInstanceId } from "../stores/instances" import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" import { useI18n } from "../lib/i18n" import type { DeleteHoverState } from "../types/delete-hover" function DeleteUpToIcon() { return ( ) } const TOOL_ICON = "🔧" const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const TOOL_BORDER_COLOR = "var(--message-tool-border)" const LazyToolCall = lazy(() => import("./tool-call")) function ToolCallFallback() { return
} type ToolCallPart = Extract type ToolState = import("@opencode-ai/sdk/v2").ToolState type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { return Boolean(state && state.status === "running") } function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted { return Boolean(state && state.status === "completed") } function isToolStateError(state: ToolState | undefined): state is ToolStateError { return Boolean(state && state.status === "error") } function extractTaskSessionId(state: ToolState | undefined): string { if (!state) return "" const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} const directId = metadata?.sessionId ?? metadata?.sessionID return typeof directId === "string" ? directId : "" } 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 } interface TaskSessionLocation { sessionId: string instanceId: string parentId: string | null } function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null { if (!sessionId) return null if (preferredInstanceId) { const session = sessions().get(preferredInstanceId)?.get(sessionId) if (session) { return { sessionId: session.id, instanceId: preferredInstanceId, parentId: session.parentId ?? null, } } } const allSessions = sessions() for (const [instanceId, sessionMap] of allSessions) { const session = sessionMap?.get(sessionId) if (session) { return { sessionId: session.id, instanceId, parentId: session.parentId ?? null, } } } return null } function navigateToTaskSession(location: TaskSessionLocation) { setActiveInstanceId(location.instanceId) const parentToActivate = location.parentId ?? location.sessionId setActiveParentSession(location.instanceId, parentToActivate) if (location.parentId) { setActiveSession(location.instanceId, location.sessionId) } } interface CachedBlockEntry { signature: string block: MessageDisplayBlock contentKeys: string[] toolKeys: string[] } interface SessionRenderCache { messageItems: Map toolItems: Map messageBlocks: Map } const renderCaches = new Map() function makeSessionCacheKey(instanceId: string, sessionId: string) { return `${instanceId}:${sessionId}` } export function clearSessionRenderCache(instanceId: string, sessionId: string) { renderCaches.delete(makeSessionCacheKey(instanceId, sessionId)) } function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache { const key = makeSessionCacheKey(instanceId, sessionId) let cache = renderCaches.get(key) if (!cache) { cache = { messageItems: new Map(), toolItems: new Map(), messageBlocks: new Map(), } renderCaches.set(key, cache) } return cache } function clearInstanceCaches(instanceId: string) { clearRecordDisplayCacheForInstance(instanceId) const prefix = `${instanceId}:` for (const key of renderCaches.keys()) { if (key.startsWith(prefix)) { renderCaches.delete(key) } } } messageStoreBus.onInstanceDestroyed(clearInstanceCaches) interface ContentDisplayItem { type: "content" key: string messageId: string startPartId: string } interface ToolDisplayItem { type: "tool" key: string messageId: string partId: string } interface MessageContentItemProps { instanceId: string sessionId: string store: () => InstanceMessageStore messageId: string startPartId: string messageIndex: number lastAssistantIndex: () => number onRevert?: (messageId: string) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void onContentRendered?: () => void showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void selectedMessageIds?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function isSupportedPartType(part: unknown): boolean { const type = (part as any)?.type // Ignore part types the UI does not support rendering yet. return !(typeof type === "string" && type === "patch") } function isContentPartType(type: unknown): boolean { return type === "text" || type === "file" } function MessageContentItem(props: MessageContentItemProps) { const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const isQueued = createMemo(() => { const current = record() if (!current) return false if (current.role !== "user") return false const lastAssistant = props.lastAssistantIndex() return lastAssistant === -1 || props.messageIndex > lastAssistant }) const parts = createMemo(() => { const current = record() if (!current) return [] const ids = current.partIds const startIndex = ids.indexOf(props.startPartId) if (startIndex === -1) return [] const resolved: ClientPart[] = [] for (let idx = startIndex; idx < ids.length; idx++) { const partId = ids[idx] const part = current.parts[partId]?.data if (!part) continue if (!isSupportedPartType(part)) continue if (!isContentPartType((part as any).type)) break resolved.push(part) } return resolved }) const showAgentMeta = createMemo(() => { const current = record() if (!current) return false if (current.role !== "assistant") return false const currentParts = parts() if (!currentParts.some((part) => partHasRenderableText(part))) { return false } const ids = current.partIds const startIndex = ids.indexOf(props.startPartId) if (startIndex === -1) return false // Only show agent meta on the first content segment that contains renderable content. for (let idx = 0; idx < startIndex; idx++) { const partId = ids[idx] const part = current.parts[partId]?.data if (!part) continue if (!isSupportedPartType(part)) continue if (!isContentPartType((part as any).type)) continue if (partHasRenderableText(part)) { return false } } return true }) return ( {(resolvedRecord) => ( )} ) } interface ToolCallItemProps { instanceId: string sessionId: string store: () => InstanceMessageStore messageId: string partId: string onContentRendered?: () => void showDeleteMessage?: boolean deleteHover?: () => DeleteHoverState onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set selectedToolPartKeys?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function ToolCallItem(props: ToolCallItemProps) { const { t } = useI18n() const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) const isSelectedToolPartForDeletion = () => Boolean(props.selectedToolPartKeys?.().has(`${props.messageId}:${props.partId}`)) const isDeleteOverlayActive = () => { if (isSelectedForDeletion()) return true if (isSelectedToolPartForDeletion()) return true const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState) if (hover.kind === "message") { return hover.messageId === props.messageId } if (hover.kind === "deleteUpTo") { const ids = props.store().getSessionMessageIds(props.sessionId) const targetIndex = ids.indexOf(hover.messageId) if (targetIndex === -1) return false const currentIndex = ids.indexOf(props.messageId) if (currentIndex === -1) return false return currentIndex >= targetIndex } return false } const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const partEntry = createMemo(() => record()?.parts?.[props.partId]) const toolPart = createMemo(() => { const part = partEntry()?.data as ClientPart | undefined if (!part || part.type !== "tool") return undefined return part as ToolCallPart }) const toolState = createMemo(() => toolPart()?.state as ToolState | undefined) const toolName = createMemo(() => toolPart()?.tool || "") const messageVersion = createMemo(() => record()?.revision ?? 0) const partVersion = createMemo(() => partEntry()?.revision ?? 0) const taskSessionId = createMemo(() => { const state = toolState() if (!state) return "" if (!(isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))) { return "" } return extractTaskSessionId(state) }) const taskLocation = createMemo(() => { const id = taskSessionId() if (!id) return null return findTaskSessionLocation(id, props.instanceId) }) const handleGoToTaskSession = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() const location = taskLocation() if (!location) return navigateToTaskSession(location) } const handleDeleteMessage = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!props.showDeleteMessage) return if (deletingMessage()) return setDeletingMessage(true) try { await deleteMessage(props.instanceId, props.sessionId, props.messageId) } catch (error) { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { title: t("messageItem.actions.deleteMessageFailedTitle"), detail: error instanceof Error ? error.message : String(error), variant: "error", }) } finally { setDeletingMessage(false) } } const handleDeleteUpTo = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!props.showDeleteMessage) return if (!props.onDeleteMessagesUpTo) return if (deletingUpTo()) return setDeletingUpTo(true) try { await props.onDeleteMessagesUpTo(props.messageId) } finally { setDeletingUpTo(false) } } return ( {(resolvedToolPart) => (
{ event.stopPropagation() }} onChange={(event) => { event.stopPropagation() const next = Boolean((event.currentTarget as HTMLInputElement).checked) props.onToggleSelectedMessage?.(props.messageId, next) }} aria-label={t("messageItem.selection.checkboxAriaLabel")} title={t("messageItem.selection.checkboxAriaLabel")} /> {TOOL_ICON} {t("messageBlock.tool.header")} {toolName() || t("messageBlock.tool.unknown")}
}>
)}
) } interface StepDisplayItem { type: "step-start" | "step-finish" key: string part: ClientPart messageInfo?: MessageInfo accentColor?: string } type ReasoningDisplayItem = { type: "reasoning" key: string part: ClientPart messageInfo?: MessageInfo showAgentMeta?: boolean defaultExpanded: boolean messageId: string partId: string } type CompactionDisplayItem = { type: "compaction" key: string part: ClientPart messageInfo?: MessageInfo accentColor?: string messageId: string partId: string } type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem interface MessageDisplayBlock { record: MessageRecord items: MessageBlockItem[] } interface MessageBlockProps { messageId: string instanceId: string sessionId: string store: () => InstanceMessageStore messageIndex: number lastAssistantIndex: () => number showThinking: () => boolean thinkingDefaultExpanded: () => boolean showUsageMetrics: () => boolean deleteHover?: () => DeleteHoverState onDeleteHoverChange?: (state: DeleteHoverState) => void selectedMessageIds?: () => Set selectedToolPartKeys?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onRevert?: (messageId: string) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void onContentRendered?: () => void } export default function MessageBlock(props: MessageBlockProps) { const { t } = useI18n() const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) const isDeleteMessageHovered = () => { const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState) const selected = props.selectedMessageIds?.() ?? new Set() if (selected.has(props.messageId)) { return true } if (hover.kind === "message") { return hover.messageId === props.messageId } if (hover.kind === "deleteUpTo") { const ids = props.store().getSessionMessageIds(props.sessionId) const targetIndex = ids.indexOf(hover.messageId) if (targetIndex === -1) return false const currentIndex = ids.indexOf(props.messageId) if (currentIndex === -1) return false return currentIndex >= targetIndex } return false } const block = createMemo(() => { const current = record() if (!current) return null const index = props.messageIndex const lastAssistantIdx = props.lastAssistantIndex() const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx) // Intentionally untracked: messageInfoVersion updates should not trigger // a full message block rebuild; record revision is the invalidation key. const info = untrack(messageInfo) const cacheSignature = [ current.id, current.revision, isQueued ? 1 : 0, props.showThinking() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0, props.showUsageMetrics() ? 1 : 0, ].join("|") const cachedBlock = sessionCache.messageBlocks.get(current.id) if (cachedBlock && cachedBlock.signature === cacheSignature) { return cachedBlock.block } const { orderedParts } = buildRecordDisplayData(props.instanceId, current) const items: MessageBlockItem[] = [] const blockContentKeys: string[] = [] const blockToolKeys: string[] = [] let pendingParts: ClientPart[] = [] let agentMetaAttached = current.role !== "assistant" const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR let lastAccentColor = defaultAccentColor const flushContent = () => { if (pendingParts.length === 0) return const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : "" if (!startPartId) { pendingParts = [] return } if (!agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part))) { agentMetaAttached = true } const segmentKey = `${current.id}:content:${startPartId}` let cached = sessionCache.messageItems.get(segmentKey) if (!cached) { cached = { type: "content", key: segmentKey, messageId: current.id, startPartId, } sessionCache.messageItems.set(segmentKey, cached) } items.push(cached) blockContentKeys.push(segmentKey) lastAccentColor = defaultAccentColor pendingParts = [] } orderedParts.forEach((part, partIndex) => { if (!isSupportedPartType(part)) { return } if (part.type === "tool") { flushContent() const partId = part.id if (!partId) { // Tool parts are required to have ids; if one slips through, skip rendering // to avoid unstable keys and accidental remount cascades. return } const key = `${current.id}:${partId}` let toolItem = sessionCache.toolItems.get(key) if (!toolItem) { toolItem = { type: "tool", key, messageId: current.id, partId, } sessionCache.toolItems.set(key, toolItem) } else { toolItem.key = key toolItem.messageId = current.id toolItem.partId = partId } items.push(toolItem) blockToolKeys.push(key) lastAccentColor = TOOL_BORDER_COLOR return } if (part.type === "compaction") { flushContent() const partId = part.id ?? "" const key = `${current.id}:${partId || partIndex}:compaction` const isAuto = Boolean((part as any)?.auto) items.push({ type: "compaction", key, part, messageInfo: info, accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR, messageId: current.id, partId, }) lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR return } if (part.type === "step-start") { flushContent() return } if (part.type === "step-finish") { flushContent() if (props.showUsageMetrics()) { const key = `${current.id}:${part.id ?? partIndex}:${part.type}` const accentColor = lastAccentColor || defaultAccentColor items.push({ type: part.type, key, part, messageInfo: info, accentColor }) lastAccentColor = accentColor } return } if (part.type === "reasoning") { flushContent() if (props.showThinking() && reasoningHasRenderableContent(part)) { const partId = part.id ?? "" const key = `${current.id}:${partId || partIndex}:reasoning` const showAgentMeta = current.role === "assistant" && !agentMetaAttached if (showAgentMeta) { agentMetaAttached = true } items.push({ type: "reasoning", key, part, messageInfo: info, showAgentMeta, defaultExpanded: props.thinkingDefaultExpanded(), messageId: current.id, partId, }) lastAccentColor = ASSISTANT_BORDER_COLOR } return } pendingParts.push(part) }) flushContent() const resultBlock: MessageDisplayBlock = { record: current, items } sessionCache.messageBlocks.set(current.id, { signature: cacheSignature, block: resultBlock, contentKeys: blockContentKeys.slice(), toolKeys: blockToolKeys.slice(), }) const messagePrefix = `${current.id}:` for (const [key] of sessionCache.messageItems) { if (key.startsWith(messagePrefix) && !blockContentKeys.includes(key)) { sessionCache.messageItems.delete(key) } } for (const [key] of sessionCache.toolItems) { if (key.startsWith(messagePrefix) && !blockToolKeys.includes(key)) { sessionCache.toolItems.delete(key) } } return resultBlock }) return ( {(resolvedBlock) => (
{(item, index) => ( {(() => { const toolItem = item as ToolDisplayItem return (
) })()}
)}
)}
) } interface StepCardProps { kind: "start" | "finish" part: ClientPart messageInfo?: MessageInfo showAgentMeta?: boolean showUsage?: boolean borderColor?: string showDeleteMessage?: boolean instanceId?: string sessionId?: string messageId?: string onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } interface CompactionCardProps { part: ClientPart messageInfo?: MessageInfo borderColor?: string instanceId: string sessionId: string messageId: string showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function CompactionCard(props: CompactionCardProps) { const { t } = useI18n() const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) const isAuto = () => Boolean((props.part as any)?.auto) const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel")) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) const containerClass = () => `message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}` const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const handleDeleteMessage = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!props.showDeleteMessage) return if (!canDeleteMessage()) return setDeletingMessage(true) try { await deleteMessage(props.instanceId, props.sessionId, props.messageId) } catch (error) { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { title: t("messageItem.actions.deleteMessageFailedTitle"), detail: error instanceof Error ? error.message : String(error), variant: "error", }) } finally { setDeletingMessage(false) } } const handleDeleteUpTo = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!props.showDeleteMessage) return if (!props.onDeleteMessagesUpTo) return if (deletingUpTo()) return setDeletingUpTo(true) try { await props.onDeleteMessagesUpTo(props.messageId) } finally { setDeletingUpTo(false) } } return (
{ event.stopPropagation() }} onChange={(event) => { event.stopPropagation() const next = Boolean((event.currentTarget as HTMLInputElement).checked) props.onToggleSelectedMessage?.(props.messageId, next) }} aria-label={t("messageItem.selection.checkboxAriaLabel")} title={t("messageItem.selection.checkboxAriaLabel")} />
) } function StepCard(props: StepCardProps) { const { t } = useI18n() const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId)) 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 return { input: tokens.input ?? 0, output: tokens.output ?? 0, reasoning: tokens.reasoning ?? 0, cacheRead: tokens.cache?.read ?? 0, cacheWrite: tokens.cache?.write ?? 0, cost: info.cost ?? 0, } } const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) const canDeleteMessage = () => Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage() const handleDeleteMessage = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!canDeleteMessage()) return setDeletingMessage(true) try { await deleteMessage(props.instanceId!, props.sessionId!, props.messageId!) } catch (error) { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { title: t("messageItem.actions.deleteMessageFailedTitle"), detail: error instanceof Error ? error.message : String(error), variant: "error", }) } finally { setDeletingMessage(false) } } const handleDeleteUpTo = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!props.messageId) return if (!props.onDeleteMessagesUpTo) return if (deletingUpTo()) return setDeletingUpTo(true) try { await props.onDeleteMessagesUpTo(props.messageId) } finally { setDeletingUpTo(false) } } const renderUsageChips = (usage: NonNullable>) => { const entries = [ { label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal }, { label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal }, { label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal }, { label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal }, { label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal }, { label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue }, ] return (
{(entry) => ( {entry.formatter(entry.value)} )}
) } if (props.kind === "finish") { const usage = usageStats() if (!usage) { return null } return (
{ event.stopPropagation() }} onChange={(event) => { event.stopPropagation() const next = Boolean((event.currentTarget as HTMLInputElement).checked) props.onToggleSelectedMessage?.(props.messageId!, next) }} aria-label={t("messageItem.selection.checkboxAriaLabel")} title={t("messageItem.selection.checkboxAriaLabel")} />
{renderUsageChips(usage)}
) } return (
{ event.stopPropagation() }} onChange={(event) => { event.stopPropagation() const next = Boolean((event.currentTarget as HTMLInputElement).checked) props.onToggleSelectedMessage?.(props.messageId!, next) }} aria-label={t("messageItem.selection.checkboxAriaLabel")} title={t("messageItem.selection.checkboxAriaLabel")} /> {(value) => {t("messageBlock.step.agentLabel", { agent: value() })}} {(value) => {t("messageBlock.step.modelLabel", { model: value() })}}
{timestamp()}
) } 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 messageId: string showAgentMeta?: boolean defaultExpanded?: boolean showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onContentRendered?: () => void } function ReasoningCard(props: ReasoningCardProps) { const { t } = useI18n() const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) let pendingRenderNotificationFrame: number | null = null const notifyContentRendered = () => { if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return if (pendingRenderNotificationFrame !== null) { cancelAnimationFrame(pendingRenderNotificationFrame) } pendingRenderNotificationFrame = requestAnimationFrame(() => { pendingRenderNotificationFrame = null props.onContentRendered?.() }) } onCleanup(() => { if (pendingRenderNotificationFrame !== null) { cancelAnimationFrame(pendingRenderNotificationFrame) pendingRenderNotificationFrame = null } }) createEffect(() => { setExpanded(Boolean(props.defaultExpanded)) }) 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 = () => { const info = props.messageInfo if (!info || info.role !== "assistant") return "" return info.mode || "" } const modelIdentifier = () => { 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 hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier())) 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) const viewHideLabel = () => expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") createEffect(() => { if (!expanded()) return reasoningText() notifyContentRendered() }) const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const handleDeleteMessage = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!props.showDeleteMessage) return if (!canDeleteMessage()) return setDeletingMessage(true) try { await deleteMessage(props.instanceId, props.sessionId, props.messageId) } catch (error) { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { title: t("messageItem.actions.deleteMessageFailedTitle"), detail: error instanceof Error ? error.message : String(error), variant: "error", }) } finally { setDeletingMessage(false) } } const handleDeleteUpTo = async (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!props.showDeleteMessage) return if (!props.onDeleteMessagesUpTo) return if (deletingUpTo()) return setDeletingUpTo(true) try { await props.onDeleteMessagesUpTo(props.messageId) } finally { setDeletingUpTo(false) } } return (
{timestamp()}
{(value) => ( {t("messageBlock.step.agentLabel", { agent: value() })} )} {(value) => ( {t("messageBlock.step.modelLabel", { model: value() })} )}
{reasoningText() || ""}
) }