Batch hydrate normalized messages for session load
This commit is contained in:
@@ -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 │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ClientPart, { type: "tool" }>
|
||||
|
||||
// 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<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
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<string, MessageInfo>
|
||||
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<string, MessageCacheEntry>
|
||||
toolItemCache: Map<string, ToolCacheEntry>
|
||||
}
|
||||
|
||||
const sessionCaches = new Map<string, SessionCache>()
|
||||
|
||||
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<string, MessageCacheEntry>()
|
||||
const newToolCache = new Map<string, ToolCacheEntry>()
|
||||
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 (
|
||||
<div class="message-stream-container">
|
||||
<div class="connection-status">
|
||||
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||
<span class="font-semibold text-primary">{formatTokens(sessionInfo().actualUsageTokens ?? 0)}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||
<span class="font-semibold text-primary">
|
||||
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-text connection-status-shortcut">
|
||||
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
Command Palette
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
Connected
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
Connecting...
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
Disconnected
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
|
||||
<Show when={!props.loading && displayItems().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={displayItems()} fallback={null}>
|
||||
{(item) => {
|
||||
if (item.type === "message") {
|
||||
return (
|
||||
<MessageItem
|
||||
message={item.message}
|
||||
messageInfo={item.messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={item.isQueued}
|
||||
parts={item.combinedParts}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
/>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div class="tool-call-message" data-key={item.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">🔧</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={toolPart}
|
||||
toolCallId={item.key}
|
||||
messageId={item.messageId}
|
||||
messageVersion={item.messageVersion}
|
||||
partVersion={item.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToTop({ smooth: true })}
|
||||
aria-label="Scroll to first message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom({ smooth: true })}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<SessionViewProps> = (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
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
interface CacheLocation {
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
scope?: string
|
||||
}
|
||||
|
||||
const GLOBAL_KEY = "GLOBAL"
|
||||
|
||||
type CacheScope = Map<string, unknown>
|
||||
type ScopeCollection = Map<string, CacheScope>
|
||||
type SessionMap = Map<string, ScopeCollection>
|
||||
const cacheRoot = new Map<string, SessionMap>()
|
||||
|
||||
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<T = unknown>(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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface InstanceMessageStore {
|
||||
state: InstanceMessageState
|
||||
setState: SetStoreFunction<InstanceMessageState>
|
||||
addOrUpdateSession: (input: SessionUpsertInput) => void
|
||||
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => 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<MessageInfo>) {
|
||||
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<string, MessageRecord> = {}
|
||||
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,
|
||||
|
||||
96
packages/ui/src/stores/message-v2/normalizers.ts
Normal file
96
packages/ui/src/stores/message-v2/normalizers.ts
Normal file
@@ -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<string, any> = { ...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<string, any> = { ...part, renderCache: undefined }
|
||||
|
||||
if (typeof normalized.text === "string") {
|
||||
normalized.text = decodeHtmlEntities(normalized.text)
|
||||
} else if (normalized.text && typeof normalized.text === "object") {
|
||||
const textObject: Record<string, any> = { ...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<string, any> = { ...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 }
|
||||
}
|
||||
139
packages/ui/src/stores/message-v2/session-info.ts
Normal file
139
packages/ui/src/stores/message-v2/session-info.ts
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
diff: apiSession.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,8 +187,6 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
diff: response.data.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
@@ -291,8 +288,6 @@ async function forkSession(
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} as unknown as Session
|
||||
|
||||
setSessions((prev) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, number>
|
||||
partIndex: Map<string, Map<string, number>>
|
||||
}
|
||||
|
||||
interface AssistantUsageEntry {
|
||||
info: MessageInfo
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
combinedTokens: number
|
||||
cost: number
|
||||
hasContextUsage: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface SessionUsageState {
|
||||
entries: Map<string, AssistantUsageEntry>
|
||||
totalInputTokens: number
|
||||
totalOutputTokens: number
|
||||
totalReasoningTokens: number
|
||||
totalCost: number
|
||||
latestEntry: AssistantUsageEntry | null
|
||||
}
|
||||
|
||||
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
||||
const sessionUsageStates = new Map<string, Map<string, SessionUsageState>>()
|
||||
|
||||
function createEmptyUsageState(): SessionUsageState {
|
||||
return {
|
||||
entries: new Map(),
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalReasoningTokens: 0,
|
||||
totalCost: 0,
|
||||
latestEntry: null,
|
||||
}
|
||||
}
|
||||
|
||||
function getUsageInstance(instanceId: string): Map<string, SessionUsageState> {
|
||||
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<string, MessageInfo>) {
|
||||
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<string, any> = { ...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<string, any> = { ...part, renderCache: undefined }
|
||||
|
||||
if (typeof normalized.text === "string") {
|
||||
normalized.text = decodeHtmlEntities(normalized.text)
|
||||
} else if (normalized.text && typeof normalized.text === "object") {
|
||||
const textObject: Record<string, any> = { ...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<string, any> = { ...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<string, number>()
|
||||
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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<import("@opencode-ai/sdk").Session, 'projectID' | 'directory' | 'parentID'> {
|
||||
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<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
|
||||
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<string, MessageInfo> // 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user