Files
openchamber/packages/ui/src/hooks/useAssistantStatus.ts
T

465 lines
16 KiB
TypeScript

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<string, string> = {
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<ParsedStatusResult>(() => {
return decodeParsedStatus(lastAssistantStatusSignature);
}, [lastAssistantStatusSignature]);
const abortState = React.useMemo(() => {
const hasActiveAbort = Boolean(sessionAbortRecord && !sessionAbortRecord.acknowledged);
return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort };
}, [sessionAbortRecord]);
const baseWorking = React.useMemo<WorkingSummary>(() => {
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<FormingSummary>(() => {
const isActive = isPhaseWorking && parsedStatus.activePartType === 'text';
return { isActive, characterCount: 0 };
}, [isPhaseWorking, parsedStatus.activePartType]);
const working = React.useMemo<WorkingSummary>(() => {
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,
};
}