import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js" import type { Message, MessageDisplayParts, SDKPart, MessageInfo, ClientPart } from "../types/message" type ToolCallPart = Extract // Import ToolState types from SDK 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 // Type guards function isToolStateRunning(state: ToolState): state is ToolStateRunning { return state.status === "running" } function isToolStateCompleted(state: ToolState): state is ToolStateCompleted { return state.status === "completed" } function isToolStateError(state: ToolState): state is ToolStateError { return state.status === "error" } // Type guard to check if a part is a tool part function isToolPart(part: ClientPart): part is ToolCallPart { return part.type === "tool" } import MessageItem from "./message-item" import ToolCall from "./tool-call" import { sseManager } from "../lib/sse-manager" import Kbd from "./kbd" import { preferences } from "../stores/preferences" import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions" import { setActiveInstanceId } from "../stores/instances" const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href const SCROLL_OFFSET = 64 interface TaskSessionLocation { sessionId: string instanceId: string parentId: string | null } const messageScrollState = new Map() 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) } } // Format tokens like TUI (e.g., "110K", "1.2M") function formatTokens(tokens: number): string { if (tokens >= 1000000) { return `${(tokens / 1000000).toFixed(1)}M` } else if (tokens >= 1000) { return `${(tokens / 1000).toFixed(0)}K` } return tokens.toString() } // Format session info for the session view header function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string { if (contextWindow > 0) { const windowStr = formatTokens(contextWindow) const usageStr = formatTokens(usageTokens) const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100))) return `${usageStr} of ${windowStr} (${percent}%)` } return formatTokens(usageTokens) } interface MessageStreamProps { instanceId: string sessionId: string messages: Message[] messagesInfo?: Map revert?: { messageID: string partID?: string snapshot?: string diff?: string } loading?: boolean onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void } interface MessageDisplayItem { type: "message" message: Message combinedParts: ClientPart[] isQueued: boolean messageInfo?: MessageInfo } interface ToolDisplayItem { type: "tool" key: string toolPart: ToolCallPart messageInfo?: MessageInfo messageId: string messageVersion: number partVersion: number } type DisplayItem = MessageDisplayItem | ToolDisplayItem interface MessageCacheEntry { message: Message version: number showThinking: boolean isQueued: boolean messageInfo?: MessageInfo displayParts: MessageDisplayParts item: MessageDisplayItem } interface ToolCacheEntry { toolPart: ClientPart messageInfo?: MessageInfo signature: string contentKey: string item: ToolDisplayItem } interface SessionCache { messageItemCache: Map toolItemCache: Map } const sessionCaches = new Map() function getSessionCache(instanceId: string, sessionId: string): SessionCache { const key = `${instanceId}:${sessionId}` let cache = sessionCaches.get(key) if (!cache) { cache = { messageItemCache: new Map(), toolItemCache: new Map(), } sessionCaches.set(key, cache) } return cache } export default function MessageStream(props: MessageStreamProps) { let containerRef: HTMLDivElement | undefined const [autoScroll, setAutoScroll] = createSignal(true) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const sessionCache = getSessionCache(props.instanceId, props.sessionId) let messageItemCache = sessionCache.messageItemCache let toolItemCache = sessionCache.toolItemCache let scrollAnimationFrame: number | null = null let lastKnownScrollTop = 0 const makeScrollKey = (instanceId: string, sessionId: string) => `${instanceId}:${sessionId}` const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId) const connectionStatus = () => sseManager.getStatus(props.instanceId) function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string { const messageId = message.id const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}` return `${messageId}:${partId}` } function createToolContentKey(toolPart: ClientPart, messageInfo?: MessageInfo): string { const state = isToolPart(toolPart) ? toolPart.state : undefined const version = typeof toolPart?.version === "number" ? toolPart.version : 0 const status = state?.status ?? "unknown" return `${toolPart.id}:${version}:${status}` } const sessionInfo = createMemo(() => getSessionInfo(props.instanceId, props.sessionId) ?? { tokens: 0, cost: 0, contextWindow: 0, isSubscriptionModel: false, contextUsageTokens: 0, contextUsagePercent: null, }, ) const formattedSessionInfo = createMemo(() => { const info = sessionInfo() return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent) }) function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) { const { scrollTop, scrollHeight, clientHeight } = element const distance = scrollHeight - (scrollTop + clientHeight) return distance <= offset } function isNearTop(element: HTMLDivElement, offset = SCROLL_OFFSET) { return element.scrollTop <= offset } function scrollToBottom(options: { smooth?: boolean } = {}) { if (!containerRef) return const behavior = options.smooth ? "smooth" : "auto" requestAnimationFrame(() => { if (!containerRef) return containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) setAutoScroll(true) updateScrollIndicators(containerRef) }) } function scrollToTop(options: { smooth?: boolean } = {}) { if (!containerRef) return const behavior = options.smooth ? "smooth" : "auto" setAutoScroll(false) requestAnimationFrame(() => { if (!containerRef) return containerRef.scrollTo({ top: 0, behavior }) setShowScrollTopButton(false) updateScrollIndicators(containerRef) }) } function handleScroll(event: Event) { if (!containerRef) return if (scrollAnimationFrame !== null) { cancelAnimationFrame(scrollAnimationFrame) } const isUserScroll = event.isTrusted scrollAnimationFrame = requestAnimationFrame(() => { if (!containerRef) return const currentScrollTop = containerRef.scrollTop const movingUp = currentScrollTop < lastKnownScrollTop - 1 lastKnownScrollTop = currentScrollTop const atBottom = isNearBottom(containerRef) if (isUserScroll) { if ((movingUp || !atBottom) && autoScroll()) { setAutoScroll(false) } else if (!movingUp && atBottom && !autoScroll()) { setAutoScroll(true) } } updateScrollIndicators(containerRef) scrollAnimationFrame = null }) } const messageView = createMemo(() => { const showThinking = preferences().showThinkingBlocks const items: DisplayItem[] = [] const newMessageCache = new Map() const newToolCache = new Map() const tokenSegments: string[] = [] let lastAssistantIndex = -1 for (let i = props.messages.length - 1; i >= 0; i--) { if (props.messages[i].type === "assistant") { lastAssistantIndex = i break } } tokenSegments.push(`count:${props.messages.length}`) tokenSegments.push(`revert:${props.revert?.messageID ?? ""}`) tokenSegments.push(`thinking:${showThinking ? 1 : 0}`) for (let index = 0; index < props.messages.length; index++) { const message = props.messages[index] const messageInfo = props.messagesInfo?.get(message.id) if (props.revert?.messageID && message.id === props.revert.messageID) { break } tokenSegments.push(`${message.id}:${message.version ?? 0}:${message.status}:${message.parts.length}`) const baseDisplayParts = message.displayParts const displayParts: MessageDisplayParts = !baseDisplayParts || baseDisplayParts.showThinking !== showThinking ? computeDisplayParts(message, showThinking) : (baseDisplayParts as MessageDisplayParts) const combinedParts = displayParts.combined const version = message.version ?? 0 const isQueued = message.type === "user" && (lastAssistantIndex === -1 || index > lastAssistantIndex) const hasRenderableContent = message.type !== "assistant" || combinedParts.length > 0 || Boolean(messageInfo && messageInfo.role === "assistant" && messageInfo.error) || message.status === "error" if (hasRenderableContent) { const cacheEntry = messageItemCache.get(message.id) if ( cacheEntry && cacheEntry.version === version && cacheEntry.showThinking === showThinking && cacheEntry.isQueued === isQueued && cacheEntry.messageInfo === messageInfo ) { cacheEntry.displayParts = displayParts cacheEntry.version = version cacheEntry.showThinking = showThinking cacheEntry.isQueued = isQueued cacheEntry.messageInfo = messageInfo cacheEntry.item.message = message cacheEntry.item.combinedParts = combinedParts cacheEntry.item.isQueued = isQueued cacheEntry.item.messageInfo = messageInfo newMessageCache.set(message.id, cacheEntry) items.push(cacheEntry.item) } else { const messageItem: MessageDisplayItem = { type: "message", message, combinedParts, isQueued, messageInfo, } newMessageCache.set(message.id, { message, version, showThinking, isQueued, messageInfo, displayParts, item: messageItem, }) items.push(messageItem) } } const toolParts = displayParts.tool.filter(isToolPart) for (let toolIndex = 0; toolIndex < toolParts.length; toolIndex++) { const toolPart = toolParts[toolIndex] const originalIndex = displayParts.tool.indexOf(toolPart) const toolKey = toolPart?.id || `${message.id}-tool-${originalIndex}` const messageVersion = typeof message.version === "number" ? message.version : 0 const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0 const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo) const contentKey = createToolContentKey(toolPart, messageInfo) const toolEntry = toolItemCache.get(toolKey) if (toolEntry && toolEntry.signature === toolSignature) { if (toolEntry.contentKey !== contentKey) { const updatedItem: ToolDisplayItem = { ...toolEntry.item, toolPart, messageInfo, messageId: message.id, messageVersion, partVersion, } toolEntry.toolPart = toolPart toolEntry.messageInfo = messageInfo toolEntry.signature = toolSignature toolEntry.contentKey = contentKey toolEntry.item = updatedItem console.debug("[ToolCall] update", toolKey, toolPart.state?.status) newToolCache.set(toolKey, toolEntry) items.push(updatedItem) } else { const cachedItem = toolEntry.item cachedItem.toolPart = toolPart cachedItem.messageInfo = messageInfo cachedItem.messageId = message.id cachedItem.messageVersion = messageVersion cachedItem.partVersion = partVersion toolEntry.toolPart = toolPart toolEntry.messageInfo = messageInfo newToolCache.set(toolKey, toolEntry) items.push(cachedItem) } } else { const toolItem: ToolDisplayItem = { type: "tool", key: toolKey, toolPart, messageInfo, messageId: message.id, messageVersion, partVersion, } console.debug("[ToolCall] create", toolKey, toolPart.state?.status) newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem }) items.push(toolItem) } } } messageItemCache = newMessageCache toolItemCache = newToolCache sessionCache.messageItemCache = messageItemCache sessionCache.toolItemCache = toolItemCache tokenSegments.push(`items:${items.length}`) if (items.length > 0) { const tail = items[items.length - 1] if (tail.type === "message") { tokenSegments.push(`tail:${tail.message.id}:${tail.message.version ?? 0}`) } else { tokenSegments.push(`tail:${tail.key}`) } } return { items, token: tokenSegments.join("|") } }) const displayItems = () => messageView().items const changeToken = () => messageView().token function updateScrollIndicators(element: HTMLDivElement) { const itemsLength = displayItems().length setShowScrollBottomButton(!isNearBottom(element) && itemsLength > 0) setShowScrollTopButton(!isNearTop(element) && itemsLength > 0) persistScrollState() } function getActiveScrollKey() { return containerRef?.dataset.scrollKey || scrollStateKey() } function persistScrollState() { if (!containerRef) return const key = getActiveScrollKey() messageScrollState.set(key, { scrollTop: containerRef.scrollTop, autoScroll: autoScroll(), }) } createEffect(() => { const key = scrollStateKey() if (containerRef) { containerRef.dataset.scrollKey = key } const savedState = messageScrollState.get(key) const shouldAutoScroll = savedState?.autoScroll ?? true setAutoScroll(shouldAutoScroll) requestAnimationFrame(() => { if (!containerRef) return if (savedState) { if (shouldAutoScroll) { scrollToBottom({ smooth: false }) } else { const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0) containerRef.scrollTop = Math.min(savedState.scrollTop, maxScrollTop) updateScrollIndicators(containerRef) } } else { scrollToBottom({ smooth: false }) } }) onCleanup(() => { if (containerRef) { messageScrollState.set(key, { scrollTop: containerRef.scrollTop, autoScroll: autoScroll(), }) if (containerRef.dataset.scrollKey === key) { delete containerRef.dataset.scrollKey } } }) }) let previousToken: string | undefined createEffect(() => { const token = changeToken() const shouldScroll = autoScroll() if (!token || token === previousToken) { return } previousToken = token if (!shouldScroll) { return } scrollToBottom() }) createEffect(() => { if (displayItems().length === 0) { setShowScrollBottomButton(false) setShowScrollTopButton(false) setAutoScroll(true) persistScrollState() } }) onCleanup(() => { if (scrollAnimationFrame !== null) { cancelAnimationFrame(scrollAnimationFrame) } }) return (
{formattedSessionInfo()}
Command Palette
Connected Connecting... Disconnected
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...

{(item) => { if (item.type === "message") { return ( ) } const toolPart = item.toolPart const taskSessionId = (isToolStateRunning(toolPart.state) || isToolStateCompleted(toolPart.state) || isToolStateError(toolPart.state)) ? toolPart.state.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : "" : "" const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null const handleGoToTaskSession = (event: Event) => { event.preventDefault() event.stopPropagation() if (!taskLocation) return navigateToTaskSession(taskLocation) } return (
🔧 Tool Call {toolPart?.tool || "unknown"}
) }}
) }