import type { MessagePartRemovedEvent, MessagePartUpdatedEvent, MessageRemovedEvent, MessageUpdateEvent, } from "../types/message" import type { EventPermissionReplied, EventPermissionUpdated, EventSessionCompacted, EventSessionError, EventSessionIdle, EventSessionUpdated, } from "@opencode-ai/sdk" import { showToastNotification, ToastVariant } from "../lib/notifications" import { preferences } from "./preferences" import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances" import { showAlertDialog } from "./alerts" import { sessions, setSessions, withSession, } from "./session-state" import { bumpPartVersion, computeDisplayParts, getSessionIndex, initializePartVersion, normalizeMessagePart, rebuildSessionIndex, updateSessionInfo, } from "./session-messages" import { loadMessages } from "./session-api" import { setSessionCompactionState } from "./session-compaction" interface TuiToastEvent { type: "tui.toast.show" properties: { title?: string message: string variant: "info" | "success" | "warning" | "error" duration?: number } } const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]) function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): 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) { 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) { const newMessage: any = { 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 { const message = session.messages[messageIndex] if (typeof message.version !== "number") { message.version = 0 } let filteredSynthetics = false if (message.parts.some((partItem: any) => partItem.synthetic === true)) { message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true) filteredSynthetics = true 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 baseParts.forEach((partItem: any) => { if (partItem.type === "text") { partItem.renderCache = undefined } }) } else { baseParts = message.parts } 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 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 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) } 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) } } if (filteredSynthetics || replacedTemp) { const refreshed = new Map() message.parts.forEach((partItem, idx) => { if (partItem.id && typeof partItem.id === "string") { refreshed.set(partItem.id, idx) } }) index.partIndex.set(message.id, refreshed) } } withSession(instanceId, part.sessionID, () => { /* mutations already applied above */ }) updateSessionInfo(instanceId, part.sessionID) refreshPermissionsForSession(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) { 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) { 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 { const newMessage: any = { 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 { 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, () => { /* ensure reactivity */ }) updateSessionInfo(instanceId, info.sessionID) refreshPermissionsForSession(instanceId, info.sessionID) } } function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { const info = event.properties?.info if (!info) return const compactingFlag = info.time?.compacting const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag) setSessionCompactionState(instanceId, info.id, isCompacting) const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return const existingSession = instanceSessions.get(info.id) if (!existingSession) { const newSession = { id: info.id, instanceId, title: info.title || "Untitled", parentId: info.parentID || null, agent: "", model: { providerId: "", modelId: "", }, version: info.version || "0", time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now(), }, messages: [], messagesInfo: new Map(), } as any setSessions((prev) => { const next = new Map(prev) const updated = new Map(prev.get(instanceId)) updated.set(newSession.id, newSession) next.set(instanceId, updated) return next }) console.log(`[SSE] New session created: ${info.id}`, newSession) } else { const mergedTime = { ...existingSession.time, ...(info.time ?? {}), } if (!info.time?.updated) { mergedTime.updated = Date.now() } const updatedSession = { ...existingSession, title: info.title || existingSession.title, time: mergedTime, revert: info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID, snapshot: info.revert.snapshot, diff: info.revert.diff, } : existingSession.revert, } setSessions((prev) => { const next = new Map(prev) const updated = new Map(prev.get(instanceId)) updated.set(existingSession.id, updatedSession) next.set(instanceId, updated) return next }) } } function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void { const sessionId = event.properties?.sessionID if (!sessionId) return console.log(`[SSE] Session idle: ${sessionId}`) } function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void { const sessionID = event.properties?.sessionID if (!sessionID) return console.log(`[SSE] Session compacted: ${sessionID}`) setSessionCompactionState(instanceId, sessionID, false) withSession(instanceId, sessionID, (session) => { const time = { ...(session.time ?? {}) } time.compacting = 0 session.time = time }) loadMessages(instanceId, sessionID, true).catch(console.error) const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionID) const label = session?.title?.trim() ? session.title : sessionID const instanceFolder = instances().get(instanceId)?.folder ?? instanceId const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder showToastNotification({ title: instanceName, message: `Session ${label ? `"${label}"` : sessionID} was compacted`, variant: "info", duration: 10000, }) } function handleSessionError(_instanceId: string, event: EventSessionError): void { const error = event.properties?.error console.error(`[SSE] Session error:`, error) let message = "Unknown error" if (error) { if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) { message = error.data.message as string } else if ("message" in error && typeof error.message === "string") { message = error.message } } showAlertDialog(`Error: ${message}`, { title: "Session error", variant: "error", }) } function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): 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: MessagePartRemovedEvent): 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: TuiToastEvent): 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, }) } function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void { const permission = event.properties if (!permission) return console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`) addPermissionToQueue(instanceId, permission) } function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void { const { permissionID } = event.properties if (!permissionID) return console.log(`[SSE] Permission replied: ${permissionID}`) removePermissionFromQueue(instanceId, permissionID) } export { handleMessagePartRemoved, handleMessageRemoved, handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, handleSessionCompacted, handleSessionError, handleSessionIdle, handleSessionUpdate, handleTuiToast, }