import React from 'react'; import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectorySync, useSessionMessages, useSessionPermissions, useSessionQuestions, useSessionStatus } from '@/sync/sync-context'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { useCurrentSessionActivity } from './useSessionActivity'; type AssistantActivity = 'idle' | 'streaming' | 'tooling' | 'cooldown' | 'permission'; interface WorkingSummary { activity: AssistantActivity; hasWorkingContext: boolean; hasActiveTools: boolean; isWorking: boolean; isStreaming: boolean; isCooldown: boolean; lifecyclePhase: MessageStreamPhase | null; statusText: string | null; isGenericStatus: boolean; isWaitingForPermission: boolean; canAbort: boolean; compactionDeadline: number | null; activePartType?: 'text' | 'tool' | 'reasoning' | 'editing'; activeToolName?: string; wasAborted: boolean; abortActive: boolean; lastCompletionId: string | null; isComplete: boolean; retryInfo: { attempt?: number; next?: number } | null; } interface FormingSummary { isActive: boolean; characterCount: number; } export interface AssistantStatusSnapshot { activeModel: ActiveAssistantModel | null; forming: FormingSummary; working: WorkingSummary; } interface ActiveAssistantModel { providerId: string; modelId: string; } interface ActiveAssistantContext { assistantId: string | null; model: ActiveAssistantModel | null; } const DEFAULT_WORKING: WorkingSummary = { activity: 'idle', hasWorkingContext: false, hasActiveTools: false, isWorking: false, isStreaming: false, isCooldown: false, lifecyclePhase: null, statusText: null, isGenericStatus: true, isWaitingForPermission: false, canAbort: false, compactionDeadline: null, activePartType: undefined, activeToolName: undefined, wasAborted: false, abortActive: false, lastCompletionId: null, isComplete: false, retryInfo: null, }; const EMPTY_PARTS: Part[] = []; const STATUS_SIGNATURE_SEPARATOR = '\u0000'; const EDITING_TOOLS = new Set(['edit', 'write', 'multiedit', 'apply_patch']); const TOOL_STATUS_PHRASES: Record = { read: 'reading file', write: 'writing file', edit: 'editing file', multiedit: 'editing files', apply_patch: 'applying patch', bash: 'running command', grep: 'searching content', glob: 'finding files', list: 'listing directory', task: 'delegating task', webfetch: 'fetching URL', websearch: 'searching web', codesearch: 'web code search', todowrite: 'updating todos', todoread: 'reading todos', skill: 'learning skill', question: 'asking question', plan_enter: 'switching to planning', plan_exit: 'switching to building', }; const WORKING_PHRASES = [ 'working', 'processing', 'preparing', 'warming up', 'gears turning', 'computing', 'calculating', 'analyzing', 'wheels spinning', 'calibrating', 'synthesizing', 'connecting dots', 'inspecting logic', 'weighing options', ]; type ParsedStatusResult = { activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined; activeToolName: string | undefined; statusText: string; isGenericStatus: boolean; }; const getToolStatusPhrase = (toolName: string): string => { return TOOL_STATUS_PHRASES[toolName] ?? `using ${toolName}`; }; const hashString = (value: string): number => { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; } return Math.abs(hash); }; const getStableWorkingPhrase = (key: string): string => { return WORKING_PHRASES[hashString(key) % WORKING_PHRASES.length] ?? 'working'; }; const createParsedStatus = (parts: Part[], genericKey: string): ParsedStatusResult => { let activePartType: ParsedStatusResult['activePartType'] = undefined; let activeToolName: string | undefined = undefined; if (!isFullySyntheticMessage(parts)) { for (let index = parts.length - 1; index >= 0; index -= 1) { const part = parts[index]; if (!part) continue; switch (part.type) { case 'reasoning': { const time = part.time ?? getPartTimeInfo(part); const stillRunning = !time || typeof time.end === 'undefined'; if (stillRunning && !activePartType) { activePartType = 'reasoning'; } break; } case 'tool': { const toolStatus = part.state?.status; if ((toolStatus === 'running' || toolStatus === 'pending') && !activePartType) { const toolName = getToolDisplayName(part); if (EDITING_TOOLS.has(toolName)) { activePartType = 'editing'; activeToolName = toolName; } else { activePartType = 'tool'; activeToolName = toolName; } } break; } case 'text': { const rawContent = getLegacyTextContent(part) ?? ''; if (typeof rawContent === 'string' && rawContent.trim().length > 0) { const time = getPartTimeInfo(part); const streamingPart = !time || typeof time.end === 'undefined'; if (streamingPart && !activePartType) { activePartType = 'text'; } } break; } default: break; } } } const isGenericStatus = activePartType === undefined; const statusText = (() => { if (activePartType === 'editing') return activeToolName === 'multiedit' ? getToolStatusPhrase(activeToolName) : 'editing file'; if (activePartType === 'tool' && activeToolName) return getToolStatusPhrase(activeToolName); if (activePartType === 'reasoning') return 'thinking'; if (activePartType === 'text') return 'composing'; return getStableWorkingPhrase(genericKey); })(); return { activePartType, activeToolName, statusText, isGenericStatus }; }; const encodeParsedStatus = (status: ParsedStatusResult): string => { return [ status.activePartType ?? '', status.activeToolName ?? '', status.statusText, status.isGenericStatus ? '1' : '0', ].join(STATUS_SIGNATURE_SEPARATOR); }; const decodeParsedStatus = (signature: string): ParsedStatusResult => { const [activePartType, activeToolName, statusText = 'working', isGenericStatus] = signature.split(STATUS_SIGNATURE_SEPARATOR); return { activePartType: activePartType === 'text' || activePartType === 'tool' || activePartType === 'reasoning' || activePartType === 'editing' ? activePartType : undefined, activeToolName: activeToolName || undefined, statusText, isGenericStatus: isGenericStatus === '1', }; }; const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning'; const isTextPart = (part: Part): part is TextPart => part.type === 'text'; const getLegacyTextContent = (part: Part): string | undefined => { if (isTextPart(part)) { return part.text; } const candidate = part as Partial<{ text?: unknown; content?: unknown; value?: unknown }>; if (typeof candidate.text === 'string') { return candidate.text; } if (typeof candidate.content === 'string') { return candidate.content; } if (typeof candidate.value === 'string') { return candidate.value; } return undefined; }; const getPartTimeInfo = (part: Part): { end?: number } | undefined => { if (isTextPart(part) || isReasoningPart(part)) { return part.time; } const candidate = part as Partial<{ time?: { end?: number } }>; return candidate.time; }; const getToolDisplayName = (part: ToolPart): string => { if (part.tool) { return part.tool; } const candidate = part as ToolPart & Partial<{ name?: unknown }>; return typeof candidate.name === 'string' ? candidate.name : 'tool'; }; export const getActiveAssistantContext = (messages: Message[]): ActiveAssistantContext => { let assistantId: string | null = null; let parentId: string | null = null; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message?.role !== 'assistant') continue; const candidate = message as Message & { parentID?: unknown }; assistantId = message.id; parentId = typeof candidate.parentID === 'string' && candidate.parentID.trim().length > 0 ? candidate.parentID : null; break; } if (!assistantId || !parentId) { return { assistantId, model: null }; } for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message?.role !== 'user' || message.id !== parentId) continue; const candidate = message as Message & { model?: { providerID?: unknown; modelID?: unknown }; }; const providerId = typeof candidate.model?.providerID === 'string' ? candidate.model.providerID.trim() : ''; const modelId = typeof candidate.model?.modelID === 'string' ? candidate.model.modelID.trim() : ''; return { assistantId, model: providerId && modelId ? { providerId, modelId } : null, }; } return { assistantId, model: null }; }; export function useAssistantStatus(): AssistantStatusSnapshot { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); const rawSessionMessages = useSessionMessages( currentSessionId ?? '', currentSessionDirectory ?? undefined, ); const activeAssistant = React.useMemo( () => getActiveAssistantContext(rawSessionMessages), [rawSessionMessages], ); const lastAssistantId = activeAssistant.assistantId; const lastAssistantStatusSignature = useDirectorySync( React.useCallback((state) => { const genericKey = `${currentSessionId ?? ''}:${lastAssistantId ?? ''}`; const parts = lastAssistantId ? (state.part[lastAssistantId] ?? EMPTY_PARTS) : EMPTY_PARTS; return encodeParsedStatus(createParsedStatus(parts, genericKey)); }, [currentSessionId, lastAssistantId]), currentSessionDirectory ?? undefined, ); const sessionPermissionRequests = useSessionPermissions(currentSessionId ?? '', currentSessionDirectory ?? undefined); const sessionQuestionRequests = useSessionQuestions(currentSessionId ?? '', currentSessionDirectory ?? undefined); const sessionAbortRecord = useSessionUIStore( React.useCallback((state) => { if (!currentSessionId) { return null; } return state.sessionAbortFlags?.get(currentSessionId) ?? null; }, [currentSessionId]) ); const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity(); const currentSessionStatus = useSessionStatus(currentSessionId ?? '', currentSessionDirectory ?? undefined); const sessionRetryAttempt = currentSessionStatus?.type === 'retry' ? (currentSessionStatus as { type: 'retry'; attempt?: number }).attempt : undefined; const sessionRetryNext = currentSessionStatus?.type === 'retry' ? (currentSessionStatus as { type: 'retry'; next?: number }).next : undefined; const parsedStatus = React.useMemo(() => { return decodeParsedStatus(lastAssistantStatusSignature); }, [lastAssistantStatusSignature]); const abortState = React.useMemo(() => { const hasActiveAbort = Boolean(sessionAbortRecord && !sessionAbortRecord.acknowledged); return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort }; }, [sessionAbortRecord]); const baseWorking = React.useMemo(() => { if (abortState.wasAborted) { return { ...DEFAULT_WORKING, wasAborted: true, abortActive: abortState.abortActive, activity: 'idle', hasWorkingContext: false, isWorking: false, isStreaming: false, isCooldown: false, statusText: null, canAbort: false, retryInfo: null, }; } const isWorking = isPhaseWorking; const isStreaming = activityPhase === 'busy'; const isCooldown = false; const isRetry = activityPhase === 'retry'; let activity: AssistantActivity = 'idle'; if (isWorking) { if (parsedStatus.activePartType === 'tool' || parsedStatus.activePartType === 'editing') { activity = 'tooling'; } else { activity = isCooldown ? 'cooldown' : 'streaming'; } } const retryInfo = isRetry ? { attempt: sessionRetryAttempt, next: sessionRetryNext } : null; return { activity, hasWorkingContext: isWorking, hasActiveTools: parsedStatus.activePartType === 'tool' || parsedStatus.activePartType === 'editing', isWorking, isStreaming, isCooldown, lifecyclePhase: isStreaming ? 'streaming' : isCooldown ? 'cooldown' : null, statusText: isWorking ? parsedStatus.statusText : null, isGenericStatus: isWorking ? parsedStatus.isGenericStatus : true, isWaitingForPermission: false, canAbort: isWorking, compactionDeadline: null, activePartType: isWorking ? parsedStatus.activePartType : undefined, activeToolName: isWorking ? parsedStatus.activeToolName : undefined, wasAborted: false, abortActive: false, lastCompletionId: null, isComplete: false, retryInfo, }; }, [activityPhase, isPhaseWorking, parsedStatus, abortState, sessionRetryAttempt, sessionRetryNext]); const forming = React.useMemo(() => { const isActive = isPhaseWorking && parsedStatus.activePartType === 'text'; return { isActive, characterCount: 0 }; }, [isPhaseWorking, parsedStatus.activePartType]); const working = React.useMemo(() => { if (baseWorking.wasAborted || baseWorking.abortActive) { return baseWorking; } const hasPendingPermission = sessionPermissionRequests.length > 0; const hasPendingQuestion = sessionQuestionRequests.length > 0; if (!hasPendingPermission && !hasPendingQuestion) { return baseWorking; } if (hasPendingQuestion) { return { ...baseWorking, statusText: null, isWorking: false, hasWorkingContext: false, hasActiveTools: false, canAbort: false, activePartType: undefined, activeToolName: undefined, retryInfo: null, }; } return { ...baseWorking, statusText: 'waiting for permission', isWaitingForPermission: true, canAbort: false, retryInfo: null, }; }, [baseWorking, sessionPermissionRequests, sessionQuestionRequests]); return { activeModel: activeAssistant.model, forming, working, }; }