refine thinking cards and message layout
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user