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" import type { MessageInfo, ClientPart } from "../types/message" import { partHasRenderableText } from "../types/message" import { getSessionInfo, sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { showCommandPalette } from "../stores/command-palette" import { messageStoreBus } from "../stores/message-v2/bus" import type { MessageRecord } from "../stores/message-v2/types" 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" import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { setActiveInstanceId } from "../stores/instances" const SCROLL_SCOPE = "session" const TOOL_ICON = "🔧" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const messageItemCache = new Map() const toolItemCache = new Map() type ToolCallPart = Extract type ToolState = import("@opencode-ai/sdk").ToolState type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted type ToolStateError = import("@opencode-ai/sdk").ToolStateError function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { return Boolean(state && state.status === "running") } function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted { return Boolean(state && state.status === "completed") } function isToolStateError(state: ToolState | undefined): state is ToolStateError { return Boolean(state && state.status === "error") } function extractTaskSessionId(state: ToolState | undefined): string { if (!state) return "" const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} const directId = metadata?.sessionId ?? metadata?.sessionID return typeof directId === "string" ? directId : "" } interface TaskSessionLocation { sessionId: string instanceId: string parentId: string | null } function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null { if (!sessionId) return null const allSessions = sessions() for (const [instanceId, sessionMap] of allSessions) { const session = sessionMap?.get(sessionId) if (session) { return { sessionId: session.id, instanceId, parentId: session.parentId ?? null, } } } return null } function navigateToTaskSession(location: TaskSessionLocation) { setActiveInstanceId(location.instanceId) const parentToActivate = location.parentId ?? location.sessionId setActiveParentSession(location.instanceId, parentToActivate) if (location.parentId) { setActiveSession(location.instanceId, location.sessionId) } } function formatTokens(tokens: number): string { return formatTokenTotal(tokens) } function makeInstanceCacheKey(instanceId: string, id: string) { return `${instanceId}:${id}` } function clearInstanceCaches(instanceId: string) { clearRecordDisplayCacheForInstance(instanceId) const prefix = `${instanceId}:` for (const key of messageItemCache.keys()) { if (key.startsWith(prefix)) { messageItemCache.delete(key) } } for (const key of toolItemCache.keys()) { if (key.startsWith(prefix)) { toolItemCache.delete(key) } } } messageStoreBus.onInstanceDestroyed(clearInstanceCaches) interface MessageStreamV2Props { instanceId: string sessionId: string loading?: boolean onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void } interface ContentDisplayItem { type: "content" key: string record: MessageRecord parts: ClientPart[] messageInfo?: MessageInfo isQueued: boolean showAgentMeta?: boolean } interface ToolDisplayItem { type: "tool" key: string toolPart: ToolCallPart messageInfo?: MessageInfo messageId: string messageVersion: number partVersion: number } interface StepDisplayItem { type: "step-start" | "step-finish" key: string part: ClientPart messageInfo?: MessageInfo } type ReasoningDisplayItem = { type: "reasoning" key: string part: ClientPart messageInfo?: MessageInfo showAgentMeta?: boolean } 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(() => messageIds() .map((id) => store().getMessage(id)) .filter((record): record is MessageRecord => Boolean(record)), ) const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId)) const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId)) const sessionInfo = createMemo(() => getSessionInfo(props.instanceId, props.sessionId) ?? { cost: 0, contextWindow: 0, isSubscriptionModel: false, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, actualUsageTokens: 0, modelOutputLimit: 0, contextAvailableTokens: null, }, ) const tokenStats = createMemo(() => { const usage = usageSnapshot() const info = sessionInfo() return { used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0, avail: info.contextAvailableTokens, } }) const connectionStatus = () => sseManager.getStatus(props.instanceId) const handleCommandPaletteClick = () => { showCommandPalette(props.instanceId) } const messageInfoMap = createMemo(() => { const map = new Map() messageRecords().forEach((record) => { const info = store().getMessageInfo(record.id) if (info) { map.set(record.id, info) } }) return map }) const revertTarget = createMemo(() => store().getSessionRevert(props.sessionId)) const messageIndexMap = createMemo(() => { const map = new Map() const records = messageRecords() records.forEach((record, index) => map.set(record.id, index)) return map }) const lastAssistantIndex = createMemo(() => { const records = messageRecords() for (let index = records.length - 1; index >= 0; index--) { if (records[index].role === "assistant") { return index } } return -1 }) function reasoningHasRenderableContent(part: ClientPart): boolean { if (!part || part.type !== "reasoning") { return false } const checkSegment = (segment: unknown): boolean => { if (typeof segment === "string") { return segment.trim().length > 0 } if (segment && typeof segment === "object") { const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] } if (typeof candidate.text === "string" && candidate.text.trim().length > 0) { return true } if (typeof candidate.value === "string" && candidate.value.trim().length > 0) { return true } if (Array.isArray(candidate.content)) { return candidate.content.some((entry) => checkSegment(entry)) } } return false } if (checkSegment((part as any).text)) { return true } if (Array.isArray((part as any).content)) { return (part as any).content.some((entry: unknown) => checkSegment(entry)) } return false } const displayBlocks = createMemo(() => { const infoMap = messageInfoMap() const showThinking = preferences().showThinkingBlocks const showUsageMetrics = showUsagePreference() const revert = revertTarget() const instanceId = props.instanceId const blocks: MessageDisplayBlock[] = [] const usedMessageKeys = new Set() const usedToolKeys = new Set() const records = messageRecords() const assistantIndex = lastAssistantIndex() const indexMap = messageIndexMap() for (const record of records) { if (revert?.messageID && record.id === revert.messageID) { break } const { orderedParts } = buildRecordDisplayData(instanceId, record) const messageInfo = infoMap.get(record.id) const recordIndex = indexMap.get(record.id) ?? 0 const isQueued = record.role === "user" && (assistantIndex === -1 || recordIndex > assistantIndex) const items: MessageBlockItem[] = [] let segmentIndex = 0 let pendingParts: ClientPart[] = [] let agentMetaAttached = record.role !== "assistant" const flushContent = () => { if (pendingParts.length === 0) return const segmentKey = makeInstanceCacheKey(instanceId, `${record.id}:segment:${segmentIndex}`) segmentIndex += 1 const shouldShowAgentMeta = record.role === "assistant" && !agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part)) let cached = messageItemCache.get(segmentKey) if (!cached) { cached = { type: "content", key: segmentKey, record, parts: pendingParts.slice(), messageInfo, isQueued, showAgentMeta: shouldShowAgentMeta, } messageItemCache.set(segmentKey, cached) } else { cached.record = record cached.parts = pendingParts.slice() cached.messageInfo = messageInfo cached.isQueued = isQueued cached.showAgentMeta = shouldShowAgentMeta } if (shouldShowAgentMeta) { agentMetaAttached = true } items.push(cached) usedMessageKeys.add(segmentKey) pendingParts = [] } 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 } items.push(toolItem) usedToolKeys.add(cacheKey) return } if (part.type === "step-start") { flushContent() return } if (part.type === "step-finish") { flushContent() if (showUsageMetrics) { 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`) const showAgentMeta = record.role === "assistant" && !agentMetaAttached if (showAgentMeta) { agentMetaAttached = true } items.push({ type: "reasoning", key, part, messageInfo, showAgentMeta }) } return } pendingParts.push(part) }) flushContent() if (items.length === 0) { continue } blocks.push({ record, items }) } for (const key of messageItemCache.keys()) { if (!usedMessageKeys.has(key)) { messageItemCache.delete(key) } } for (const key of toolItemCache.keys()) { if (!usedToolKeys.has(key)) { toolItemCache.delete(key) } } return blocks }) const changeToken = createMemo(() => { const revisionValue = sessionRevision() const blocks = displayBlocks() if (blocks.length === 0) { return `${revisionValue}:empty` } const lastBlock = blocks[blocks.length - 1] 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}` }) const scrollCache = useScrollCache({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, scope: SCROLL_SCOPE, }) const [autoScroll, setAutoScroll] = createSignal(true) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) let containerRef: HTMLDivElement | undefined function isNearBottom(element: HTMLDivElement, offset = 48) { const { scrollTop, scrollHeight, clientHeight } = element return scrollHeight - (scrollTop + clientHeight) <= offset } function isNearTop(element: HTMLDivElement, offset = 48) { return element.scrollTop <= offset } function updateScrollIndicators(element: HTMLDivElement) { const hasItems = displayBlocks().length > 0 setShowScrollBottomButton(hasItems && !isNearBottom(element)) setShowScrollTopButton(hasItems && !isNearTop(element)) } function scrollToBottom(immediate = false) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) setAutoScroll(true) requestAnimationFrame(() => { if (!containerRef) return updateScrollIndicators(containerRef) persistScrollState() }) } function scrollToTop(immediate = false) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" setAutoScroll(false) containerRef.scrollTo({ top: 0, behavior }) requestAnimationFrame(() => { if (!containerRef) return updateScrollIndicators(containerRef) persistScrollState() }) } function persistScrollState() { if (!containerRef) return scrollCache.persist(containerRef, { atBottomOffset: 48 }) } function handleScroll(event: Event) { if (!containerRef) return updateScrollIndicators(containerRef) if (event.isTrusted) { const atBottom = isNearBottom(containerRef) if (!atBottom) { setAutoScroll(false) } else { setAutoScroll(true) } } persistScrollState() } createEffect(() => { const target = containerRef if (!target) return scrollCache.restore(target, { fallback: () => scrollToBottom(true), onApplied: (snapshot) => { if (snapshot) { setAutoScroll(snapshot.atBottom) } else { const atBottom = isNearBottom(target) setAutoScroll(atBottom) } updateScrollIndicators(target) }, }) }) let previousToken: string | undefined createEffect(() => { const token = changeToken() if (!token || token === previousToken) { return } previousToken = token if (autoScroll()) { requestAnimationFrame(() => scrollToBottom(true)) } }) createEffect(() => { if (messageRecords().length === 0) { setShowScrollTopButton(false) setShowScrollBottomButton(false) setAutoScroll(true) } }) onCleanup(() => { persistScrollState() }) return (
Used {formatTokens(tokenStats().used)}
Avail {sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
Connected Connecting... Disconnected
{ containerRef = element || undefined }} onScroll={handleScroll} >
CodeNomad logo

