From 755695a35a8c0c0984c67099a86faf6e48b10d10 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 27 Nov 2025 10:24:41 +0000 Subject: [PATCH] refine thinking cards and message layout --- packages/ui/src/components/message-item.tsx | 193 ++---- packages/ui/src/components/message-part.tsx | 50 +- .../ui/src/components/message-stream-v2.tsx | 556 ++++++++++++++---- .../stores/message-v2/record-display-cache.ts | 38 +- .../ui/src/styles/messaging/message-base.css | 107 ++++ 5 files changed, 638 insertions(+), 306 deletions(-) diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 648ddc5f..92f7fc5b 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -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 (
-
+
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} - - {name} - - -
- {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} + + {name} + + +
+ {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) { - - -
-
-
- {isReasoningExpanded() ? "▼" : "▶"} - Reasoning -
- -
- -
-
-
-
-
-
+ ) } 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 ( -
-
-
- {TOOL_ICON} - Tool Call - {item.toolPart.tool || "unknown"} -
- - - -
- +
+
+ {TOOL_ICON} + Tool Call + {toolItem.toolPart.tool || "unknown"} +
+ + + +
+ +
+ ) + })()} +
+ + + + + + + + -
- ) - }} + + + )}
)} @@ -626,3 +731,206 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
) } + +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 ( +
+
+
+
+ + + {(value) => Agent: {value()}} + {(value) => Model: {value()}} + + + {props.kind === "start" ? "Step started" : "Step finished"} +
+ {timestamp()} +
+ {(value) => {value()}} +
+ + {(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)} +
+
+ )} +
+ + + ) +} + +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 ( +
+
+
+ Thinking +
+
+ + {timestamp()} +
+
+ +
+
{reasoningText() || ""}
+
+
+
+ ) +} diff --git a/packages/ui/src/stores/message-v2/record-display-cache.ts b/packages/ui/src/stores/message-v2/record-display-cache.ts index d568e0cd..b653e4f0 100644 --- a/packages/ui/src/stores/message-v2/record-display-cache.ts +++ b/packages/ui/src/stores/message-v2/record-display-cache.ts @@ -1,13 +1,8 @@ import type { ClientPart } from "../../types/message" -import { partHasRenderableText } from "../../types/message" import type { MessageRecord } from "./types" -export type ToolCallPart = Extract - export interface RecordDisplayData { orderedParts: ClientPart[] - textAndReasoningParts: ClientPart[] - toolParts: ToolCallPart[] } interface RecordDisplayCacheEntry { @@ -17,47 +12,26 @@ interface RecordDisplayCacheEntry { const recordDisplayCache = new Map() -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 } diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index ad288471..667f5e0b 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -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; +} +