Files
CodeNomad/packages/ui/src/components/message-block.tsx
VooDisss c9c1cf21f0 fix(ui): stop forced auto-follow during streaming (#309)
# PR Draft: Fix sticky auto-scroll during streaming chat responses

Fixes #308

## Summary

This change makes chat auto-scroll easier to escape while assistant
output is still streaming.

The goal is to stop the viewport from repeatedly pulling the user back
toward the bottom once they begin scrolling upward to inspect earlier
content.

## Why

Before this change, streaming updates could keep reasserting
bottom-follow behavior during active rendering. That made auto-scroll
feel sticky and forced users to scroll repeatedly or forcefully just to
review earlier parts of an in-progress response.

The intended behavior is simpler: once the user scrolls upward to leave
follow mode, the UI should respect that decision instead of fighting it
during subsequent stream updates.

## What Changed

1. Removed render-time force-bottom behavior from the shared
follow-scroll helper path.
2. Updated streamed reasoning output to restore scroll without forcing
the viewport back to the bottom.
3. Updated streamed tool-call output to use the same non-forcing restore
behavior.

## Scope Boundaries

Included:

- Sticky auto-scroll behavior during streamed chat output
- Shared follow-scroll behavior used by streamed nested panes
- Reasoning and tool-call streaming paths that reused the same forced
follow behavior

Not included:

- A full rewrite of the virtualized message list follow model
- Broader scroll UX changes outside the streaming follow/escape behavior
- Unrelated UI or plugin configuration changes in the worktree

## Technical Notes

The core problem was not basic auto-scroll itself, but a render-time
path that could keep forcing bottom-follow behavior while new streamed
content was arriving.

That meant a user's attempt to scroll upward could be overridden
repeatedly by subsequent stream updates, which is why the auto-scroll
felt sticky. The fix removes that override and keeps render-time
restoration dependent on the current follow state instead.

## Files Changed

- `packages/ui/src/lib/follow-scroll.tsx`
- `packages/ui/src/components/message-block.tsx`
- `packages/ui/src/components/tool-call.tsx`

## Verification

Performed:

1. Reproduced the sticky auto-scroll behavior with a long multi-line
streaming response.
2. Verified that scrolling upward during streaming now disengages follow
more naturally in the affected streamed panes.
3. Ran `npm run typecheck --workspace @codenomad/ui`.
4. Ran `npm run build --workspace @codenomad/ui`.

Build note:

- The UI typecheck passes.
- The UI build succeeds.
- The build still emits existing third-party and chunk-size warnings
unrelated to this change.

## Risks and Follow-up

1. The broader scroll-follow model is still more heuristic-heavy than
ideal, so there may be future follow-up work to simplify it further.
2. This PR intentionally applies the smallest targeted fix to the known
snap-back path instead of rewriting the full chat scroll system.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-10 16:26:33 +01:00

1630 lines
57 KiB
TypeScript

import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } 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 { isHiddenSyntheticTextPart, 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 { selectInstanceTab } from "../stores/app-tabs"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
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 REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
const LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
return <div class="tool-call tool-call-loading" />
}
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
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<string, unknown> }).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) {
selectInstanceTab(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<string, ContentDisplayItem>
toolItems: Map<string, ToolDisplayItem>
messageBlocks: Map<string, CachedBlockEntry>
}
const renderCaches = new Map<string, SessionRenderCache>()
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<void>
onFork?: (messageId?: string) => void
onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
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 isVisibleContentPart(part: ClientPart): boolean {
if (!part || !isContentPartType((part as any).type)) return false
if (isHiddenSyntheticTextPart(part)) return false
return partHasRenderableText(part)
}
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<ClientPart[]>(() => {
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 visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
const showAgentMeta = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "assistant") return false
const currentParts = parts()
if (visibleParts().length === 0) {
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 (isVisibleContentPart(part)) {
return false
}
}
return true
})
return (
<Show when={record()}>
{(resolvedRecord) => (
<MessageItem
record={resolvedRecord()}
messageInfo={messageInfo()}
parts={visibleParts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={isQueued()}
showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage}
onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
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<void>
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
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 (
<Show when={toolPart()}>
{(resolvedToolPart) => (
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
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")}
/>
</Show>
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div>
<div class="flex items-center gap-0">
<Show when={taskSessionId()}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation()}
onClick={handleGoToTaskSession}
title={t("messageBlock.tool.goToSession.label")}
aria-label={t("messageBlock.tool.goToSession.label")}
>
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="tool-call-header-button"
type="button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="tool-call-header-button"
type="button"
disabled={deletingMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
</div>
<Suspense fallback={<ToolCallFallback />}>
<LazyToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</Suspense>
</div>
)}
</Show>
)
}
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<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
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<string>()
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<MessageDisplayBlock | null>(() => {
const current = record()
if (!current) return null
const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
const cacheSignature = [
current.id,
current.revision,
messageInfoVersion,
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
}
// Only capture info after cache check fails - ensures fresh data on version bump
const info = untrack(messageInfo)
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 (
<Show when={block()}>
{(resolvedBlock) => (
<div
class="message-stream-block"
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<Index each={resolvedBlock().items}>
{(item, index) => (
<Switch>
<Match when={item().type === "content"}>
<MessageContentItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={(item() as ContentDisplayItem).messageId}
startPartId={(item() as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</Match>
<Match when={item().type === "tool"}>
{(() => {
const toolItem = item() as ToolDisplayItem
return (
<div class="tool-call-message" data-key={toolItem.key}>
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index === 0}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/>
</div>
)
})()}
</Match>
<Match when={item().type === "step-start"}>
<StepCard
kind="start"
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showAgentMeta
showDeleteMessage={index === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item().type === "step-finish"}>
<StepCard
kind="finish"
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
borderColor={(item() as StepDisplayItem).accentColor}
showDeleteMessage={index === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item().type === "compaction"}>
<CompactionCard
part={(item() as CompactionDisplayItem).part}
messageInfo={(item() as CompactionDisplayItem).messageInfo}
borderColor={(item() as CompactionDisplayItem).accentColor}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item() as CompactionDisplayItem).messageId}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item().type === "reasoning"}>
<ReasoningCard
part={(item() as ReasoningDisplayItem).part}
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/>
</Match>
</Switch>
)}
</Index>
</div>
)}
</Show>
)
}
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<void>
selectedMessageIds?: () => Set<string>
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<void>
selectedMessageIds?: () => Set<string>
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 (
<div
class={`delete-hover-scope ${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }}
role="status"
aria-label={t("messageBlock.compaction.ariaLabel")}
>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<Show when={props.showDeleteMessage}>
<button
type="button"
class="tool-call-header-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="tool-call-header-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
<div class="message-compaction-row">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
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")}
/>
</Show>
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span>
</div>
</div>
)
}
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
const part = props.part as any
// step-finish parts have tokens embedded; also check messageInfo
const partTokens = part?.tokens
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
const tokens = partTokens ?? infoTokens
if (!tokens) {
return null
}
return {
input: tokens.input ?? 0,
output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 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<ReturnType<typeof usageStats>>) => {
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 (
<div class="message-step-usage">
<For each={entries}>
{(entry) => (
<span class="message-step-usage-chip" data-label={entry.label}>
{entry.formatter(entry.value)}
</span>
)}
</For>
</div>
)
}
if (props.kind === "finish") {
const usage = usageStats()
if (!usage) {
return null
}
return (
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}>
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox absolute left-2 top-1/2 -translate-y-1/2"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
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")}
/>
</Show>
<Show when={props.showDeleteMessage}>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
class="message-action-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
{renderUsageChips(usage)}
</div>
)
}
return (
<div class={`message-step-card message-step-start relative`}>
<div class="message-step-heading">
<div class="message-step-title">
<div class="message-step-title-left">
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
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")}
/>
</Show>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>{t("messageBlock.step.modelLabel", { model: value() })}</span>}</Show>
</span>
</Show>
</div>
<span class="message-step-time">{timestamp()}</span>
</div>
</div>
</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
messageId: string
showAgentMeta?: boolean
defaultExpanded?: boolean
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
}
function ReasoningStreamOutput(props: {
text: Accessor<string>
scrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
onContentRendered?: () => void
ariaLabel: string
}) {
let preRef: HTMLPreElement | undefined
let pendingRenderNotificationFrame: number | null = null
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "reasoning-scroll-sentinel",
})
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
createEffect(() => {
const nextText = props.text()
if (preRef && preRef.textContent !== nextText) {
preRef.textContent = nextText
}
followScroll.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
return (
<div
ref={followScroll.registerContainer}
class="message-reasoning-output"
role="region"
aria-label={props.ariaLabel}
onScroll={followScroll.handleScroll}
>
<pre
ref={(element) => {
preRef = element || undefined
if (preRef) {
preRef.textContent = props.text() || ""
}
}}
class="message-reasoning-text"
dir="auto"
/>
{followScroll.renderSentinel()}
</div>
)
}
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 [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
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")
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
text: reasoningText,
})
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
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 (
<div class="delete-hover-scope message-reasoning-card">
<div class="message-reasoning-header">
<button
type="button"
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-label">
<span class="message-reasoning-label-primary">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
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")}
/>
</Show>
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
</span>
</span>
</button>
<div class="message-reasoning-actions">
<Show when={canSpeakReasoning()}>
<SpeechActionButton
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void speech.toggle()
}}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<button
type="button"
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggle()
}}
aria-label={viewHideLabel()}
title={viewHideLabel()}
>
<Show when={expanded()} fallback={<ChevronsUpDown class="w-3.5 h-3.5" aria-hidden="true" />}>
<ChevronsDownUp class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
</button>
<Show when={props.showDeleteMessage}>
<button
type="button"
class="message-action-button"
onClick={handleDeleteUpTo}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
title={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
onClick={handleDeleteMessage}
disabled={!canDeleteMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<span class="message-reasoning-time">{timestamp()}</span>
</div>
</div>
<Show when={hasMeta()}>
<div class="message-reasoning-meta-row">
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</div>
</Show>
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<ReasoningStreamOutput
text={reasoningText}
scrollTopSnapshot={scrollTopSnapshot}
setScrollTopSnapshot={setScrollTopSnapshot}
onContentRendered={props.onContentRendered}
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
/>
</div>
</div>
</Show>
</div>
)
}