import { create } from 'zustand' import { wsManager } from '../api/websocket' import { sessionsApi } from '../api/sessions' import { useTeamStore } from './teamStore' import { useSessionStore } from './sessionStore' import { useCLITaskStore } from './cliTaskStore' import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import { notifyDesktop } from '../lib/desktopNotifications' import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle' import { AGENT_LIFECYCLE_TYPES } from '../types/team' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' import type { RuntimeSelection } from '../types/runtime' import type { ActiveGoalState, AgentTaskNotification, AttachmentRef, ChatState, ComputerUsePermissionRequest, ComputerUsePermissionResponse, GoalEventAction, MemoryEventFile, UIAttachment, UIMessage, ServerMessage, TokenUsage, } from '../types/chat' type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' export type PerSessionState = { messages: UIMessage[] chatState: ChatState connectionState: ConnectionState streamingText: string streamingToolInput: string activeToolUseId: string | null activeToolName: string | null activeThinkingId: string | null pendingPermission: { requestId: string toolName: string toolUseId?: string input: unknown description?: string } | null pendingComputerUsePermission: { requestId: string request: ComputerUsePermissionRequest } | null tokenUsage: TokenUsage elapsedSeconds: number statusVerb: string slashCommands: Array<{ name: string; description: string; argumentHint?: string }> agentTaskNotifications: Record activeGoal?: ActiveGoalState | null elapsedTimer: ReturnType | null composerPrefill?: { text: string attachments?: UIAttachment[] nonce: number } | null } const DEFAULT_SESSION_STATE: PerSessionState = { messages: [], chatState: 'idle', connectionState: 'disconnected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, activeGoal: null, elapsedTimer: null, composerPrefill: null, } function createDefaultSessionState(): PerSessionState { return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } } } type ChatStore = { sessions: Record getSession: (sessionId: string) => PerSessionState connectToSession: (sessionId: string) => void disconnectSession: (sessionId: string) => void sendMessage: ( sessionId: string, content: string, attachments?: AttachmentRef[], options?: { displayContent?: string; displayAttachments?: AttachmentRef[] }, ) => void respondToPermission: ( sessionId: string, requestId: string, allowed: boolean, options?: { rule?: string updatedInput?: Record }, ) => void respondToComputerUsePermission: ( sessionId: string, requestId: string, response: ComputerUsePermissionResponse, ) => void setSessionRuntime: (sessionId: string, selection: RuntimeSelection) => void setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void stopGeneration: (sessionId: string) => void loadHistory: (sessionId: string) => Promise reloadHistory: (sessionId: string) => Promise queueComposerPrefill: ( sessionId: string, prefill: { text: string; attachments?: UIAttachment[] }, ) => void clearMessages: (sessionId: string) => void handleServerMessage: (sessionId: string, msg: ServerMessage) => void } const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) const pendingTaskToolUseIdsBySession = new Map>() function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void { const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set() ids.add(toolUseId) pendingTaskToolUseIdsBySession.set(sessionId, ids) } function consumePendingTaskToolUseId(sessionId: string, toolUseId: string): boolean { const ids = pendingTaskToolUseIdsBySession.get(sessionId) if (!ids?.has(toolUseId)) return false ids.delete(toolUseId) if (ids.size === 0) pendingTaskToolUseIdsBySession.delete(sessionId) return true } function clearPendingTaskToolUseIds(sessionId: string): void { pendingTaskToolUseIdsBySession.delete(sessionId) } const AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS = 160 let msgCounter = 0 const nextId = () => `msg-${++msgCounter}-${Date.now()}` // Streaming throttle for content_delta. Buffers must be per-session because // multiple desktop tabs can stream at the same time. const pendingDeltaBySession = new Map() const flushTimerBySession = new Map>() function consumePendingDelta(sessionId: string): string { const flushTimer = flushTimerBySession.get(sessionId) if (flushTimer) { clearTimeout(flushTimer) flushTimerBySession.delete(sessionId) } const text = pendingDeltaBySession.get(sessionId) ?? '' pendingDeltaBySession.delete(sessionId) return text } function appendPendingDelta(sessionId: string, text: string): void { pendingDeltaBySession.set( sessionId, `${pendingDeltaBySession.get(sessionId) ?? ''}${text}`, ) } function clearPendingDelta(sessionId: string): void { const flushTimer = flushTimerBySession.get(sessionId) if (flushTimer) { clearTimeout(flushTimer) flushTimerBySession.delete(sessionId) } pendingDeltaBySession.delete(sessionId) } function appendAssistantTextMessage( messages: UIMessage[], content: string, timestamp: number, model?: string, ): UIMessage[] { if (!content.trim()) return messages const last = messages[messages.length - 1] if (last?.type === 'assistant_text') { const merged: UIMessage = { ...last, content: last.content + content, ...(model ?? last.model ? { model: model ?? last.model } : {}), } return [...messages.slice(0, -1), merged] } return [ ...messages, { id: nextId(), type: 'assistant_text', content, timestamp, ...(model ? { model } : {}), }, ] } function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] { if (!data || typeof data !== 'object') return [] const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths if (!Array.isArray(writtenPaths)) return [] return writtenPaths .filter((path): path is string => typeof path === 'string' && path.trim().length > 0) .map((path) => ({ path, action: 'saved' as const })) } function normalizeMemoryTeamCount(data: unknown): number | undefined { if (!data || typeof data !== 'object') return undefined const teamCount = (data as { teamCount?: unknown }).teamCount return typeof teamCount === 'number' && Number.isFinite(teamCount) ? teamCount : undefined } function normalizeNotificationPreview(content: string): string { return content .replace(/```[\s\S]*?```/g, ' code block ') .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/[*_~>#-]+/g, ' ') .replace(/\s+/g, ' ') .trim() } function buildAgentCompletionNotification( sessionId: string, messages: UIMessage[], text: string, ): { title: string; body: string; dedupeKey: string } | null { const preview = normalizeNotificationPreview(text) if (!preview) return null const lastAssistant = [...messages].reverse().find((message) => message.type === 'assistant_text') const suffix = preview.length > AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS ? '...' : '' return { title: 'Claude Code Haha 已完成回复', body: preview.slice(0, AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS) + suffix, dedupeKey: `agent-completion:${sessionId}:${lastAssistant?.id ?? Date.now()}`, } } /** Helper: immutably update a specific session within the sessions record */ function updateSessionIn( sessions: Record, sessionId: string, updater: (s: PerSessionState) => Partial, ): Record { const session = sessions[sessionId] if (!session) return sessions return { ...sessions, [sessionId]: { ...session, ...updater(session) } } } async function fetchAndMapSessionHistory(sessionId: string) { const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) const uiMessages = mapHistoryMessagesToUiMessages(messages) return { rawMessages: messages, uiMessages, activeGoal: deriveActiveGoalFromMessages(uiMessages), restoredNotifications: { ...reconstructAgentNotifications(messages), ...agentNotificationRecordFromList(taskNotifications ?? []), }, lastTodos: extractLastTodoWriteFromHistory(messages), hasMessagesAfterTaskCompletion: hasUserMessagesAfterTaskCompletion(messages), } } export const useChatStore = create((set, get) => ({ sessions: {}, getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(), connectToSession: (sessionId) => { void useCLITaskStore.getState().fetchSessionTasks(sessionId) const existing = get().sessions[sessionId] if (existing && existing.connectionState !== 'disconnected') return set((s) => ({ sessions: { ...s.sessions, [sessionId]: { ...createDefaultSessionState(), connectionState: 'connecting', messages: existing?.messages ?? [], activeGoal: existing?.activeGoal ?? null, }, }, })) wsManager.clearHandlers(sessionId) wsManager.connect(sessionId) wsManager.onMessage(sessionId, (msg) => { if (msg.type === 'connected') { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) })) } get().handleServerMessage(sessionId, msg) }) const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId] if (runtimeSelection) { wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection }) } if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) { wsManager.send(sessionId, { type: 'prewarm_session' }) } get().loadHistory(sessionId) sessionsApi.getSlashCommands(sessionId) .then(({ commands }) => { if (get().sessions[sessionId]) { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) })) } }) .catch(() => { if (get().sessions[sessionId]) { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) })) } }) }, disconnectSession: (sessionId) => { const session = get().sessions[sessionId] if (session?.elapsedTimer) clearInterval(session.elapsedTimer) if (pendingDeltaBySession.has(sessionId)) { const text = consumePendingDelta(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) })) } clearPendingTaskToolUseIds(sessionId) wsManager.disconnect(sessionId) set((s) => { const { [sessionId]: _, ...rest } = s.sessions return { sessions: rest } }) }, sendMessage: (sessionId, content, attachments, options) => { const userFacingContent = options?.displayContent?.trim() || content.trim() const modelFacingContent = buildModelContent(content, attachments) const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId) const visibleAttachments = options?.displayAttachments ?? attachments const uiAttachments: UIAttachment[] | undefined = visibleAttachments && visibleAttachments.length > 0 ? visibleAttachments.map((a) => ({ type: a.type, name: a.name || a.path || a.mimeType || a.type, path: a.path, data: a.data, mimeType: a.mimeType, lineStart: a.lineStart, lineEnd: a.lineEnd, note: a.note, quote: a.quote, })) : undefined const taskStore = useCLITaskStore.getState() const sessionTasks = taskStore.sessionId === sessionId ? taskStore.tasks : [] const allTasksDone = sessionTasks.length > 0 && sessionTasks.every((t) => t.status === 'completed') const completedTaskSummary = allTasksDone ? sessionTasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })) : [] if (!isMemberSession && allTasksDone) { void taskStore.resetCompletedTasks(sessionId) } if (!isMemberSession) { updateOptimisticSessionTitle(sessionId, userFacingContent) } set((s) => { const session = s.sessions[sessionId] ?? createDefaultSessionState() const bufferedDelta = consumePendingDelta(sessionId) const pendingAssistantText = `${session.streamingText}${bufferedDelta}` const newMessages = pendingAssistantText.trim() ? appendAssistantTextMessage(session.messages, pendingAssistantText, Date.now()) : [...session.messages] if (!isMemberSession && allTasksDone) { newMessages.push({ id: nextId(), type: 'task_summary', tasks: completedTaskSummary, timestamp: Date.now(), }) } newMessages.push({ id: nextId(), type: 'user_text', content: userFacingContent, ...(userFacingContent !== modelFacingContent ? { modelContent: modelFacingContent } : {}), attachments: isMemberSession ? undefined : uiAttachments, timestamp: Date.now(), ...(isMemberSession ? { pending: true } : {}), }) if (!isMemberSession && session.elapsedTimer) clearInterval(session.elapsedTimer) const timer = !isMemberSession ? setInterval(() => { set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) })) }, 1000) : null return { sessions: { ...s.sessions, [sessionId]: { ...session, messages: newMessages, chatState: 'thinking', elapsedSeconds: 0, streamingText: '', statusVerb: isMemberSession ? '' : randomSpinnerVerb(), elapsedTimer: timer, connectionState: isMemberSession ? 'connected' : session.connectionState, }, }, } }) if (isMemberSession) { void useTeamStore.getState().sendMessageToMember(sessionId, userFacingContent) .catch((err) => { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (session) => ({ chatState: 'idle', messages: [ ...session.messages, { id: nextId(), type: 'error', message: err instanceof Error ? err.message : String(err), code: 'TEAM_MEMBER_MESSAGE_FAILED', timestamp: Date.now(), }, ], })), })) }) return } wsManager.send(sessionId, { type: 'user_message', content, attachments }) }, respondToPermission: (sessionId, requestId, allowed, options) => { wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(options?.rule ? { rule: options.rule } : {}), ...(options?.updatedInput ? { updatedInput: options.updatedInput } : {}), }) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) })) }, respondToComputerUsePermission: (sessionId, requestId, response) => { wsManager.send(sessionId, { type: 'computer_use_permission_response', requestId, response, }) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingComputerUsePermission: null, chatState: response.userConsented === false ? 'idle' : 'tool_executing', })), })) }, setSessionRuntime: (sessionId, selection) => { wsManager.send(sessionId, { type: 'set_runtime_config', ...selection, }) }, setSessionPermissionMode: (sessionId, mode) => { if (!get().sessions[sessionId]) return wsManager.send(sessionId, { type: 'set_permission_mode', mode }) }, stopGeneration: (sessionId) => { wsManager.send(sessionId, { type: 'stop_generation' }) if (pendingDeltaBySession.has(sessionId)) { const text = consumePendingDelta(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) })) } set((s) => { const session = s.sessions[sessionId] if (!session) return s if (session.elapsedTimer) clearInterval(session.elapsedTimer) return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', pendingPermission: null, pendingComputerUsePermission: null, elapsedTimer: null, }, }, } }) }, loadHistory: async (sessionId) => { try { const { uiMessages, activeGoal, restoredNotifications, lastTodos, hasMessagesAfterTaskCompletion, } = await fetchAndMapSessionHistory(sessionId) set((state) => { const session = state.sessions[sessionId] if (!session || session.messages.length > 0) return state return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ messages: uiMessages, activeGoal, agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, })) } }) if (lastTodos && lastTodos.length > 0) { const taskStore = useCLITaskStore.getState() if (taskStore.sessionId === sessionId && taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos, sessionId) } else { useCLITaskStore.getState().setTasksFromTodos([], sessionId) } if (hasMessagesAfterTaskCompletion) { useCLITaskStore.getState().markCompletedAndDismissed(sessionId) } } catch { // Session may not have messages yet } }, reloadHistory: async (sessionId) => { try { const { uiMessages, activeGoal, restoredNotifications, lastTodos, hasMessagesAfterTaskCompletion, } = await fetchAndMapSessionHistory(sessionId) set((state) => { const session = state.sessions[sessionId] if (!session) return state if (session.elapsedTimer) clearInterval(session.elapsedTimer) return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages, activeGoal, agentTaskNotifications: restoredNotifications, chatState: 'idle', activeThinkingId: null, activeToolUseId: null, activeToolName: null, streamingText: '', streamingToolInput: '', pendingPermission: null, pendingComputerUsePermission: null, elapsedTimer: null, statusVerb: '', })), } }) if (lastTodos && lastTodos.length > 0) { useCLITaskStore.getState().setTasksFromTodos(lastTodos, sessionId) } else { useCLITaskStore.getState().setTasksFromTodos([], sessionId) } if (hasMessagesAfterTaskCompletion) { useCLITaskStore.getState().markCompletedAndDismissed(sessionId) } } catch { // Session may not have messages yet } }, queueComposerPrefill: (sessionId, prefill) => { set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ composerPrefill: { text: prefill.text, attachments: prefill.attachments, nonce: Date.now(), }, })), })) }, clearMessages: (sessionId) => { clearPendingTaskToolUseIds(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, streamingText: '', chatState: 'idle' })) })) }, handleServerMessage: (sessionId, msg) => { const update = (updater: (session: PerSessionState) => Partial) => { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) })) } switch (msg.type) { case 'connected': break case 'status': update((session) => { const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}` const hasPendingStreamText = session.chatState === 'streaming' && pendingText.trim().length > 0 // Background task progress can arrive while the assistant is still // streaming one markdown reply. Keep that turn intact so we do not // split formatting markers (for example backticks/strong markers) // across separate bubbles. const preserveStreamingTurn = hasPendingStreamText && msg.state !== 'idle' const shouldFlush = hasPendingStreamText && msg.state === 'idle' return { chatState: preserveStreamingTurn ? 'streaming' : msg.state, ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}), ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), ...(shouldFlush ? { messages: appendAssistantTextMessage(session.messages, pendingText, Date.now()), streamingText: '', } : pendingText !== session.streamingText ? { streamingText: pendingText } : {}), } }) if (msg.state === 'idle') { const session = get().sessions[sessionId] if (session?.elapsedTimer) { clearInterval(session.elapsedTimer) update(() => ({ elapsedTimer: null })) } } // Sync tab status useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running') break case 'content_start': { const session = get().sessions[sessionId] if (!session) break const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}` if (msg.blockType !== 'text' && pendingText.trim()) { update((s) => ({ messages: appendAssistantTextMessage(s.messages, pendingText, Date.now()), streamingText: '', })) } if (msg.blockType === 'text') { update((s) => ({ ...(pendingText !== s.streamingText ? { streamingText: pendingText } : {}), chatState: 'streaming', activeThinkingId: null, })) } else if (msg.blockType === 'tool_use') { update(() => ({ activeToolUseId: msg.toolUseId ?? null, activeToolName: msg.toolName ?? null, streamingToolInput: '', chatState: 'tool_executing', activeThinkingId: null, })) } break } case 'content_delta': if (msg.text !== undefined) { if (!get().sessions[sessionId]) break appendPendingDelta(sessionId, msg.text) if (!flushTimerBySession.has(sessionId)) { const timer = setTimeout(() => { const text = pendingDeltaBySession.get(sessionId) ?? '' pendingDeltaBySession.delete(sessionId) flushTimerBySession.delete(sessionId) update((s) => ({ streamingText: s.streamingText + text })) }, 50) flushTimerBySession.set(sessionId, timer) } } if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) break case 'thinking': update((s) => { const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}` const base = pendingText.trim() ? appendAssistantTextMessage(s.messages, pendingText, Date.now()) : s.messages const last = base[base.length - 1] if (last && last.type === 'thinking') { const updated = [...base] updated[updated.length - 1] = { ...last, content: last.content + msg.text } return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } } const id = nextId() return { messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], chatState: 'thinking', activeThinkingId: id, streamingText: '', } }) break case 'tool_use_complete': { const session = get().sessions[sessionId] const toolName = msg.toolName || session?.activeToolName || 'unknown' update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'tool_use', toolName, toolUseId: msg.toolUseId || s.activeToolUseId || '', input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '', })) if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos, sessionId) } else if (TASK_TOOL_NAMES.has(toolName)) { const useId = msg.toolUseId || session?.activeToolUseId if (useId) addPendingTaskToolUseId(sessionId, useId) } break } case 'tool_result': update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId, content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], chatState: 'thinking', activeThinkingId: null, })) if (consumePendingTaskToolUseId(sessionId, msg.toolUseId)) { useCLITaskStore.getState().refreshTasks(sessionId) } break case 'permission_request': notifyDesktop({ dedupeKey: `permission:${msg.requestId}`, cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.toolName ? `${msg.toolName} 请求执行,正在等待允许。` : '有一个工具请求正在等待允许。', target: { type: 'session', sessionId }, }) update((s) => ({ pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, toolUseId: msg.toolUseId, input: msg.input, description: msg.description, }, pendingComputerUsePermission: null, chatState: 'permission_pending', activeThinkingId: null, messages: msg.toolName === 'AskUserQuestion' ? s.messages : [...s.messages, { id: nextId(), type: 'permission_request', requestId: msg.requestId, toolName: msg.toolName, toolUseId: msg.toolUseId, input: msg.input, description: msg.description, timestamp: Date.now(), }], })) break case 'computer_use_permission_request': notifyDesktop({ dedupeKey: `computer-use-permission:${msg.requestId}`, cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.request.reason || 'Computer Use 正在等待允许。', target: { type: 'session', sessionId }, }) update(() => ({ pendingComputerUsePermission: { requestId: msg.requestId, request: msg.request, }, pendingPermission: null, chatState: 'permission_pending', activeThinkingId: null, })) break case 'message_complete': { const session = get().sessions[sessionId] if (!session) break const wasAgentRunning = session.chatState !== 'idle' const text = `${session.streamingText}${consumePendingDelta(sessionId)}` let completionMessages = session.messages if (text.trim()) { completionMessages = appendAssistantTextMessage(session.messages, text, Date.now()) update(() => ({ messages: completionMessages, streamingText: '', })) } else if (text !== session.streamingText) { update(() => ({ streamingText: text })) } if (session.elapsedTimer) clearInterval(session.elapsedTimer) update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, elapsedTimer: null, })) const notification = wasAgentRunning ? buildAgentCompletionNotification(sessionId, completionMessages, text) : null if (notification) { void notifyDesktop({ dedupeKey: notification.dedupeKey, cooldownScope: 'agent-completion', title: notification.title, body: notification.body, target: { type: 'session', sessionId }, }) } break } case 'error': update((s) => { const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}` let newMessages = s.messages if (pendingText.trim()) { newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now()) } newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }] return { messages: newMessages, chatState: 'idle', activeThinkingId: null, streamingText: '', pendingPermission: null, pendingComputerUsePermission: null, } }) useTabStore.getState().updateTabStatus(sessionId, 'error') { const session = get().sessions[sessionId] if (session?.elapsedTimer) { clearInterval(session.elapsedTimer) update(() => ({ elapsedTimer: null })) } } break case 'team_created': useTeamStore.getState().handleTeamCreated(msg.teamName) break case 'team_update': useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members) break case 'team_deleted': useTeamStore.getState().handleTeamDeleted(msg.teamName) break case 'task_update': break case 'session_title_updated': useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) useTabStore.getState().updateTabTitle(msg.sessionId, msg.title) break case 'system_notification': if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string; argumentHint?: string }> })) } if (msg.subtype === 'session_cleared') { const session = get().sessions[sessionId] if (session?.elapsedTimer) clearInterval(session.elapsedTimer) update(() => ({ messages: [], streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, chatState: 'idle', elapsedTimer: null, elapsedSeconds: 0, statusVerb: '', tokenUsage: { input_tokens: 0, output_tokens: 0 }, slashCommands: [], activeGoal: null, })) clearPendingDelta(sessionId) clearPendingTaskToolUseIds(sessionId) useCLITaskStore.getState().clearTasks(sessionId) useSessionStore.getState().updateSessionTitle(sessionId, 'New Session') useTabStore.getState().updateTabTitle(sessionId, 'New Session') useTabStore.getState().updateTabStatus(sessionId, 'idle') } if (msg.subtype === 'compact_boundary') { update((session) => ({ messages: [ ...session.messages, { id: nextId(), type: 'system', content: typeof msg.message === 'string' && msg.message.trim() ? msg.message : 'Context compacted', timestamp: Date.now(), }, ], })) } if (msg.subtype === 'memory_saved') { const files = normalizeMemoryEventFiles(msg.data) if (files.length > 0) { update((session) => ({ messages: [ ...session.messages, { id: nextId(), type: 'memory_event', event: 'saved', files, message: msg.message, teamCount: normalizeMemoryTeamCount(msg.data), timestamp: Date.now(), }, ], })) } } if (msg.subtype === 'goal_event') { const goalEvent = normalizeGoalEventData(msg.data, msg.message) if (goalEvent) { update((session) => ({ activeGoal: applyGoalEventToActiveGoal(session.activeGoal ?? null, goalEvent, Date.now()), messages: [ ...session.messages, { id: nextId(), type: 'goal_event', ...goalEvent, timestamp: Date.now(), }, ], })) } } if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') { const data = msg.data as Record const toolUseId = typeof data.tool_use_id === 'string' && data.tool_use_id.trim() ? data.tool_use_id : null const taskStatus = data.status if ( toolUseId && (taskStatus === 'completed' || taskStatus === 'failed' || taskStatus === 'stopped') ) { update((session) => ({ agentTaskNotifications: { ...session.agentTaskNotifications, [toolUseId]: { taskId: typeof data.task_id === 'string' && data.task_id.trim() ? data.task_id : toolUseId, toolUseId, status: taskStatus, summary: typeof data.summary === 'string' && data.summary.trim() ? data.summary : undefined, outputFile: typeof data.output_file === 'string' && data.output_file.trim() ? data.output_file : undefined, }, }, })) } } break case 'pong': break } }, })) function updateOptimisticSessionTitle(sessionId: string, content: string): void { const title = deriveSessionTitle(content) if (!title) return const session = useSessionStore.getState().sessions.find((item) => item.id === sessionId) if (!session || session.messageCount > 0 || !isPlaceholderSessionTitle(session.title)) return useSessionStore.getState().updateSessionTitle(sessionId, title) useTabStore.getState().updateTabTitle(sessionId, title) } // ─── History mapping helpers (unchanged from original) ───────── type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown } type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i const GOAL_CLEAR_ALIASES = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel']) const GOAL_EVENT_ACTIONS = new Set([ 'created', 'replaced', 'status', 'paused', 'resumed', 'completed', 'cleared', 'message', ]) /** * Check if text is a teammate-message (internal agent-to-agent communication). * Uses full open+close tag match to avoid false positives on user text * that merely mentions the tag name (e.g., pasting code or discussing the protocol). */ function isTeammateMessage(text: string): boolean { return text.includes('') } function extractHistoryTextBlocks(content: unknown): string[] { if (typeof content === 'string') return [content] if (!Array.isArray(content)) return [] return content .flatMap((block) => { if (!block || typeof block !== 'object') return [] const record = block as Record return record.type === 'text' && typeof record.text === 'string' ? [record.text] : [] }) .map((text) => text.trim()) .filter(Boolean) } function isTaskNotificationContent(content: unknown): boolean { const textBlocks = extractHistoryTextBlocks(content) return textBlocks.length > 0 && textBlocks.every((text) => extractTaskNotificationXml(text) !== null) } function extractTaskNotificationXml(text: string): string | null { const trimmed = text.trim() if (TASK_NOTIFICATION_RE.test(trimmed)) return trimmed return trimmed.match(/\s*[\s\S]*?<\/task-notification>/i)?.[0] ?? null } function decodeXmlText(text: string): string { return text .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') } function readXmlTag(xml: string, tag: string): string | undefined { const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i')) return match?.[1] ? decodeXmlText(match[1].trim()) : undefined } function normalizeGoalEventData( data: unknown, fallbackMessage?: string, ): Omit, 'id' | 'type' | 'timestamp'> | null { if (!data || typeof data !== 'object') { const message = typeof fallbackMessage === 'string' ? fallbackMessage.trim() : '' return message ? { action: 'message', message } : null } const record = data as Record const action = typeof record.action === 'string' && GOAL_EVENT_ACTIONS.has(record.action as GoalEventAction) ? record.action as GoalEventAction : 'message' const read = (key: string) => typeof record[key] === 'string' && record[key].trim() ? record[key].trim() : undefined return { action, status: read('status'), objective: read('objective'), budget: read('budget'), elapsed: read('elapsed'), continuations: read('continuations'), message: read('message') ?? (typeof fallbackMessage === 'string' ? fallbackMessage.trim() : undefined), } } function applyGoalEventToActiveGoal( current: ActiveGoalState | null, event: Omit, 'id' | 'type' | 'timestamp'>, updatedAt: number, ): ActiveGoalState | null { if (event.action === 'cleared') return null if ( event.action === 'message' && event.message && /no (active )?goal/i.test(event.message) ) { return null } if (event.action === 'message') return current const baseGoal = event.action === 'created' || event.action === 'replaced' ? null : current return { action: event.action, status: event.status ?? (event.action === 'completed' ? 'complete' : baseGoal?.status), objective: event.objective ?? baseGoal?.objective, budget: event.budget ?? baseGoal?.budget, elapsed: event.elapsed ?? baseGoal?.elapsed, continuations: event.continuations ?? baseGoal?.continuations, message: event.message ?? baseGoal?.message, updatedAt, } } function deriveActiveGoalFromMessages(messages: UIMessage[]): ActiveGoalState | null { return messages.reduce((activeGoal, message) => { if (message.type !== 'goal_event') return activeGoal return applyGoalEventToActiveGoal(activeGoal, message, message.timestamp) }, null) } function extractLocalCommandText(content: unknown): string | null { if (typeof content !== 'string') return null return content.trim() || null } function parseGoalCommandFromLocalCommand(content: unknown): { name: string; args: string } | null { const text = extractLocalCommandText(content) if (!text) return null const commandName = readXmlTag(text, 'command-name') if (!commandName) return null return { name: commandName.replace(/^\//, ''), args: readXmlTag(text, 'command-args') ?? '', } } function extractLocalCommandOutputText(content: unknown): string | null { const text = extractLocalCommandText(content) if (!text) return null return readXmlTag(text, 'local-command-stdout') ?? readXmlTag(text, 'local-command-stderr') ?? null } function parseGoalEventFromLocalCommandOutput( output: string, command: { name: string; args: string } | null, ): Omit, 'id' | 'type' | 'timestamp'> | null { if (command && command.name !== 'goal') return null const trimmed = output.trim() if (!trimmed) return null if (trimmed === 'Goal cleared.') return { action: 'cleared', message: trimmed } if (trimmed === 'Goal marked complete.') return { action: 'completed', message: trimmed } if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return { action: 'message', message: trimmed } const actionPrefix = trimmed.startsWith('Goal replaced.\n') ? 'replaced' : trimmed.startsWith('Goal created.\n') ? 'created' : null const body = actionPrefix ? trimmed.split(/\r?\n/).slice(1).join('\n').trim() : trimmed const fields = Object.fromEntries( body .split(/\r?\n/) .map((line) => { const index = line.indexOf(':') if (index < 0) return null return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()] }) .filter((entry): entry is [string, string] => entry !== null), ) if (!fields.goal) return command?.name === 'goal' ? { action: 'message', message: trimmed } : null return { action: actionPrefix ?? resolveHistoryGoalAction(command, fields.goal), status: fields.goal, objective: fields.objective, budget: fields.budget, elapsed: fields.elapsed, continuations: fields.continuations, message: trimmed, } } function resolveHistoryGoalAction( command: { name: string; args: string } | null, status: string, ): GoalEventAction { const args = command?.args.trim() ?? '' if (args === 'pause') return 'paused' if (args === 'resume') return 'resumed' if (GOAL_CLEAR_ALIASES.has(args)) return 'cleared' if (args === 'complete') return 'completed' if (!args || args === 'status') return 'status' if (status === 'active') return 'created' if (status === 'paused') return 'paused' if (status === 'complete') return 'completed' return 'status' } function extractTaskNotification(content: unknown): AgentTaskNotification | null { const xml = extractHistoryTextBlocks(content) .map((text) => extractTaskNotificationXml(text)) .find((value): value is string => value !== null) if (!xml) return null const toolUseId = readXmlTag(xml, 'tool-use-id') const status = readXmlTag(xml, 'status') if ( !toolUseId || (status !== 'completed' && status !== 'failed' && status !== 'stopped') ) { return null } const taskId = readXmlTag(xml, 'task-id') || toolUseId const summary = readXmlTag(xml, 'summary') const outputFile = readXmlTag(xml, 'output-file') return { taskId, toolUseId, status, ...(summary ? { summary } : {}), ...(outputFile ? { outputFile } : {}), } } function agentNotificationRecordFromList( notifications: AgentTaskNotification[], ): Record { return Object.fromEntries( notifications.map((notification) => [notification.toolUseId, notification]), ) } const TEAMMATE_CONTENT_REGEX = /]*>\n?([\s\S]*?)\n?<\/teammate-message>/g function extractVisibleTeammateMessageContents(text: string): string[] { const contents: string[] = [] for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) { const content = match[2]?.trim() if (!content) continue if (content.startsWith('{') && content.endsWith('}')) { try { const parsed = JSON.parse(content) as Record if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) { continue } } catch { // Keep non-JSON payloads that happen to look like JSON. } } contents.push(content) } return contents } function pushAssistantHistoryText( messages: UIMessage[], content: string, timestamp: number, model?: string, ): void { if (!content.trim()) return const last = messages[messages.length - 1] if (last?.type === 'assistant_text') { last.content += content if (model && !last.model) last.model = model return } messages.push({ id: nextId(), type: 'assistant_text', content, timestamp, ...(model ? { model } : {}), }) } type HistoryMappingOptions = { includeTeammateMessages?: boolean } function buildModelContent(content: string, attachments?: AttachmentRef[]): string { const paths = attachments ?.map((attachment) => attachment.path) .filter((path): path is string => typeof path === 'string' && path.length > 0) ?? [] const trimmed = content.trim() if (paths.length === 0) return trimmed const prefix = paths.map((path) => `@"${path}"`).join(' ') return `${prefix} ${trimmed || 'Please analyze the attached files.'}`.trim() } function getReferenceName(referencePath: string): string { const normalized = referencePath.replace(/\\/g, '/').replace(/\/+$/, '') const name = normalized.split('/').filter(Boolean).pop() return name || referencePath } function extractLeadingFileReferences(text: string): { content: string attachments?: UIAttachment[] modelContent?: string } { const attachments: UIAttachment[] = [] let remaining = text while (true) { const match = remaining.match(/^@"([^"]+)"\s*/) if (!match?.[1]) break attachments.push({ type: 'file', name: getReferenceName(match[1]), path: match[1], }) remaining = remaining.slice(match[0].length) } if (attachments.length === 0) { return { content: text } } return { content: remaining.trimStart(), attachments, modelContent: text, } } /** * Reconstruct agentTaskNotifications from history. * * During a live session, background agents report completion via system_notification * events (task_notification). These are NOT persisted in JSONL history. On reload, * we reconstruct them by correlating Agent tool_use names with * teammate_ids found in subsequent user messages. */ export function reconstructAgentNotifications(messages: MessageEntry[]): Record { const taskNotifications = messages .filter((message) => message.type === 'user') .map((message) => extractTaskNotification(message.content)) .filter((notification): notification is AgentTaskNotification => notification !== null) // Step 1: Collect Agent tool_use blocks → map agent name to toolUseId const agentNameToToolUseId = new Map() for (const msg of messages) { if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { for (const block of msg.content as AssistantHistoryBlock[]) { if (block.type === 'tool_use' && block.name === 'Agent' && block.id) { const input = block.input as Record | undefined const name = input?.name as string | undefined // Keep first toolUseId per name (consistent with first-wins for teammateContent) if (name && !agentNameToToolUseId.has(name)) agentNameToToolUseId.set(name, block.id) } } } } if (agentNameToToolUseId.size === 0) { return agentNotificationRecordFromList(taskNotifications) } // Step 2: Extract content by teammate_id // Skip lifecycle messages (shutdown_approved, idle_notification, etc.) // which overwrite actual review content if stored later in history const teammateContent = new Map() for (const msg of messages) { if (msg.type !== 'user') continue const text = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? (msg.content as Array<{ type?: string; text?: string }>).filter((b) => b.type === 'text' && b.text).map((b) => b.text).join('\n') : '' if (!text.includes(' if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) continue } catch { /* not JSON, keep it */ } } // Only store the first meaningful content per teammate (avoid overwrite by later lifecycle msgs) if (!teammateContent.has(match[1])) { teammateContent.set(match[1], content) } } } } // Step 3: Correlate and build notifications const notifications: Record = {} for (const [name, toolUseId] of agentNameToToolUseId) { const content = teammateContent.get(name) if (content) { notifications[toolUseId] = { taskId: toolUseId, toolUseId, status: 'completed', summary: content, } } } for (const notification of taskNotifications) { notifications[notification.toolUseId] = notification } return notifications } export function mapHistoryMessagesToUiMessages( messages: MessageEntry[], options?: HistoryMappingOptions, ): UIMessage[] { const includeTeammateMessages = options?.includeTeammateMessages === true const uiMessages: UIMessage[] = [] let suppressTaskNotificationResponse = false let pendingGoalCommand: { name: string; args: string } | null = null for (const msg of messages) { if (msg.type === 'user' && isTaskNotificationContent(msg.content)) { suppressTaskNotificationResponse = true continue } if (msg.type === 'user') { suppressTaskNotificationResponse = false } else if (suppressTaskNotificationResponse) { continue } const timestamp = new Date(msg.timestamp).getTime() if (msg.type === 'system' && typeof msg.content === 'string') { const localCommand = parseGoalCommandFromLocalCommand(msg.content) if (localCommand) { pendingGoalCommand = localCommand continue } const localCommandOutput = extractLocalCommandOutputText(msg.content) if (localCommandOutput) { const goalEvent = parseGoalEventFromLocalCommandOutput(localCommandOutput, pendingGoalCommand) pendingGoalCommand = null if (goalEvent) { uiMessages.push({ id: msg.id || nextId(), type: 'goal_event', ...goalEvent, timestamp, }) } continue } } if (msg.type === 'user' && typeof msg.content === 'string') { if (isTeammateMessage(msg.content)) { if (!includeTeammateMessages) continue const teammateContents = extractVisibleTeammateMessageContents(msg.content) if (teammateContents.length === 0) continue uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: teammateContents.join('\n\n'), timestamp, }) continue } const parsed = extractLeadingFileReferences(msg.content) uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: parsed.content, ...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}), ...(parsed.attachments ? { attachments: parsed.attachments } : {}), timestamp, }) continue } if (msg.type === 'assistant' && typeof msg.content === 'string') { if (!msg.content.trim()) continue uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model }) continue } if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { for (const block of msg.content as AssistantHistoryBlock[]) { if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp }) else if (block.type === 'text' && block.text) pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model) else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId }) } continue } if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) { const textParts: string[] = [] const attachments: UIAttachment[] = [] for (const block of msg.content as UserHistoryBlock[]) { if (block.type === 'text' && block.text && isTeammateMessage(block.text)) { if (!includeTeammateMessages) continue textParts.push(...extractVisibleTeammateMessageContents(block.text)) } else if (block.type === 'text' && block.text) { textParts.push(block.text) } else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type }) else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' }) else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId }) } if (textParts.length > 0 || attachments.length > 0) { const parsed = extractLeadingFileReferences(textParts.join('\n')) const allAttachments = [...(parsed.attachments ?? []), ...attachments] uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: parsed.content, ...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}), attachments: allAttachments.length > 0 ? allAttachments : undefined, timestamp, }) } } if (msg.type === 'system' && msg.content && typeof msg.content === 'object') { const subtype = (msg.content as { subtype?: unknown }).subtype if (subtype === 'memory_saved') { const files = normalizeMemoryEventFiles(msg.content) if (files.length > 0) { uiMessages.push({ id: msg.id || nextId(), type: 'memory_event', event: 'saved', files, message: typeof (msg.content as { message?: unknown }).message === 'string' ? (msg.content as { message: string }).message : undefined, teamCount: normalizeMemoryTeamCount(msg.content), timestamp, }) } } } } return uiMessages } function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null { let foundIndex = -1 let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { const blocks = msg.content as AssistantHistoryBlock[] for (let j = blocks.length - 1; j >= 0; j--) { const block = blocks[j]! if (block.type === 'tool_use' && block.name === 'TodoWrite') { const input = block.input as { todos?: unknown } | undefined if (input && Array.isArray(input.todos)) { todos = input.todos as Array<{ content: string; status: string; activeForm?: string }> foundIndex = i break } } } if (todos) break } } if (!todos) return null const allDone = todos.every((t) => t.status === 'completed') if (allDone) { for (let i = foundIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user' && messages[i]!.content) return null } } return todos } const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean { let lastTaskIndex = -1 for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { const blocks = msg.content as AssistantHistoryBlock[] if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break } } } if (lastTaskIndex < 0) return false for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true } return false }