2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
|
|
|
|
|
|
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
|
|
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
|
|
|
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
|
|
|
|
import { useCurrentSessionActivity } from './useSessionActivity';
|
|
|
|
|
|
|
|
|
|
export 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;
|
2025-12-24 03:32:24 +02:00
|
|
|
isGenericStatus: boolean;
|
2025-12-07 19:32:53 +02:00
|
|
|
isWaitingForPermission: boolean;
|
|
|
|
|
canAbort: boolean;
|
|
|
|
|
compactionDeadline: number | null;
|
|
|
|
|
activePartType?: 'text' | 'tool' | 'reasoning' | 'editing';
|
|
|
|
|
activeToolName?: string;
|
|
|
|
|
wasAborted: boolean;
|
|
|
|
|
abortActive: boolean;
|
|
|
|
|
lastCompletionId: string | null;
|
|
|
|
|
isComplete: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FormingSummary {
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
characterCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AssistantStatusSnapshot {
|
|
|
|
|
forming: FormingSummary;
|
|
|
|
|
working: WorkingSummary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AssistantMessageWithState = AssistantMessage & {
|
|
|
|
|
status?: string;
|
|
|
|
|
streaming?: boolean;
|
|
|
|
|
abortedAt?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface AssistantSessionMessageRecord {
|
|
|
|
|
info: AssistantMessageWithState;
|
|
|
|
|
parts: Part[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_WORKING: WorkingSummary = {
|
|
|
|
|
activity: 'idle',
|
|
|
|
|
hasWorkingContext: false,
|
|
|
|
|
hasActiveTools: false,
|
|
|
|
|
isWorking: false,
|
|
|
|
|
isStreaming: false,
|
|
|
|
|
isCooldown: false,
|
|
|
|
|
lifecyclePhase: null,
|
|
|
|
|
statusText: null,
|
2025-12-24 03:32:24 +02:00
|
|
|
isGenericStatus: true,
|
2025-12-07 19:32:53 +02:00
|
|
|
isWaitingForPermission: false,
|
|
|
|
|
canAbort: false,
|
|
|
|
|
compactionDeadline: null,
|
|
|
|
|
activePartType: undefined,
|
|
|
|
|
activeToolName: undefined,
|
|
|
|
|
wasAborted: false,
|
|
|
|
|
abortActive: false,
|
|
|
|
|
lastCompletionId: null,
|
|
|
|
|
isComplete: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant';
|
|
|
|
|
|
|
|
|
|
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 function useAssistantStatus(): AssistantStatusSnapshot {
|
|
|
|
|
const { currentSessionId, messages, permissions, sessionAbortFlags } = useSessionStore(
|
|
|
|
|
useShallow((state) => ({
|
|
|
|
|
currentSessionId: state.currentSessionId,
|
|
|
|
|
messages: state.messages,
|
|
|
|
|
permissions: state.permissions,
|
|
|
|
|
sessionAbortFlags: state.sessionAbortFlags,
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { phase: activityPhase, isWorking: isPhaseWorking, isCooldown: isPhaseCooldown } = useCurrentSessionActivity();
|
|
|
|
|
|
|
|
|
|
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
|
|
|
|
|
if (!currentSessionId) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const records = messages.get(currentSessionId) ?? [];
|
|
|
|
|
return records as Array<{ info: Message; parts: Part[] }>;
|
|
|
|
|
}, [currentSessionId, messages]);
|
|
|
|
|
|
|
|
|
|
type ParsedStatusResult = {
|
|
|
|
|
activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined;
|
|
|
|
|
activeToolName: string | undefined;
|
|
|
|
|
statusText: string;
|
2025-12-24 03:32:24 +02:00
|
|
|
isGenericStatus: boolean;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parsedStatus = React.useMemo<ParsedStatusResult>(() => {
|
|
|
|
|
if (sessionMessages.length === 0) {
|
2025-12-24 03:32:24 +02:00
|
|
|
return { activePartType: undefined, activeToolName: undefined, statusText: 'working', isGenericStatus: true };
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const assistantMessages = sessionMessages
|
|
|
|
|
.filter(
|
|
|
|
|
(msg): msg is AssistantSessionMessageRecord =>
|
|
|
|
|
isAssistantMessage(msg.info) && !isFullySyntheticMessage(msg.parts)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (assistantMessages.length === 0) {
|
2025-12-24 03:32:24 +02:00
|
|
|
return { activePartType: undefined, activeToolName: undefined, statusText: 'working', isGenericStatus: true };
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sortedAssistantMessages = [...assistantMessages].sort((a, b) => {
|
|
|
|
|
const aCreated = typeof a.info.time?.created === 'number' ? a.info.time.created : null;
|
|
|
|
|
const bCreated = typeof b.info.time?.created === 'number' ? b.info.time.created : null;
|
|
|
|
|
|
|
|
|
|
if (aCreated !== null && bCreated !== null && aCreated !== bCreated) {
|
|
|
|
|
return aCreated - bCreated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return a.info.id.localeCompare(b.info.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const lastAssistant = sortedAssistantMessages[sortedAssistantMessages.length - 1];
|
|
|
|
|
|
|
|
|
|
let activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined = undefined;
|
|
|
|
|
let activeToolName: string | undefined = undefined;
|
|
|
|
|
|
2026-01-22 19:09:07 +02:00
|
|
|
const editingTools = new Set(['edit', 'write', 'apply_patch']);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
for (let i = (lastAssistant.parts ?? []).length - 1; i >= 0; i -= 1) {
|
|
|
|
|
const part = lastAssistant.parts?.[i];
|
|
|
|
|
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 (editingTools.has(toolName)) {
|
|
|
|
|
activePartType = 'editing';
|
|
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 13:49:03 +02:00
|
|
|
const TOOL_STATUS_PHRASES: Record<string, string> = {
|
|
|
|
|
read: 'reading file',
|
|
|
|
|
write: 'writing file',
|
|
|
|
|
edit: 'editing file',
|
|
|
|
|
multiedit: 'editing files',
|
2026-01-22 19:09:07 +02:00
|
|
|
apply_patch: 'applying patch',
|
2025-12-19 13:49:03 +02:00
|
|
|
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',
|
2025-12-30 15:11:07 +02:00
|
|
|
skill: 'learning skill',
|
2026-01-09 21:38:22 +02:00
|
|
|
question: 'asking question',
|
2026-01-25 15:52:02 +02:00
|
|
|
plan_enter: 'switching to planning',
|
|
|
|
|
plan_exit: 'switching to building',
|
2025-12-19 13:49:03 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const WORKING_PHRASES = [
|
|
|
|
|
'working',
|
|
|
|
|
'processing',
|
|
|
|
|
'preparing',
|
|
|
|
|
'warming up',
|
|
|
|
|
'gears turning',
|
|
|
|
|
'computing',
|
|
|
|
|
'calculating',
|
|
|
|
|
'analyzing',
|
|
|
|
|
'wheels spinning',
|
|
|
|
|
'calibrating',
|
|
|
|
|
'synthesizing',
|
|
|
|
|
'connecting dots',
|
|
|
|
|
'inspecting logic',
|
|
|
|
|
'weighing options',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const getToolStatusPhrase = (toolName: string): string => {
|
|
|
|
|
return TOOL_STATUS_PHRASES[toolName] ?? `using ${toolName}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getRandomWorkingPhrase = (): string => {
|
|
|
|
|
return WORKING_PHRASES[Math.floor(Math.random() * WORKING_PHRASES.length)];
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-24 03:32:24 +02:00
|
|
|
const isGenericStatus = activePartType === undefined;
|
2025-12-07 19:32:53 +02:00
|
|
|
const statusText = (() => {
|
2025-12-19 13:49:03 +02:00
|
|
|
if (activePartType === 'editing') return 'editing file';
|
|
|
|
|
if (activePartType === 'tool' && activeToolName) return getToolStatusPhrase(activeToolName);
|
2025-12-07 19:32:53 +02:00
|
|
|
if (activePartType === 'reasoning') return 'thinking';
|
|
|
|
|
if (activePartType === 'text') return 'composing';
|
2025-12-19 13:49:03 +02:00
|
|
|
return getRandomWorkingPhrase();
|
2025-12-07 19:32:53 +02:00
|
|
|
})();
|
|
|
|
|
|
2025-12-24 03:32:24 +02:00
|
|
|
return { activePartType, activeToolName, statusText, isGenericStatus };
|
2025-12-07 19:32:53 +02:00
|
|
|
}, [sessionMessages]);
|
|
|
|
|
|
|
|
|
|
const abortState = React.useMemo(() => {
|
|
|
|
|
const sessionId = currentSessionId;
|
|
|
|
|
const abortRecord = sessionId ? sessionAbortFlags?.get(sessionId) ?? null : null;
|
|
|
|
|
const hasActiveAbort = Boolean(abortRecord && !abortRecord.acknowledged);
|
|
|
|
|
return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort };
|
|
|
|
|
}, [currentSessionId, sessionAbortFlags]);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isWorking = isPhaseWorking;
|
|
|
|
|
const isStreaming = activityPhase === 'busy';
|
|
|
|
|
const isCooldown = isPhaseCooldown;
|
|
|
|
|
|
|
|
|
|
let activity: AssistantActivity = 'idle';
|
|
|
|
|
if (isWorking) {
|
|
|
|
|
if (parsedStatus.activePartType === 'tool' || parsedStatus.activePartType === 'editing') {
|
|
|
|
|
activity = 'tooling';
|
|
|
|
|
} else {
|
|
|
|
|
activity = isCooldown ? 'cooldown' : 'streaming';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2025-12-24 03:32:24 +02:00
|
|
|
isGenericStatus: isWorking ? parsedStatus.isGenericStatus : true,
|
2025-12-07 19:32:53 +02:00
|
|
|
isWaitingForPermission: false,
|
|
|
|
|
canAbort: isWorking,
|
|
|
|
|
compactionDeadline: null,
|
|
|
|
|
activePartType: isWorking ? parsedStatus.activePartType : undefined,
|
|
|
|
|
activeToolName: isWorking ? parsedStatus.activeToolName : undefined,
|
|
|
|
|
wasAborted: false,
|
|
|
|
|
abortActive: false,
|
|
|
|
|
lastCompletionId: null,
|
|
|
|
|
isComplete: isCooldown,
|
|
|
|
|
};
|
|
|
|
|
}, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]);
|
|
|
|
|
|
|
|
|
|
const forming = React.useMemo<FormingSummary>(() => {
|
|
|
|
|
|
|
|
|
|
const isActive = isPhaseWorking && parsedStatus.activePartType === 'text';
|
|
|
|
|
|
|
|
|
|
if (!isActive || sessionMessages.length === 0) {
|
|
|
|
|
return { isActive, characterCount: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const assistantMessages = sessionMessages.filter(
|
|
|
|
|
(msg): msg is AssistantSessionMessageRecord =>
|
|
|
|
|
isAssistantMessage(msg.info) && !isFullySyntheticMessage(msg.parts)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (assistantMessages.length === 0) {
|
|
|
|
|
return { isActive, characterCount: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
|
|
|
|
let characterCount = 0;
|
|
|
|
|
|
|
|
|
|
(lastAssistant.parts ?? []).forEach((part) => {
|
|
|
|
|
if (part.type !== 'text') return;
|
|
|
|
|
const rawContent = getLegacyTextContent(part) ?? '';
|
|
|
|
|
if (typeof rawContent === 'string' && rawContent.trim().length > 0) {
|
|
|
|
|
characterCount += rawContent.length;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { isActive, characterCount };
|
|
|
|
|
}, [sessionMessages, isPhaseWorking, parsedStatus.activePartType]);
|
|
|
|
|
|
|
|
|
|
const working = React.useMemo<WorkingSummary>(() => {
|
|
|
|
|
if (baseWorking.wasAborted || baseWorking.abortActive) {
|
|
|
|
|
return baseWorking;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sessionId = currentSessionId;
|
|
|
|
|
const permissionList = sessionId ? permissions?.get(sessionId) ?? [] : [];
|
|
|
|
|
const hasPendingPermission = permissionList.length > 0;
|
|
|
|
|
|
|
|
|
|
if (!hasPendingPermission) {
|
|
|
|
|
return baseWorking;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...baseWorking,
|
|
|
|
|
statusText: 'waiting for permission',
|
|
|
|
|
isWaitingForPermission: true,
|
|
|
|
|
canAbort: false,
|
|
|
|
|
};
|
|
|
|
|
}, [currentSessionId, permissions, baseWorking]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
forming,
|
|
|
|
|
working,
|
|
|
|
|
};
|
|
|
|
|
}
|