Preserve stable snapshots and stabilize Electron e2e (#734)
This commit is contained in:
committed by
GitHub
Unverified
parent
34bbb039d3
commit
5a3da41562
@@ -90,6 +90,8 @@ class ErrorBoundary extends Component<
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const skipSetupForE2E = typeof window !== 'undefined'
|
||||
&& new URLSearchParams(window.location.search).get('e2eSkipSetup') === '1';
|
||||
const initSettings = useSettingsStore((state) => state.init);
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
const language = useSettingsStore((state) => state.language);
|
||||
@@ -120,10 +122,10 @@ function App() {
|
||||
|
||||
// Redirect to setup wizard if not complete
|
||||
useEffect(() => {
|
||||
if (!setupComplete && !location.pathname.startsWith('/setup')) {
|
||||
if (!setupComplete && !skipSetupForE2E && !location.pathname.startsWith('/setup')) {
|
||||
navigate('/setup');
|
||||
}
|
||||
}, [setupComplete, location.pathname, navigate]);
|
||||
}, [setupComplete, skipSetupForE2E, location.pathname, navigate]);
|
||||
|
||||
// Listen for navigation events from main process
|
||||
useEffect(() => {
|
||||
|
||||
@@ -106,6 +106,7 @@ export function Agents() {
|
||||
deleteAgent,
|
||||
} = useAgentsStore();
|
||||
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
|
||||
const [hasCompletedInitialLoad, setHasCompletedInitialLoad] = useState(() => agents.length > 0);
|
||||
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
@@ -116,13 +117,21 @@ export function Agents() {
|
||||
const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts');
|
||||
setChannelGroups(response.channels || []);
|
||||
} catch {
|
||||
setChannelGroups([]);
|
||||
// Keep the last rendered snapshot when channel account refresh fails.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]);
|
||||
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]).finally(() => {
|
||||
if (mounted) {
|
||||
setHasCompletedInitialLoad(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -150,11 +159,15 @@ export function Agents() {
|
||||
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
|
||||
[activeAgentId, agents],
|
||||
);
|
||||
|
||||
const visibleAgents = agents;
|
||||
const visibleChannelGroups = channelGroups;
|
||||
const isUsingStableValue = loading && hasCompletedInitialLoad;
|
||||
const handleRefresh = () => {
|
||||
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading && !hasCompletedInitialLoad) {
|
||||
return (
|
||||
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
@@ -163,7 +176,7 @@ export function Agents() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div data-testid="agents-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
|
||||
<div>
|
||||
@@ -181,7 +194,7 @@ export function Agents() {
|
||||
onClick={handleRefresh}
|
||||
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-2" />
|
||||
<RefreshCw className={cn('h-3.5 w-3.5 mr-2', isUsingStableValue && 'animate-spin')} />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -214,11 +227,11 @@ export function Agents() {
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
{visibleAgents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
channelGroups={channelGroups}
|
||||
channelGroups={visibleChannelGroups}
|
||||
onOpenSettings={() => setActiveAgentId(agent.id)}
|
||||
onDelete={() => setAgentToDelete(agent)}
|
||||
/>
|
||||
@@ -241,7 +254,7 @@ export function Agents() {
|
||||
{activeAgent && (
|
||||
<AgentSettingsModal
|
||||
agent={activeAgent}
|
||||
channelGroups={channelGroups}
|
||||
channelGroups={visibleChannelGroups}
|
||||
onClose={() => setActiveAgentId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -91,6 +91,10 @@ export function Channels() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
||||
|
||||
const displayedChannelTypes = getPrimaryChannels();
|
||||
const visibleChannelGroups = channelGroups;
|
||||
const visibleAgents = agents;
|
||||
const hasStableValue = visibleChannelGroups.length > 0 || visibleAgents.length > 0;
|
||||
const isUsingStableValue = hasStableValue && (loading || Boolean(error));
|
||||
|
||||
const fetchPageData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -143,21 +147,21 @@ export function Channels() {
|
||||
}, [fetchPageData, gatewayStatus.state]);
|
||||
|
||||
const configuredTypes = useMemo(
|
||||
() => channelGroups.map((group) => group.channelType),
|
||||
[channelGroups],
|
||||
() => visibleChannelGroups.map((group) => group.channelType),
|
||||
[visibleChannelGroups],
|
||||
);
|
||||
|
||||
const groupedByType = useMemo(() => {
|
||||
return Object.fromEntries(channelGroups.map((group) => [group.channelType, group]));
|
||||
}, [channelGroups]);
|
||||
return Object.fromEntries(visibleChannelGroups.map((group) => [group.channelType, group]));
|
||||
}, [visibleChannelGroups]);
|
||||
|
||||
const configuredGroups = useMemo(() => {
|
||||
const known = displayedChannelTypes
|
||||
.map((type) => groupedByType[type])
|
||||
.filter((group): group is ChannelGroupItem => Boolean(group));
|
||||
const unknown = channelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType));
|
||||
const unknown = visibleChannelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType));
|
||||
return [...known, ...unknown];
|
||||
}, [channelGroups, displayedChannelTypes, groupedByType]);
|
||||
}, [visibleChannelGroups, displayedChannelTypes, groupedByType]);
|
||||
|
||||
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
||||
|
||||
@@ -217,7 +221,7 @@ export function Channels() {
|
||||
return nextAccountId;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading && !hasStableValue) {
|
||||
return (
|
||||
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
@@ -226,7 +230,7 @@ export function Channels() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div data-testid="channels-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
|
||||
<div>
|
||||
@@ -245,7 +249,7 @@ export function Channels() {
|
||||
disabled={gatewayStatus.state !== 'running'}
|
||||
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-2" />
|
||||
<RefreshCw className={cn('h-3.5 w-3.5 mr-2', isUsingStableValue && 'animate-spin')} />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -368,7 +372,7 @@ export function Channels() {
|
||||
}}
|
||||
>
|
||||
<option value="">{t('account.unassigned')}</option>
|
||||
{agents.map((agent) => (
|
||||
{visibleAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>{agent.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { FeedbackState } from '@/components/common/FeedbackState';
|
||||
import {
|
||||
filterUsageHistoryByWindow,
|
||||
groupUsageHistory,
|
||||
resolveStableUsageHistory,
|
||||
resolveVisibleUsageHistory,
|
||||
type UsageGroupBy,
|
||||
type UsageHistoryEntry,
|
||||
type UsageWindow,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2;
|
||||
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3;
|
||||
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
|
||||
const USAGE_AUTO_REFRESH_INTERVAL_MS = 15_000;
|
||||
|
||||
export function Models() {
|
||||
const { t } = useTranslation(['dashboard', 'settings']);
|
||||
@@ -36,6 +39,7 @@ export function Models() {
|
||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
||||
const [usagePage, setUsagePage] = useState(1);
|
||||
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
|
||||
const [usageRefreshNonce, setUsageRefreshNonce] = useState(0);
|
||||
const HIDDEN_USAGE_SOURCES = new Set([
|
||||
'gateway-injected',
|
||||
'delivery-mirror',
|
||||
@@ -71,35 +75,79 @@ export function Models() {
|
||||
type FetchState = {
|
||||
status: 'idle' | 'loading' | 'done';
|
||||
data: UsageHistoryEntry[];
|
||||
stableData: UsageHistoryEntry[];
|
||||
};
|
||||
type FetchAction =
|
||||
| { type: 'start' }
|
||||
| { type: 'done'; data: UsageHistoryEntry[] }
|
||||
| { type: 'failed' }
|
||||
| { type: 'reset' };
|
||||
|
||||
const [fetchState, dispatchFetch] = useReducer(
|
||||
(state: FetchState, action: FetchAction): FetchState => {
|
||||
switch (action.type) {
|
||||
case 'start':
|
||||
return { status: 'loading', data: state.data };
|
||||
return { ...state, status: 'loading' };
|
||||
case 'done':
|
||||
return { status: 'done', data: action.data };
|
||||
return {
|
||||
status: 'done',
|
||||
data: action.data,
|
||||
stableData: resolveStableUsageHistory(state.stableData, action.data),
|
||||
};
|
||||
case 'failed':
|
||||
return { ...state, status: 'done' };
|
||||
case 'reset':
|
||||
return { status: 'idle', data: [] };
|
||||
return { status: 'idle', data: [], stableData: [] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
{ status: 'idle' as const, data: [] as UsageHistoryEntry[] },
|
||||
{ status: 'idle' as const, data: [] as UsageHistoryEntry[], stableData: [] as UsageHistoryEntry[] },
|
||||
);
|
||||
|
||||
const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const usageFetchGenerationRef = useRef(0);
|
||||
const usageFetchStatusRef = useRef<FetchState['status']>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
usageFetchStatusRef.current = fetchState.status;
|
||||
}, [fetchState.status]);
|
||||
|
||||
useEffect(() => {
|
||||
trackUiEvent('models.page_viewed');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGatewayRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestRefresh = () => {
|
||||
if (usageFetchStatusRef.current === 'loading') return;
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
setUsageRefreshNonce((value) => value + 1);
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(requestRefresh, USAGE_AUTO_REFRESH_INTERVAL_MS);
|
||||
const handleFocus = () => {
|
||||
requestRefresh();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
requestRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [isGatewayRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (usageFetchTimerRef.current) {
|
||||
clearTimeout(usageFetchTimerRef.current);
|
||||
@@ -128,7 +176,7 @@ export function Models() {
|
||||
generation,
|
||||
restartMarker,
|
||||
});
|
||||
dispatchFetch({ type: 'done', data: [] });
|
||||
dispatchFetch({ type: 'failed' });
|
||||
}, 30_000);
|
||||
|
||||
const fetchUsageHistoryWithRetry = async (attempt: number) => {
|
||||
@@ -191,7 +239,7 @@ export function Models() {
|
||||
}, USAGE_FETCH_RETRY_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
dispatchFetch({ type: 'done', data: [] });
|
||||
dispatchFetch({ type: 'failed' });
|
||||
trackUiEvent('models.token_usage_fetch_exhausted', {
|
||||
generation,
|
||||
attempt,
|
||||
@@ -210,18 +258,25 @@ export function Models() {
|
||||
usageFetchTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
|
||||
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts, usageRefreshNonce]);
|
||||
|
||||
const visibleUsageHistory = isGatewayRunning
|
||||
const usageHistory = isGatewayRunning
|
||||
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
|
||||
: [];
|
||||
const stableUsageHistory = isGatewayRunning
|
||||
? fetchState.stableData.filter((entry) => !shouldHideUsageEntry(entry))
|
||||
: [];
|
||||
const visibleUsageHistory = resolveVisibleUsageHistory(usageHistory, stableUsageHistory, {
|
||||
preferStableOnEmpty: isGatewayRunning && fetchState.status === 'loading',
|
||||
});
|
||||
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
||||
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
|
||||
const usagePageSize = 5;
|
||||
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
|
||||
const safeUsagePage = Math.min(usagePage, usageTotalPages);
|
||||
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
|
||||
const usageLoading = isGatewayRunning && fetchState.status === 'loading';
|
||||
const usageLoading = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length === 0;
|
||||
const usageRefreshing = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length > 0;
|
||||
|
||||
return (
|
||||
<div data-testid="models-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
@@ -328,7 +383,9 @@ export function Models() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[13px] font-medium text-muted-foreground">
|
||||
{t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
|
||||
{usageRefreshing
|
||||
? t('dashboard:recentTokenHistory.loading')
|
||||
: t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,30 @@ export type UsageGroup = {
|
||||
sortKey: number | string;
|
||||
};
|
||||
|
||||
export function resolveStableUsageHistory(
|
||||
previousStableEntries: UsageHistoryEntry[],
|
||||
nextEntries: UsageHistoryEntry[],
|
||||
options: { preservePreviousOnEmpty?: boolean } = {},
|
||||
): UsageHistoryEntry[] {
|
||||
if (nextEntries.length > 0) {
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
return options.preservePreviousOnEmpty ? previousStableEntries : [];
|
||||
}
|
||||
|
||||
export function resolveVisibleUsageHistory(
|
||||
currentEntries: UsageHistoryEntry[],
|
||||
stableEntries: UsageHistoryEntry[],
|
||||
options: { preferStableOnEmpty?: boolean } = {},
|
||||
): UsageHistoryEntry[] {
|
||||
if (options.preferStableOnEmpty && currentEntries.length === 0) {
|
||||
return stableEntries;
|
||||
}
|
||||
|
||||
return currentEntries;
|
||||
}
|
||||
|
||||
export function formatUsageDay(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return timestamp;
|
||||
|
||||
@@ -1316,11 +1316,48 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}, 15_000);
|
||||
|
||||
const loadPromise = (async () => {
|
||||
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) => {
|
||||
// Guard: if the user switched sessions while this async load was in
|
||||
// flight, discard the result to prevent overwriting the new session's
|
||||
// messages with stale data from the old session.
|
||||
if (get().currentSessionKey !== currentSessionKey) return;
|
||||
if (!isCurrentSession()) return;
|
||||
|
||||
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
||||
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
||||
@@ -1379,17 +1416,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
// Async: load missing image previews from disk (updates in background)
|
||||
loadMissingPreviews(finalMessages).then((updated) => {
|
||||
if (!isCurrentSession()) return;
|
||||
if (updated) {
|
||||
// Create new object references so React.memo detects changes.
|
||||
// loadMissingPreviews mutates AttachedFileMeta in place, so we
|
||||
// must produce fresh message + file references for each affected msg.
|
||||
set({
|
||||
messages: finalMessages.map(msg =>
|
||||
msg._attachedFiles
|
||||
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
|
||||
: msg
|
||||
),
|
||||
});
|
||||
set((state) => ({
|
||||
messages: mergeHydratedMessages(state.messages, finalMessages),
|
||||
}));
|
||||
}
|
||||
});
|
||||
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
||||
@@ -1445,7 +1476,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (fallbackMessages.length > 0) {
|
||||
applyLoadedMessages(fallbackMessages, null);
|
||||
} else {
|
||||
set({ messages: [], loading: false });
|
||||
applyLoadFailure('Failed to load chat history');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1454,7 +1485,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (fallbackMessages.length > 0) {
|
||||
applyLoadedMessages(fallbackMessages, null);
|
||||
} else {
|
||||
set({ messages: [], loading: false });
|
||||
applyLoadFailure(String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -37,7 +37,45 @@ export function createHistoryActions(
|
||||
const { currentSessionKey } = get();
|
||||
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;
|
||||
// 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));
|
||||
@@ -95,17 +133,11 @@ export function createHistoryActions(
|
||||
|
||||
// Async: load missing image previews from disk (updates in background)
|
||||
loadMissingPreviews(finalMessages).then((updated) => {
|
||||
if (!isCurrentSession()) return;
|
||||
if (updated) {
|
||||
// Create new object references so React.memo detects changes.
|
||||
// loadMissingPreviews mutates AttachedFileMeta in place, so we
|
||||
// must produce fresh message + file references for each affected msg.
|
||||
set({
|
||||
messages: finalMessages.map(msg =>
|
||||
msg._attachedFiles
|
||||
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
|
||||
: msg
|
||||
),
|
||||
});
|
||||
set((state) => ({
|
||||
messages: mergeHydratedMessages(state.messages, finalMessages),
|
||||
}));
|
||||
}
|
||||
});
|
||||
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
||||
@@ -163,7 +195,7 @@ export function createHistoryActions(
|
||||
if (fallbackMessages.length > 0) {
|
||||
applyLoadedMessages(fallbackMessages, null);
|
||||
} else {
|
||||
set({ messages: [], loading: false });
|
||||
applyLoadFailure(result.error || 'Failed to load chat history');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -172,7 +204,7 @@ export function createHistoryActions(
|
||||
if (fallbackMessages.length > 0) {
|
||||
applyLoadedMessages(fallbackMessages, null);
|
||||
} else {
|
||||
set({ messages: [], loading: false });
|
||||
applyLoadFailure(String(err));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user