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 { RiArrowDownLine } from '@remixicon/react';
import { useShallow } from 'zustand/react/shallow';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { ChatInput } from './ChatInput';
import { useSessionStore } from '@/stores/useSessionStore';
@@ -10,39 +12,99 @@ import MessageList from './MessageList';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useDeviceInfo } from '@/lib/device';
import { getMemoryLimits } from '@/stores/types/sessionTypes';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
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 = () => {
const {
currentSessionId,
messages,
permissions,
questions,
streamingMessageIds,
isLoading,
loadMessages,
loadMoreMessages,
updateViewportAnchor,
sessionMemoryState,
openNewSessionDraft,
isSyncing,
messageStreamStates,
trimToViewportWindow,
sessionStatus,
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 {
isTimelineDialogOpen,
setTimelineDialogOpen,
} = useUIStore();
const streamingMessageId = React.useMemo(() => {
if (!currentSessionId) return null;
return streamingMessageIds.get(currentSessionId) ?? null;
}, [currentSessionId, streamingMessageIds]);
const sessionMessages = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES),
[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 draftOpen = Boolean(newSessionDraft?.open);
@@ -53,18 +115,90 @@ export const ChatContainer: React.FC = () => {
}
}, [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) || [] : [];
}, [currentSessionId, messages]);
const userTurnIndexes = React.useMemo(() => {
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(() => {
return currentSessionId ? permissions.get(currentSessionId) || [] : [];
}, [currentSessionId, permissions]);
const cancelTurnBackfill = React.useCallback(() => {
const handle = turnHandleRef.current;
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(() => {
return currentSessionId ? questions.get(currentSessionId) || [] : [];
}, [currentSessionId, questions]);
const renderedSessionMessages = React.useMemo(() => {
if (turnStart <= 0 || userTurnIndexes.length === 0) {
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(() => {
return [...sessionPermissions, ...sessionQuestions];
@@ -80,9 +214,9 @@ export const ChatContainer: React.FC = () => {
isPinned,
} = useChatScrollManager({
currentSessionId,
sessionMessages,
sessionMessages: renderedSessionMessages,
streamingMessageId,
sessionMemoryState,
sessionMemoryState: sessionMemoryStateMap,
updateViewportAnchor,
isSyncing,
isMobile,
@@ -91,13 +225,46 @@ export const ChatContainer: React.FC = () => {
trimToViewportWindow,
});
const memoryState = React.useMemo(() => {
React.useEffect(() => {
cancelTurnBackfill();
if (!currentSessionId) {
return null;
setTurnStart(0);
return;
}
return sessionMemoryState.get(currentSessionId) ?? null;
}, [currentSessionId, sessionMemoryState]);
const hasMoreAbove = Boolean(memoryState?.hasMoreAbove);
const turnCount = userTurnIndexes.length;
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);
React.useEffect(() => {
setIsLoadingOlder(false);
@@ -108,6 +275,9 @@ export const ChatContainer: React.FC = () => {
return;
}
cancelTurnBackfill();
setTurnStart(0);
const container = scrollRef.current;
const prevHeight = container?.scrollHeight ?? null;
const prevTop = container?.scrollTop ?? null;
@@ -122,7 +292,12 @@ export const ChatContainer: React.FC = () => {
} finally {
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)
const scrollToMessage = React.useCallback((messageId: string) => {
@@ -150,7 +325,7 @@ export const ChatContainer: React.FC = () => {
return;
}
const hasSessionMessages = messages.has(currentSessionId);
const hasSessionMessages = hasSessionMessagesEntry;
if (hasSessionMessages) {
return;
}
@@ -159,7 +334,7 @@ export const ChatContainer: React.FC = () => {
try {
await loadMessages(currentSessionId);
} finally {
const statusType = sessionStatus?.get(currentSessionId)?.type ?? 'idle';
const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
// When pinned and active, scroll is already maintained automatically
const shouldSkipScroll = isActivePhase && isPinned;
@@ -177,7 +352,7 @@ export const ChatContainer: React.FC = () => {
};
void load();
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionStatus]);
}, [currentSessionId, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
if (!currentSessionId && !draftOpen) {
return (
@@ -211,7 +386,7 @@ export const ChatContainer: React.FC = () => {
}
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
const hasMessagesEntry = messages.has(currentSessionId);
const hasMessagesEntry = hasSessionMessagesEntry;
if (!hasMessagesEntry) {
return (
<div
@@ -270,7 +445,7 @@ export const ChatContainer: React.FC = () => {
>
<div className="relative z-0 min-h-full">
<MessageList
messages={sessionMessages}
messages={renderedSessionMessages}
permissions={sessionPermissions}
questions={sessionQuestions}
onMessageContentChange={handleMessageContentChange}
@@ -278,6 +453,8 @@ export const ChatContainer: React.FC = () => {
hasMoreAbove={hasMoreAbove}
isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder}
hasRenderEarlier={turnStart > 0}
onRenderEarlier={handleRenderEarlier}
scrollToBottom={scrollToBottom}
/>
</div>
@@ -25,6 +25,8 @@ interface MessageListProps {
hasMoreAbove: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
hasRenderEarlier?: boolean;
onRenderEarlier?: () => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
}
@@ -198,6 +200,8 @@ const MessageList: React.FC<MessageListProps> = ({
hasMoreAbove,
isLoadingOlder,
onLoadOlder,
hasRenderEarlier,
onRenderEarlier,
scrollToBottom,
}) => {
React.useEffect(() => {
@@ -256,6 +260,18 @@ const MessageList: React.FC<MessageListProps> = ({
return (
<TurnGroupingProvider messages={displayMessages}>
<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 && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
@@ -52,6 +52,7 @@ interface TurnGroupingUiStateData {
// Streaming state that changes frequently during assistant response
interface TurnGroupingStreamingData {
sessionIsWorking: boolean;
lastTurnActivityInfo?: TurnActivityInfo;
}
// Separate contexts to prevent unnecessary re-renders
@@ -86,6 +87,9 @@ export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupin
if (!isAssistantMessage) return undefined;
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
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
// - sessionIsWorking (last turn only): streaming state affects "working" indicator
const cacheKey = isLastTurn
? `${messageId}-${isExpanded}-${streamingData.sessionIsWorking}`
? `${messageId}-${isExpanded}-${streamingData.sessionIsWorking}-${lastTurnActivityVersion}`
: `${messageId}-${isExpanded}`;
const cached = contextCache.get(cacheKey);
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 activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
const hasTools = Boolean(activityInfo?.hasTools);
@@ -498,14 +504,43 @@ const buildNeighborMap = (messages: ChatMessageEntry[]): Map<string, NeighborInf
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 }) => {
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
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 cached = staticCacheRef.current;
if (
cached &&
cached.structureKey === structureKey &&
cached.defaultActivityExpanded === defaultActivityExpanded &&
cached.showTextJustificationActivity === showTextJustificationActivity
) {
return cached.value;
}
const turns = detectTurns(messages);
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>();
turns.forEach((turn) => {
if (turn.turnId === lastTurnId) return;
turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity));
});
@@ -534,7 +570,7 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
});
}
return {
const value: TurnGroupingStaticData = {
turns,
messageToTurn,
turnActivityInfo,
@@ -543,7 +579,36 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
defaultActivityExpanded,
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
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
const streamingValue = React.useMemo<TurnGroupingStreamingData>(() => ({
sessionIsWorking,
}), [sessionIsWorking]);
lastTurnActivityInfo,
}), [lastTurnActivityInfo, sessionIsWorking]);
return (
<TurnGroupingStaticContext.Provider value={staticValue}>
@@ -6,24 +6,16 @@ import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence';
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 MAX_HISTORICAL = 500;
const MIN_VIEWPORT = 20;
const MAX_VIEWPORT = 500;
const MIN_ACTIVE = 30;
const MAX_ACTIVE = 1000;
const MIN_LIMIT = 10;
const MAX_LIMIT = 500;
export const MemoryLimitsSettings: React.FC = () => {
const { isMobile } = useDeviceInfo();
const memoryLimitHistorical = useUIStore((state) => state.memoryLimitHistorical);
const memoryLimitViewport = useUIStore((state) => state.memoryLimitViewport);
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 messageLimit = useUIStore((state) => state.messageLimit);
const setMessageLimit = useUIStore((state) => state.setMessageLimit);
const [isLoading, setIsLoading] = React.useState(true);
@@ -31,7 +23,7 @@ export const MemoryLimitsSettings: React.FC = () => {
React.useEffect(() => {
const loadSettings = async () => {
try {
let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null;
let data: { messageLimit?: number } | null = null;
// 1. Runtime settings API (VSCode)
if (!data) {
@@ -42,9 +34,7 @@ export const MemoryLimitsSettings: React.FC = () => {
const settings = result?.settings as Record<string, unknown> | undefined;
if (settings) {
data = {
memoryLimitHistorical: typeof settings.memoryLimitHistorical === 'number' ? settings.memoryLimitHistorical : undefined,
memoryLimitViewport: typeof settings.memoryLimitViewport === 'number' ? settings.memoryLimitViewport : undefined,
memoryLimitActiveSession: typeof settings.memoryLimitActiveSession === 'number' ? settings.memoryLimitActiveSession : undefined,
messageLimit: typeof settings.messageLimit === 'number' ? settings.messageLimit : undefined,
};
}
} catch {
@@ -64,16 +54,8 @@ export const MemoryLimitsSettings: React.FC = () => {
}
}
if (data) {
if (typeof data.memoryLimitHistorical === 'number') {
setMemoryLimitHistorical(data.memoryLimitHistorical);
}
if (typeof data.memoryLimitViewport === 'number') {
setMemoryLimitViewport(data.memoryLimitViewport);
}
if (typeof data.memoryLimitActiveSession === 'number') {
setMemoryLimitActiveSession(data.memoryLimitActiveSession);
}
if (data && typeof data.messageLimit === 'number') {
setMessageLimit(data.messageLimit);
}
} catch (error) {
console.warn('Failed to load memory limits settings:', error);
@@ -82,35 +64,21 @@ export const MemoryLimitsSettings: React.FC = () => {
}
};
loadSettings();
}, [setMemoryLimitHistorical, setMemoryLimitViewport, setMemoryLimitActiveSession]);
}, [setMessageLimit]);
const persistSetting = React.useCallback(async (key: string, value: number) => {
try {
await updateDesktopSettings({ [key]: value });
} catch (error) {
console.warn(`Failed to save ${key}:`, error);
}
}, []);
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]);
const handleChange = React.useCallback((value: number) => {
setMessageLimit(value);
void updateDesktopSettings({ messageLimit: value }).catch((error: unknown) => {
console.warn('Failed to save messageLimit:', error);
});
}, [setMessageLimit]);
if (isLoading) {
return null;
}
const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT;
return (
<div className="space-y-4">
<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" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Control how many messages are kept in memory for performance optimization.<br />
Lower values use less memory but may require reloading older messages.
How many messages to keep in view per session.<br />
Older messages are available via "Load more". Background sessions are trimmed automatically.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-3">
<MemoryLimitRow
label="Initial load limit"
description="Messages loaded when opening a session"
value={memoryLimitHistorical}
defaultValue={DEFAULT_MEMORY_LIMITS.HISTORICAL_MESSAGES}
min={MIN_HISTORICAL}
max={MAX_HISTORICAL}
onChange={handleHistoricalChange}
isMobile={isMobile}
/>
<MemoryLimitRow
label="Background trim limit"
description="Max messages kept when switching away"
value={memoryLimitViewport}
defaultValue={DEFAULT_MEMORY_LIMITS.VIEWPORT_MESSAGES}
min={MIN_VIEWPORT}
max={MAX_VIEWPORT}
onChange={handleViewportChange}
isMobile={isMobile}
/>
<MemoryLimitRow
label="Active session limit"
description="Max messages for active session"
value={memoryLimitActiveSession}
defaultValue={DEFAULT_ACTIVE_SESSION_WINDOW}
min={MIN_ACTIVE}
max={MAX_ACTIVE}
onChange={handleActiveSessionChange}
isMobile={isMobile}
/>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">Message limit</span>
<span className="typography-meta text-muted-foreground">Messages loaded per session</span>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="typography-meta text-muted-foreground/60">(default: {DEFAULT_MESSAGE_LIMIT})</span>
)}
{isMobile ? (
<MobileInput value={messageLimit} min={MIN_LIMIT} max={MAX_LIMIT} onChange={handleChange} />
) : (
<NumberInput
value={messageLimit}
onValueChange={handleChange}
min={MIN_LIMIT}
max={MAX_LIMIT}
step={10}
aria-label="Message limit"
/>
)}
</div>
</div>
</div>
</div>
</div>
);
};
interface MemoryLimitRowProps {
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,
const MobileInput: React.FC<{ value: number; min: number; max: number; onChange: (v: number) => void }> = ({
value,
defaultValue,
min,
max,
onChange,
isMobile,
}) => {
const [draft, setDraft] = React.useState(String(value));
@@ -193,21 +139,16 @@ const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
setDraft(String(value));
}, [value]);
const handleMobileChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = e.target.value;
setDraft(nextValue);
if (nextValue.trim() === '') {
return;
}
if (nextValue.trim() === '') return;
const parsed = Number(nextValue);
if (!Number.isFinite(parsed)) {
return;
}
const clamped = Math.min(max, Math.max(min, Math.round(parsed)));
onChange(clamped);
if (!Number.isFinite(parsed)) return;
onChange(Math.min(max, Math.max(min, Math.round(parsed))));
}, [min, max, onChange]);
const handleMobileBlur = React.useCallback(() => {
const handleBlur = React.useCallback(() => {
if (draft.trim() === '') {
setDraft(String(value));
return;
@@ -222,41 +163,15 @@ const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
setDraft(String(clamped));
}, [draft, value, min, max, onChange]);
const isDefault = value === defaultValue;
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">{label}</span>
<span className="typography-meta text-muted-foreground">{description}</span>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<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>
<input
type="number"
inputMode="numeric"
value={draft}
onChange={handleChange}
onBlur={handleBlur}
aria-label="Message limit"
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"
/>
);
};
@@ -1,5 +1,6 @@
import React from 'react';
import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore';
import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
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="flex justify-between">
<span className="text-muted-foreground">Viewport Window:</span>
<span>{MEMORY_LIMITS.VIEWPORT_MESSAGES} messages</span>
<span>{getBackgroundTrimLimit()} messages</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Background Stream Limit:</span>
@@ -119,7 +120,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
</div>
<div className="flex items-center gap-2">
<span className={`font-mono ${
stat.messageCount > MEMORY_LIMITS.VIEWPORT_MESSAGES ? 'text-status-warning' : ''
stat.messageCount > getMessageLimit() ? 'text-status-warning' : ''
}`}>
{stat.messageCount} msgs
</span>