Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
import { invokeIpc } from '@/lib/api-client';
|
|
import { hostApiFetch } from '@/lib/host-api';
|
|
import { useGatewayStore } from '@/stores/gateway';
|
|
import {
|
|
clearHistoryPoll,
|
|
enrichWithCachedImages,
|
|
enrichWithToolResultFiles,
|
|
getLatestOptimisticUserMessage,
|
|
getMessageText,
|
|
hasNonToolAssistantContent,
|
|
isInternalMessage,
|
|
isToolResultRole,
|
|
loadMissingPreviews,
|
|
matchesOptimisticUserMessage,
|
|
toMs,
|
|
} from './helpers';
|
|
import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils';
|
|
import {
|
|
CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS,
|
|
classifyHistoryStartupRetryError,
|
|
getStartupHistoryTimeoutOverride,
|
|
shouldRetryStartupHistoryLoad,
|
|
sleep,
|
|
} from './history-startup-retry';
|
|
import type { RawMessage } from './types';
|
|
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
|
|
|
const foregroundHistoryLoadSeen = new Set<string>();
|
|
|
|
async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise<RawMessage[]> {
|
|
if (!isCronSessionKey(sessionKey)) return [];
|
|
try {
|
|
const response = await hostApiFetch<{ messages?: RawMessage[] }>(
|
|
buildCronSessionHistoryPath(sessionKey, limit),
|
|
);
|
|
return Array.isArray(response.messages) ? response.messages : [];
|
|
} catch (error) {
|
|
console.warn('Failed to load cron fallback history:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function createHistoryActions(
|
|
set: ChatSet,
|
|
get: ChatGet,
|
|
): Pick<SessionHistoryActions, 'loadHistory'> {
|
|
return {
|
|
loadHistory: async (quiet = false) => {
|
|
const { currentSessionKey } = get();
|
|
const isInitialForegroundLoad = !quiet && !foregroundHistoryLoadSeen.has(currentSessionKey);
|
|
const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad);
|
|
if (!quiet) set({ loading: true, error: null });
|
|
|
|
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
|
const getPreviewMergeKey = (message: RawMessage): string => (
|
|
`${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}`
|
|
);
|
|
const mergeHydratedMessages = (
|
|
currentMessages: RawMessage[],
|
|
hydratedMessages: RawMessage[],
|
|
): RawMessage[] => {
|
|
const hydratedFilesByKey = new Map(
|
|
hydratedMessages
|
|
.filter((message) => message._attachedFiles?.length)
|
|
.map((message) => [
|
|
getPreviewMergeKey(message),
|
|
message._attachedFiles!.map((file) => ({ ...file })),
|
|
]),
|
|
);
|
|
|
|
return currentMessages.map((message) => {
|
|
const attachedFiles = hydratedFilesByKey.get(getPreviewMergeKey(message));
|
|
return attachedFiles
|
|
? { ...message, _attachedFiles: attachedFiles }
|
|
: message;
|
|
});
|
|
};
|
|
|
|
const applyLoadFailure = (errorMessage: string | null) => {
|
|
if (!isCurrentSession()) return;
|
|
set((state) => {
|
|
const hasMessages = state.messages.length > 0;
|
|
return {
|
|
loading: false,
|
|
error: !quiet && errorMessage ? errorMessage : state.error,
|
|
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
|
|
};
|
|
});
|
|
};
|
|
|
|
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
|
|
if (!isCurrentSession()) return false;
|
|
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
|
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
|
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role) && !isInternalMessage(msg));
|
|
// Restore file attachments for user/assistant messages (from cache + text patterns)
|
|
const enrichedMessages = enrichWithCachedImages(filteredMessages);
|
|
|
|
// Preserve the optimistic user message during an active send.
|
|
// The Gateway may not include the user's message in chat.history
|
|
// until the run completes, causing it to flash out of the UI.
|
|
let finalMessages = enrichedMessages;
|
|
const userMsgAt = get().lastUserMessageAt;
|
|
if (get().sending && userMsgAt) {
|
|
const userMsMs = toMs(userMsgAt);
|
|
const optimistic = getLatestOptimisticUserMessage(get().messages, userMsMs);
|
|
const hasMatchingUser = optimistic
|
|
? enrichedMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs))
|
|
: false;
|
|
if (optimistic && !hasMatchingUser) {
|
|
finalMessages = [...enrichedMessages, optimistic];
|
|
}
|
|
}
|
|
|
|
set({ messages: finalMessages, thinkingLevel, loading: false });
|
|
|
|
// Extract first user message text as a session label for display in the toolbar.
|
|
// Skip main sessions (key ends with ":main") — they rely on the Gateway-provided
|
|
// displayName (e.g. the configured agent name "ClawX") instead.
|
|
const isMainSession = currentSessionKey.endsWith(':main');
|
|
if (!isMainSession) {
|
|
const firstUserMsg = finalMessages.find((m) => m.role === 'user');
|
|
if (firstUserMsg) {
|
|
const labelText = getMessageText(firstUserMsg.content).trim();
|
|
if (labelText) {
|
|
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
|
set((s) => ({
|
|
sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated },
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Record last activity time from the last message in history
|
|
const lastMsg = finalMessages[finalMessages.length - 1];
|
|
if (lastMsg?.timestamp) {
|
|
const lastAt = toMs(lastMsg.timestamp);
|
|
set((s) => ({
|
|
sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt },
|
|
}));
|
|
}
|
|
|
|
// Async: load missing image previews from disk (updates in background)
|
|
loadMissingPreviews(finalMessages).then((updated) => {
|
|
if (!isCurrentSession()) return;
|
|
if (updated) {
|
|
set((state) => ({
|
|
messages: mergeHydratedMessages(state.messages, finalMessages),
|
|
}));
|
|
}
|
|
});
|
|
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
|
|
|
// If we're sending but haven't received streaming events, check
|
|
// whether the loaded history reveals intermediate tool-call activity.
|
|
// This surfaces progress via the pendingFinal → ActivityIndicator path.
|
|
const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0;
|
|
const isAfterUserMsg = (msg: RawMessage): boolean => {
|
|
if (!userMsTs || !msg.timestamp) return true;
|
|
return toMs(msg.timestamp) >= userMsTs;
|
|
};
|
|
|
|
if (isSendingNow && !pendingFinal) {
|
|
const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => {
|
|
if (msg.role !== 'assistant') return false;
|
|
return isAfterUserMsg(msg);
|
|
});
|
|
if (hasRecentAssistantActivity) {
|
|
set({ pendingFinal: true });
|
|
}
|
|
}
|
|
|
|
// If pendingFinal, check whether the AI produced a final text response.
|
|
if (pendingFinal || get().pendingFinal) {
|
|
const recentAssistant = [...filteredMessages].reverse().find((msg) => {
|
|
if (msg.role !== 'assistant') return false;
|
|
if (!hasNonToolAssistantContent(msg)) return false;
|
|
return isAfterUserMsg(msg);
|
|
});
|
|
if (recentAssistant) {
|
|
clearHistoryPoll();
|
|
set({ sending: false, activeRunId: null, pendingFinal: false });
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
try {
|
|
let result: { success: boolean; result?: Record<string, unknown>; error?: string } | null = null;
|
|
let lastError: unknown = null;
|
|
|
|
for (let attempt = 0; attempt <= CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
if (!isCurrentSession()) {
|
|
break;
|
|
}
|
|
|
|
try {
|
|
result = await invokeIpc(
|
|
'gateway:rpc',
|
|
'chat.history',
|
|
{ sessionKey: currentSessionKey, limit: 200 },
|
|
...(historyTimeoutOverride != null ? [historyTimeoutOverride] as const : []),
|
|
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
|
|
|
if (result.success) {
|
|
lastError = null;
|
|
break;
|
|
}
|
|
|
|
lastError = new Error(result.error || 'Failed to load chat history');
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
|
|
if (!isCurrentSession()) {
|
|
break;
|
|
}
|
|
|
|
const errorKind = classifyHistoryStartupRetryError(lastError);
|
|
const shouldRetry = result?.success !== true
|
|
&& isInitialForegroundLoad
|
|
&& attempt < CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length
|
|
&& shouldRetryStartupHistoryLoad(useGatewayStore.getState().status, errorKind);
|
|
|
|
if (!shouldRetry) {
|
|
break;
|
|
}
|
|
|
|
console.warn('[chat.history] startup retry scheduled', {
|
|
sessionKey: currentSessionKey,
|
|
attempt: attempt + 1,
|
|
gatewayState: useGatewayStore.getState().status.state,
|
|
errorKind,
|
|
error: String(lastError),
|
|
});
|
|
await sleep(CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS[attempt]!);
|
|
}
|
|
|
|
if (result?.success && result.result) {
|
|
const data = result.result;
|
|
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
|
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
|
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
|
|
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
|
}
|
|
const applied = applyLoadedMessages(rawMessages, thinkingLevel);
|
|
if (applied && isInitialForegroundLoad) {
|
|
foregroundHistoryLoadSeen.add(currentSessionKey);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const errorKind = classifyHistoryStartupRetryError(lastError);
|
|
if (isCurrentSession() && isInitialForegroundLoad && errorKind) {
|
|
console.warn('[chat.history] startup retry exhausted', {
|
|
sessionKey: currentSessionKey,
|
|
gatewayState: useGatewayStore.getState().status.state,
|
|
errorKind,
|
|
error: String(lastError),
|
|
});
|
|
}
|
|
|
|
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
|
if (fallbackMessages.length > 0) {
|
|
const applied = applyLoadedMessages(fallbackMessages, null);
|
|
if (applied && isInitialForegroundLoad) {
|
|
foregroundHistoryLoadSeen.add(currentSessionKey);
|
|
}
|
|
} else if (errorKind === 'gateway_startup') {
|
|
// Suppress error UI for gateway startup -- the history will load
|
|
// once the gateway finishes initializing (via sidebar refresh or
|
|
// the next session switch).
|
|
set({ loading: false });
|
|
} else {
|
|
applyLoadFailure(
|
|
result?.error
|
|
|| (lastError instanceof Error ? lastError.message : String(lastError))
|
|
|| 'Failed to load chat history',
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to load chat history:', err);
|
|
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
|
if (fallbackMessages.length > 0) {
|
|
const applied = applyLoadedMessages(fallbackMessages, null);
|
|
if (applied && isInitialForegroundLoad) {
|
|
foregroundHistoryLoadSeen.add(currentSessionKey);
|
|
}
|
|
} else {
|
|
applyLoadFailure(String(err));
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|