/** * Chat State Store * Manages chat messages, sessions, streaming, and thinking state. * Communicates with OpenClaw Gateway via gateway:rpc IPC. */ import { create } from 'zustand'; // ── Types ──────────────────────────────────────────────────────── /** Metadata for locally-attached files (not from Gateway) */ export interface AttachedFileMeta { fileName: string; mimeType: string; fileSize: number; preview: string | null; } /** Raw message from OpenClaw chat.history */ export interface RawMessage { role: 'user' | 'assistant' | 'system' | 'toolresult'; content: unknown; // string | ContentBlock[] timestamp?: number; id?: string; toolCallId?: string; toolName?: string; details?: unknown; isError?: boolean; /** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */ _attachedFiles?: AttachedFileMeta[]; } /** Content block inside a message */ export interface ContentBlock { type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult'; text?: string; thinking?: string; source?: { type: string; media_type: string; data: string }; id?: string; name?: string; input?: unknown; arguments?: unknown; content?: unknown; } /** Session from sessions.list */ export interface ChatSession { key: string; label?: string; displayName?: string; thinkingLevel?: string; model?: string; } export interface ToolStatus { id?: string; toolCallId?: string; name: string; status: 'running' | 'completed' | 'error'; durationMs?: number; summary?: string; updatedAt: number; } interface ChatState { // Messages messages: RawMessage[]; loading: boolean; error: string | null; // Streaming sending: boolean; activeRunId: string | null; streamingText: string; streamingMessage: unknown | null; streamingTools: ToolStatus[]; pendingFinal: boolean; lastUserMessageAt: number | null; // Sessions sessions: ChatSession[]; currentSessionKey: string; // Thinking showThinking: boolean; thinkingLevel: string | null; // Actions loadSessions: () => Promise; switchSession: (key: string) => void; newSession: () => void; loadHistory: () => Promise; sendMessage: (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => Promise; abortRun: () => Promise; handleChatEvent: (event: Record) => void; toggleThinking: () => void; refresh: () => Promise; clearError: () => void; } const DEFAULT_CANONICAL_PREFIX = 'agent:main'; const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`; // ── Local image cache ───────────────────────────────────────── // The Gateway doesn't store image attachments in session content blocks, // so we cache them locally keyed by staged file path (which appears in the // [media attached: ...] reference in the Gateway's user message text). // Keying by path avoids the race condition of keying by runId (which is only // available after the RPC returns, but history may load before that). const IMAGE_CACHE_KEY = 'clawx:image-cache'; const IMAGE_CACHE_MAX = 100; // max entries to prevent unbounded growth function loadImageCache(): Map { try { const raw = localStorage.getItem(IMAGE_CACHE_KEY); if (raw) { const entries = JSON.parse(raw) as Array<[string, AttachedFileMeta]>; return new Map(entries); } } catch { /* ignore parse errors */ } return new Map(); } function saveImageCache(cache: Map): void { try { // Evict oldest entries if over limit const entries = Array.from(cache.entries()); const trimmed = entries.length > IMAGE_CACHE_MAX ? entries.slice(entries.length - IMAGE_CACHE_MAX) : entries; localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(trimmed)); } catch { /* ignore quota errors */ } } const _imageCache = loadImageCache(); /** Extract plain text from message content (string or content blocks) */ function getMessageText(content: unknown): string { if (typeof content === 'string') return content; if (Array.isArray(content)) { return (content as Array<{ type?: string; text?: string }>) .filter(b => b.type === 'text' && b.text) .map(b => b.text!) .join('\n'); } return ''; } /** Extract media file refs from [media attached: () | ...] patterns */ function extractMediaRefs(text: string): Array<{ filePath: string; mimeType: string }> { const refs: Array<{ filePath: string; mimeType: string }> = []; const regex = /\[media attached:\s*([^\s(]+)\s*\(([^)]+)\)\s*\|[^\]]*\]/g; let match; while ((match = regex.exec(text)) !== null) { refs.push({ filePath: match[1], mimeType: match[2] }); } return refs; } /** * Restore _attachedFiles for user messages loaded from history. * Uses local cache for previews when available, but ALWAYS creates entries * from [media attached: ...] text patterns so file cards show even without cache. */ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { return messages.map(msg => { if (msg.role !== 'user' || msg._attachedFiles) return msg; const text = getMessageText(msg.content); const refs = extractMediaRefs(text); if (refs.length === 0) return msg; const files: AttachedFileMeta[] = refs.map(ref => { const cached = _imageCache.get(ref.filePath); if (cached) return cached; // Fallback: create entry from text pattern (preview loaded later via IPC) const fileName = ref.filePath.split(/[\\/]/).pop() || 'file'; return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null }; }); return { ...msg, _attachedFiles: files }; }); } /** * Async: load missing previews from disk via IPC for messages that have * _attachedFiles with null previews. Updates messages in-place and triggers re-render. */ async function loadMissingPreviews(messages: RawMessage[]): Promise { // Collect all image paths that need previews const needPreview: Array<{ filePath: string; mimeType: string }> = []; for (const msg of messages) { if (msg.role !== 'user' || !msg._attachedFiles) continue; const text = getMessageText(msg.content); const refs = extractMediaRefs(text); for (let i = 0; i < refs.length; i++) { const file = msg._attachedFiles[i]; if (file && file.mimeType.startsWith('image/') && !file.preview) { needPreview.push(refs[i]); } } } if (needPreview.length === 0) return false; try { const thumbnails = await window.electron.ipcRenderer.invoke( 'media:getThumbnails', needPreview, ) as Record; let updated = false; for (const msg of messages) { if (msg.role !== 'user' || !msg._attachedFiles) continue; const text = getMessageText(msg.content); const refs = extractMediaRefs(text); for (let i = 0; i < refs.length; i++) { const file = msg._attachedFiles[i]; const thumb = thumbnails[refs[i]?.filePath]; if (file && thumb && (thumb.preview || thumb.fileSize)) { if (thumb.preview) file.preview = thumb.preview; if (thumb.fileSize) file.fileSize = thumb.fileSize; // Update cache for future loads _imageCache.set(refs[i].filePath, { ...file }); updated = true; } } } if (updated) saveImageCache(_imageCache); return updated; } catch (err) { console.warn('[loadMissingPreviews] Failed:', err); return false; } } function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null { const canonical = sessions.find((s) => s.key.startsWith('agent:'))?.key; if (!canonical) return null; const parts = canonical.split(':'); if (parts.length < 2) return null; return `${parts[0]}:${parts[1]}`; } function isToolOnlyMessage(message: RawMessage | undefined): boolean { if (!message) return false; if (isToolResultRole(message.role)) return true; const content = message.content; if (!Array.isArray(content)) return false; let hasTool = false; let hasText = false; let hasNonToolContent = false; for (const block of content as ContentBlock[]) { if (block.type === 'tool_use' || block.type === 'tool_result' || block.type === 'toolCall' || block.type === 'toolResult') { hasTool = true; continue; } if (block.type === 'text' && block.text && block.text.trim()) { hasText = true; continue; } if (block.type === 'image' || block.type === 'thinking') { hasNonToolContent = true; } } return hasTool && !hasText && !hasNonToolContent; } function isToolResultRole(role: unknown): boolean { if (!role) return false; const normalized = String(role).toLowerCase(); return normalized === 'toolresult' || normalized === 'tool_result'; } function extractTextFromContent(content: unknown): string { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; const parts: string[] = []; for (const block of content as ContentBlock[]) { if (block.type === 'text' && block.text) { parts.push(block.text); } } return parts.join('\n'); } function summarizeToolOutput(text: string): string | undefined { const trimmed = text.trim(); if (!trimmed) return undefined; const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); if (lines.length === 0) return undefined; const summaryLines = lines.slice(0, 2); let summary = summaryLines.join(' / '); if (summary.length > 160) { summary = `${summary.slice(0, 157)}...`; } return summary; } function normalizeToolStatus(rawStatus: unknown, fallback: 'running' | 'completed'): ToolStatus['status'] { const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : ''; if (status === 'error' || status === 'failed') return 'error'; if (status === 'completed' || status === 'success' || status === 'done') return 'completed'; return fallback; } function parseDurationMs(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) return value; const parsed = typeof value === 'string' ? Number(value) : NaN; return Number.isFinite(parsed) ? parsed : undefined; } function extractToolUseUpdates(message: unknown): ToolStatus[] { if (!message || typeof message !== 'object') return []; const msg = message as Record; const content = msg.content; if (!Array.isArray(content)) return []; const updates: ToolStatus[] = []; for (const block of content as ContentBlock[]) { if ((block.type !== 'tool_use' && block.type !== 'toolCall') || !block.name) continue; updates.push({ id: block.id || block.name, toolCallId: block.id, name: block.name, status: 'running', updatedAt: Date.now(), }); } return updates; } function extractToolResultBlocks(message: unknown, eventState: string): ToolStatus[] { if (!message || typeof message !== 'object') return []; const msg = message as Record; const content = msg.content; if (!Array.isArray(content)) return []; const updates: ToolStatus[] = []; for (const block of content as ContentBlock[]) { if (block.type !== 'tool_result' && block.type !== 'toolResult') continue; const outputText = extractTextFromContent(block.content ?? block.text ?? ''); const summary = summarizeToolOutput(outputText); updates.push({ id: block.id || block.name || 'tool', toolCallId: block.id, name: block.name || block.id || 'tool', status: normalizeToolStatus(undefined, eventState === 'delta' ? 'running' : 'completed'), summary, updatedAt: Date.now(), }); } return updates; } function extractToolResultUpdate(message: unknown, eventState: string): ToolStatus | null { if (!message || typeof message !== 'object') return null; const msg = message as Record; const role = typeof msg.role === 'string' ? msg.role.toLowerCase() : ''; if (!isToolResultRole(role)) return null; const toolName = typeof msg.toolName === 'string' ? msg.toolName : (typeof msg.name === 'string' ? msg.name : ''); const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined; const details = (msg.details && typeof msg.details === 'object') ? msg.details as Record : undefined; const rawStatus = (msg.status ?? details?.status); const fallback = eventState === 'delta' ? 'running' : 'completed'; const status = normalizeToolStatus(rawStatus, fallback); const durationMs = parseDurationMs(details?.durationMs ?? details?.duration ?? (msg as Record).durationMs); const outputText = (details && typeof details.aggregated === 'string') ? details.aggregated : extractTextFromContent(msg.content); const summary = summarizeToolOutput(outputText) ?? summarizeToolOutput(String(details?.error ?? msg.error ?? '')); const name = toolName || toolCallId || 'tool'; const id = toolCallId || name; return { id, toolCallId, name, status, durationMs, summary, updatedAt: Date.now(), }; } function mergeToolStatus(existing: ToolStatus['status'], incoming: ToolStatus['status']): ToolStatus['status'] { const order: Record = { running: 0, completed: 1, error: 2 }; return order[incoming] >= order[existing] ? incoming : existing; } function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] { if (updates.length === 0) return current; const next = [...current]; for (const update of updates) { const key = update.toolCallId || update.id || update.name; if (!key) continue; const index = next.findIndex((tool) => (tool.toolCallId || tool.id || tool.name) === key); if (index === -1) { next.push(update); continue; } const existing = next[index]; next[index] = { ...existing, ...update, name: update.name || existing.name, status: mergeToolStatus(existing.status, update.status), durationMs: update.durationMs ?? existing.durationMs, summary: update.summary ?? existing.summary, updatedAt: update.updatedAt || existing.updatedAt, }; } return next; } function collectToolUpdates(message: unknown, eventState: string): ToolStatus[] { const updates: ToolStatus[] = []; const toolResultUpdate = extractToolResultUpdate(message, eventState); if (toolResultUpdate) updates.push(toolResultUpdate); updates.push(...extractToolResultBlocks(message, eventState)); updates.push(...extractToolUseUpdates(message)); return updates; } function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { if (!message) return false; if (typeof message.content === 'string' && message.content.trim()) return true; const content = message.content; if (Array.isArray(content)) { for (const block of content as ContentBlock[]) { if (block.type === 'text' && block.text && block.text.trim()) return true; if (block.type === 'thinking' && block.thinking && block.thinking.trim()) return true; if (block.type === 'image') return true; } } const msg = message as unknown as Record; if (typeof msg.text === 'string' && msg.text.trim()) return true; return false; } // ── Store ──────────────────────────────────────────────────────── export const useChatStore = create((set, get) => ({ messages: [], loading: false, error: null, sending: false, activeRunId: null, streamingText: '', streamingMessage: null, streamingTools: [], pendingFinal: false, lastUserMessageAt: null, sessions: [], currentSessionKey: DEFAULT_SESSION_KEY, showThinking: true, thinkingLevel: null, // ── Load sessions via sessions.list ── loadSessions: async () => { try { const result = await window.electron.ipcRenderer.invoke( 'gateway:rpc', 'sessions.list', { limit: 50 } ) as { success: boolean; result?: Record; error?: string }; if (result.success && result.result) { const data = result.result; const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; const sessions: ChatSession[] = rawSessions.map((s: Record) => ({ key: String(s.key || ''), label: s.label ? String(s.label) : undefined, displayName: s.displayName ? String(s.displayName) : undefined, thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined, model: s.model ? String(s.model) : undefined, })).filter((s: ChatSession) => s.key); const canonicalBySuffix = new Map(); for (const session of sessions) { if (!session.key.startsWith('agent:')) continue; const parts = session.key.split(':'); if (parts.length < 3) continue; const suffix = parts.slice(2).join(':'); if (suffix && !canonicalBySuffix.has(suffix)) { canonicalBySuffix.set(suffix, session.key); } } // Deduplicate: if both short and canonical existed, keep canonical only const seen = new Set(); const dedupedSessions = sessions.filter((s) => { if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false; if (seen.has(s.key)) return false; seen.add(s.key); return true; }); const { currentSessionKey } = get(); let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY; if (!nextSessionKey.startsWith('agent:')) { const canonicalMatch = canonicalBySuffix.get(nextSessionKey); if (canonicalMatch) { nextSessionKey = canonicalMatch; } } if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) { // Current session not found at all — switch to the first available session nextSessionKey = dedupedSessions[0].key; } const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey ? [ ...dedupedSessions, { key: nextSessionKey, displayName: nextSessionKey }, ] : dedupedSessions; set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey }); if (currentSessionKey !== nextSessionKey) { get().loadHistory(); } } } catch (err) { console.warn('Failed to load sessions:', err); } }, // ── Switch session ── switchSession: (key: string) => { set({ currentSessionKey: key, messages: [], streamingText: '', streamingMessage: null, streamingTools: [], activeRunId: null, error: null, pendingFinal: false, lastUserMessageAt: null, }); // Load history for new session get().loadHistory(); }, // ── New session ── newSession: () => { // Generate a new unique session key and switch to it const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX; const newKey = `${prefix}:session-${Date.now()}`; const newSessionEntry: ChatSession = { key: newKey, displayName: newKey }; set((s) => ({ currentSessionKey: newKey, sessions: [...s.sessions, newSessionEntry], messages: [], streamingText: '', streamingMessage: null, streamingTools: [], activeRunId: null, error: null, pendingFinal: false, lastUserMessageAt: null, })); }, // ── Load chat history ── loadHistory: async () => { const { currentSessionKey } = get(); set({ loading: true, error: null }); try { const result = await window.electron.ipcRenderer.invoke( 'gateway:rpc', 'chat.history', { sessionKey: currentSessionKey, limit: 200 } ) as { success: boolean; result?: Record; error?: string }; if (result.success && result.result) { const data = result.result; const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : []; const filteredMessages = rawMessages.filter((msg) => !isToolResultRole(msg.role)); // Restore file attachments for user messages (from cache + text patterns) const enrichedMessages = enrichWithCachedImages(filteredMessages); const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null; set({ messages: enrichedMessages, thinkingLevel, loading: false }); // Async: load missing image previews from disk (updates in background) loadMissingPreviews(enrichedMessages).then((updated) => { if (updated) { // Trigger re-render with updated previews set({ messages: [...enrichedMessages] }); } }); const { pendingFinal, lastUserMessageAt } = get(); if (pendingFinal) { const recentAssistant = [...filteredMessages].reverse().find((msg) => { if (msg.role !== 'assistant') return false; if (!hasNonToolAssistantContent(msg)) return false; if (lastUserMessageAt && msg.timestamp && msg.timestamp < lastUserMessageAt) return false; return true; }); if (recentAssistant) { set({ sending: false, activeRunId: null, pendingFinal: false }); } } } else { set({ messages: [], loading: false }); } } catch (err) { console.warn('Failed to load chat history:', err); set({ messages: [], loading: false }); } }, // ── Send message ── sendMessage: async (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => { const trimmed = text.trim(); if (!trimmed && (!attachments || attachments.length === 0)) return; const { currentSessionKey } = get(); // Add user message optimistically (with local file metadata for UI display) const userMsg: RawMessage = { role: 'user', content: trimmed || (attachments?.length ? '(file attached)' : ''), timestamp: Date.now() / 1000, id: crypto.randomUUID(), _attachedFiles: attachments?.map(a => ({ fileName: a.fileName, mimeType: a.mimeType, fileSize: a.fileSize, preview: a.preview, })), }; set((s) => ({ messages: [...s.messages, userMsg], sending: true, error: null, streamingText: '', streamingMessage: null, streamingTools: [], pendingFinal: false, lastUserMessageAt: userMsg.timestamp ?? null, })); try { const idempotencyKey = crypto.randomUUID(); const hasMedia = attachments && attachments.length > 0; console.log(`[sendMessage] hasMedia=${hasMedia}, attachmentCount=${attachments?.length ?? 0}`); if (hasMedia) { console.log('[sendMessage] Media paths:', attachments!.map(a => a.stagedPath)); } // Cache image attachments BEFORE the IPC call to avoid race condition: // history may reload (via Gateway event) before the RPC returns. // Keyed by staged file path which appears in [media attached: ...]. if (hasMedia && attachments) { for (const a of attachments) { _imageCache.set(a.stagedPath, { fileName: a.fileName, mimeType: a.mimeType, fileSize: a.fileSize, preview: a.preview, }); } saveImageCache(_imageCache); } let result: { success: boolean; result?: { runId?: string }; error?: string }; if (hasMedia) { // Use dedicated chat:sendWithMedia handler — main process reads staged files // from disk and builds base64 attachments, avoiding large IPC transfers result = await window.electron.ipcRenderer.invoke( 'chat:sendWithMedia', { sessionKey: currentSessionKey, message: trimmed || 'Process the attached file(s).', deliver: false, idempotencyKey, media: attachments.map((a) => ({ filePath: a.stagedPath, mimeType: a.mimeType, fileName: a.fileName, })), }, ) as { success: boolean; result?: { runId?: string }; error?: string }; } else { // No media — use standard lightweight RPC result = await window.electron.ipcRenderer.invoke( 'gateway:rpc', 'chat.send', { sessionKey: currentSessionKey, message: trimmed, deliver: false, idempotencyKey, }, ) as { success: boolean; result?: { runId?: string }; error?: string }; } console.log(`[sendMessage] RPC result: success=${result.success}, error=${result.error || 'none'}, runId=${result.result?.runId || 'none'}`); if (!result.success) { set({ error: result.error || 'Failed to send message', sending: false }); } else if (result.result?.runId) { set({ activeRunId: result.result.runId }); } else { // No runId from gateway; keep sending state and wait for events. } } catch (err) { set({ error: String(err), sending: false }); } }, // ── Abort active run ── abortRun: async () => { const { currentSessionKey } = get(); set({ sending: false, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null }); set({ streamingTools: [] }); try { await window.electron.ipcRenderer.invoke( 'gateway:rpc', 'chat.abort', { sessionKey: currentSessionKey }, ); } catch (err) { set({ error: String(err) }); } }, // ── Handle incoming chat events from Gateway ── handleChatEvent: (event: Record) => { const runId = String(event.runId || ''); const eventState = String(event.state || ''); const { activeRunId } = get(); // Only process events for the active run (or if no active run set) if (activeRunId && runId && runId !== activeRunId) return; switch (eventState) { case 'delta': { // Streaming update - store the cumulative message const updates = collectToolUpdates(event.message, eventState); set((s) => ({ streamingMessage: (() => { if (event.message && typeof event.message === 'object') { const msgRole = (event.message as RawMessage).role; if (isToolResultRole(msgRole)) return s.streamingMessage; } return event.message ?? s.streamingMessage; })(), streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, })); break; } case 'final': { // Message complete - add to history and clear streaming const finalMsg = event.message as RawMessage | undefined; if (finalMsg) { const updates = collectToolUpdates(finalMsg, eventState); if (isToolResultRole(finalMsg.role)) { set((s) => ({ streamingText: '', pendingFinal: true, streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, })); break; } const toolOnly = isToolOnlyMessage(finalMsg); const hasOutput = hasNonToolAssistantContent(finalMsg); const msgId = finalMsg.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`); set((s) => { const nextTools = updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools; const streamingTools = hasOutput ? [] : nextTools; // Check if message already exists (prevent duplicates) const alreadyExists = s.messages.some(m => m.id === msgId); if (alreadyExists) { // Just clear streaming state, don't add duplicate return toolOnly ? { streamingText: '', streamingMessage: null, pendingFinal: true, streamingTools, } : { streamingText: '', streamingMessage: null, sending: hasOutput ? false : s.sending, activeRunId: hasOutput ? null : s.activeRunId, pendingFinal: hasOutput ? false : true, streamingTools, }; } return toolOnly ? { messages: [...s.messages, { ...finalMsg, role: finalMsg.role || 'assistant', id: msgId, }], streamingText: '', streamingMessage: null, pendingFinal: true, streamingTools, } : { messages: [...s.messages, { ...finalMsg, role: finalMsg.role || 'assistant', id: msgId, }], streamingText: '', streamingMessage: null, sending: hasOutput ? false : s.sending, activeRunId: hasOutput ? null : s.activeRunId, pendingFinal: hasOutput ? false : true, streamingTools, }; }); } else { // No message in final event - reload history to get complete data set({ streamingText: '', streamingMessage: null, pendingFinal: true }); get().loadHistory(); } break; } case 'error': { const errorMsg = String(event.errorMessage || 'An error occurred'); set({ error: errorMsg, sending: false, activeRunId: null, streamingText: '', streamingMessage: null, streamingTools: [], pendingFinal: false, lastUserMessageAt: null, }); break; } case 'aborted': { set({ sending: false, activeRunId: null, streamingText: '', streamingMessage: null, streamingTools: [], pendingFinal: false, lastUserMessageAt: null, }); break; } } }, // ── Toggle thinking visibility ── toggleThinking: () => set((s) => ({ showThinking: !s.showThinking })), // ── Refresh: reload history + sessions ── refresh: async () => { const { loadHistory, loadSessions } = get(); await Promise.all([loadHistory(), loadSessions()]); }, clearError: () => set({ error: null }), }));