import { createSignal } from "solid-js" import type { Session, Agent, Provider } from "../types/session" import type { Message, MessageDisplayParts } from "../types/message" import { partHasRenderableText } from "../types/message" import { instances } from "./instances" import { sseManager } from "../lib/sse-manager" import { decodeHtmlEntities } from "../lib/markdown" import { showToastNotification, ToastVariant } from "../lib/notifications" import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences" interface SessionInfo { tokens: number cost: number contextWindow: number isSubscriptionModel: boolean contextUsageTokens: number } const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]) const [sessions, setSessions] = createSignal>>(new Map()) const [activeSessionId, setActiveSessionId] = createSignal>(new Map()) const [activeParentSessionId, setActiveParentSessionId] = createSignal>(new Map()) const [agents, setAgents] = createSignal>(new Map()) const [providers, setProviders] = createSignal>(new Map()) const [sessionDraftPrompts, setSessionDraftPrompts] = createSignal>(new Map()) function getDraftKey(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` } function getSessionDraftPrompt(instanceId: string, sessionId: string): string { if (!instanceId || !sessionId) return "" const key = getDraftKey(instanceId, sessionId) return sessionDraftPrompts().get(key) ?? "" } function setSessionDraftPrompt(instanceId: string, sessionId: string, value: string) { const key = getDraftKey(instanceId, sessionId) setSessionDraftPrompts((prev) => { const next = new Map(prev) if (!value) { next.delete(key) } else { next.set(key, value) } return next }) } function clearSessionDraftPrompt(instanceId: string, sessionId: string) { const key = getDraftKey(instanceId, sessionId) setSessionDraftPrompts((prev) => { if (!prev.has(key)) return prev const next = new Map(prev) next.delete(key) return next }) } function clearInstanceDraftPrompts(instanceId: string) { if (!instanceId) return setSessionDraftPrompts((prev) => { let changed = false const next = new Map(prev) const prefix = `${instanceId}:` for (const key of Array.from(next.keys())) { if (key.startsWith(prefix)) { next.delete(key) changed = true } } return changed ? next : prev }) } function pruneDraftPrompts(instanceId: string, validSessionIds: Set) { setSessionDraftPrompts((prev) => { let changed = false const next = new Map(prev) const prefix = `${instanceId}:` for (const key of Array.from(next.keys())) { if (key.startsWith(prefix)) { const sessionId = key.slice(prefix.length) if (!validSessionIds.has(sessionId)) { next.delete(key) changed = true } } } return changed ? next : prev }) } const [loading, setLoading] = createSignal({ fetchingSessions: new Map(), creatingSession: new Map(), deletingSession: new Map>(), loadingMessages: new Map>(), }) const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) if (typeof globalThis !== "undefined") { const debugGlobal = globalThis as any debugGlobal.__OPENCODE_DEBUG__ = { ...(debugGlobal.__OPENCODE_DEBUG__ ?? {}), getSessions: () => sessions(), } } // Message index cache structure: instanceId -> sessionId -> { messageIndex, partIndex } const sessionIndexes = new Map< string, Map; partIndex: Map> }> >() 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 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) } } } function removeSessionIndexes(instanceId: string) { sessionIndexes.delete(instanceId) } 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 } } function initializePartVersion(part: any, version = 0) { if (!part || typeof part !== "object") return const partAny = part as any partAny.version = 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 initializePartVersion(nextPart, nextVersion) return nextVersion } function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) { const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return const session = instanceSessions.get(sessionId) if (!session) return updater(session) // Create new session object with fresh references to trigger reactivity const updatedSession = { ...session, messages: [...session.messages], messagesInfo: new Map(session.messagesInfo), } setSessions((prev) => { const next = new Map(prev) const newInstanceSessions = new Map(instanceSessions) newInstanceSessions.set(sessionId, updatedSession) next.set(instanceId, newInstanceSessions) return next }) } const ID_LENGTH = 26 const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" let lastTimestamp = 0 let localCounter = 0 function randomBase62(length: number): string { let result = "" const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { const bytes = new Uint8Array(length) cryptoObj.getRandomValues(bytes) for (let i = 0; i < length; i++) { result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length] } } else { for (let i = 0; i < length; i++) { const idx = Math.floor(Math.random() * BASE62_CHARS.length) result += BASE62_CHARS[idx] } } return result } function createId(prefix: string): string { const timestamp = Date.now() if (timestamp !== lastTimestamp) { lastTimestamp = timestamp localCounter = 0 } localCounter++ const value = (BigInt(timestamp) << BigInt(12)) + BigInt(localCounter) const bytes = new Array(6) for (let i = 0; i < 6; i++) { const shift = BigInt(8 * (5 - i)) bytes[i] = Number((value >> shift) & BigInt(0xff)) } const hex = bytes.map((b) => b.toString(16).padStart(2, "0")).join("") const random = randomBase62(ID_LENGTH - 12) return `${prefix}_${hex}${random}` } async function fetchSessions(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, true) return next }) try { const response = await instance.client.session.list() const sessionMap = new Map() if (!response.data || !Array.isArray(response.data)) { return } for (const apiSession of response.data) { sessionMap.set(apiSession.id, { id: apiSession.id, instanceId, title: apiSession.title || "Untitled", parentId: apiSession.parentID || null, agent: "", model: { providerId: "", modelId: "" }, time: { created: apiSession.time.created, updated: apiSession.time.updated, }, revert: apiSession.revert ? { messageID: apiSession.revert.messageID, partID: apiSession.revert.partID, snapshot: apiSession.revert.snapshot, diff: apiSession.revert.diff, } : undefined, messages: [], messagesInfo: new Map(), }) } setSessions((prev) => { const next = new Map(prev) next.set(instanceId, sessionMap) return next }) pruneDraftPrompts(instanceId, new Set(sessionMap.keys())) } catch (error) { console.error("Failed to fetch sessions:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, false) return next }) } } function isModelValid( instanceId: string, model?: { providerId: string; modelId: string } | null, ): model is { providerId: string; modelId: string } { if (!model?.providerId || !model.modelId) return false const instanceProviders = providers().get(instanceId) || [] const provider = instanceProviders.find((p) => p.id === model.providerId) if (!provider) return false return provider.models.some((item) => item.id === model.modelId) } function getRecentModelPreferenceForInstance( instanceId: string, ): { providerId: string; modelId: string } | undefined { const recents = preferences().modelRecents ?? [] for (const item of recents) { if (isModelValid(instanceId, item)) { return item } } } async function getDefaultModel( instanceId: string, agentName?: string, ): Promise<{ providerId: string; modelId: string }> { const instanceProviders = providers().get(instanceId) || [] const instanceAgents = agents().get(instanceId) || [] if (agentName) { const stored = getAgentModelPreference(instanceId, agentName) if (isModelValid(instanceId, stored)) { return stored } } if (agentName) { const agent = instanceAgents.find((a) => a.name === agentName) if (agent && agent.model && isModelValid(instanceId, agent.model)) { return { providerId: agent.model.providerId, modelId: agent.model.modelId, } } } const recent = getRecentModelPreferenceForInstance(instanceId) if (recent) { return recent } for (const provider of instanceProviders) { if (provider.defaultModelId) { const model = provider.models.find((m) => m.id === provider.defaultModelId) if (model) { return { providerId: provider.id, modelId: model.id, } } } } if (instanceProviders.length > 0) { const firstProvider = instanceProviders[0] const firstModel = firstProvider.models[0] if (firstModel) { return { providerId: firstProvider.id, modelId: firstModel.id, } } } return { providerId: "", modelId: "" } } function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | undefined { return sessionInfoByInstance().get(instanceId)?.get(sessionId) } function updateSessionInfo(instanceId: string, sessionId: string) { const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return const session = instanceSessions.get(sessionId) if (!session) return let tokens = 0 let cost = 0 let contextWindow = 0 let isSubscriptionModel = false let modelID = "" let providerID = "" let inputTokensForUsage = 0 let cacheReadTokensForUsage = 0 // Calculate from last assistant message in this session only if (session.messagesInfo.size > 0) { // Go backwards through messagesInfo to find the last relevant assistant message (like TUI) const messageArray = Array.from(session.messagesInfo.values()).reverse() for (const info of messageArray) { if (info.role === "assistant" && info.tokens) { const usage = info.tokens if (usage.output > 0) { if (info.summary) { // If summary message, only count output tokens and stop (like TUI) tokens = usage.output || 0 cost = info.cost || 0 } else { // Regular message - count all token types (like TUI) tokens = (usage.input || 0) + (usage.cache?.read || 0) + (usage.cache?.write || 0) + (usage.output || 0) + (usage.reasoning || 0) cost = info.cost || 0 } inputTokensForUsage = usage.input || 0 cacheReadTokensForUsage = usage.cache?.read || 0 // Get model info identifiers for context lookups modelID = info.modelID || "" providerID = info.providerID || "" isSubscriptionModel = cost === 0 break // Break after finding the last assistant message } } } } 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 contextUsageTokens = inputTokensForUsage + cacheReadTokensForUsage + modelOutputLimit setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) instanceInfo.set(sessionId, { tokens, cost, contextWindow, isSubscriptionModel, contextUsageTokens, }) next.set(instanceId, instanceInfo) return next }) } async function createSession(instanceId: string, agent?: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const instanceAgents = agents().get(instanceId) || [] const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent") const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "") const defaultModel = await getDefaultModel(instanceId, selectedAgent) if (selectedAgent && isModelValid(instanceId, defaultModel)) { setAgentModelPreference(instanceId, selectedAgent, defaultModel) } setLoading((prev) => { const next = { ...prev } next.creatingSession.set(instanceId, true) return next }) try { const response = await instance.client.session.create() if (!response.data) { throw new Error("Failed to create session: No data returned") } const session: Session = { id: response.data.id, instanceId, title: response.data.title || "New Session", parentId: null, agent: selectedAgent, model: defaultModel, time: { created: response.data.time.created, updated: response.data.time.updated, }, revert: response.data.revert ? { messageID: response.data.revert.messageID, partID: response.data.revert.partID, snapshot: response.data.revert.snapshot, diff: response.data.revert.diff, } : undefined, messages: [], messagesInfo: new Map(), } setSessions((prev) => { const next = new Map(prev) const instanceSessions = next.get(instanceId) || new Map() instanceSessions.set(session.id, session) next.set(instanceId, instanceSessions) return next }) // Initialize session info with zeros for the new session const instanceProviders = providers().get(instanceId) || [] const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId) const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId) const initialContextWindow = initialModel?.limit?.context ?? 0 const initialOutputLimit = initialModel?.limit?.output && initialModel.limit.output > 0 ? initialModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0 setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) instanceInfo.set(session.id, { tokens: 0, cost: 0, contextWindow: initialContextWindow, isSubscriptionModel: Boolean(initialSubscriptionModel), contextUsageTokens: initialOutputLimit, }) next.set(instanceId, instanceInfo) return next }) // Initialize cache entry for new session (empty messages) getSessionIndex(instanceId, session.id) return session } catch (error) { console.error("Failed to create session:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } next.creatingSession.set(instanceId, false) return next }) } } async function forkSession( instanceId: string, sourceSessionId: string, options?: { messageId?: string }, ): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const request: { path: { id: string } body?: { messageID: string } } = { path: { id: sourceSessionId }, } if (options?.messageId) { request.body = { messageID: options.messageId } } const response = await instance.client.session.fork(request) if (!response.data) { throw new Error("Failed to fork session: No data returned") } const info = response.data const forkedSession: Session = { id: info.id, instanceId, title: info.title || "Forked Session", parentId: info.parentID || null, agent: info.agent || "", model: { providerId: info.model?.providerID || "", modelId: info.model?.modelID || "", }, time: { created: info.time?.created || Date.now(), updated: info.time?.updated || Date.now(), }, revert: info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID, snapshot: info.revert.snapshot, diff: info.revert.diff, } : undefined, messages: [], messagesInfo: new Map(), } setSessions((prev) => { const next = new Map(prev) const instanceSessions = next.get(instanceId) || new Map() instanceSessions.set(forkedSession.id, forkedSession) next.set(instanceId, instanceSessions) return next }) const instanceProviders = providers().get(instanceId) || [] const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId) const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId) const forkContextWindow = forkModel?.limit?.context ?? 0 const forkOutputLimit = forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0 setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) instanceInfo.set(forkedSession.id, { tokens: 0, cost: 0, contextWindow: forkContextWindow, isSubscriptionModel: Boolean(forkSubscriptionModel), contextUsageTokens: forkOutputLimit, }) next.set(instanceId, instanceInfo) return next }) getSessionIndex(instanceId, forkedSession.id) return forkedSession } async function deleteSession(instanceId: string, sessionId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } setLoading((prev) => { const next = { ...prev } const deleting = next.deletingSession.get(instanceId) || new Set() deleting.add(sessionId) next.deletingSession.set(instanceId, deleting) return next }) try { await instance.client.session.delete({ path: { id: sessionId } }) setSessions((prev) => { const next = new Map(prev) const instanceSessions = next.get(instanceId) if (instanceSessions) { instanceSessions.delete(sessionId) } return next }) clearSessionDraftPrompt(instanceId, sessionId) // Remove session info entry setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = next.get(instanceId) if (instanceInfo) { const updatedInstanceInfo = new Map(instanceInfo) updatedInstanceInfo.delete(sessionId) if (updatedInstanceInfo.size === 0) { next.delete(instanceId) } else { next.set(instanceId, updatedInstanceInfo) } } return next }) // Clear cache entry for deleted session clearSessionIndex(instanceId, sessionId) if (activeSessionId().get(instanceId) === sessionId) { setActiveSessionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) } } catch (error) { console.error("Failed to delete session:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } const deleting = next.deletingSession.get(instanceId) if (deleting) { deleting.delete(sessionId) } return next }) } } async function fetchAgents(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } try { const response = await instance.client.app.agents() const agentList = (response.data ?? []).map((agent) => ({ name: agent.name, description: agent.description || "", mode: agent.mode, model: agent.model?.modelID ? { providerId: agent.model.providerID || "", modelId: agent.model.modelID, } : undefined, })) setAgents((prev) => { const next = new Map(prev) next.set(instanceId, agentList) return next }) } catch (error) { console.error("Failed to fetch agents:", error) } } async function fetchProviders(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } try { const response = await instance.client.config.providers() if (!response.data) return const providerList = response.data.providers.map((provider) => ({ id: provider.id, name: provider.name, defaultModelId: response.data?.default?.[provider.id], models: Object.entries(provider.models).map(([id, model]) => ({ id, name: model.name, providerId: provider.id, limit: model.limit, cost: model.cost, })), })) setProviders((prev) => { const next = new Map(prev) next.set(instanceId, providerList) return next }) } catch (error) { console.error("Failed to fetch providers:", error) } } function setActiveSession(instanceId: string, sessionId: string): void { setActiveSessionId((prev) => { const next = new Map(prev) next.set(instanceId, sessionId) return next }) } function setActiveParentSession(instanceId: string, parentSessionId: string): void { setActiveParentSessionId((prev) => { const next = new Map(prev) next.set(instanceId, parentSessionId) return next }) setActiveSession(instanceId, parentSessionId) } function clearActiveParentSession(instanceId: string): void { setActiveParentSessionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) setActiveSessionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) } function getActiveParentSession(instanceId: string): Session | null { const parentId = activeParentSessionId().get(instanceId) if (!parentId) return null const instanceSessions = sessions().get(instanceId) return instanceSessions?.get(parentId) || null } function getActiveSession(instanceId: string): Session | null { const sessionId = activeSessionId().get(instanceId) if (!sessionId) return null const instanceSessions = sessions().get(instanceId) return instanceSessions?.get(sessionId) || null } function getSessions(instanceId: string): Session[] { const instanceSessions = sessions().get(instanceId) return instanceSessions ? Array.from(instanceSessions.values()) : [] } function getParentSessions(instanceId: string): Session[] { const allSessions = getSessions(instanceId) return allSessions.filter((s) => s.parentId === null) } function getChildSessions(instanceId: string, parentId: string): Session[] { const allSessions = getSessions(instanceId) return allSessions.filter((s) => s.parentId === parentId) } function getSessionFamily(instanceId: string, parentId: string): Session[] { const parent = sessions().get(instanceId)?.get(parentId) if (!parent) return [] const children = getChildSessions(instanceId, parentId) return [parent, ...children] } function isSessionBusy(instanceId: string, sessionId: string): boolean { const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return false if (!instanceSessions.has(sessionId)) return false return true } async function loadMessages(instanceId: string, sessionId: string, force = false): Promise { // If force reload, clear the loaded cache if (force) { setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) if (loadedSet) { loadedSet.delete(sessionId) } return next }) } const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId) if (alreadyLoaded && !force) { return } const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId) if (isLoading) { return } const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { throw new Error("Session not found") } setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) || new Set() loadingSet.add(sessionId) next.loadingMessages.set(instanceId, loadingSet) return next }) try { const response = await instance.client.session.messages({ path: { id: sessionId } }) if (!response.data || !Array.isArray(response.data)) { return } const messagesInfo = new Map() const messages: Message[] = response.data.map((apiMessage: any) => { const info = apiMessage.info || apiMessage const role = info.role || "assistant" const messageId = info.id || String(Date.now()) messagesInfo.set(messageId, info) // Normalize parts to decode entities and clear caches for text segments const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) const message: Message = { id: messageId, sessionId, type: role === "user" ? "user" : "assistant", parts, timestamp: info.time?.created || Date.now(), status: "complete" as const, version: 0, } parts.forEach((part: any) => initializePartVersion(part)) message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) return message }) let agentName = "" let providerID = "" let modelID = "" for (let i = response.data.length - 1; i >= 0; i--) { const apiMessage = response.data[i] const info = apiMessage.info || apiMessage if (info.role === "assistant") { agentName = (info as any).mode || (info as any).agent || "" providerID = (info as any).providerID || "" modelID = (info as any).modelID || "" if (agentName && providerID && modelID) break } } if (!agentName && !providerID && !modelID) { const defaultModel = await getDefaultModel(instanceId, session.agent) agentName = session.agent providerID = defaultModel.providerId modelID = defaultModel.modelId } setSessions((prev) => { const next = new Map(prev) const instanceSessions = next.get(instanceId) if (instanceSessions) { const session = instanceSessions.get(sessionId) if (session) { const updatedSession = { ...session, messages, messagesInfo, agent: agentName || session.agent, model: providerID && modelID ? { providerId: providerID, modelId: modelID } : session.model, } const updatedInstanceSessions = new Map(instanceSessions) updatedInstanceSessions.set(sessionId, updatedSession) next.set(instanceId, updatedInstanceSessions) } } return next }) // Rebuild index after loading messages rebuildSessionIndex(instanceId, sessionId, messages) setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() loadedSet.add(sessionId) next.set(instanceId, loadedSet) return next }) } catch (error) { console.error("Failed to load messages:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) if (loadingSet) { loadingSet.delete(sessionId) } return next }) } updateSessionInfo(instanceId, sessionId) } function handleMessageUpdate(instanceId: string, event: any): void { const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return if (event.type === "message.part.updated") { const rawPart = event.properties?.part if (!rawPart) return const part = normalizeMessagePart(rawPart) const session = instanceSessions.get(part.sessionID) if (!session) return const index = getSessionIndex(instanceId, part.sessionID) let messageIndex = index.messageIndex.get(part.messageID) let replacedTemp = false if (messageIndex === undefined) { // Search for queued message with status 'sending' and no server id for (let i = 0; i < session.messages.length; i++) { const msg = session.messages[i] if (msg.sessionId === part.sessionID && msg.status === "sending") { messageIndex = i replacedTemp = true break } } } if (messageIndex === undefined) { // Create new message const newMessage: Message = { id: part.messageID, sessionId: part.sessionID, type: "assistant" as const, parts: [part], timestamp: Date.now(), status: "streaming" as const, version: 0, } initializePartVersion(part) newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks) let insertIndex = session.messages.length for (let i = session.messages.length - 1; i >= 0; i--) { if (session.messages[i].id < newMessage.id) { insertIndex = i + 1 break } } session.messages.splice(insertIndex, 0, newMessage) rebuildSessionIndex(instanceId, part.sessionID, session.messages) } else { // Update existing message const message = session.messages[messageIndex] if (typeof message.version !== "number") { message.version = 0 } // Strip synthetic parts when real data arrives let filteredSynthetics = false if (message.parts.some((partItem: any) => partItem.synthetic === true)) { message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true) filteredSynthetics = true // Clear render cache from remaining parts when synthetic parts are removed message.parts.forEach((partItem: any) => { if (partItem.type === "text") { partItem.renderCache = undefined } }) } let baseParts: any[] if (replacedTemp) { baseParts = message.parts.filter((partItem: any) => partItem.type !== "text") message.parts = baseParts // Clear render cache when replacing temp content baseParts.forEach((partItem: any) => { if (partItem.type === "text") { partItem.renderCache = undefined } }) } else { baseParts = message.parts } // Update part in place let partMap = index.partIndex.get(message.id) if (!partMap) { partMap = new Map() index.partIndex.set(message.id, partMap) } let shouldIncrementVersion = filteredSynthetics || replacedTemp const partIndex = partMap.get(part.id) if (partIndex === undefined) { initializePartVersion(part) baseParts.push(part) if (part.id && typeof part.id === "string") { partMap.set(part.id, baseParts.length - 1) } shouldIncrementVersion = true // Clear render cache for new text parts if (part.type === "text") { part.renderCache = undefined } } else { const previousPart = baseParts[partIndex] const textUnchanged = !filteredSynthetics && !replacedTemp && part.type === "text" && previousPart?.type === "text" && previousPart.text === part.text if (textUnchanged) { return } bumpPartVersion(previousPart, part) baseParts[partIndex] = part if (part.type !== "text" || !previousPart || previousPart.text !== part.text) { shouldIncrementVersion = true // Clear render cache when text changes if (part.type === "text") { part.renderCache = undefined } } } const oldId = message.id message.id = replacedTemp ? part.messageID : message.id message.status = message.status === "sending" ? "streaming" : message.status message.parts = baseParts if (shouldIncrementVersion) { message.version += 1 message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) } else if ( !message.displayParts || message.displayParts.showThinking !== preferences().showThinkingBlocks || message.displayParts.version !== message.version ) { message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) } // Update message index if ID changed if (oldId !== message.id) { index.messageIndex.delete(oldId) index.messageIndex.set(message.id, messageIndex) const existingPartMap = index.partIndex.get(oldId) if (existingPartMap) { index.partIndex.delete(oldId) index.partIndex.set(message.id, existingPartMap) } } // Refresh part indexes after filtering synthetic parts or replacing optimistic content if (filteredSynthetics || replacedTemp) { const partMap = new Map() message.parts.forEach((partItem, idx) => { if (partItem.id && typeof partItem.id === "string") { partMap.set(partItem.id, idx) } }) index.partIndex.set(message.id, partMap) } } withSession(instanceId, part.sessionID, (session) => { // Session already mutated in place }) updateSessionInfo(instanceId, part.sessionID) } else if (event.type === "message.updated") { const info = event.properties?.info if (!info) return const session = instanceSessions.get(info.sessionID) if (!session) return const index = getSessionIndex(instanceId, info.sessionID) let messageIndex = index.messageIndex.get(info.id) if (messageIndex === undefined) { // Look for queued message to replace let tempMessageIndex = -1 for (let i = 0; i < session.messages.length; i++) { const msg = session.messages[i] if ( msg.sessionId === info.sessionID && msg.type === (info.role === "user" ? "user" : "assistant") && msg.status === "sending" ) { tempMessageIndex = i break } } if (tempMessageIndex === -1) { for (let i = 0; i < session.messages.length; i++) { const msg = session.messages[i] if (msg.sessionId === info.sessionID && msg.status === "sending") { tempMessageIndex = i break } } } if (tempMessageIndex > -1) { // Replace queued message const message = session.messages[tempMessageIndex] if (typeof message.version !== "number") { message.version = 0 } const oldId = message.id message.id = info.id message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant" message.timestamp = info.time?.created || Date.now() message.status = "complete" as const message.version += 1 message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) if (oldId !== message.id) { index.messageIndex.delete(oldId) index.messageIndex.set(message.id, tempMessageIndex) const existingPartMap = index.partIndex.get(oldId) if (existingPartMap) { index.partIndex.delete(oldId) index.partIndex.set(message.id, existingPartMap) } } } else { // Append new message const newMessage: Message = { id: info.id, sessionId: info.sessionID, type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant", parts: [], timestamp: info.time?.created || Date.now(), status: "complete" as const, version: 0, } newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks) let insertIndex = session.messages.length for (let i = session.messages.length - 1; i >= 0; i--) { if (session.messages[i].id < newMessage.id) { insertIndex = i + 1 break } } session.messages.splice(insertIndex, 0, newMessage) rebuildSessionIndex(instanceId, info.sessionID, session.messages) } } else { // Update existing message status const message = session.messages[messageIndex] if (typeof message.version !== "number") { message.version = 0 } message.status = "complete" as const message.version += 1 message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) } session.messagesInfo.set(info.id, info) withSession(instanceId, info.sessionID, (session) => { // Session already mutated in place }) updateSessionInfo(instanceId, info.sessionID) } } function handleSessionUpdate(instanceId: string, event: any): void { const info = event.properties?.info if (!info) return const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return const existingSession = instanceSessions.get(info.id) if (!existingSession) { const newSession: Session = { id: info.id, instanceId, title: info.title || "Untitled", parentId: info.parentID || null, agent: info.agent || "", model: { providerId: info.model?.providerID || "", modelId: info.model?.modelID || "", }, time: { created: info.time?.created || Date.now(), updated: info.time?.updated || Date.now(), }, messages: [], messagesInfo: new Map(), } setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(prev.get(instanceId)) instanceSessions.set(newSession.id, newSession) next.set(instanceId, instanceSessions) return next }) console.log(`[SSE] New session created: ${info.id}`, newSession) } else { const updatedSession = { ...existingSession, title: info.title || existingSession.title, agent: info.agent || existingSession.agent, model: info.model ? { providerId: info.model.providerID || existingSession.model.providerId, modelId: info.model.modelID || existingSession.model.modelId, } : existingSession.model, time: { ...existingSession.time, updated: info.time?.updated || Date.now(), }, revert: info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID, snapshot: info.revert.snapshot, diff: info.revert.diff, } : undefined, } setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(prev.get(instanceId)) instanceSessions.set(existingSession.id, updatedSession) next.set(instanceId, instanceSessions) return next }) } } function resolvePastedPlaceholders(prompt: string, attachments: any[] = []): string { if (!prompt || !prompt.includes("[pasted #")) { return prompt } if (!attachments || attachments.length === 0) { return prompt } const lookup = new Map() for (const attachment of attachments) { const source = attachment?.source if (!source || source.type !== "text") continue const display: string | undefined = attachment?.display const value: unknown = source.value if (typeof display !== "string" || typeof value !== "string") continue const match = display.match(/pasted #(\d+)/) if (!match) continue const placeholder = `[pasted #${match[1]}]` if (!lookup.has(placeholder)) { lookup.set(placeholder, value) } } if (lookup.size === 0) { return prompt } return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { const replacement = lookup.get(fullMatch) return typeof replacement === "string" ? replacement : fullMatch }) } async function sendMessage( instanceId: string, sessionId: string, prompt: string, attachments: any[] = [], ): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { throw new Error("Session not found") } const messageId = createId("msg") const textPartId = createId("part") const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments) const optimisticParts: any[] = [ { id: textPartId, type: "text" as const, text: resolvedPrompt, synthetic: true, renderCache: undefined, }, ] const optimisticMessage: Message = { id: messageId, sessionId, type: "user", parts: optimisticParts, timestamp: Date.now(), status: "sending", version: 0, } optimisticParts.forEach((part: any) => initializePartVersion(part)) optimisticMessage.displayParts = computeDisplayParts(optimisticMessage, preferences().showThinkingBlocks) withSession(instanceId, sessionId, (session) => { session.messages.push(optimisticMessage) const index = getSessionIndex(instanceId, sessionId) index.messageIndex.set(optimisticMessage.id, session.messages.length - 1) }) const requestParts: any[] = [ { id: textPartId, type: "text" as const, text: resolvedPrompt, }, ] if (attachments.length > 0) { for (const att of attachments) { const source = att.source if (source.type === "file") { const partId = createId("part") requestParts.push({ id: partId, type: "file" as const, url: att.url, mime: source.mime, filename: att.filename, }) optimisticParts.push({ id: partId, type: "file" as const, url: att.url, mime: source.mime, filename: att.filename, synthetic: true, }) } else if (source.type === "text") { const display: string | undefined = att.display const value: unknown = source.value const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display) if (isPastedPlaceholder || typeof value !== "string") { continue } const partId = createId("part") requestParts.push({ id: partId, type: "text" as const, text: value, }) optimisticParts.push({ id: partId, type: "text" as const, text: value, synthetic: true, renderCache: undefined, }) } } } const requestBody = { messageID: messageId, parts: requestParts, ...(session.agent && { agent: session.agent }), ...(session.model.providerId && session.model.modelId && { model: { providerID: session.model.providerId, modelID: session.model.modelId, }, }), } console.log("[sendMessage] Sending prompt:", { sessionId, requestBody, }) try { const response = await instance.client.session.prompt({ path: { id: sessionId }, body: requestBody, }) console.log("[sendMessage] Response:", response) if (response.error) { console.error("[sendMessage] Server returned error:", response.error) throw new Error(JSON.stringify(response.error) || "Failed to send message") } } catch (error) { console.error("[sendMessage] Failed to send prompt:", error) throw error } } async function abortSession(instanceId: string, sessionId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } console.log("[abortSession] Aborting session:", { instanceId, sessionId }) try { await instance.client.session.abort({ path: { id: sessionId }, }) console.log("[abortSession] Session aborted successfully") } catch (error) { console.error("[abortSession] Failed to abort session:", error) throw error } } async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise { const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { throw new Error("Session not found") } const nextModel = await getDefaultModel(instanceId, agent) const shouldApplyModel = isModelValid(instanceId, nextModel) setSessions((prev) => { const next = new Map(prev) const map = new Map(prev.get(instanceId)) const current = map.get(sessionId) if (current) { map.set(sessionId, { ...current, agent, model: shouldApplyModel ? nextModel : current.model, }) next.set(instanceId, map) } return next }) if (agent && shouldApplyModel) { setAgentModelPreference(instanceId, agent, nextModel) } if (shouldApplyModel) { updateSessionInfo(instanceId, sessionId) } } async function updateSessionModel( instanceId: string, sessionId: string, model: { providerId: string; modelId: string }, ): Promise { const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { throw new Error("Session not found") } if (!isModelValid(instanceId, model)) { console.warn("Invalid model selection", model) return } const currentAgent = session.agent setSessions((prev) => { const next = new Map(prev) const map = new Map(prev.get(instanceId)) const existing = map.get(sessionId) if (existing) { map.set(sessionId, { ...existing, model }) next.set(instanceId, map) } return next }) if (currentAgent) { setAgentModelPreference(instanceId, currentAgent, model) } addRecentModelPreference(model) updateSessionInfo(instanceId, sessionId) } function handleSessionCompacted(instanceId: string, event: any): void { const sessionID = event.properties?.sessionID if (!sessionID) return console.log(`[SSE] Session compacted: ${sessionID}`) loadMessages(instanceId, sessionID, true).catch(console.error) } function handleSessionError(instanceId: string, event: any): void { const error = event.properties?.error const sessionID = event.properties?.sessionID console.error(`[SSE] Session error:`, error) let message = error?.data?.message || error?.message || "Unknown error" if (error?.data?.responseBody) { try { const body = JSON.parse(error.data.responseBody) if (body.error) { message = body.error } } catch {} } alert(`Error: ${message}`) } function handleMessageRemoved(instanceId: string, event: any): void { const sessionID = event.properties?.sessionID if (!sessionID) return console.log(`[SSE] Message removed from session ${sessionID}, reloading messages`) loadMessages(instanceId, sessionID, true).catch(console.error) } function handleMessagePartRemoved(instanceId: string, event: any): void { const sessionID = event.properties?.sessionID if (!sessionID) return console.log(`[SSE] Message part removed from session ${sessionID}, reloading messages`) loadMessages(instanceId, sessionID, true).catch(console.error) } function handleTuiToast(_instanceId: string, event: any): void { const payload = event?.properties if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return if (!payload.message.trim()) return const variant: ToastVariant = ALLOWED_TOAST_VARIANTS.has(payload.variant as ToastVariant) ? (payload.variant as ToastVariant) : "info" showToastNotification({ title: typeof payload.title === "string" ? payload.title : undefined, message: payload.message, variant, duration: typeof payload.duration === "number" ? payload.duration : undefined, }) } sseManager.onMessageUpdate = handleMessageUpdate sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved sseManager.onSessionUpdate = handleSessionUpdate sseManager.onSessionCompacted = handleSessionCompacted sseManager.onSessionError = handleSessionError sseManager.onTuiToast = handleTuiToast export { sessions, activeSessionId, activeParentSessionId, agents, providers, loading, sessionInfoByInstance, getSessionInfo, fetchSessions, createSession, forkSession, deleteSession, fetchAgents, fetchProviders, loadMessages, sendMessage, abortSession, setActiveSession, setActiveParentSession, clearActiveParentSession, getActiveSession, getActiveParentSession, getSessions, getParentSessions, getChildSessions, getSessionFamily, isSessionBusy, updateSessionAgent, updateSessionModel, getDefaultModel, removeSessionIndexes, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, clearInstanceDraftPrompts, }