cc-haha/desktop/src/stores/chatStore.ts
程序员阿江(Relakkes) 174c6757f2 Drop prelaunch goal transcript compatibility
The /goal feature has not shipped, so the desktop and CLI paths should only understand the current set/clear protocol instead of carrying transitional transcript formats. This removes Goal created/replaced and old status-block parsing from hydration, WebSocket forwarding, and desktop history mapping.

Constraint: Feature is still pre-release, so historical Goal created/replaced transcript compatibility is unnecessary.

Rejected: Keep old status-block hydration | it preserves a surface that users never received and makes the simplified /goal protocol harder to reason about.

Confidence: high

Scope-risk: narrow

Directive: Do not re-add legacy /goal status-block parsing unless a shipped transcript format requires it.

Tested: bun test src/commands/goal/goal.test.tsx src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts src/server/__tests__/ws-memory-events.test.ts src/server/__tests__/sessions.test.ts src/server/__tests__/conversations.test.ts

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/composerUtils.test.ts src/__tests__/pages.test.tsx
2026-05-16 12:39:27 +08:00

1632 lines
57 KiB
TypeScript

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<string, AgentTaskNotification>
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | 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<string, PerSessionState>
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<string, unknown>
},
) => 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<void>
reloadHistory: (sessionId: string) => Promise<void>
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<string, Set<string>>()
function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void {
const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set<string>()
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<string, string>()
const flushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
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<string, PerSessionState>,
sessionId: string,
updater: (s: PerSessionState) => Partial<PerSessionState>,
): Record<string, PerSessionState> {
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<ChatStore>((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<PerSessionState>) => {
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<string, unknown>
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 = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const GOAL_EVENT_ACTIONS = new Set<GoalEventAction>([
'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('<teammate-message') && text.includes('</teammate-message>')
}
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<string, unknown>
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(/<task-notification>\s*[\s\S]*?<\/task-notification>/i)?.[0] ?? null
}
function decodeXmlText(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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<Extract<UIMessage, { type: 'goal_event' }>, '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<string, unknown>
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<Extract<UIMessage, { type: 'goal_event' }>, '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 current
}
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<ActiveGoalState | null>((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 formatVisibleLocalCommand(command: { name: string; args: string }): string {
const normalizedName = command.name.replace(/^\//, '')
const args = command.args.trim()
return `/${normalizedName}${args ? ` ${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<Extract<UIMessage, { type: 'goal_event' }>, 'id' | 'type' | 'timestamp'> | null {
if (command && command.name !== 'goal') return null
const trimmed = output.trim()
if (!trimmed) return null
if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) return { action: 'cleared', message: trimmed }
if (trimmed === 'Goal marked complete.') return { action: 'completed', message: trimmed }
if (trimmed === 'No active goal.') return { action: 'message', message: trimmed }
if (trimmed.startsWith('Goal set:')) {
const objective = trimmed.slice('Goal set:'.length).trim()
return {
action: 'created',
status: 'active',
objective: objective || undefined,
message: trimmed,
}
}
return command?.name === 'goal' ? { action: 'message', message: trimmed } : null
}
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<string, AgentTaskNotification> {
return Object.fromEntries(
notifications.map((notification) => [notification.toolUseId, notification]),
)
}
const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\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<string, unknown>
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-message>
* teammate_ids found in subsequent user messages.
*/
export function reconstructAgentNotifications(messages: MessageEntry[]): Record<string, AgentTaskNotification> {
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<string, string>()
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<string, unknown> | 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 <teammate-message> 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<string, string>()
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('<teammate-message')) continue
for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) {
if (match[1] && match[2]) {
const content = match[2].trim()
// Skip lifecycle JSON messages (shutdown, idle, terminated notifications)
if (content.startsWith('{') && content.endsWith('}')) {
try {
const parsed = JSON.parse(content) as Record<string, unknown>
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<string, AgentTaskNotification> = {}
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
if (localCommand.name === 'goal') {
uiMessages.push({
id: msg.id || nextId(),
type: 'user_text',
content: formatVisibleLocalCommand(localCommand),
timestamp,
})
}
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
}