From 91ace253334418374f1a1a496657cf7ec679d70b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 26 Nov 2025 10:57:39 +0000 Subject: [PATCH] Batch hydrate normalized messages for session load --- dev-docs/architecture.md | 4 +- dev-docs/technical-implementation.md | 18 +- packages/ui/src/components/message-stream.tsx | 732 ------------------ .../src/components/session/session-view.tsx | 21 +- packages/ui/src/lib/global-cache.ts | 97 --- packages/ui/src/stores/instances.ts | 5 +- packages/ui/src/stores/message-v2/bridge.ts | 34 +- .../src/stores/message-v2/instance-store.ts | 77 ++ .../ui/src/stores/message-v2/normalizers.ts | 96 +++ .../ui/src/stores/message-v2/session-info.ts | 139 ++++ packages/ui/src/stores/message-v2/types.ts | 2 +- packages/ui/src/stores/session-actions.ts | 2 +- packages/ui/src/stores/session-api.ts | 9 +- packages/ui/src/stores/session-events.ts | 7 +- packages/ui/src/stores/session-messages.ts | 462 ----------- packages/ui/src/stores/sessions.ts | 3 - packages/ui/src/types/session.ts | 28 +- tasks/done/007-message-display.md | 2 + tasks/done/008-sse-integration.md | 2 + tasks/done/048-message-stream-refactor.md | 1 + 20 files changed, 370 insertions(+), 1371 deletions(-) delete mode 100644 packages/ui/src/components/message-stream.tsx delete mode 100644 packages/ui/src/lib/global-cache.ts create mode 100644 packages/ui/src/stores/message-v2/normalizers.ts create mode 100644 packages/ui/src/stores/message-v2/session-info.ts delete mode 100644 packages/ui/src/stores/session-messages.ts diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index d342e258..b13f121a 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi │ │ │ State Management (SolidJS Stores) │ │ │ │ │ │ - instances[] │ │ │ │ │ │ - sessions[] per instance │ │ │ -│ │ │ - messages[] per session │ │ │ +│ │ │ - normalized message store per session │ │ │ │ │ └────────────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ │ │ UI Components │ │ │ │ │ │ - InstanceTabs │ │ │ │ │ │ - SessionTabs │ │ │ -│ │ │ - MessageStream │ │ │ +│ │ │ - MessageStreamV2 │ │ │ │ │ │ - PromptInput │ │ │ │ │ └────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────┘ │ diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index e08be617..5d7e6d68 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -49,7 +49,7 @@ packages/opencode-client/ │ ├── components/ │ │ ├── instance-tabs.tsx # Level 1 tabs │ │ ├── session-tabs.tsx # Level 2 tabs -│ │ ├── message-stream.tsx # Messages display +│ │ ├── message-stream-v2.tsx # Messages display (normalized store) │ │ ├── message-item.tsx # Single message │ │ ├── tool-call.tsx # Tool execution display │ │ ├── prompt-input.tsx # Input with attachments @@ -153,16 +153,24 @@ interface Session { providerId: string modelId: string } - messages: Message[] - status: SessionStatus - createdAt: number - updatedAt: number + version: string + time: { created: number; updated: number } + revert?: { + messageID?: string + partID?: string + snapshot?: string + diff?: string + } } +// Message content lives in the normalized message-v2 store +// keyed by instanceId/sessionId/messageId + type SessionStatus = | "idle" // No activity | "streaming" // Assistant responding | "error" // Error occurred + ``` ### UI Store diff --git a/packages/ui/src/components/message-stream.tsx b/packages/ui/src/components/message-stream.tsx deleted file mode 100644 index 55172ab5..00000000 --- a/packages/ui/src/components/message-stream.tsx +++ /dev/null @@ -1,732 +0,0 @@ -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 { useConfig } from "../stores/preferences" -import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions" -import { formatTokenTotal } from "../lib/formatters" -import { setActiveInstanceId } from "../stores/instances" -import { showCommandPalette } from "../stores/command-palette" - -const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href -const SCROLL_OFFSET = 64 -const SCROLL_DIRECTION_THRESHOLD = 10 - -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 session sidebar (comma-separated totals) -function formatTokens(tokens: number): string { - return formatTokenTotal(tokens) -} - -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) { - const { preferences } = useConfig() - 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) - const handleCommandPaletteClick = () => { - showCommandPalette(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) ?? { - cost: 0, - contextWindow: 0, - isSubscriptionModel: false, - inputTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - actualUsageTokens: 0, - modelOutputLimit: 0, - contextAvailableTokens: null, - }, - ) - - const tokenStats = createMemo(() => { - const info = sessionInfo() - return { - input: info.inputTokens ?? 0, - output: info.outputTokens ?? 0, - cost: info.cost ?? 0, - used: info.actualUsageTokens ?? 0, - avail: info.contextAvailableTokens, - } - }) - - 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 - SCROLL_DIRECTION_THRESHOLD - 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) - tokenSegments.push(`tool:${toolKey}:${partVersion}`) - 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 ( -
-
-
-
- Used - {formatTokens(sessionInfo().actualUsageTokens ?? 0)} -
-
- Avail - - {sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"} - -
-
- -
- -
- - - - -
-
-
- - - - - - - - - - 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 toolState = toolPart.state - const hasToolState = isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState) - const taskSessionId = - hasToolState && typeof toolState?.metadata?.sessionId === "string" - ? toolState.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"} -
- - - -
- -
- ) - }} -
-
- - -
- - - - - - -
-
-
- ) -} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 0b491458..19cd7e30 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createEffect, onCleanup, type Component } from "solid-js" +import { Show, createMemo, createEffect, type Component } from "solid-js" import type { Session } from "../../types/session" import type { Attachment } from "../../types/attachment" import type { ClientPart } from "../../types/message" @@ -52,24 +52,11 @@ export const SessionView: Component = (props) => { return textParts.map((part) => part.text).join("\n") } } - - const currentSession = session() - if (!currentSession) return null - - const targetMessage = currentSession.messages.find((m) => m.id === messageId) - const targetInfo = currentSession.messagesInfo.get(messageId) - if (!targetMessage || targetInfo?.role !== "user") { - return null - } - - const textParts = targetMessage.parts.filter(isTextPart) - if (textParts.length === 0) { - return null - } - - return textParts.map((p) => p.text).join("\n") + + return null } + async function handleRevert(messageId: string) { const instance = instances().get(props.instanceId) if (!instance || !instance.client) return diff --git a/packages/ui/src/lib/global-cache.ts b/packages/ui/src/lib/global-cache.ts deleted file mode 100644 index 2c7b941b..00000000 --- a/packages/ui/src/lib/global-cache.ts +++ /dev/null @@ -1,97 +0,0 @@ -interface CacheLocation { - instanceId?: string - sessionId?: string - scope?: string -} - -const GLOBAL_KEY = "GLOBAL" - -type CacheScope = Map -type ScopeCollection = Map -type SessionMap = Map -const cacheRoot = new Map() - -function resolveKey(value?: string) { - return value && value.length > 0 ? value : GLOBAL_KEY -} - -function resolveCacheScope(location: CacheLocation, createIfMissing: boolean): CacheScope | undefined { - const instanceKey = resolveKey(location.instanceId) - const sessionKey = resolveKey(location.sessionId) - const scopeKey = resolveKey(location.scope) - - let sessionMap = cacheRoot.get(instanceKey) - if (!sessionMap) { - if (!createIfMissing) return undefined - sessionMap = new Map() - cacheRoot.set(instanceKey, sessionMap) - } - - let scopeCollection = sessionMap.get(sessionKey) - if (!scopeCollection) { - if (!createIfMissing) return undefined - scopeCollection = new Map() - sessionMap.set(sessionKey, scopeCollection) - } - - let cacheScope = scopeCollection.get(scopeKey) - if (!cacheScope) { - if (!createIfMissing) return undefined - cacheScope = new Map() - scopeCollection.set(scopeKey, cacheScope) - } - - return cacheScope -} - -export function setGlobalCacheValue(location: CacheLocation, key: string, value: unknown): void { - const cacheScope = resolveCacheScope(location, true) - cacheScope?.set(key, value) -} - -export function getGlobalCacheValue(location: CacheLocation, key: string): T | undefined { - const cacheScope = resolveCacheScope(location, false) - return (cacheScope?.get(key) as T | undefined) ?? undefined -} - -export function deleteGlobalCacheValue(location: CacheLocation, key: string): void { - const cacheScope = resolveCacheScope(location, false) - cacheScope?.delete(key) -} - -export function clearGlobalCacheScope(location: CacheLocation): void { - const instanceKey = resolveKey(location.instanceId) - const sessionKey = resolveKey(location.sessionId) - const scopeKey = resolveKey(location.scope) - const sessionMap = cacheRoot.get(instanceKey) - if (!sessionMap) return - const scopeCollection = sessionMap.get(sessionKey) - if (!scopeCollection) return - scopeCollection.delete(scopeKey) - if (scopeCollection.size === 0) { - sessionMap.delete(sessionKey) - } - if (sessionMap.size === 0) { - cacheRoot.delete(instanceKey) - } -} - -export function clearGlobalCacheSession(instanceId?: string, sessionId?: string): void { - const instanceKey = resolveKey(instanceId) - const sessionKey = resolveKey(sessionId) - const sessionMap = cacheRoot.get(instanceKey) - if (!sessionMap) return - sessionMap.delete(sessionKey) - if (sessionMap.size === 0) { - cacheRoot.delete(instanceKey) - } -} - -export function clearGlobalCacheInstance(instanceId?: string): void { - const instanceKey = resolveKey(instanceId) - cacheRoot.delete(instanceKey) -} - -export function clearAllGlobalCache(): void { - cacheRoot.clear() -} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 1e26e919..911453ee 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -13,7 +13,6 @@ import { fetchSessions, fetchAgents, fetchProviders, - removeSessionIndexes, clearInstanceDraftPrompts, } from "./sessions" import { fetchCommands, clearCommands } from "./commands" @@ -21,6 +20,7 @@ import { preferences } from "./preferences" import { setSessionPendingPermission } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" +import { clearScrollCacheForInstance } from "../lib/scroll-cache" import type { MessageRecord } from "./message-v2/types" @@ -296,7 +296,8 @@ function removeInstance(id: string) { } // Clean up session indexes and drafts for removed instance - removeSessionIndexes(id) + clearScrollCacheForInstance(id) + messageStoreBus.unregisterInstance(id) clearInstanceDraftPrompts(id) } diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 7fe5875a..2226eeba 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -41,37 +41,27 @@ export function seedSessionMessagesV2( if (!session || !Array.isArray(messages)) return const store = messageStoreBus.getOrCreate(instanceId) const metadata: SessionMetadata = "id" in session ? { id: session.id, title: session.title, parentId: session.parentId ?? null } : session - const messageIds = messages.map((message) => message.id) store.addOrUpdateSession({ id: metadata.id, title: metadata.title, parentId: metadata.parentId ?? null, - messageIds, revert: (session as Session)?.revert ?? undefined, }) - messages.forEach((message) => { - store.upsertMessage({ - id: message.id, - sessionId: message.sessionId, - role: message.type, - status: normalizeStatus(message.status), - createdAt: message.timestamp, - updatedAt: message.timestamp, - parts: message.parts, - isEphemeral: message.status === "sending" || message.status === "streaming", - bumpRevision: false, - }) - const info = messageInfos?.get(message.id) - if (info) { - store.setMessageInfo(message.id, info) - } - }) + const normalizedMessages = messages.map((message) => ({ + id: message.id, + sessionId: message.sessionId, + role: message.type, + status: normalizeStatus(message.status), + createdAt: message.timestamp, + updatedAt: message.timestamp, + parts: message.parts, + isEphemeral: message.status === "sending" || message.status === "streaming", + bumpRevision: false, + })) - if (messageInfos) { - store.rebuildUsage(metadata.id, messageInfos.values()) - } + store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values()) } interface MessageInfoOptions { diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 485cb042..8d56b106 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -141,6 +141,7 @@ export interface InstanceMessageStore { state: InstanceMessageState setState: SetStoreFunction addOrUpdateSession: (input: SessionUpsertInput) => void + hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void bufferPendingPart: (entry: PendingPartEntry) => void @@ -234,6 +235,81 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS }) } + function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { + if (!Array.isArray(inputs) || inputs.length === 0) return + + ensureSessionEntry(sessionId) + + const incomingIds = inputs.map((item) => item.id) + const incomingIdSet = new Set(incomingIds) + const existingIds = state.sessions[sessionId]?.messageIds ?? [] + const removedIds = existingIds.filter((id) => !incomingIdSet.has(id)) + + const normalizedRecords: Record = {} + const now = Date.now() + + inputs.forEach((input) => { + const normalizedParts = normalizeParts(input.id, input.parts) + const shouldBump = Boolean(input.bumpRevision || normalizedParts) + const previous = state.messages[input.id] + normalizedRecords[input.id] = { + id: input.id, + sessionId: input.sessionId, + role: input.role, + status: input.status, + createdAt: input.createdAt ?? previous?.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false, + revision: previous ? previous.revision + (shouldBump ? 1 : 0) : 0, + partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], + parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, + } + }) + + const infoList = infos ? Array.from(infos) : undefined + const usageState = infoList ? rebuildUsageStateFromInfos(infoList) : state.usage[sessionId] + + setState( + produce((draft) => { + removedIds.forEach((id) => { + if (draft.messages[id]?.sessionId === sessionId) { + delete draft.messages[id] + delete draft.messageInfoVersion[id] + delete draft.pendingParts[id] + if (draft.permissions.byMessage[id]) { + delete draft.permissions.byMessage[id] + } + } + }) + + Object.entries(normalizedRecords).forEach(([id, record]) => { + draft.messages[id] = record as MessageRecord + }) + + const session = draft.sessions[sessionId]! + session.messageIds = incomingIds + session.updatedAt = Date.now() + + if (usageState) { + draft.usage[sessionId] = usageState + } + + if (infoList) { + for (const info of infoList) { + const messageId = info.id as string + messageInfoCache.set(messageId, info) + const currentVersion = draft.messageInfoVersion[messageId] ?? 0 + draft.messageInfoVersion[messageId] = currentVersion + 1 + } + } + }), + ) + + removedIds.forEach((id) => { + messageInfoCache.delete(id) + }) + } + function insertMessageIntoSession(sessionId: string, messageId: string) { ensureSessionEntry(sessionId) setState("sessions", sessionId, "messageIds", (ids = []) => { @@ -508,6 +584,7 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS state, setState, addOrUpdateSession, + hydrateMessages, upsertMessage, applyPartUpdate, bufferPendingPart, diff --git a/packages/ui/src/stores/message-v2/normalizers.ts b/packages/ui/src/stores/message-v2/normalizers.ts new file mode 100644 index 00000000..109cef8f --- /dev/null +++ b/packages/ui/src/stores/message-v2/normalizers.ts @@ -0,0 +1,96 @@ +import { decodeHtmlEntities } from "../../lib/markdown" +import { partHasRenderableText } from "../../types/message" +import type { MessageDisplayParts, Message } from "../../types/message" + +function decodeTextSegment(segment: any): any { + if (typeof segment === "string") { + return decodeHtmlEntities(segment) + } + + if (segment && typeof segment === "object") { + const updated: Record = { ...segment } + + if (typeof updated.text === "string") { + updated.text = decodeHtmlEntities(updated.text) + } + + if (typeof updated.value === "string") { + updated.value = decodeHtmlEntities(updated.value) + } + + if (Array.isArray(updated.content)) { + updated.content = updated.content.map((item: any) => decodeTextSegment(item)) + } + + return updated + } + + return segment +} + +export function normalizeMessagePart(part: any): any { + if (!part || typeof part !== "object") { + return part + } + + if (part.type !== "text") { + return part + } + + const normalized: Record = { ...part, renderCache: undefined } + + if (typeof normalized.text === "string") { + normalized.text = decodeHtmlEntities(normalized.text) + } else if (normalized.text && typeof normalized.text === "object") { + const textObject: Record = { ...normalized.text } + + if (typeof textObject.value === "string") { + textObject.value = decodeHtmlEntities(textObject.value) + } + + if (Array.isArray(textObject.content)) { + textObject.content = textObject.content.map((item: any) => decodeTextSegment(item)) + } + + if (typeof textObject.text === "string") { + textObject.text = decodeHtmlEntities(textObject.text) + } + + normalized.text = textObject + } + + if (Array.isArray(normalized.content)) { + normalized.content = normalized.content.map((item: any) => decodeTextSegment(item)) + } + + if (normalized.thinking && typeof normalized.thinking === "object") { + const thinking: Record = { ...normalized.thinking } + if (Array.isArray(thinking.content)) { + thinking.content = thinking.content.map((item: any) => decodeTextSegment(item)) + } + normalized.thinking = thinking + } + + return normalized +} + +export function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts { + const text: any[] = [] + const tool: any[] = [] + const reasoning: any[] = [] + + for (const part of message.parts) { + if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) { + text.push(part) + } else if (part.type === "tool") { + tool.push(part) + } else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) { + reasoning.push(part) + } + } + + const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text] + const version = typeof message.version === "number" ? message.version : 0 + + return { text, tool, reasoning, combined, showThinking, version } +} diff --git a/packages/ui/src/stores/message-v2/session-info.ts b/packages/ui/src/stores/message-v2/session-info.ts new file mode 100644 index 00000000..dd0fe16f --- /dev/null +++ b/packages/ui/src/stores/message-v2/session-info.ts @@ -0,0 +1,139 @@ +import type { Provider } from "../../types/session" +import { DEFAULT_MODEL_OUTPUT_LIMIT } from "../session-models" +import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "../session-state" +import { messageStoreBus } from "./bus" +import type { SessionUsageState } from "./types" + +function getLatestUsageEntry(usage?: SessionUsageState) { + if (!usage?.latestMessageId) return undefined + return usage.entries[usage.latestMessageId] +} + +function resolveSelectedModel(instanceProviders: Provider[], providerId?: string, modelId?: string) { + if (!providerId || !modelId) return undefined + const provider = instanceProviders.find((p) => p.id === providerId) + return provider?.models.find((m) => m.id === modelId) +} + +export function updateSessionInfo(instanceId: string, sessionId: string): void { + const instanceSessions = sessions().get(instanceId) + if (!instanceSessions) return + const session = instanceSessions.get(sessionId) + if (!session) return + + const store = messageStoreBus.getOrCreate(instanceId) + const usage = store.getSessionUsage(sessionId) + const hasUsageEntries = Boolean(usage && Object.keys(usage.entries).length > 0) + + let totalInputTokens = usage?.totalInputTokens ?? 0 + let totalOutputTokens = usage?.totalOutputTokens ?? 0 + let totalReasoningTokens = usage?.totalReasoningTokens ?? 0 + let totalCost = usage?.totalCost ?? 0 + let actualUsageTokens = usage?.actualUsageTokens ?? 0 + + const latestEntry = getLatestUsageEntry(usage) + let latestHasContextUsage = latestEntry?.hasContextUsage ?? false + + const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId) + let contextWindow = 0 + let contextAvailableTokens: number | null = null + let contextAvailableFromPrevious = false + let isSubscriptionModel = false + + if (!hasUsageEntries && previousInfo) { + totalInputTokens = previousInfo.inputTokens + totalOutputTokens = previousInfo.outputTokens + totalReasoningTokens = previousInfo.reasoningTokens + totalCost = previousInfo.cost + actualUsageTokens = previousInfo.actualUsageTokens + } + + const instanceProviders = providers().get(instanceId) || [] + + const sessionModel = session.model + const sessionProviderId = sessionModel?.providerId + const sessionModelId = sessionModel?.modelId + + const latestInfo = latestEntry?.messageId ? store.getMessageInfo(latestEntry.messageId) : undefined + const latestProviderId = (latestInfo as any)?.providerID || (latestInfo as any)?.providerId || "" + const latestModelId = (latestInfo as any)?.modelID || (latestInfo as any)?.modelId || "" + + const selectedModel = + resolveSelectedModel(instanceProviders, sessionProviderId, sessionModelId) ?? + resolveSelectedModel(instanceProviders, latestProviderId, latestModelId) + + let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT + + if (selectedModel) { + contextWindow = selectedModel.limit?.context ?? 0 + const outputLimit = selectedModel.limit?.output + if (typeof outputLimit === "number" && outputLimit > 0) { + modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) + } + if ((selectedModel.cost?.input ?? 0) === 0 && (selectedModel.cost?.output ?? 0) === 0) { + isSubscriptionModel = true + } + } + + if (contextWindow === 0 && previousInfo) { + contextWindow = previousInfo.contextWindow + } + + modelOutputLimit = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) + + if (previousInfo) { + const previousContextWindow = previousInfo.contextWindow + const previousContextAvailable = previousInfo.contextAvailableTokens ?? null + const previousHasContextUsage = previousContextAvailable !== null && previousContextWindow > 0 + ? previousContextAvailable < previousContextWindow + : false + + if (contextWindow !== previousContextWindow) { + contextAvailableTokens = null + contextAvailableFromPrevious = false + latestHasContextUsage = previousHasContextUsage + } else { + contextAvailableTokens = previousContextAvailable + contextAvailableFromPrevious = true + latestHasContextUsage = previousHasContextUsage + } + + if (!hasUsageEntries) { + isSubscriptionModel = previousInfo.isSubscriptionModel + } else if (!isSubscriptionModel) { + isSubscriptionModel = previousInfo.isSubscriptionModel + } + } + + const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) + + if (!contextAvailableFromPrevious) { + if (contextWindow > 0) { + if (latestHasContextUsage && actualUsageTokens > 0) { + contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0) + } else { + contextAvailableTokens = contextWindow + } + } else { + contextAvailableTokens = null + } + } + + setSessionInfoByInstance((prev) => { + const next = new Map(prev) + const instanceInfo = new Map(prev.get(instanceId)) + instanceInfo.set(sessionId, { + cost: totalCost, + contextWindow, + isSubscriptionModel, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + reasoningTokens: totalReasoningTokens, + actualUsageTokens, + modelOutputLimit, + contextAvailableTokens, + }) + next.set(instanceId, instanceInfo) + return next + }) +} diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index dc21a8f9..1b06f4ed 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,4 +1,4 @@ -import type { ClientPart, MessageInfo } from "../../types/message" +import type { ClientPart } from "../../types/message" import type { Permission } from "@opencode-ai/sdk" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 5326108b..b9e343a7 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -4,7 +4,7 @@ import { instances } from "./instances" import { addRecentModelPreference, setAgentModelPreference } from "./preferences" import { sessions, withSession } from "./session-state" import { getDefaultModel, isModelValid } from "./session-models" -import { updateSessionInfo } from "./session-messages" +import { updateSessionInfo } from "./message-v2/session-info" import { messageStoreBus } from "./message-v2/bus" const ID_LENGTH = 26 diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index a87ecfcb..5337c7ce 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -22,7 +22,8 @@ import { setLoading, } from "./session-state" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" -import { normalizeMessagePart, updateSessionInfo } from "./session-messages" +import { normalizeMessagePart } from "./message-v2/normalizers" +import { updateSessionInfo } from "./message-v2/session-info" import { seedSessionMessagesV2 } from "./message-v2/bridge" interface SessionForkResponse { @@ -92,8 +93,6 @@ async function fetchSessions(instanceId: string): Promise { diff: apiSession.revert.diff, } : undefined, - messages: [], - messagesInfo: new Map(), }) } @@ -188,8 +187,6 @@ async function createSession(instanceId: string, agent?: string): Promise { @@ -291,8 +288,6 @@ async function forkSession( diff: info.revert.diff, } : undefined, - messages: [], - messagesInfo: new Map(), } as unknown as Session setSessions((prev) => { diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 5bf7ec37..87564eee 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -22,7 +22,8 @@ import { setSessions, withSession, } from "./session-state" -import { normalizeMessagePart, updateSessionInfo } from "./session-messages" +import { normalizeMessagePart } from "./message-v2/normalizers" +import { updateSessionInfo } from "./message-v2/session-info" import { loadMessages } from "./session-api" import { setSessionCompactionState } from "./session-compaction" import { @@ -89,7 +90,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!session) return const store = messageStoreBus.getOrCreate(instanceId) - const messageInfo = event.properties?.message as MessageInfo | undefined + const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined const role: MessageRole = resolveMessageRole(messageInfo) const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now() @@ -204,8 +205,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo created: Date.now(), updated: Date.now(), }, - messages: [], - messagesInfo: new Map(), } as any setSessions((prev) => { diff --git a/packages/ui/src/stores/session-messages.ts b/packages/ui/src/stores/session-messages.ts deleted file mode 100644 index 3935d64e..00000000 --- a/packages/ui/src/stores/session-messages.ts +++ /dev/null @@ -1,462 +0,0 @@ -import type { Message, MessageDisplayParts } from "../types/message" -import { partHasRenderableText, type MessageInfo } from "../types/message" -import type { Provider } from "../types/session" - -import { decodeHtmlEntities } from "../lib/markdown" -import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "./session-state" -import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models" - -interface SessionIndexCache { - messageIndex: Map - partIndex: Map> -} - -interface AssistantUsageEntry { - info: MessageInfo - inputTokens: number - outputTokens: number - reasoningTokens: number - combinedTokens: number - cost: number - hasContextUsage: boolean - timestamp: number -} - -interface SessionUsageState { - entries: Map - totalInputTokens: number - totalOutputTokens: number - totalReasoningTokens: number - totalCost: number - latestEntry: AssistantUsageEntry | null -} - -const sessionIndexes = new Map>() -const sessionUsageStates = new Map>() - -function createEmptyUsageState(): SessionUsageState { - return { - entries: new Map(), - totalInputTokens: 0, - totalOutputTokens: 0, - totalReasoningTokens: 0, - totalCost: 0, - latestEntry: null, - } -} - -function getUsageInstance(instanceId: string): Map { - let usageMap = sessionUsageStates.get(instanceId) - if (!usageMap) { - usageMap = new Map() - sessionUsageStates.set(instanceId, usageMap) - } - return usageMap -} - -function getSessionUsageState(instanceId: string, sessionId: string): SessionUsageState { - const usageMap = getUsageInstance(instanceId) - let state = usageMap.get(sessionId) - if (!state) { - state = createEmptyUsageState() - usageMap.set(sessionId, state) - } - return state -} - -function recomputeLatestEntry(state: SessionUsageState) { - state.latestEntry = null - for (const entry of state.entries.values()) { - if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) { - state.latestEntry = entry - } - } -} - -function extractAssistantUsage(info: MessageInfo): AssistantUsageEntry | null { - if (!info || info.role !== "assistant") return null - if (!info.tokens) return null - const tokens = info.tokens - const inputTokens = tokens.input ?? 0 - const outputTokens = tokens.output ?? 0 - const reasoningTokens = tokens.reasoning ?? 0 - if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0) { - return null - } - const cacheReadTokens = tokens.cache?.read ?? 0 - const cacheWriteTokens = tokens.cache?.write ?? 0 - const combinedTokens = info.summary - ? outputTokens - : inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens - const cost = info.cost ?? 0 - const hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0 - return { - info, - inputTokens, - outputTokens, - reasoningTokens, - combinedTokens, - cost, - hasContextUsage, - timestamp: info.time?.created ?? 0, - } -} - -function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) { - if (!messageId) return - const existing = state.entries.get(messageId) - if (!existing) return - state.entries.delete(messageId) - state.totalInputTokens -= existing.inputTokens - state.totalOutputTokens -= existing.outputTokens - state.totalReasoningTokens -= existing.reasoningTokens - state.totalCost -= existing.cost - if (state.latestEntry?.info.id === messageId) { - recomputeLatestEntry(state) - } -} - -function addUsageEntry(state: SessionUsageState, entry: AssistantUsageEntry) { - state.entries.set(entry.info.id, entry) - state.totalInputTokens += entry.inputTokens - state.totalOutputTokens += entry.outputTokens - state.totalReasoningTokens += entry.reasoningTokens - state.totalCost += entry.cost - if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) { - state.latestEntry = entry - } -} - -function updateUsageFromMessageInfo(instanceId: string, sessionId: string, info: MessageInfo) { - const messageId = typeof info.id === "string" ? info.id : undefined - if (!messageId) return - const state = getSessionUsageState(instanceId, sessionId) - removeUsageEntry(state, messageId) - const entry = extractAssistantUsage(info) - if (entry) { - addUsageEntry(state, entry) - } -} - -function rebuildSessionUsage(instanceId: string, sessionId: string, messagesInfo: Map) { - const usageMap = getUsageInstance(instanceId) - const nextState = createEmptyUsageState() - for (const info of messagesInfo.values()) { - const entry = extractAssistantUsage(info) - if (entry) { - addUsageEntry(nextState, entry) - } - } - usageMap.set(sessionId, nextState) -} - -function clearSessionUsage(instanceId: string, sessionId: string) { - const usageMap = sessionUsageStates.get(instanceId) - if (!usageMap) return - usageMap.delete(sessionId) - if (usageMap.size === 0) { - sessionUsageStates.delete(instanceId) - } -} - -function decodeTextSegment(segment: any): any { - if (typeof segment === "string") { - return decodeHtmlEntities(segment) - } - - if (segment && typeof segment === "object") { - const updated: Record = { ...segment } - - if (typeof updated.text === "string") { - updated.text = decodeHtmlEntities(updated.text) - } - - if (typeof updated.value === "string") { - updated.value = decodeHtmlEntities(updated.value) - } - - if (Array.isArray(updated.content)) { - updated.content = updated.content.map((item: any) => decodeTextSegment(item)) - } - - return updated - } - - return segment -} - -function normalizeMessagePart(part: any): any { - if (!part || typeof part !== "object") { - return part - } - - if (part.type !== "text") { - return part - } - - const normalized: Record = { ...part, renderCache: undefined } - - if (typeof normalized.text === "string") { - normalized.text = decodeHtmlEntities(normalized.text) - } else if (normalized.text && typeof normalized.text === "object") { - const textObject: Record = { ...normalized.text } - - if (typeof textObject.value === "string") { - textObject.value = decodeHtmlEntities(textObject.value) - } - - if (Array.isArray(textObject.content)) { - textObject.content = textObject.content.map((item: any) => decodeTextSegment(item)) - } - - if (typeof textObject.text === "string") { - textObject.text = decodeHtmlEntities(textObject.text) - } - - normalized.text = textObject - } - - if (Array.isArray(normalized.content)) { - normalized.content = normalized.content.map((item: any) => decodeTextSegment(item)) - } - - if (normalized.thinking && typeof normalized.thinking === "object") { - const thinking: Record = { ...normalized.thinking } - if (Array.isArray(thinking.content)) { - thinking.content = thinking.content.map((item: any) => decodeTextSegment(item)) - } - normalized.thinking = thinking - } - - return normalized -} - -function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts { - const text: any[] = [] - const tool: any[] = [] - const reasoning: any[] = [] - - for (const part of message.parts) { - if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) { - text.push(part) - } else if (part.type === "tool") { - tool.push(part) - } else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) { - reasoning.push(part) - } - } - - const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text] - const version = typeof message.version === "number" ? message.version : 0 - - return { text, tool, reasoning, combined, showThinking, version } -} - -function initializePartVersion(part: any, version = 0) { - if (!part || typeof part !== "object") return - const partAny = part as any - if (typeof partAny.version !== "number") { - partAny.version = version - } -} - -function bumpPartVersion(previousPart: any, nextPart: any): number { - const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1 - const nextVersion = prevVersion + 1 - nextPart.version = nextVersion - return nextVersion -} - -function getSessionIndex(instanceId: string, sessionId: string) { - let instanceMap = sessionIndexes.get(instanceId) - if (!instanceMap) { - instanceMap = new Map() - sessionIndexes.set(instanceId, instanceMap) - } - - let sessionMap = instanceMap.get(sessionId) - if (!sessionMap) { - sessionMap = { messageIndex: new Map(), partIndex: new Map() } - instanceMap.set(sessionId, sessionMap) - } - - return sessionMap -} - -function rebuildSessionIndex(instanceId: string, sessionId: string, messages: Message[]) { - const index = getSessionIndex(instanceId, sessionId) - index.messageIndex.clear() - index.partIndex.clear() - - messages.forEach((message, messageIdx) => { - index.messageIndex.set(message.id, messageIdx) - - const partMap = new Map() - message.parts.forEach((part, partIdx) => { - if (part.id && typeof part.id === "string") { - partMap.set(part.id, partIdx) - } - }) - index.partIndex.set(message.id, partMap) - }) -} - -function clearSessionIndex(instanceId: string, sessionId: string) { - const instanceMap = sessionIndexes.get(instanceId) - if (instanceMap) { - instanceMap.delete(sessionId) - if (instanceMap.size === 0) { - sessionIndexes.delete(instanceId) - } - } - clearSessionUsage(instanceId, sessionId) -} - -function removeSessionIndexes(instanceId: string) { - sessionIndexes.delete(instanceId) - sessionUsageStates.delete(instanceId) -} - -function updateSessionInfo(instanceId: string, sessionId: string) { - const instanceSessions = sessions().get(instanceId) - if (!instanceSessions) return - - const session = instanceSessions.get(sessionId) - if (!session) return - - let contextWindow = 0 - let isSubscriptionModel = false - let modelID = "" - let providerID = "" - let actualUsageTokens = 0 - - const usageState = getSessionUsageState(instanceId, sessionId) - const hasUsageEntries = usageState.entries.size > 0 - - let totalInputTokens = hasUsageEntries ? usageState.totalInputTokens : 0 - let totalOutputTokens = hasUsageEntries ? usageState.totalOutputTokens : 0 - let totalReasoningTokens = hasUsageEntries ? usageState.totalReasoningTokens : 0 - let totalCost = hasUsageEntries ? usageState.totalCost : 0 - - let latestAssistantInfo: MessageInfo | null = usageState.latestEntry?.info ?? null - let latestHasContextUsage = usageState.latestEntry?.hasContextUsage ?? false - const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId) - let contextAvailableTokens: number | null = null - let contextAvailableFromPrevious = false - - if (latestAssistantInfo) { - const infoAny = latestAssistantInfo as any - actualUsageTokens = usageState.latestEntry?.combinedTokens ?? 0 - modelID = infoAny.modelID || "" - providerID = infoAny.providerID || "" - } else if (previousInfo) { - totalInputTokens = previousInfo.inputTokens - totalOutputTokens = previousInfo.outputTokens - totalReasoningTokens = previousInfo.reasoningTokens - totalCost = previousInfo.cost - actualUsageTokens = previousInfo.actualUsageTokens - - const previousContextWindow = previousInfo.contextWindow - const previousContextAvailable = previousInfo.contextAvailableTokens ?? null - const previousHasContextUsage = - previousContextAvailable !== null && previousContextWindow > 0 - ? previousContextAvailable < previousContextWindow - : false - - if (contextWindow === 0) { - contextWindow = previousContextWindow - } - - if (contextWindow !== previousContextWindow) { - contextAvailableTokens = null - contextAvailableFromPrevious = false - latestHasContextUsage = previousHasContextUsage - } else { - contextAvailableTokens = previousContextAvailable - contextAvailableFromPrevious = true - latestHasContextUsage = previousHasContextUsage - } - - isSubscriptionModel = previousInfo.isSubscriptionModel - } - - const instanceProviders = providers().get(instanceId) || [] - - - - - const sessionModel = session.model - let selectedModel: Provider["models"][number] | undefined - - if (sessionModel?.providerId && sessionModel?.modelId) { - const provider = instanceProviders.find((p) => p.id === sessionModel.providerId) - selectedModel = provider?.models.find((m) => m.id === sessionModel.modelId) - } - - if (!selectedModel && modelID && providerID) { - const provider = instanceProviders.find((p) => p.id === providerID) - selectedModel = provider?.models.find((m) => m.id === modelID) - } - - let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT - - if (selectedModel) { - if (selectedModel.limit?.context) { - contextWindow = selectedModel.limit.context - } - - if (selectedModel.limit?.output && selectedModel.limit.output > 0) { - modelOutputLimit = selectedModel.limit.output - } - - if (selectedModel.cost?.input === 0 && selectedModel.cost?.output === 0) { - isSubscriptionModel = true - } - } - - const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) - - if (!contextAvailableFromPrevious) { - if (contextWindow > 0) { - if (latestHasContextUsage && actualUsageTokens > 0) { - contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0) - } else { - contextAvailableTokens = contextWindow - } - } else { - contextAvailableTokens = null - } - } - - setSessionInfoByInstance((prev) => { - const next = new Map(prev) - const instanceInfo = new Map(prev.get(instanceId)) - instanceInfo.set(sessionId, { - cost: totalCost, - contextWindow, - isSubscriptionModel, - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - reasoningTokens: totalReasoningTokens, - actualUsageTokens, - modelOutputLimit, - contextAvailableTokens, - }) - next.set(instanceId, instanceInfo) - return next - }) -} - -export { - bumpPartVersion, - clearSessionIndex, - computeDisplayParts, - getSessionIndex, - initializePartVersion, - normalizeMessagePart, - rebuildSessionIndex, - rebuildSessionUsage, - removeSessionIndexes, - updateSessionInfo, - updateUsageFromMessageInfo, -} diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index ce6aabdb..d50350ea 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -28,7 +28,6 @@ import { setSessionDraftPrompt, } from "./session-state" import { getDefaultModel } from "./session-models" -import { computeDisplayParts, removeSessionIndexes } from "./session-messages" import { createSession, deleteSession, @@ -79,7 +78,6 @@ export { clearActiveParentSession, clearInstanceDraftPrompts, clearSessionDraftPrompt, - computeDisplayParts, createSession, deleteSession, executeCustomCommand, @@ -102,7 +100,6 @@ export { loadMessages, loading, providers, - removeSessionIndexes, sendMessage, sessionInfoByInstance, sessions, diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 0e6ef1d7..f888244b 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -1,9 +1,8 @@ -import type { Message, MessageInfo } from "./message" -import type { +import type { Session as SDKSession, - Agent as SDKAgent, + Agent as SDKAgent, Provider as SDKProvider, - Model as SDKModel + Model as SDKModel, } from "@opencode-ai/sdk" // Export SDK types for external use @@ -17,18 +16,17 @@ export type { export type SessionStatus = "idle" | "working" | "compacting" // Our client-specific Session interface extending SDK Session -export interface Session extends Omit { - instanceId: string // Client-specific field - parentId: string | null // Client-specific field (override parentID) - agent: string // Client-specific field - model: { // Client-specific field +export interface Session + extends Omit { + instanceId: string // Client-specific field + parentId: string | null // Client-specific field (override parentID) + agent: string // Client-specific field + model: { providerId: string modelId: string } - messages: Message[] // Client-specific field - messagesInfo: Map // Client-specific field - version: string // Include version from SDK Session - pendingPermission?: boolean // Indicates if session is waiting on user permission + version: string // Include version from SDK Session + pendingPermission?: boolean // Indicates if session is waiting on user permission } // Adapter function to convert SDK Session to client Session @@ -36,7 +34,7 @@ export function createClientSession( sdkSession: import("@opencode-ai/sdk").Session, instanceId: string, agent: string = "", - model: { providerId: string; modelId: string } = { providerId: "", modelId: "" } + model: { providerId: string; modelId: string } = { providerId: "", modelId: "" }, ): Session { return { ...sdkSession, @@ -44,8 +42,6 @@ export function createClientSession( parentId: sdkSession.parentID || null, agent, model, - messages: [], - messagesInfo: new Map(), } } diff --git a/tasks/done/007-message-display.md b/tasks/done/007-message-display.md index 00c4599e..6c01510d 100644 --- a/tasks/done/007-message-display.md +++ b/tasks/done/007-message-display.md @@ -4,6 +4,8 @@ Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states. +> Note: This legacy task predates `message-stream-v2` and the normalized message store; the new implementation lives under `packages/ui/src/components/message-stream-v2.tsx`. + ## Prerequisites - Task 006 completed (Tab navigation in place) diff --git a/tasks/done/008-sse-integration.md b/tasks/done/008-sse-integration.md index 0f1041ca..ec49de66 100644 --- a/tasks/done/008-sse-integration.md +++ b/tasks/done/008-sse-integration.md @@ -1,5 +1,7 @@ # Task 008: SSE Integration - Real-time Message Streaming +> Note: References to `message-stream.tsx` here are legacy; the current UI uses `message-stream-v2.tsx` with the normalized message store. + ## Status: TODO ## Objective diff --git a/tasks/done/048-message-stream-refactor.md b/tasks/done/048-message-stream-refactor.md index 8859bedc..f25f3d36 100644 --- a/tasks/done/048-message-stream-refactor.md +++ b/tasks/done/048-message-stream-refactor.md @@ -32,3 +32,4 @@ Finish migrating the message stream container, tool call blocks, and reasoning U ## Notes - Branch suggestion: `feature/task-048-message-stream-refactor`. - Capture short screen recording or screenshots if tool call layout adjustments were required. +- Legacy `message-stream.tsx` has since been replaced by `message-stream-v2.tsx` using the normalized message store.