refactor: message list optimizations and single messageLimit setting (#409)

* feat: message list refactoring and optimizations

* refactor: collapse 3 memory-limit settings into single messageLimit

- Replace memoryLimitHistorical/Viewport/ActiveSession with one `messageLimit` (default 200)
- Background trim derived automatically (limit * 0.6), not user-facing
- Fix stale historyLimit overriding current messageLimit on session reload
- Remove streaming cap that reduced fetch to VIEWPORT_MESSAGES during active streams
- Deduplicate detectTurns() call in TurnGroupingContext (reuse staticValue.turns)
- Zustand v3 migration: collapse old fields, preserve user-customized values
- Server: sanitize single messageLimit field (10-500)
This commit is contained in:
Bohdan Triapitsyn
2026-02-12 20:36:02 +02:00
committed by GitHub
parent 3bf0843ebb
commit 252d4bd5ab
13 changed files with 627 additions and 330 deletions
+212 -35
View File
@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import { RiArrowDownLine } from '@remixicon/react'; import { RiArrowDownLine } from '@remixicon/react';
import { useShallow } from 'zustand/react/shallow';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { ChatInput } from './ChatInput'; import { ChatInput } from './ChatInput';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
@@ -10,39 +12,99 @@ import MessageList from './MessageList';
import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager'; import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { getMemoryLimits } from '@/stores/types/sessionTypes';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog'; import { TimelineDialog } from './TimelineDialog';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
export const ChatContainer: React.FC = () => { export const ChatContainer: React.FC = () => {
const { const {
currentSessionId, currentSessionId,
messages,
permissions,
questions,
streamingMessageIds,
isLoading, isLoading,
loadMessages, loadMessages,
loadMoreMessages, loadMoreMessages,
updateViewportAnchor, updateViewportAnchor,
sessionMemoryState,
openNewSessionDraft, openNewSessionDraft,
isSyncing,
messageStreamStates,
trimToViewportWindow, trimToViewportWindow,
sessionStatus,
newSessionDraft, newSessionDraft,
} = useSessionStore(); } = useSessionStore(
useShallow((state) => ({
currentSessionId: state.currentSessionId,
isLoading: state.isLoading,
loadMessages: state.loadMessages,
loadMoreMessages: state.loadMoreMessages,
updateViewportAnchor: state.updateViewportAnchor,
openNewSessionDraft: state.openNewSessionDraft,
trimToViewportWindow: state.trimToViewportWindow,
newSessionDraft: state.newSessionDraft,
}))
);
const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore(
useShallow((state) => ({
isSyncing: state.isSyncing,
messageStreamStates: state.messageStreamStates,
sessionMemoryStateMap: state.sessionMemoryState,
}))
);
const { const {
isTimelineDialogOpen, isTimelineDialogOpen,
setTimelineDialogOpen, setTimelineDialogOpen,
} = useUIStore(); } = useUIStore();
const streamingMessageId = React.useMemo(() => { const sessionMessages = useSessionStore(
if (!currentSessionId) return null; React.useCallback(
return streamingMessageIds.get(currentSessionId) ?? null; (state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES),
}, [currentSessionId, streamingMessageIds]); [currentSessionId]
)
);
const sessionPermissions = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.permissions.get(currentSessionId) ?? EMPTY_PERMISSIONS : EMPTY_PERMISSIONS),
[currentSessionId]
)
);
const sessionQuestions = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.questions.get(currentSessionId) ?? EMPTY_QUESTIONS : EMPTY_QUESTIONS),
[currentSessionId]
)
);
const memoryState = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null),
[currentSessionId]
)
);
const streamingMessageId = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null),
[currentSessionId]
)
);
const sessionStatusForCurrent = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS),
[currentSessionId]
)
);
const hasSessionMessagesEntry = useSessionStore(
React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId])
);
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open); const draftOpen = Boolean(newSessionDraft?.open);
@@ -53,18 +115,90 @@ export const ChatContainer: React.FC = () => {
} }
}, [currentSessionId, draftOpen, openNewSessionDraft]); }, [currentSessionId, draftOpen, openNewSessionDraft]);
const sessionMessages = React.useMemo(() => { const [turnStart, setTurnStart] = React.useState(0);
const turnHandleRef = React.useRef<number | null>(null);
const turnIdleRef = React.useRef(false);
const TURN_INIT = 20;
const TURN_BATCH = 20;
return currentSessionId ? messages.get(currentSessionId) || [] : []; const userTurnIndexes = React.useMemo(() => {
}, [currentSessionId, messages]); const indexes: number[] = [];
for (let i = 0; i < sessionMessages.length; i += 1) {
const message = sessionMessages[i];
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
if (role === 'user') {
indexes.push(i);
}
}
return indexes;
}, [sessionMessages]);
const sessionPermissions = React.useMemo(() => { const cancelTurnBackfill = React.useCallback(() => {
return currentSessionId ? permissions.get(currentSessionId) || [] : []; const handle = turnHandleRef.current;
}, [currentSessionId, permissions]); if (handle === null) {
return;
}
turnHandleRef.current = null;
if (turnIdleRef.current && typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(handle);
return;
}
if (typeof window !== 'undefined') {
window.clearTimeout(handle);
}
}, []);
const sessionQuestions = React.useMemo(() => { const renderedSessionMessages = React.useMemo(() => {
return currentSessionId ? questions.get(currentSessionId) || [] : []; if (turnStart <= 0 || userTurnIndexes.length === 0) {
}, [currentSessionId, questions]); return sessionMessages;
}
const startIndex = userTurnIndexes[turnStart] ?? 0;
return sessionMessages.slice(startIndex);
}, [sessionMessages, turnStart, userTurnIndexes]);
const backfillTurns = React.useCallback(() => {
if (turnStart <= 0) {
return;
}
const container = typeof document !== 'undefined'
? (document.querySelector('[data-scrollbar="chat"]') as HTMLDivElement | null)
: null;
const beforeTop = container?.scrollTop ?? null;
const beforeHeight = container?.scrollHeight ?? null;
setTurnStart((prev) => (prev - TURN_BATCH > 0 ? prev - TURN_BATCH : 0));
if (container && beforeTop !== null && beforeHeight !== null) {
window.requestAnimationFrame(() => {
const delta = container.scrollHeight - beforeHeight;
if (delta !== 0) {
container.scrollTop = beforeTop + delta;
}
});
}
}, [turnStart]);
const scheduleTurnBackfill = React.useCallback(() => {
if (turnHandleRef.current !== null || turnStart <= 0) {
return;
}
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
turnIdleRef.current = true;
turnHandleRef.current = window.requestIdleCallback(() => {
turnHandleRef.current = null;
backfillTurns();
});
return;
}
turnIdleRef.current = false;
turnHandleRef.current = window.setTimeout(() => {
turnHandleRef.current = null;
backfillTurns();
}, 0);
}, [backfillTurns, turnStart]);
const sessionBlockingCards = React.useMemo(() => { const sessionBlockingCards = React.useMemo(() => {
return [...sessionPermissions, ...sessionQuestions]; return [...sessionPermissions, ...sessionQuestions];
@@ -80,9 +214,9 @@ export const ChatContainer: React.FC = () => {
isPinned, isPinned,
} = useChatScrollManager({ } = useChatScrollManager({
currentSessionId, currentSessionId,
sessionMessages, sessionMessages: renderedSessionMessages,
streamingMessageId, streamingMessageId,
sessionMemoryState, sessionMemoryState: sessionMemoryStateMap,
updateViewportAnchor, updateViewportAnchor,
isSyncing, isSyncing,
isMobile, isMobile,
@@ -91,13 +225,46 @@ export const ChatContainer: React.FC = () => {
trimToViewportWindow, trimToViewportWindow,
}); });
const memoryState = React.useMemo(() => { React.useEffect(() => {
cancelTurnBackfill();
if (!currentSessionId) { if (!currentSessionId) {
return null; setTurnStart(0);
return;
} }
return sessionMemoryState.get(currentSessionId) ?? null;
}, [currentSessionId, sessionMemoryState]); const turnCount = userTurnIndexes.length;
const hasMoreAbove = Boolean(memoryState?.hasMoreAbove); const start = turnCount > TURN_INIT ? turnCount - TURN_INIT : 0;
setTurnStart(start);
}, [cancelTurnBackfill, currentSessionId, userTurnIndexes.length]);
React.useEffect(() => {
scheduleTurnBackfill();
return () => {
cancelTurnBackfill();
};
}, [cancelTurnBackfill, scheduleTurnBackfill, turnStart]);
const hasMoreAbove = React.useMemo(() => {
if (!memoryState) {
return false;
}
if (memoryState.historyComplete === true) {
return false;
}
if (memoryState.hasMoreAbove) {
return true;
}
if (memoryState.historyComplete === false) {
return true;
}
// Backward compatibility: older persisted sessions may miss history flags.
if (memoryState.hasMoreAbove === undefined && memoryState.historyComplete === undefined) {
return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
}
return false;
}, [memoryState, sessionMessages.length]);
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false); const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setIsLoadingOlder(false); setIsLoadingOlder(false);
@@ -108,6 +275,9 @@ export const ChatContainer: React.FC = () => {
return; return;
} }
cancelTurnBackfill();
setTurnStart(0);
const container = scrollRef.current; const container = scrollRef.current;
const prevHeight = container?.scrollHeight ?? null; const prevHeight = container?.scrollHeight ?? null;
const prevTop = container?.scrollTop ?? null; const prevTop = container?.scrollTop ?? null;
@@ -122,7 +292,12 @@ export const ChatContainer: React.FC = () => {
} finally { } finally {
setIsLoadingOlder(false); setIsLoadingOlder(false);
} }
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]); }, [cancelTurnBackfill, currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
const handleRenderEarlier = React.useCallback(() => {
cancelTurnBackfill();
setTurnStart(0);
}, [cancelTurnBackfill]);
// Scroll to a specific message by ID (for timeline dialog) // Scroll to a specific message by ID (for timeline dialog)
const scrollToMessage = React.useCallback((messageId: string) => { const scrollToMessage = React.useCallback((messageId: string) => {
@@ -150,7 +325,7 @@ export const ChatContainer: React.FC = () => {
return; return;
} }
const hasSessionMessages = messages.has(currentSessionId); const hasSessionMessages = hasSessionMessagesEntry;
if (hasSessionMessages) { if (hasSessionMessages) {
return; return;
} }
@@ -159,7 +334,7 @@ export const ChatContainer: React.FC = () => {
try { try {
await loadMessages(currentSessionId); await loadMessages(currentSessionId);
} finally { } finally {
const statusType = sessionStatus?.get(currentSessionId)?.type ?? 'idle'; const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry'; const isActivePhase = statusType === 'busy' || statusType === 'retry';
// When pinned and active, scroll is already maintained automatically // When pinned and active, scroll is already maintained automatically
const shouldSkipScroll = isActivePhase && isPinned; const shouldSkipScroll = isActivePhase && isPinned;
@@ -177,7 +352,7 @@ export const ChatContainer: React.FC = () => {
}; };
void load(); void load();
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionStatus]); }, [currentSessionId, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
if (!currentSessionId && !draftOpen) { if (!currentSessionId && !draftOpen) {
return ( return (
@@ -211,7 +386,7 @@ export const ChatContainer: React.FC = () => {
} }
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) { if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
const hasMessagesEntry = messages.has(currentSessionId); const hasMessagesEntry = hasSessionMessagesEntry;
if (!hasMessagesEntry) { if (!hasMessagesEntry) {
return ( return (
<div <div
@@ -270,7 +445,7 @@ export const ChatContainer: React.FC = () => {
> >
<div className="relative z-0 min-h-full"> <div className="relative z-0 min-h-full">
<MessageList <MessageList
messages={sessionMessages} messages={renderedSessionMessages}
permissions={sessionPermissions} permissions={sessionPermissions}
questions={sessionQuestions} questions={sessionQuestions}
onMessageContentChange={handleMessageContentChange} onMessageContentChange={handleMessageContentChange}
@@ -278,6 +453,8 @@ export const ChatContainer: React.FC = () => {
hasMoreAbove={hasMoreAbove} hasMoreAbove={hasMoreAbove}
isLoadingOlder={isLoadingOlder} isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder} onLoadOlder={handleLoadOlder}
hasRenderEarlier={turnStart > 0}
onRenderEarlier={handleRenderEarlier}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
/> />
</div> </div>
@@ -25,6 +25,8 @@ interface MessageListProps {
hasMoreAbove: boolean; hasMoreAbove: boolean;
isLoadingOlder: boolean; isLoadingOlder: boolean;
onLoadOlder: () => void; onLoadOlder: () => void;
hasRenderEarlier?: boolean;
onRenderEarlier?: () => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollRef?: React.RefObject<HTMLDivElement | null>; scrollRef?: React.RefObject<HTMLDivElement | null>;
} }
@@ -198,6 +200,8 @@ const MessageList: React.FC<MessageListProps> = ({
hasMoreAbove, hasMoreAbove,
isLoadingOlder, isLoadingOlder,
onLoadOlder, onLoadOlder,
hasRenderEarlier,
onRenderEarlier,
scrollToBottom, scrollToBottom,
}) => { }) => {
React.useEffect(() => { React.useEffect(() => {
@@ -256,6 +260,18 @@ const MessageList: React.FC<MessageListProps> = ({
return ( return (
<TurnGroupingProvider messages={displayMessages}> <TurnGroupingProvider messages={displayMessages}>
<div> <div>
{hasRenderEarlier && (
<div className="flex justify-center py-3">
<button
type="button"
onClick={onRenderEarlier}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Render earlier messages
</button>
</div>
)}
{hasMoreAbove && ( {hasMoreAbove && (
<div className="flex justify-center py-3"> <div className="flex justify-center py-3">
{isLoadingOlder ? ( {isLoadingOlder ? (
@@ -52,6 +52,7 @@ interface TurnGroupingUiStateData {
// Streaming state that changes frequently during assistant response // Streaming state that changes frequently during assistant response
interface TurnGroupingStreamingData { interface TurnGroupingStreamingData {
sessionIsWorking: boolean; sessionIsWorking: boolean;
lastTurnActivityInfo?: TurnActivityInfo;
} }
// Separate contexts to prevent unnecessary re-renders // Separate contexts to prevent unnecessary re-renders
@@ -86,6 +87,9 @@ export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupin
if (!isAssistantMessage) return undefined; if (!isAssistantMessage) return undefined;
const isLastTurn = staticData.lastTurnId === turn.turnId; const isLastTurn = staticData.lastTurnId === turn.turnId;
const lastTurnActivityVersion = isLastTurn
? `${streamingData.lastTurnActivityInfo?.activityParts.length ?? 0}:${streamingData.lastTurnActivityInfo?.summaryBody ?? ''}`
: '';
// Get UI state early - needed for cache key to ensure expand/collapse updates propagate // Get UI state early - needed for cache key to ensure expand/collapse updates propagate
const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded }; const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded };
@@ -96,13 +100,15 @@ export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupin
// - isExpanded: UI state for this turn's activity group // - isExpanded: UI state for this turn's activity group
// - sessionIsWorking (last turn only): streaming state affects "working" indicator // - sessionIsWorking (last turn only): streaming state affects "working" indicator
const cacheKey = isLastTurn const cacheKey = isLastTurn
? `${messageId}-${isExpanded}-${streamingData.sessionIsWorking}` ? `${messageId}-${isExpanded}-${streamingData.sessionIsWorking}-${lastTurnActivityVersion}`
: `${messageId}-${isExpanded}`; : `${messageId}-${isExpanded}`;
const cached = contextCache.get(cacheKey); const cached = contextCache.get(cacheKey);
if (cached) return cached; if (cached) return cached;
const activityInfo = staticData.turnActivityInfo.get(turn.turnId); const activityInfo = isLastTurn
? streamingData.lastTurnActivityInfo
: staticData.turnActivityInfo.get(turn.turnId);
const activityParts = activityInfo?.activityParts ?? []; const activityParts = activityInfo?.activityParts ?? [];
const activityGroupSegments = activityInfo?.activityGroupSegments ?? []; const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
const hasTools = Boolean(activityInfo?.hasTools); const hasTools = Boolean(activityInfo?.hasTools);
@@ -498,14 +504,43 @@ const buildNeighborMap = (messages: ChatMessageEntry[]): Map<string, NeighborInf
return map; return map;
}; };
const getMessageRole = (message: ChatMessageEntry): string => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
return typeof role === 'string' ? role : '';
};
const getStructureKey = (messages: ChatMessageEntry[]): string => {
if (messages.length === 0) return '';
return messages
.map((message) => `${message.info?.id ?? ''}:${getMessageRole(message)}`)
.join('|');
};
export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ messages, children }) => { export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ messages, children }) => {
const { isWorking: sessionIsWorking } = useCurrentSessionActivity(); const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion); const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity); const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed'; const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
const staticCacheRef = React.useRef<{
structureKey: string;
defaultActivityExpanded: boolean;
showTextJustificationActivity: boolean;
value: TurnGroupingStaticData;
} | null>(null);
// Static data - only changes when messages change or justification setting changes // Static data - avoid identity churn while assistant streams within existing turn structure.
const staticValue = React.useMemo<TurnGroupingStaticData>(() => { const staticValue = React.useMemo<TurnGroupingStaticData>(() => {
const cached = staticCacheRef.current;
if (
cached &&
cached.structureKey === structureKey &&
cached.defaultActivityExpanded === defaultActivityExpanded &&
cached.showTextJustificationActivity === showTextJustificationActivity
) {
return cached.value;
}
const turns = detectTurns(messages); const turns = detectTurns(messages);
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null; const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
@@ -519,6 +554,7 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
const turnActivityInfo = new Map<string, TurnActivityInfo>(); const turnActivityInfo = new Map<string, TurnActivityInfo>();
turns.forEach((turn) => { turns.forEach((turn) => {
if (turn.turnId === lastTurnId) return;
turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity)); turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity));
}); });
@@ -534,7 +570,7 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
}); });
} }
return { const value: TurnGroupingStaticData = {
turns, turns,
messageToTurn, messageToTurn,
turnActivityInfo, turnActivityInfo,
@@ -543,7 +579,36 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
defaultActivityExpanded, defaultActivityExpanded,
messageNeighbors, messageNeighbors,
}; };
}, [messages, defaultActivityExpanded, showTextJustificationActivity]);
staticCacheRef.current = {
structureKey,
defaultActivityExpanded,
showTextJustificationActivity,
value,
};
return value;
}, [defaultActivityExpanded, messages, showTextJustificationActivity, structureKey]);
const lastTurnActivityInfo = React.useMemo<TurnActivityInfo | undefined>(() => {
const lastTurnId = staticValue.lastTurnId;
if (!lastTurnId) return undefined;
// Find the last turn's user message in the current messages array to pick up
// streaming content changes without a second full detectTurns() pass.
const turns = staticValue.turns;
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : undefined;
if (!lastTurn) return undefined;
// Re-slice assistant messages from the live `messages` array so that
// streamed part updates are reflected without re-detecting all turns.
const userIdx = messages.findIndex((m) => m.info.id === lastTurn.userMessage.info.id);
if (userIdx < 0) return getTurnActivityInfo(lastTurn, showTextJustificationActivity);
const liveAssistant = messages.slice(userIdx + 1).filter((m) => {
const role = (m.info as { clientRole?: string | null }).clientRole ?? m.info.role;
return role === 'assistant';
});
const liveTurn: Turn = { ...lastTurn, assistantMessages: liveAssistant };
return getTurnActivityInfo(liveTurn, showTextJustificationActivity);
}, [staticValue, messages, showTextJustificationActivity]);
// UI state for expansion toggles // UI state for expansion toggles
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, { isExpanded: boolean }>>( const [turnUiStates, setTurnUiStates] = React.useState<Map<string, { isExpanded: boolean }>>(
@@ -573,7 +638,8 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
// Streaming state - changes frequently during assistant response // Streaming state - changes frequently during assistant response
const streamingValue = React.useMemo<TurnGroupingStreamingData>(() => ({ const streamingValue = React.useMemo<TurnGroupingStreamingData>(() => ({
sessionIsWorking, sessionIsWorking,
}), [sessionIsWorking]); lastTurnActivityInfo,
}), [lastTurnActivityInfo, sessionIsWorking]);
return ( return (
<TurnGroupingStaticContext.Provider value={staticValue}> <TurnGroupingStaticContext.Provider value={staticValue}>
@@ -6,24 +6,16 @@ import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } from '@/stores/types/sessionTypes'; import { DEFAULT_MESSAGE_LIMIT } from '@/stores/types/sessionTypes';
const MIN_HISTORICAL = 10; const MIN_LIMIT = 10;
const MAX_HISTORICAL = 500; const MAX_LIMIT = 500;
const MIN_VIEWPORT = 20;
const MAX_VIEWPORT = 500;
const MIN_ACTIVE = 30;
const MAX_ACTIVE = 1000;
export const MemoryLimitsSettings: React.FC = () => { export const MemoryLimitsSettings: React.FC = () => {
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const memoryLimitHistorical = useUIStore((state) => state.memoryLimitHistorical); const messageLimit = useUIStore((state) => state.messageLimit);
const memoryLimitViewport = useUIStore((state) => state.memoryLimitViewport); const setMessageLimit = useUIStore((state) => state.setMessageLimit);
const memoryLimitActiveSession = useUIStore((state) => state.memoryLimitActiveSession);
const setMemoryLimitHistorical = useUIStore((state) => state.setMemoryLimitHistorical);
const setMemoryLimitViewport = useUIStore((state) => state.setMemoryLimitViewport);
const setMemoryLimitActiveSession = useUIStore((state) => state.setMemoryLimitActiveSession);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
@@ -31,7 +23,7 @@ export const MemoryLimitsSettings: React.FC = () => {
React.useEffect(() => { React.useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null; let data: { messageLimit?: number } | null = null;
// 1. Runtime settings API (VSCode) // 1. Runtime settings API (VSCode)
if (!data) { if (!data) {
@@ -42,9 +34,7 @@ export const MemoryLimitsSettings: React.FC = () => {
const settings = result?.settings as Record<string, unknown> | undefined; const settings = result?.settings as Record<string, unknown> | undefined;
if (settings) { if (settings) {
data = { data = {
memoryLimitHistorical: typeof settings.memoryLimitHistorical === 'number' ? settings.memoryLimitHistorical : undefined, messageLimit: typeof settings.messageLimit === 'number' ? settings.messageLimit : undefined,
memoryLimitViewport: typeof settings.memoryLimitViewport === 'number' ? settings.memoryLimitViewport : undefined,
memoryLimitActiveSession: typeof settings.memoryLimitActiveSession === 'number' ? settings.memoryLimitActiveSession : undefined,
}; };
} }
} catch { } catch {
@@ -64,16 +54,8 @@ export const MemoryLimitsSettings: React.FC = () => {
} }
} }
if (data) { if (data && typeof data.messageLimit === 'number') {
if (typeof data.memoryLimitHistorical === 'number') { setMessageLimit(data.messageLimit);
setMemoryLimitHistorical(data.memoryLimitHistorical);
}
if (typeof data.memoryLimitViewport === 'number') {
setMemoryLimitViewport(data.memoryLimitViewport);
}
if (typeof data.memoryLimitActiveSession === 'number') {
setMemoryLimitActiveSession(data.memoryLimitActiveSession);
}
} }
} catch (error) { } catch (error) {
console.warn('Failed to load memory limits settings:', error); console.warn('Failed to load memory limits settings:', error);
@@ -82,35 +64,21 @@ export const MemoryLimitsSettings: React.FC = () => {
} }
}; };
loadSettings(); loadSettings();
}, [setMemoryLimitHistorical, setMemoryLimitViewport, setMemoryLimitActiveSession]); }, [setMessageLimit]);
const persistSetting = React.useCallback(async (key: string, value: number) => { const handleChange = React.useCallback((value: number) => {
try { setMessageLimit(value);
await updateDesktopSettings({ [key]: value }); void updateDesktopSettings({ messageLimit: value }).catch((error: unknown) => {
} catch (error) { console.warn('Failed to save messageLimit:', error);
console.warn(`Failed to save ${key}:`, error); });
} }, [setMessageLimit]);
}, []);
const handleHistoricalChange = React.useCallback((value: number) => {
setMemoryLimitHistorical(value);
persistSetting('memoryLimitHistorical', value);
}, [setMemoryLimitHistorical, persistSetting]);
const handleViewportChange = React.useCallback((value: number) => {
setMemoryLimitViewport(value);
persistSetting('memoryLimitViewport', value);
}, [setMemoryLimitViewport, persistSetting]);
const handleActiveSessionChange = React.useCallback((value: number) => {
setMemoryLimitActiveSession(value);
persistSetting('memoryLimitActiveSession', value);
}, [setMemoryLimitActiveSession, persistSetting]);
if (isLoading) { if (isLoading) {
return null; return null;
} }
const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -121,71 +89,49 @@ export const MemoryLimitsSettings: React.FC = () => {
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs"> <TooltipContent sideOffset={8} className="max-w-xs">
Control how many messages are kept in memory for performance optimization.<br /> How many messages to keep in view per session.<br />
Lower values use less memory but may require reloading older messages. Older messages are available via "Load more". Background sessions are trimmed automatically.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<MemoryLimitRow <div className="flex flex-col gap-1">
label="Initial load limit" <div className="flex items-center justify-between gap-4">
description="Messages loaded when opening a session" <div className="flex flex-col">
value={memoryLimitHistorical} <span className="typography-ui-label text-foreground">Message limit</span>
defaultValue={DEFAULT_MEMORY_LIMITS.HISTORICAL_MESSAGES} <span className="typography-meta text-muted-foreground">Messages loaded per session</span>
min={MIN_HISTORICAL} </div>
max={MAX_HISTORICAL} <div className="flex items-center gap-2">
onChange={handleHistoricalChange} {!isDefault && (
isMobile={isMobile} <span className="typography-meta text-muted-foreground/60">(default: {DEFAULT_MESSAGE_LIMIT})</span>
/> )}
{isMobile ? (
<MemoryLimitRow <MobileInput value={messageLimit} min={MIN_LIMIT} max={MAX_LIMIT} onChange={handleChange} />
label="Background trim limit" ) : (
description="Max messages kept when switching away" <NumberInput
value={memoryLimitViewport} value={messageLimit}
defaultValue={DEFAULT_MEMORY_LIMITS.VIEWPORT_MESSAGES} onValueChange={handleChange}
min={MIN_VIEWPORT} min={MIN_LIMIT}
max={MAX_VIEWPORT} max={MAX_LIMIT}
onChange={handleViewportChange} step={10}
isMobile={isMobile} aria-label="Message limit"
/> />
)}
<MemoryLimitRow </div>
label="Active session limit" </div>
description="Max messages for active session" </div>
value={memoryLimitActiveSession}
defaultValue={DEFAULT_ACTIVE_SESSION_WINDOW}
min={MIN_ACTIVE}
max={MAX_ACTIVE}
onChange={handleActiveSessionChange}
isMobile={isMobile}
/>
</div> </div>
</div> </div>
); );
}; };
interface MemoryLimitRowProps { const MobileInput: React.FC<{ value: number; min: number; max: number; onChange: (v: number) => void }> = ({
label: string;
description: string;
value: number;
defaultValue: number;
min: number;
max: number;
onChange: (value: number) => void;
isMobile: boolean;
}
const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
label,
description,
value, value,
defaultValue,
min, min,
max, max,
onChange, onChange,
isMobile,
}) => { }) => {
const [draft, setDraft] = React.useState(String(value)); const [draft, setDraft] = React.useState(String(value));
@@ -193,21 +139,16 @@ const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
setDraft(String(value)); setDraft(String(value));
}, [value]); }, [value]);
const handleMobileChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = e.target.value; const nextValue = e.target.value;
setDraft(nextValue); setDraft(nextValue);
if (nextValue.trim() === '') { if (nextValue.trim() === '') return;
return;
}
const parsed = Number(nextValue); const parsed = Number(nextValue);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) return;
return; onChange(Math.min(max, Math.max(min, Math.round(parsed))));
}
const clamped = Math.min(max, Math.max(min, Math.round(parsed)));
onChange(clamped);
}, [min, max, onChange]); }, [min, max, onChange]);
const handleMobileBlur = React.useCallback(() => { const handleBlur = React.useCallback(() => {
if (draft.trim() === '') { if (draft.trim() === '') {
setDraft(String(value)); setDraft(String(value));
return; return;
@@ -222,41 +163,15 @@ const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
setDraft(String(clamped)); setDraft(String(clamped));
}, [draft, value, min, max, onChange]); }, [draft, value, min, max, onChange]);
const isDefault = value === defaultValue;
return ( return (
<div className="flex flex-col gap-1"> <input
<div className="flex items-center justify-between gap-4"> type="number"
<div className="flex flex-col"> inputMode="numeric"
<span className="typography-ui-label text-foreground">{label}</span> value={draft}
<span className="typography-meta text-muted-foreground">{description}</span> onChange={handleChange}
</div> onBlur={handleBlur}
<div className="flex items-center gap-2"> aria-label="Message limit"
{!isDefault && ( className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50"
<span className="typography-meta text-muted-foreground/60">(default: {defaultValue})</span> />
)}
{isMobile ? (
<input
type="number"
inputMode="numeric"
value={draft}
onChange={handleMobileChange}
onBlur={handleMobileBlur}
aria-label={label}
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
) : (
<NumberInput
value={value}
onValueChange={onChange}
min={min}
max={max}
step={10}
aria-label={label}
/>
)}
</div>
</div>
</div>
); );
}; };
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore'; import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore';
import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
@@ -85,7 +86,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
<div className="typography-meta space-y-1 border-t pt-2"> <div className="typography-meta space-y-1 border-t pt-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Viewport Window:</span> <span className="text-muted-foreground">Viewport Window:</span>
<span>{MEMORY_LIMITS.VIEWPORT_MESSAGES} messages</span> <span>{getBackgroundTrimLimit()} messages</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Background Stream Limit:</span> <span className="text-muted-foreground">Background Stream Limit:</span>
@@ -119,7 +120,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`font-mono ${ <span className={`font-mono ${
stat.messageCount > MEMORY_LIMITS.VIEWPORT_MESSAGES ? 'text-status-warning' : '' stat.messageCount > getMessageLimit() ? 'text-status-warning' : ''
}`}> }`}>
{stat.messageCount} msgs {stat.messageCount} msgs
</span> </span>
+8 -8
View File
@@ -3,7 +3,7 @@ import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'
import { saveSessionCursor } from '@/lib/messageCursorPersistence'; import { saveSessionCursor } from '@/lib/messageCursorPersistence';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useMessageStore } from '@/stores/messageStore'; import { useMessageStore } from '@/stores/messageStore';
import { getActiveSessionWindow } from '@/stores/types/sessionTypes'; import { getMessageLimit } from '@/stores/types/sessionTypes';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -295,7 +295,7 @@ export const useEventStream = () => {
console.info('[useEventStream] Bootstrapping state:', reason); console.info('[useEventStream] Bootstrapping state:', reason);
} }
try { try {
const activeLimit = getActiveSessionWindow(); const activeLimit = getMessageLimit();
await Promise.all([ await Promise.all([
loadSessions(), loadSessions(),
currentSessionId ? resyncMessages(currentSessionId, reason, activeLimit) : Promise.resolve(), currentSessionId ? resyncMessages(currentSessionId, reason, activeLimit) : Promise.resolve(),
@@ -308,7 +308,7 @@ export const useEventStream = () => {
); );
const scheduleSoftResync = React.useCallback( const scheduleSoftResync = React.useCallback(
(sessionId: string, reason: string, limit = getActiveSessionWindow()): Promise<void> => { (sessionId: string, reason: string, limit = getMessageLimit()): Promise<void> => {
if (!sessionId) return Promise.resolve(); if (!sessionId) return Promise.resolve();
const memory = useSessionStore.getState().sessionMemoryState.get(sessionId); const memory = useSessionStore.getState().sessionMemoryState.get(sessionId);
@@ -542,7 +542,7 @@ export const useEventStream = () => {
} }
lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now()); lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now());
void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getActiveSessionWindow()) void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getMessageLimit())
.finally(() => { .finally(() => {
scheduleReconnectRef.current('No message events after busy status'); scheduleReconnectRef.current('No message events after busy status');
}); });
@@ -1619,7 +1619,7 @@ export const useEventStream = () => {
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
if (sessionId) { if (sessionId) {
setTimeout(() => { setTimeout(() => {
scheduleSoftResync(sessionId, 'sse_reconnected', getActiveSessionWindow()) scheduleSoftResync(sessionId, 'sse_reconnected', getMessageLimit())
.then(() => requestSessionMetadataRefresh(sessionId)) .then(() => requestSessionMetadataRefresh(sessionId))
.catch((error: unknown) => { .catch((error: unknown) => {
console.warn('[useEventStream] Failed to resync messages after reconnect:', error); console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
@@ -1778,7 +1778,7 @@ export const useEventStream = () => {
console.info('[useEventStream] Visibility restored, triggering soft refresh...'); console.info('[useEventStream] Visibility restored, triggering soft refresh...');
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
if (sessionId) { if (sessionId) {
scheduleSoftResync(sessionId, 'visibility_restore', getActiveSessionWindow()); scheduleSoftResync(sessionId, 'visibility_restore', getMessageLimit());
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
} }
@@ -1807,7 +1807,7 @@ export const useEventStream = () => {
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
if (sessionId) { if (sessionId) {
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow()); scheduleSoftResync(sessionId, 'window_focus', getMessageLimit());
} }
// Removed: void refreshSessionStatus(); // Removed: void refreshSessionStatus();
triggerSessionStatusPoll(); triggerSessionStatusPoll();
@@ -1848,7 +1848,7 @@ export const useEventStream = () => {
if (visibilityStateRef.current === 'visible') { if (visibilityStateRef.current === 'visible') {
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
if (sessionId) { if (sessionId) {
void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow()); void scheduleSoftResync(sessionId, 'page_show', getMessageLimit());
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
} }
// Removed: void refreshSessionStatus(); // Removed: void refreshSessionStatus();
+2 -4
View File
@@ -105,10 +105,8 @@ export type DesktopSettings = {
directoryShowHidden?: boolean; directoryShowHidden?: boolean;
filesViewShowGitignored?: boolean; filesViewShowGitignored?: boolean;
// Memory limits for message viewport management // Message limit — controls fetch, trim, and Load More chunk size (default: 200)
memoryLimitHistorical?: number; // Default fetch limit when loading/syncing (default: 90) messageLimit?: number;
memoryLimitViewport?: number; // Trim target when leaving session (default: 120)
memoryLimitActiveSession?: number; // Trim target for active session (default: 180)
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
skillCatalogs?: SkillCatalogConfig[]; skillCatalogs?: SkillCatalogConfig[];
+8 -16
View File
@@ -217,14 +217,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
} }
} }
if (typeof settings.memoryLimitHistorical === 'number' && Number.isFinite(settings.memoryLimitHistorical)) { // Apply server-persisted message limit. Ignore stale legacy values.
store.setMemoryLimitHistorical(settings.memoryLimitHistorical); const STALE_LIMITS = new Set([90, 120, 180, 220]);
} if (typeof settings.messageLimit === 'number' && Number.isFinite(settings.messageLimit)) {
if (typeof settings.memoryLimitViewport === 'number' && Number.isFinite(settings.memoryLimitViewport)) { if (!STALE_LIMITS.has(settings.messageLimit)) {
store.setMemoryLimitViewport(settings.memoryLimitViewport); store.setMessageLimit(settings.messageLimit);
} }
if (typeof settings.memoryLimitActiveSession === 'number' && Number.isFinite(settings.memoryLimitActiveSession)) {
store.setMemoryLimitActiveSession(settings.memoryLimitActiveSession);
} }
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
@@ -644,14 +642,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
result.openInAppId = candidate.openInAppId; result.openInAppId = candidate.openInAppId;
} }
if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) { if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
result.memoryLimitHistorical = candidate.memoryLimitHistorical; result.messageLimit = candidate.messageLimit;
}
if (typeof candidate.memoryLimitViewport === 'number' && Number.isFinite(candidate.memoryLimitViewport)) {
result.memoryLimitViewport = candidate.memoryLimitViewport;
}
if (typeof candidate.memoryLimitActiveSession === 'number' && Number.isFinite(candidate.memoryLimitActiveSession)) {
result.memoryLimitActiveSession = candidate.memoryLimitActiveSession;
} }
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
+152 -40
View File
@@ -5,7 +5,7 @@ import type { Message, Part } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client"; import { opencodeClient } from "@/lib/opencode/client";
import { isExecutionForkMetaText } from "@/lib/messages/executionMeta"; import { isExecutionForkMetaText } from "@/lib/messages/executionMeta";
import type { SessionMemoryState, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes"; import type { SessionMemoryState, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes";
import { MEMORY_LIMITS, getMemoryLimits } from "./types/sessionTypes"; import { MEMORY_LIMITS, getMemoryLimits, getBackgroundTrimLimit } from "./types/sessionTypes";
import { import {
touchStreamingLifecycle, touchStreamingLifecycle,
removeLifecycleEntries, removeLifecycleEntries,
@@ -396,20 +396,31 @@ export const useMessageStore = create<MessageStore>()(
loadMessages: async (sessionId: string, limit?: number) => { loadMessages: async (sessionId: string, limit?: number) => {
const memLimits = getMemoryLimits(); const memLimits = getMemoryLimits();
const noLimit = limit === Infinity; const noLimit = limit === Infinity;
const effectiveLimit = noLimit ? Infinity : (limit ?? memLimits.HISTORICAL_MESSAGES); const previousMemoryState = get().sessionMemoryState.get(sessionId);
const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming; // Use explicit limit if provided, otherwise current messageLimit.
const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit; // historyLimit is only respected when it's ABOVE messageLimit
// Don't pass Infinity to API - use undefined for "fetch all" // (i.e. user pressed Load More to expand beyond the default).
const fetchLimit = isStreaming || noLimit ? undefined : targetLimit + memLimits.FETCH_BUFFER; const baseLimit = memLimits.HISTORICAL_MESSAGES;
const userExpandedLimit = previousMemoryState?.historyLimit;
const targetLimit =
typeof limit === 'number' && Number.isFinite(limit)
? limit
: Math.max(baseLimit, userExpandedLimit ?? 0);
// Don't pass Infinity to API - use undefined for "fetch all".
// For finite loads, overfetch by 1 so hasMoreAbove is accurate.
const fetchLimit = noLimit ? undefined : targetLimit + 1;
const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit)); const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit));
// Filter out reverted messages first // Filter out reverted messages first
const revertMessageId = getSessionRevertMessageId(sessionId); const revertMessageId = getSessionRevertMessageId(sessionId);
const messagesWithoutReverted = filterRevertedMessages(allMessages, revertMessageId); const messagesWithoutReverted = filterRevertedMessages(allMessages, revertMessageId);
// If we fetched more than we keep (usually via buffer), there are older messages above. // Accurate older-history detection for finite loads.
// This intentionally ignores watermark filtering so "Load older" can remain available. // If server returns > targetLimit, there are older messages above current window.
const hasMoreAbove = messagesWithoutReverted.length > targetLimit; const hasMoreAbove = typeof fetchLimit === 'number'
? messagesWithoutReverted.length > targetLimit
: false;
const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId; const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
@@ -491,7 +502,6 @@ export const useMessageStore = create<MessageStore>()(
newMessages.set(sessionId, mergedMessages); newMessages.set(sessionId, mergedMessages);
const newMemoryState = new Map(state.sessionMemoryState); const newMemoryState = new Map(state.sessionMemoryState);
const previousMemoryState = state.sessionMemoryState.get(sessionId);
newMemoryState.set(sessionId, { newMemoryState.set(sessionId, {
...previousMemoryState, ...previousMemoryState,
viewportAnchor: mergedMessages.length - 1, viewportAnchor: mergedMessages.length - 1,
@@ -500,6 +510,9 @@ export const useMessageStore = create<MessageStore>()(
backgroundMessageCount: 0, backgroundMessageCount: 0,
totalAvailableMessages: previousMemoryState?.totalAvailableMessages, totalAvailableMessages: previousMemoryState?.totalAvailableMessages,
hasMoreAbove, hasMoreAbove,
historyLoading: false,
historyComplete: !hasMoreAbove,
historyLimit: targetLimit,
trimmedHeadMaxId: previousMemoryState?.trimmedHeadMaxId, trimmedHeadMaxId: previousMemoryState?.trimmedHeadMaxId,
streamingCooldownUntil: undefined, streamingCooldownUntil: undefined,
}); });
@@ -2239,7 +2252,7 @@ export const useMessageStore = create<MessageStore>()(
}, },
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => { trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => {
const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES; const effectiveTargetSize = targetSize ?? getBackgroundTrimLimit();
const state = get(); const state = get();
const sessionMessages = state.messages.get(sessionId); const sessionMessages = state.messages.get(sessionId);
if (!sessionMessages || sessionMessages.length <= effectiveTargetSize) { if (!sessionMessages || sessionMessages.length <= effectiveTargetSize) {
@@ -2428,44 +2441,67 @@ export const useMessageStore = create<MessageStore>()(
return; return;
} }
const memLimits = getMemoryLimits(); if (memoryState.historyLoading) {
// OpenCode may default to "last N" when limit is omitted. return;
// For "Load older" we progressively increase the tail window. }
const desiredLimit = Math.max(
currentMessages.length + memLimits.VIEWPORT_MESSAGES + memLimits.FETCH_BUFFER,
memLimits.HISTORICAL_MESSAGES + memLimits.FETCH_BUFFER,
);
const allMessages = await executeWithSessionDirectory( const memLimits = getMemoryLimits();
sessionId, const baseLimit = memoryState.historyLimit ?? Math.max(
() => opencodeClient.getSessionMessages(sessionId, desiredLimit) memLimits.HISTORICAL_MESSAGES + memLimits.FETCH_BUFFER,
currentMessages.length + memLimits.FETCH_BUFFER,
); );
const desiredLimit = direction === "up" ? baseLimit + memLimits.HISTORY_CHUNK : baseLimit;
set((snapshot) => {
const nextMemory = new Map(snapshot.sessionMemoryState);
const current = nextMemory.get(sessionId);
if (!current) return snapshot;
nextMemory.set(sessionId, {
...current,
historyLoading: true,
historyLimit: desiredLimit,
});
return { sessionMemoryState: nextMemory };
});
try {
const fetchLimit = desiredLimit + 1;
const allMessages = await executeWithSessionDirectory(
sessionId,
() => opencodeClient.getSessionMessages(sessionId, fetchLimit)
);
if (direction === "up" && currentMessages.length > 0) { if (direction === "up" && currentMessages.length > 0) {
const dedupedMessages = dedupeMessagesById(allMessages); const dedupedMessages = dedupeMessagesById(allMessages);
const hasPotentialMore = allMessages.length >= fetchLimit;
const firstCurrentMessage = currentMessages[0]; const firstCurrentMessage = currentMessages[0];
const indexInAll = dedupedMessages.findIndex((message) => message.info.id === firstCurrentMessage.info.id); const indexInAll = dedupedMessages.findIndex((message) => message.info.id === firstCurrentMessage.info.id);
if (indexInAll > 0) { if (indexInAll > 0) {
const loadCount = Math.min(MEMORY_LIMITS.VIEWPORT_MESSAGES, indexInAll); const loadCount = Math.min(memLimits.HISTORY_CHUNK, indexInAll);
const newMessages = dedupedMessages.slice(indexInAll - loadCount, indexInAll); const newMessages = dedupedMessages.slice(indexInAll - loadCount, indexInAll);
set((state) => { set((snapshot) => {
const updatedMessages = [...newMessages, ...currentMessages]; const latestCurrent = snapshot.messages.get(sessionId) ?? currentMessages;
const latestMemory = snapshot.sessionMemoryState.get(sessionId) ?? memoryState;
const updatedMessages = [...newMessages, ...latestCurrent];
const mergedMessages = dedupeMessagesById(updatedMessages); const mergedMessages = dedupeMessagesById(updatedMessages);
const addedCount = Math.max(0, mergedMessages.length - currentMessages.length); const addedCount = Math.max(0, mergedMessages.length - latestCurrent.length);
const newMessagesMap = new Map(state.messages); const newMessagesMap = new Map(snapshot.messages);
newMessagesMap.set(sessionId, mergedMessages); newMessagesMap.set(sessionId, mergedMessages);
const newMemoryState = new Map(state.sessionMemoryState); const newMemoryState = new Map(snapshot.sessionMemoryState);
newMemoryState.set(sessionId, { newMemoryState.set(sessionId, {
...memoryState, ...latestMemory,
viewportAnchor: memoryState.viewportAnchor + addedCount, viewportAnchor: latestMemory.viewportAnchor + addedCount,
hasMoreAbove: indexInAll - loadCount > 0, hasMoreAbove: indexInAll - loadCount > 0 || hasPotentialMore,
historyLoading: false,
historyLimit: desiredLimit,
historyComplete: !(indexInAll - loadCount > 0 || hasPotentialMore),
totalAvailableMessages: Math.max( totalAvailableMessages: Math.max(
memoryState.totalAvailableMessages ?? 0, latestMemory.totalAvailableMessages ?? 0,
dedupedMessages.length dedupedMessages.length,
), ),
}); });
@@ -2474,21 +2510,83 @@ export const useMessageStore = create<MessageStore>()(
sessionMemoryState: newMemoryState, sessionMemoryState: newMemoryState,
}; };
}); });
} else if (indexInAll === 0) { return;
set((state) => { }
const newMemoryState = new Map(state.sessionMemoryState);
if (indexInAll === 0) {
set((snapshot) => {
const latestMemory = snapshot.sessionMemoryState.get(sessionId) ?? memoryState;
const newMemoryState = new Map(snapshot.sessionMemoryState);
newMemoryState.set(sessionId, { newMemoryState.set(sessionId, {
...memoryState, ...latestMemory,
hasMoreAbove: false, hasMoreAbove: hasPotentialMore,
historyLoading: false,
historyLimit: desiredLimit,
historyComplete: !hasPotentialMore,
totalAvailableMessages: Math.max( totalAvailableMessages: Math.max(
memoryState.totalAvailableMessages ?? 0, latestMemory.totalAvailableMessages ?? 0,
dedupedMessages.length dedupedMessages.length,
), ),
}); });
return { sessionMemoryState: newMemoryState }; return { sessionMemoryState: newMemoryState };
}); });
return;
}
// Fallback for edge-cases where current head message is no longer present
// in fetched history window (e.g. filters/watermarks/reverts). We still
// merge any older unseen messages and keep "load older" discoverable.
if (indexInAll < 0) {
set((snapshot) => {
const latestCurrent = snapshot.messages.get(sessionId) ?? currentMessages;
const latestMemory = snapshot.sessionMemoryState.get(sessionId) ?? memoryState;
const currentIds = new Set(latestCurrent.map((message) => message.info.id));
const unseenMessages = dedupedMessages.filter((message) => !currentIds.has(message.info.id));
const mergedMessages = dedupeMessagesById([...unseenMessages, ...latestCurrent]);
const addedCount = Math.max(0, mergedMessages.length - latestCurrent.length);
const hasMoreAbove = unseenMessages.length > 0 || hasPotentialMore;
const newMessagesMap = new Map(snapshot.messages);
if (addedCount > 0) {
newMessagesMap.set(sessionId, mergedMessages);
}
const newMemoryState = new Map(snapshot.sessionMemoryState);
newMemoryState.set(sessionId, {
...latestMemory,
viewportAnchor: latestMemory.viewportAnchor + addedCount,
hasMoreAbove,
historyLoading: false,
historyLimit: desiredLimit,
historyComplete: !hasMoreAbove,
totalAvailableMessages: Math.max(
latestMemory.totalAvailableMessages ?? 0,
dedupedMessages.length,
),
});
return {
messages: newMessagesMap,
sessionMemoryState: newMemoryState,
};
});
return;
} }
} }
} finally {
set((snapshot) => {
const latestMemory = snapshot.sessionMemoryState.get(sessionId);
if (!latestMemory || !latestMemory.historyLoading) {
return snapshot;
}
const newMemoryState = new Map(snapshot.sessionMemoryState);
newMemoryState.set(sessionId, {
...latestMemory,
historyLoading: false,
});
return { sessionMemoryState: newMemoryState };
});
}
}, },
getLastMessageModel: (sessionId: string) => { getLastMessageModel: (sessionId: string) => {
@@ -2526,6 +2624,9 @@ export const useMessageStore = create<MessageStore>()(
backgroundMessageCount: memory.backgroundMessageCount, backgroundMessageCount: memory.backgroundMessageCount,
totalAvailableMessages: memory.totalAvailableMessages, totalAvailableMessages: memory.totalAvailableMessages,
hasMoreAbove: memory.hasMoreAbove, hasMoreAbove: memory.hasMoreAbove,
historyLoading: memory.historyLoading,
historyComplete: memory.historyComplete,
historyLimit: memory.historyLimit,
trimmedHeadMaxId: memory.trimmedHeadMaxId, trimmedHeadMaxId: memory.trimmedHeadMaxId,
}, },
]), ]),
@@ -2542,7 +2643,18 @@ export const useMessageStore = create<MessageStore>()(
let restoredMemoryState = currentState.sessionMemoryState; let restoredMemoryState = currentState.sessionMemoryState;
if (Array.isArray(persistedState.sessionMemoryState)) { if (Array.isArray(persistedState.sessionMemoryState)) {
restoredMemoryState = new Map<string, SessionMemoryState>( restoredMemoryState = new Map<string, SessionMemoryState>(
persistedState.sessionMemoryState.map((entry: [string, SessionMemoryState]) => entry) persistedState.sessionMemoryState.map((entry: [string, SessionMemoryState]) => {
const [id, memory] = entry;
// Never trust persisted history flags — they must be
// recomputed from a fresh API fetch on session open.
return [id, {
...memory,
hasMoreAbove: undefined,
historyComplete: undefined,
historyLimit: undefined,
historyLoading: false,
}] as [string, SessionMemoryState];
})
); );
} }
+39 -23
View File
@@ -33,6 +33,9 @@ export interface SessionMemoryState {
isZombie?: boolean; isZombie?: boolean;
totalAvailableMessages?: number; totalAvailableMessages?: number;
hasMoreAbove?: boolean; hasMoreAbove?: boolean;
historyLoading?: boolean;
historyComplete?: boolean;
historyLimit?: number;
trimmedHeadMaxId?: string; trimmedHeadMaxId?: string;
streamingCooldownUntil?: number; streamingCooldownUntil?: number;
lastUserMessageAt?: number; // Timestamp when user last sent a message lastUserMessageAt?: number; // Timestamp when user last sent a message
@@ -48,44 +51,57 @@ export interface SessionContextUsage {
lastMessageId?: string; lastMessageId?: string;
} }
// Default memory limits (can be overridden via settings) // Default message limit (can be overridden via settings).
export const DEFAULT_MEMORY_LIMITS = { // Single value controls: fetch from server, active session ceiling, Load More chunk.
// Background trim is derived automatically as Math.round(limit * 0.6).
export const DEFAULT_MESSAGE_LIMIT = 200;
export const MEMORY_CONSTANTS = {
MAX_SESSIONS: 3, MAX_SESSIONS: 3,
VIEWPORT_MESSAGES: 120,
HISTORICAL_MESSAGES: 90,
FETCH_BUFFER: 20,
STREAMING_BUFFER: Infinity,
BACKGROUND_STREAMING_BUFFER: 120, BACKGROUND_STREAMING_BUFFER: 120,
ZOMBIE_TIMEOUT: 10 * 60 * 1000, ZOMBIE_TIMEOUT: 10 * 60 * 1000,
} as const; } as const;
export const DEFAULT_ACTIVE_SESSION_WINDOW = 180; // Dynamic accessors — read user setting from UI store.
// Dynamic memory limits accessor - reads directly from UI store.
// NOTE: do not use require() here (breaks in browser/desktop runtime bundles). // NOTE: do not use require() here (breaks in browser/desktop runtime bundles).
import { useUIStore } from "../useUIStore"; import { useUIStore } from "../useUIStore";
export const getMemoryLimits = () => { /** User-configured (or default) message limit. */
export const getMessageLimit = (): number => {
const state = useUIStore.getState?.(); const state = useUIStore.getState?.();
if (!state) { return state?.messageLimit ?? DEFAULT_MESSAGE_LIMIT;
return DEFAULT_MEMORY_LIMITS; };
}
/** Background trim target — automatic, not user-facing. */
export const getBackgroundTrimLimit = (): number =>
Math.round(getMessageLimit() * 0.6);
// --- Backward-compat shims (avoid mass refactor of non-critical callers) ---
export const DEFAULT_MEMORY_LIMITS = {
MAX_SESSIONS: MEMORY_CONSTANTS.MAX_SESSIONS,
VIEWPORT_MESSAGES: Math.round(DEFAULT_MESSAGE_LIMIT * 0.6),
HISTORICAL_MESSAGES: DEFAULT_MESSAGE_LIMIT,
FETCH_BUFFER: 20,
HISTORY_CHUNK: DEFAULT_MESSAGE_LIMIT,
STREAMING_BUFFER: Infinity,
BACKGROUND_STREAMING_BUFFER: MEMORY_CONSTANTS.BACKGROUND_STREAMING_BUFFER,
ZOMBIE_TIMEOUT: MEMORY_CONSTANTS.ZOMBIE_TIMEOUT,
} as const;
export const getMemoryLimits = () => {
const limit = getMessageLimit();
const bgTrim = getBackgroundTrimLimit();
return { return {
...DEFAULT_MEMORY_LIMITS, ...DEFAULT_MEMORY_LIMITS,
HISTORICAL_MESSAGES: state.memoryLimitHistorical ?? DEFAULT_MEMORY_LIMITS.HISTORICAL_MESSAGES, HISTORICAL_MESSAGES: limit,
VIEWPORT_MESSAGES: state.memoryLimitViewport ?? DEFAULT_MEMORY_LIMITS.VIEWPORT_MESSAGES, VIEWPORT_MESSAGES: bgTrim,
HISTORY_CHUNK: limit,
}; };
}; };
export const getActiveSessionWindow = () => { export const getActiveSessionWindow = () => getMessageLimit();
const state = useUIStore.getState?.();
if (!state) {
return DEFAULT_ACTIVE_SESSION_WINDOW;
}
return state.memoryLimitActiveSession ?? DEFAULT_ACTIVE_SESSION_WINDOW;
};
// Legacy exports for backward compatibility (use getMemoryLimits() for dynamic values) export const DEFAULT_ACTIVE_SESSION_WINDOW = DEFAULT_MESSAGE_LIMIT;
export const MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS; export const MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS;
export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW; export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW;
+9 -4
View File
@@ -5,7 +5,7 @@ import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import type { PermissionRequest, PermissionResponse } from "@/types/permission";
import type { QuestionRequest } from "@/types/question"; import type { QuestionRequest } from "@/types/question";
import type { SessionStore, AttachedFile, EditPermissionMode, SyntheticContextPart } from "./types/sessionTypes"; import type { SessionStore, AttachedFile, EditPermissionMode, SyntheticContextPart } from "./types/sessionTypes";
import { getActiveSessionWindow, getMemoryLimits } from "./types/sessionTypes"; import { getMessageLimit, getBackgroundTrimLimit } from "./types/sessionTypes";
import { useSessionStore as useSessionManagementStore } from "./sessionStore"; import { useSessionStore as useSessionManagementStore } from "./sessionStore";
import { useMessageStore } from "./messageStore"; import { useMessageStore } from "./messageStore";
@@ -292,7 +292,7 @@ export const useSessionStore = create<SessionStore>()(
get().updateViewportAnchor(previousSessionId, previousMessages.length - 1); get().updateViewportAnchor(previousSessionId, previousMessages.length - 1);
} }
get().trimToViewportWindow(previousSessionId, getMemoryLimits().VIEWPORT_MESSAGES); get().trimToViewportWindow(previousSessionId, getBackgroundTrimLimit());
} }
} }
@@ -301,12 +301,17 @@ export const useSessionStore = create<SessionStore>()(
if (id) { if (id) {
const existingMessages = get().messages.get(id); const existingMessages = get().messages.get(id);
if (!existingMessages) { const memoryState = get().sessionMemoryState.get(id);
const needsHistoryBootstrap =
!memoryState ||
memoryState.historyComplete === undefined;
if (!existingMessages || needsHistoryBootstrap) {
await get().loadMessages(id); await get().loadMessages(id);
} }
get().trimToViewportWindow(id, getActiveSessionWindow()); get().trimToViewportWindow(id, getMessageLimit());
// Analyze session messages to extract agent/model/variant choices // Analyze session messages to extract agent/model/variant choices
// This ensures context is available even when ModelControls isn't mounted // This ensures context is available even when ModelControls isn't mounted
+42 -37
View File
@@ -88,9 +88,7 @@ interface UIStore {
autoDeleteEnabled: boolean; autoDeleteEnabled: boolean;
autoDeleteAfterDays: number; autoDeleteAfterDays: number;
autoDeleteLastRunAt: number | null; autoDeleteLastRunAt: number | null;
memoryLimitHistorical: number; messageLimit: number;
memoryLimitViewport: number;
memoryLimitActiveSession: number;
toolCallExpansion: 'collapsed' | 'activity' | 'detailed'; toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
fontSize: number; fontSize: number;
@@ -172,9 +170,7 @@ interface UIStore {
setAutoDeleteEnabled: (value: boolean) => void; setAutoDeleteEnabled: (value: boolean) => void;
setAutoDeleteAfterDays: (days: number) => void; setAutoDeleteAfterDays: (days: number) => void;
setAutoDeleteLastRunAt: (timestamp: number | null) => void; setAutoDeleteLastRunAt: (timestamp: number | null) => void;
setMemoryLimitHistorical: (value: number) => void; setMessageLimit: (value: number) => void;
setMemoryLimitViewport: (value: number) => void;
setMemoryLimitActiveSession: (value: number) => void;
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void; setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void;
setFontSize: (size: number) => void; setFontSize: (size: number) => void;
setTerminalFontSize: (size: number) => void; setTerminalFontSize: (size: number) => void;
@@ -256,9 +252,7 @@ export const useUIStore = create<UIStore>()(
autoDeleteEnabled: false, autoDeleteEnabled: false,
autoDeleteAfterDays: 30, autoDeleteAfterDays: 30,
autoDeleteLastRunAt: null, autoDeleteLastRunAt: null,
memoryLimitHistorical: 90, messageLimit: 200,
memoryLimitViewport: 120,
memoryLimitActiveSession: 180,
toolCallExpansion: 'collapsed', toolCallExpansion: 'collapsed',
fontSize: 100, fontSize: 100,
terminalFontSize: 13, terminalFontSize: 13,
@@ -568,19 +562,9 @@ export const useUIStore = create<UIStore>()(
set({ autoDeleteLastRunAt: timestamp }); set({ autoDeleteLastRunAt: timestamp });
}, },
setMemoryLimitHistorical: (value) => { setMessageLimit: (value) => {
const clamped = Math.max(10, Math.min(500, Math.round(value))); const clamped = Math.max(10, Math.min(500, Math.round(value)));
set({ memoryLimitHistorical: clamped }); set({ messageLimit: clamped });
},
setMemoryLimitViewport: (value) => {
const clamped = Math.max(20, Math.min(500, Math.round(value)));
set({ memoryLimitViewport: clamped });
},
setMemoryLimitActiveSession: (value) => {
const clamped = Math.max(30, Math.min(1000, Math.round(value)));
set({ memoryLimitActiveSession: clamped });
}, },
setToolCallExpansion: (value) => { setToolCallExpansion: (value) => {
@@ -881,24 +865,47 @@ export const useUIStore = create<UIStore>()(
{ {
name: 'ui-store', name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()), storage: createJSONStorage(() => getSafeStorage()),
version: 1, version: 3,
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
if (version >= 1 || !persistedState || typeof persistedState !== 'object') { if (!persistedState || typeof persistedState !== 'object') {
return persistedState; return persistedState;
} }
const state = persistedState as Record<string, unknown>; const state = persistedState as Record<string, unknown>;
if (!isLegacyDefaultTemplates(state.notificationTemplates)) {
return persistedState; // v0 -> v1: reset legacy notification templates
if (version < 1) {
if (isLegacyDefaultTemplates(state.notificationTemplates)) {
state.notificationTemplates = {
completion: { ...EMPTY_NOTIFICATION_TEMPLATES.completion },
error: { ...EMPTY_NOTIFICATION_TEMPLATES.error },
question: { ...EMPTY_NOTIFICATION_TEMPLATES.question },
subtask: { ...EMPTY_NOTIFICATION_TEMPLATES.subtask },
};
}
} }
return {
...state, // v2 -> v3: collapse 3 memory-limit fields into single messageLimit.
notificationTemplates: { // Pick the best user-customised value (prefer historical, fall back to active).
completion: { ...EMPTY_NOTIFICATION_TEMPLATES.completion }, // Discard old defaults (90/120/180) — they become the new single default (200).
error: { ...EMPTY_NOTIFICATION_TEMPLATES.error }, if (version < 3) {
question: { ...EMPTY_NOTIFICATION_TEMPLATES.question }, const OLD_DEFAULTS = new Set([90, 120, 180, 220]);
subtask: { ...EMPTY_NOTIFICATION_TEMPLATES.subtask }, const hist = state.memoryLimitHistorical as number | undefined;
}, const active = state.memoryLimitActiveSession as number | undefined;
};
// If user had a non-default custom value, keep it as the new messageLimit.
if (typeof hist === 'number' && !OLD_DEFAULTS.has(hist)) {
state.messageLimit = hist;
} else if (typeof active === 'number' && !OLD_DEFAULTS.has(active)) {
state.messageLimit = active;
}
// Otherwise leave undefined → Zustand uses the initial default (200).
delete state.memoryLimitHistorical;
delete state.memoryLimitViewport;
delete state.memoryLimitActiveSession;
}
return state;
}, },
partialize: (state) => ({ partialize: (state) => ({
theme: state.theme, theme: state.theme,
@@ -918,9 +925,7 @@ export const useUIStore = create<UIStore>()(
autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteEnabled: state.autoDeleteEnabled,
autoDeleteAfterDays: state.autoDeleteAfterDays, autoDeleteAfterDays: state.autoDeleteAfterDays,
autoDeleteLastRunAt: state.autoDeleteLastRunAt, autoDeleteLastRunAt: state.autoDeleteLastRunAt,
memoryLimitHistorical: state.memoryLimitHistorical, messageLimit: state.messageLimit,
memoryLimitViewport: state.memoryLimitViewport,
memoryLimitActiveSession: state.memoryLimitActiveSession,
toolCallExpansion: state.toolCallExpansion, toolCallExpansion: state.toolCallExpansion,
fontSize: state.fontSize, fontSize: state.fontSize,
terminalFontSize: state.terminalFontSize, terminalFontSize: state.terminalFontSize,
+3 -9
View File
@@ -1507,15 +1507,9 @@ const sanitizeSettingsUpdate = (payload) => {
} }
} }
// Memory limits for message viewport management // Message limit — single setting for fetch / trim / Load More chunk
if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) { if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
result.memoryLimitHistorical = Math.max(10, Math.min(500, Math.round(candidate.memoryLimitHistorical))); result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
}
if (typeof candidate.memoryLimitViewport === 'number' && Number.isFinite(candidate.memoryLimitViewport)) {
result.memoryLimitViewport = Math.max(20, Math.min(500, Math.round(candidate.memoryLimitViewport)));
}
if (typeof candidate.memoryLimitActiveSession === 'number' && Number.isFinite(candidate.memoryLimitActiveSession)) {
result.memoryLimitActiveSession = Math.max(30, Math.min(1000, Math.round(candidate.memoryLimitActiveSession)));
} }
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);