diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index ba9ef9de..f389d5ee 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -11,7 +11,7 @@ import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' -type ChatStore = { +export type PerSessionState = { messages: UIMessage[] chatState: ChatState connectionState: ConnectionState @@ -29,31 +29,11 @@ type ChatStore = { tokenUsage: TokenUsage elapsedSeconds: number statusVerb: string - connectedSessionId: string | null slashCommands: Array<{ name: string; description: string }> - - // Actions - connectToSession: (sessionId: string) => void - disconnectSession: () => void - sendMessage: (content: string, attachments?: AttachmentRef[]) => void - respondToPermission: (requestId: string, allowed: boolean, rule?: string) => void - setSessionPermissionMode: (mode: PermissionMode) => void - stopGeneration: () => void - loadHistory: (sessionId: string) => Promise - clearMessages: () => void - handleServerMessage: (msg: ServerMessage) => void + elapsedTimer: ReturnType | null } -const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) - -/** Track tool_use IDs for task-related tools, so we can refresh on tool_result */ -const pendingTaskToolUseIds = new Set() - -let msgCounter = 0 -const nextId = () => `msg-${++msgCounter}-${Date.now()}` -let elapsedTimer: ReturnType | null = null - -export const useChatStore = create((set, get) => ({ +const DEFAULT_SESSION_STATE: PerSessionState = { messages: [], chatState: 'idle', connectionState: 'disconnected', @@ -66,97 +46,127 @@ export const useChatStore = create((set, get) => ({ tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', - connectedSessionId: null, slashCommands: [], + elapsedTimer: null, +} - connectToSession: (sessionId: string) => { - const current = get().connectedSessionId - if (current === sessionId) return +function createDefaultSessionState(): PerSessionState { + return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } } +} - // Disconnect previous - if (current) wsManager.disconnect() +type ChatStore = { + sessions: Record - set({ - connectedSessionId: sessionId, - connectionState: 'connecting', - messages: [], - chatState: 'idle', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - elapsedSeconds: 0, - slashCommands: [], - }) + getSession: (sessionId: string) => PerSessionState + connectToSession: (sessionId: string) => void + disconnectSession: (sessionId: string) => void + sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void + respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void + setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void + stopGeneration: (sessionId: string) => void + loadHistory: (sessionId: string) => Promise + clearMessages: (sessionId: string) => void + handleServerMessage: (sessionId: string, msg: ServerMessage) => void +} - // Clear all previous handlers before registering new ones - // This prevents handler accumulation causing message duplication - wsManager.clearHandlers() +const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) +const pendingTaskToolUseIds = new Set() + +let msgCounter = 0 +const nextId = () => `msg-${++msgCounter}-${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) } } +} + +export const useChatStore = create((set, get) => ({ + sessions: {}, + + getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(), + + connectToSession: (sessionId) => { + const existing = get().sessions[sessionId] + if (existing && existing.connectionState !== 'disconnected') return + + set((s) => ({ + sessions: { + ...s.sessions, + [sessionId]: { + ...createDefaultSessionState(), + connectionState: 'connecting', + messages: existing?.messages ?? [], + }, + }, + })) + + wsManager.clearHandlers(sessionId) wsManager.connect(sessionId) - wsManager.onMessage((msg) => { + wsManager.onMessage(sessionId, (msg) => { if (msg.type === 'connected') { - set({ connectionState: 'connected' }) + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) })) } - get().handleServerMessage(msg) + get().handleServerMessage(sessionId, msg) }) - // Load history and tasks get().loadHistory(sessionId) useCLITaskStore.getState().fetchSessionTasks(sessionId) sessionsApi.getSlashCommands(sessionId) .then(({ commands }) => { - if (get().connectedSessionId === sessionId) { - set({ slashCommands: commands }) + if (get().sessions[sessionId]) { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) })) } }) .catch(() => { - if (get().connectedSessionId === sessionId) { - set({ slashCommands: [] }) + if (get().sessions[sessionId]) { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) })) } }) }, - disconnectSession: () => { - wsManager.disconnect() - if (elapsedTimer) clearInterval(elapsedTimer) - useCLITaskStore.getState().clearTasks() - set({ connectedSessionId: null, chatState: 'idle' }) + disconnectSession: (sessionId) => { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) clearInterval(session.elapsedTimer) + wsManager.disconnect(sessionId) + set((s) => { + const { [sessionId]: _, ...rest } = s.sessions + return { sessions: rest } + }) }, - sendMessage: (content: string, attachments?: AttachmentRef[]) => { + sendMessage: (sessionId, content, attachments?) => { const userFacingContent = content.trim() const uiAttachments: UIAttachment[] | undefined = attachments && attachments.length > 0 - ? attachments.map((attachment) => ({ - type: attachment.type, - name: attachment.name || attachment.path || attachment.mimeType || attachment.type, - data: attachment.data, - mimeType: attachment.mimeType, + ? attachments.map((a) => ({ + type: a.type, + name: a.name || a.path || a.mimeType || a.type, + data: a.data, + mimeType: a.mimeType, })) : undefined - // If all tasks are completed, inline the task summary before the new user message const taskStore = useCLITaskStore.getState() const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') - // Add user message to UI (with optional task summary before it) set((s) => { - const newMessages = [...s.messages] + const session = s.sessions[sessionId] + if (!session) return s + + const newMessages = [...session.messages] if (allTasksDone) { newMessages.push({ id: nextId(), type: 'task_summary', - tasks: taskStore.tasks.map((t) => ({ - id: t.id, - subject: t.subject, - status: t.status, - activeForm: t.activeForm, - })), + tasks: taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })), timestamp: Date.now(), }) - // Clear sticky task bar since we inlined the summary taskStore.clearTasks() } newMessages.push({ @@ -166,64 +176,66 @@ export const useChatStore = create((set, get) => ({ attachments: uiAttachments, timestamp: Date.now(), }) + + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + + const timer = setInterval(() => { + set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) })) + }, 1000) + return { - messages: newMessages, - chatState: 'thinking', - elapsedSeconds: 0, - streamingText: '', - statusVerb: randomSpinnerVerb(), + sessions: { + ...s.sessions, + [sessionId]: { + ...session, + messages: newMessages, + chatState: 'thinking', + elapsedSeconds: 0, + streamingText: '', + statusVerb: randomSpinnerVerb(), + elapsedTimer: timer, + }, + }, } }) - // Start elapsed timer - if (elapsedTimer) clearInterval(elapsedTimer) - elapsedTimer = setInterval(() => { - set((s) => ({ elapsedSeconds: s.elapsedSeconds + 1 })) - }, 1000) - - wsManager.send({ type: 'user_message', content, attachments }) + wsManager.send(sessionId, { type: 'user_message', content, attachments }) }, - respondToPermission: (requestId: string, allowed: boolean, rule?: string) => { - wsManager.send({ type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) }) - set({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' }) + respondToPermission: (sessionId, requestId, allowed, rule?) => { + wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) }) + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) })) }, - setSessionPermissionMode: (mode) => { - if (!get().connectedSessionId) return - wsManager.send({ type: 'set_permission_mode', mode }) + setSessionPermissionMode: (sessionId, mode) => { + if (!get().sessions[sessionId]) return + wsManager.send(sessionId, { type: 'set_permission_mode', mode }) }, - stopGeneration: () => { - wsManager.send({ type: 'stop_generation' }) - if (elapsedTimer) clearInterval(elapsedTimer) - set({ chatState: 'idle' }) + stopGeneration: (sessionId) => { + wsManager.send(sessionId, { type: 'stop_generation' }) + 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', elapsedTimer: null } } } + }) }, - loadHistory: async (sessionId: string) => { + loadHistory: async (sessionId) => { try { const { messages } = await sessionsApi.getMessages(sessionId) const uiMessages = mapHistoryMessagesToUiMessages(messages) - set((state) => { - if (state.connectedSessionId !== sessionId || state.messages.length > 0) { - return state - } - return { ...state, messages: uiMessages } + const session = state.sessions[sessionId] + if (!session || session.messages.length > 0) return state + return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) } }) - - // Extract the last TodoWrite input from history so TaskBar shows for V1 sessions const lastTodos = extractLastTodoWriteFromHistory(messages) if (lastTodos && lastTodos.length > 0) { const taskStore = useCLITaskStore.getState() - // Only set if V2 task fetch didn't already populate tasks - if (taskStore.tasks.length === 0) { - taskStore.setTasksFromTodos(lastTodos) - } + if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos) } - - // For both V1 and V2: if all tasks completed and the user already - // continued chatting, suppress the sticky TaskBar on page refresh. if (hasUserMessagesAfterTaskCompletion(messages)) { useCLITaskStore.getState().markCompletedAndDismissed() } @@ -232,89 +244,77 @@ export const useChatStore = create((set, get) => ({ } }, - clearMessages: () => set({ messages: [], streamingText: '', chatState: 'idle' }), + clearMessages: (sessionId) => { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) })) + }, + + handleServerMessage: (sessionId, msg) => { + const update = (updater: (session: PerSessionState) => Partial) => { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) })) + } - handleServerMessage: (msg: ServerMessage) => { switch (msg.type) { case 'connected': break case 'status': - set({ + update((session) => ({ chatState: msg.state, - // Only override statusVerb if the server sends something other than - // the generic 'Thinking' — otherwise keep the random verb we picked - // in sendMessage so the user sees fun loading text. ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}), - ...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}), + ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), - }) - if (msg.state === 'idle' && elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null + })) + if (msg.state === 'idle') { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) { + clearInterval(session.elapsedTimer) + update(() => ({ elapsedTimer: null })) + } } break case 'content_start': { - // Flush any accumulated streamingText as assistant_text BEFORE - // switching to the next block. This preserves intermediate text - // segments between tool calls (e.g. "项目已经有 node_modules,直接启动"). - const pendingText = get().streamingText.trim() + const session = get().sessions[sessionId] + if (!session) break + const pendingText = session.streamingText.trim() if (pendingText) { - set((s) => ({ - messages: [...s.messages, { - id: nextId(), - type: 'assistant_text', - content: pendingText, - timestamp: Date.now(), - }], + update((s) => ({ + messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }], streamingText: '', })) } - if (msg.blockType === 'text') { - set({ streamingText: '', chatState: 'streaming', activeThinkingId: null }) + update(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null })) } else if (msg.blockType === 'tool_use') { - set({ + update(() => ({ activeToolUseId: msg.toolUseId ?? null, activeToolName: msg.toolName ?? null, streamingToolInput: '', chatState: 'tool_executing', activeThinkingId: null, - }) + })) } break } case 'content_delta': - if (msg.text !== undefined) { - set((s) => ({ streamingText: s.streamingText + msg.text })) - } - if (msg.toolInput !== undefined) { - set((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) - } + if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text })) + if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) break case 'thinking': - // Merge consecutive thinking deltas into one message. - // Also flush any pending streamingText first — otherwise the text - // becomes invisible because MessageList only renders streamingText - // when chatState === 'streaming', and we're about to set it to 'thinking'. - set((s) => { + update((s) => { const pendingText = s.streamingText.trim() const base = pendingText ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }] : s.messages - const last = base[base.length - 1] if (last && last.type === 'thinking') { - // Append to existing thinking message 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() - // Create new thinking message return { messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], chatState: 'thinking', @@ -325,49 +325,33 @@ export const useChatStore = create((set, get) => ({ break case 'tool_use_complete': { - const toolName = msg.toolName || get().activeToolName || 'unknown' - set((s) => ({ + const session = get().sessions[sessionId] + const toolName = msg.toolName || session?.activeToolName || 'unknown' + update((s) => ({ messages: [...s.messages, { - id: nextId(), - type: 'tool_use', - toolName, + id: nextId(), type: 'tool_use', toolName, toolUseId: msg.toolUseId || s.activeToolUseId || '', - input: msg.input, - timestamp: Date.now(), - parentToolUseId: msg.parentToolUseId, + input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - streamingToolInput: '', + activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '', })) - // TodoWrite: input contains the full todo list — update tasks immediately if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos) } else if (TASK_TOOL_NAMES.has(toolName)) { - // V2 task tools — refresh will happen on tool_result - // when the tool has actually finished executing and written to disk - const useId = msg.toolUseId || get().activeToolUseId + const useId = msg.toolUseId || session?.activeToolUseId if (useId) pendingTaskToolUseIds.add(useId) } break } case 'tool_result': - set((s) => ({ + 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, + id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId, + content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], - chatState: 'thinking', - activeThinkingId: null, + chatState: 'thinking', activeThinkingId: null, })) - // Refresh tasks after a task tool has finished executing if (pendingTaskToolUseIds.has(msg.toolUseId)) { pendingTaskToolUseIds.delete(msg.toolUseId) useCLITaskStore.getState().refreshTasks() @@ -375,230 +359,116 @@ export const useChatStore = create((set, get) => ({ break case 'permission_request': - set({ - pendingPermission: { - requestId: msg.requestId, - toolName: msg.toolName, - input: msg.input, - description: msg.description, - }, + update((s) => ({ + pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description }, chatState: 'permission_pending', activeThinkingId: null, - }) - set((s) => ({ messages: [...s.messages, { - id: nextId(), - type: 'permission_request', - requestId: msg.requestId, - toolName: msg.toolName, - input: msg.input, - description: msg.description, - timestamp: Date.now(), + id: nextId(), type: 'permission_request', requestId: msg.requestId, + toolName: msg.toolName, input: msg.input, description: msg.description, timestamp: Date.now(), }], })) break case 'message_complete': { - // Flush streaming text as a message - const text = get().streamingText + const session = get().sessions[sessionId] + if (!session) break + const text = session.streamingText if (text) { - set((s) => ({ + update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }], streamingText: '', })) } - set({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null }) - if (elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null - } + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null })) break } case 'error': - set((s) => ({ + update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }], - chatState: 'idle', - activeThinkingId: null, + chatState: 'idle', activeThinkingId: null, })) - if (elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null + { + 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) break - case 'system_notification': - // Cache slash commands from CLI init if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { - set({ slashCommands: msg.data as Array<{ name: string; description: string }> }) + update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> })) } break - case 'pong': break } }, })) -type AssistantHistoryBlock = { - type: string - text?: string - thinking?: string - name?: string - id?: string - input?: unknown -} +// ─── History mapping helpers (unchanged from original) ───────── -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 -} +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 } export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] { const uiMessages: UIMessage[] = [] - for (const msg of messages) { const timestamp = new Date(msg.timestamp).getTime() - if (msg.type === 'user' && typeof msg.content === 'string') { - uiMessages.push({ - id: msg.id || nextId(), - type: 'user_text', - content: msg.content, - timestamp, - }) + uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp }) continue } - if (msg.type === 'assistant' && typeof msg.content === 'string') { - uiMessages.push({ - id: msg.id || nextId(), - type: 'assistant_text', - content: msg.content, - timestamp, - model: msg.model, - }) + uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model }) continue } - - // Server marks assistant messages containing tool_use blocks as type 'tool_use', - // but the content array structure is the same as 'assistant' — handle both. 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) { - uiMessages.push({ - id: nextId(), - type: 'assistant_text', - content: block.text, - timestamp, - model: 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, - }) - } + if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp }) + else if (block.type === 'text' && block.text) uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: 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 } - - // Server marks user messages containing tool_result blocks as type 'tool_result', - // but the content array structure is the same as 'user' — handle both. 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) { - 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 (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) { - uiMessages.push({ - id: nextId(), - type: 'user_text', - content: textParts.join('\n'), - attachments: attachments.length > 0 ? attachments : undefined, - timestamp, - }) + uiMessages.push({ id: nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp }) } } } - return uiMessages } -/** Scan history messages for the last TodoWrite tool_use and return the todos array. - * Returns null if all todos were completed and the user continued chatting after. */ -function extractLastTodoWriteFromHistory( - messages: MessageEntry[], -): Array<{ content: string; status: string; activeForm?: string }> | null { +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 - - // Walk backwards to find the most recent TodoWrite 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)) { @@ -617,45 +487,28 @@ function extractLastTodoWriteFromHistory( if (todos) break } } - if (!todos) return null - - // If all todos are completed and there are user messages after, the tasks - // were already inlined — don't re-show the sticky bar. const allDone = todos.every((t) => t.status === 'completed') if (allDone) { for (let i = foundIndex + 1; i < messages.length; i++) { - const msg = messages[i]! - if (msg.type === 'user' && msg.content) return null + if (messages[i]!.type === 'user' && messages[i]!.content) return null } } - return todos } const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) -/** Check if there are user messages after the last task-related tool call. - * Used to determine if completed tasks should be shown as sticky or inline. */ function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean { - // Find the index of the last task-related tool call 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 (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break } } } - if (lastTaskIndex < 0) return false - - // Check for user messages after the last task tool - for (let i = lastTaskIndex + 1; i < messages.length; i++) { - if (messages[i]!.type === 'user') return true - } + for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true } return false }