CodeNomad

Start a conversation

Type a message below or open the Command Palette:

  • Command Palette
  • Ask about your codebase
  • Attach files with @

Loading messages...

{(block) => (
{(item) => ( {(() => { const toolItem = item as ToolDisplayItem const toolState = toolItem.toolPart.state as ToolState | undefined const hasToolState = Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)) const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : "" const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null const handleGoToTaskSession = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() if (!taskLocation) return navigateToTaskSession(taskLocation) } return (
{TOOL_ICON} Tool Call {toolItem.toolPart.tool || "unknown"}
) })()}
)}
)}
) } interface StepCardProps { kind: "start" | "finish" part: ClientPart messageInfo?: MessageInfo showAgentMeta?: boolean showUsage?: boolean } function StepCard(props: StepCardProps) { 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, } } const renderUsageChips = (usage: NonNullable>) => (
Input {formatTokenTotal(usage.input)}
Output {formatTokenTotal(usage.output)}
Reasoning {formatTokenTotal(usage.reasoning)}
Cache Read {formatTokenTotal(usage.cacheRead)}
Cache Write {formatTokenTotal(usage.cacheWrite)}
Cost {formatCostValue(usage.cost)}
) if (props.kind === "finish") { const usage = usageStats() if (!usage) { return null } return (
{renderUsageChips(usage)}
) } return (
{(value) => Agent: {value()}} {(value) => Model: {value()}}
{timestamp()}
) } function formatCostValue(value: number) { if (!value) return "$0.00" if (value < 0.01) return `$${value.toPrecision(2)}` return `$${value.toFixed(2)}` } interface ReasoningCardProps { part: ClientPart messageInfo?: MessageInfo instanceId: string sessionId: string showAgentMeta?: boolean } 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 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 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 (
) }