From 252d4bd5aba9b0bae7ba8c1e7535c94519be8eb5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 12 Feb 2026 20:36:02 +0200 Subject: [PATCH] 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) --- .../ui/src/components/chat/ChatContainer.tsx | 247 +++++++++++++++--- .../ui/src/components/chat/MessageList.tsx | 16 ++ .../chat/contexts/TurnGroupingContext.tsx | 78 +++++- .../openchamber/MemoryLimitsSettings.tsx | 207 +++++---------- .../ui/src/components/ui/MemoryDebugPanel.tsx | 5 +- packages/ui/src/hooks/useEventStream.ts | 16 +- packages/ui/src/lib/desktop.ts | 6 +- packages/ui/src/lib/persistence.ts | 24 +- packages/ui/src/stores/messageStore.ts | 192 +++++++++++--- packages/ui/src/stores/types/sessionTypes.ts | 62 +++-- packages/ui/src/stores/useSessionStore.ts | 13 +- packages/ui/src/stores/useUIStore.ts | 79 +++--- packages/web/server/index.js | 12 +- 13 files changed, 627 insertions(+), 330 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 2fdeabf2..fbe685ee 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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(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 (
{ >
{ hasMoreAbove={hasMoreAbove} isLoadingOlder={isLoadingOlder} onLoadOlder={handleLoadOlder} + hasRenderEarlier={turnStart > 0} + onRenderEarlier={handleRenderEarlier} scrollToBottom={scrollToBottom} />
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 5b516afb..e8808f58 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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; } @@ -198,6 +200,8 @@ const MessageList: React.FC = ({ hasMoreAbove, isLoadingOlder, onLoadOlder, + hasRenderEarlier, + onRenderEarlier, scrollToBottom, }) => { React.useEffect(() => { @@ -256,6 +260,18 @@ const MessageList: React.FC = ({ return (
+ {hasRenderEarlier && ( +
+ +
+ )} + {hasMoreAbove && (
{isLoadingOlder ? ( diff --git a/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx index 4de4c6ab..deb4170f 100644 --- a/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx +++ b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx @@ -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 { + 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 = ({ 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(() => { + 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 = ({ mess const turnActivityInfo = new Map(); turns.forEach((turn) => { + if (turn.turnId === lastTurnId) return; turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity)); }); @@ -534,7 +570,7 @@ export const TurnGroupingProvider: React.FC = ({ mess }); } - return { + const value: TurnGroupingStaticData = { turns, messageToTurn, turnActivityInfo, @@ -543,7 +579,36 @@ export const TurnGroupingProvider: React.FC = ({ mess defaultActivityExpanded, messageNeighbors, }; - }, [messages, defaultActivityExpanded, showTextJustificationActivity]); + + staticCacheRef.current = { + structureKey, + defaultActivityExpanded, + showTextJustificationActivity, + value, + }; + + return value; + }, [defaultActivityExpanded, messages, showTextJustificationActivity, structureKey]); + + const lastTurnActivityInfo = React.useMemo(() => { + 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>( @@ -573,7 +638,8 @@ export const TurnGroupingProvider: React.FC = ({ mess // Streaming state - changes frequently during assistant response const streamingValue = React.useMemo(() => ({ sessionIsWorking, - }), [sessionIsWorking]); + lastTurnActivityInfo, + }), [lastTurnActivityInfo, sessionIsWorking]); return ( diff --git a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx index de81deaf..fa00b850 100644 --- a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx @@ -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 | 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 (
@@ -121,71 +89,49 @@ export const MemoryLimitsSettings: React.FC = () => { - Control how many messages are kept in memory for performance optimization.
- Lower values use less memory but may require reloading older messages. + How many messages to keep in view per session.
+ Older messages are available via "Load more". Background sessions are trimmed automatically.
- - - - - +
+
+
+ Message limit + Messages loaded per session +
+
+ {!isDefault && ( + (default: {DEFAULT_MESSAGE_LIMIT}) + )} + {isMobile ? ( + + ) : ( + + )} +
+
+
); }; -interface MemoryLimitRowProps { - label: string; - description: string; - value: number; - defaultValue: number; - min: number; - max: number; - onChange: (value: number) => void; - isMobile: boolean; -} - -const MemoryLimitRow: React.FC = ({ - 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 = ({ setDraft(String(value)); }, [value]); - const handleMobileChange = React.useCallback((e: React.ChangeEvent) => { + const handleChange = React.useCallback((e: React.ChangeEvent) => { 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 = ({ setDraft(String(clamped)); }, [draft, value, min, max, onChange]); - const isDefault = value === defaultValue; - return ( -
-
-
- {label} - {description} -
-
- {!isDefault && ( - (default: {defaultValue}) - )} - {isMobile ? ( - - ) : ( - - )} -
-
-
+ ); }; diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 1f7939f1..d5a4a52c 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -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 = ({ onClose }) =
Viewport Window: - {MEMORY_LIMITS.VIEWPORT_MESSAGES} messages + {getBackgroundTrimLimit()} messages
Background Stream Limit: @@ -119,7 +120,7 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) =
MEMORY_LIMITS.VIEWPORT_MESSAGES ? 'text-status-warning' : '' + stat.messageCount > getMessageLimit() ? 'text-status-warning' : '' }`}> {stat.messageCount} msgs diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 90c43ba5..1bf214f0 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -3,7 +3,7 @@ import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client' import { saveSessionCursor } from '@/lib/messageCursorPersistence'; import { useSessionStore } from '@/stores/useSessionStore'; import { useMessageStore } from '@/stores/messageStore'; -import { getActiveSessionWindow } from '@/stores/types/sessionTypes'; +import { getMessageLimit } from '@/stores/types/sessionTypes'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -295,7 +295,7 @@ export const useEventStream = () => { console.info('[useEventStream] Bootstrapping state:', reason); } try { - const activeLimit = getActiveSessionWindow(); + const activeLimit = getMessageLimit(); await Promise.all([ loadSessions(), currentSessionId ? resyncMessages(currentSessionId, reason, activeLimit) : Promise.resolve(), @@ -308,7 +308,7 @@ export const useEventStream = () => { ); const scheduleSoftResync = React.useCallback( - (sessionId: string, reason: string, limit = getActiveSessionWindow()): Promise => { + (sessionId: string, reason: string, limit = getMessageLimit()): Promise => { if (!sessionId) return Promise.resolve(); const memory = useSessionStore.getState().sessionMemoryState.get(sessionId); @@ -542,7 +542,7 @@ export const useEventStream = () => { } 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(() => { scheduleReconnectRef.current('No message events after busy status'); }); @@ -1619,7 +1619,7 @@ export const useEventStream = () => { const sessionId = currentSessionIdRef.current; if (sessionId) { setTimeout(() => { - scheduleSoftResync(sessionId, 'sse_reconnected', getActiveSessionWindow()) + scheduleSoftResync(sessionId, 'sse_reconnected', getMessageLimit()) .then(() => requestSessionMetadataRefresh(sessionId)) .catch((error: unknown) => { 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...'); const sessionId = currentSessionIdRef.current; if (sessionId) { - scheduleSoftResync(sessionId, 'visibility_restore', getActiveSessionWindow()); + scheduleSoftResync(sessionId, 'visibility_restore', getMessageLimit()); requestSessionMetadataRefresh(sessionId); } @@ -1807,7 +1807,7 @@ export const useEventStream = () => { const sessionId = currentSessionIdRef.current; if (sessionId) { requestSessionMetadataRefresh(sessionId); - scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow()); + scheduleSoftResync(sessionId, 'window_focus', getMessageLimit()); } // Removed: void refreshSessionStatus(); triggerSessionStatusPoll(); @@ -1848,7 +1848,7 @@ export const useEventStream = () => { if (visibilityStateRef.current === 'visible') { const sessionId = currentSessionIdRef.current; if (sessionId) { - void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow()); + void scheduleSoftResync(sessionId, 'page_show', getMessageLimit()); requestSessionMetadataRefresh(sessionId); } // Removed: void refreshSessionStatus(); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index c1749017..d888feed 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -105,10 +105,8 @@ export type DesktopSettings = { directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; - // Memory limits for message viewport management - memoryLimitHistorical?: number; // Default fetch limit when loading/syncing (default: 90) - memoryLimitViewport?: number; // Trim target when leaving session (default: 120) - memoryLimitActiveSession?: number; // Trim target for active session (default: 180) + // Message limit — controls fetch, trim, and Load More chunk size (default: 200) + messageLimit?: number; // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) skillCatalogs?: SkillCatalogConfig[]; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 230b104b..880894d6 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -217,14 +217,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { } } - if (typeof settings.memoryLimitHistorical === 'number' && Number.isFinite(settings.memoryLimitHistorical)) { - store.setMemoryLimitHistorical(settings.memoryLimitHistorical); - } - if (typeof settings.memoryLimitViewport === 'number' && Number.isFinite(settings.memoryLimitViewport)) { - store.setMemoryLimitViewport(settings.memoryLimitViewport); - } - if (typeof settings.memoryLimitActiveSession === 'number' && Number.isFinite(settings.memoryLimitActiveSession)) { - store.setMemoryLimitActiveSession(settings.memoryLimitActiveSession); + // Apply server-persisted message limit. Ignore stale legacy values. + const STALE_LIMITS = new Set([90, 120, 180, 220]); + if (typeof settings.messageLimit === 'number' && Number.isFinite(settings.messageLimit)) { + if (!STALE_LIMITS.has(settings.messageLimit)) { + store.setMessageLimit(settings.messageLimit); + } } if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { @@ -644,14 +642,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { result.openInAppId = candidate.openInAppId; } - if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) { - result.memoryLimitHistorical = candidate.memoryLimitHistorical; - } - 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; + if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) { + result.messageLimit = candidate.messageLimit; } const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index bf1a7473..d2b99481 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -5,7 +5,7 @@ import type { Message, Part } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { isExecutionForkMetaText } from "@/lib/messages/executionMeta"; import type { SessionMemoryState, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes"; -import { MEMORY_LIMITS, getMemoryLimits } from "./types/sessionTypes"; +import { MEMORY_LIMITS, getMemoryLimits, getBackgroundTrimLimit } from "./types/sessionTypes"; import { touchStreamingLifecycle, removeLifecycleEntries, @@ -396,20 +396,31 @@ export const useMessageStore = create()( loadMessages: async (sessionId: string, limit?: number) => { const memLimits = getMemoryLimits(); const noLimit = limit === Infinity; - const effectiveLimit = noLimit ? Infinity : (limit ?? memLimits.HISTORICAL_MESSAGES); - const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming; - const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit; - // Don't pass Infinity to API - use undefined for "fetch all" - const fetchLimit = isStreaming || noLimit ? undefined : targetLimit + memLimits.FETCH_BUFFER; + const previousMemoryState = get().sessionMemoryState.get(sessionId); + // Use explicit limit if provided, otherwise current messageLimit. + // historyLimit is only respected when it's ABOVE messageLimit + // (i.e. user pressed Load More to expand beyond the default). + 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)); // Filter out reverted messages first const revertMessageId = getSessionRevertMessageId(sessionId); const messagesWithoutReverted = filterRevertedMessages(allMessages, revertMessageId); - // If we fetched more than we keep (usually via buffer), there are older messages above. - // This intentionally ignores watermark filtering so "Load older" can remain available. - const hasMoreAbove = messagesWithoutReverted.length > targetLimit; + // Accurate older-history detection for finite loads. + // If server returns > targetLimit, there are older messages above current window. + const hasMoreAbove = typeof fetchLimit === 'number' + ? messagesWithoutReverted.length > targetLimit + : false; const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId; @@ -491,7 +502,6 @@ export const useMessageStore = create()( newMessages.set(sessionId, mergedMessages); const newMemoryState = new Map(state.sessionMemoryState); - const previousMemoryState = state.sessionMemoryState.get(sessionId); newMemoryState.set(sessionId, { ...previousMemoryState, viewportAnchor: mergedMessages.length - 1, @@ -500,6 +510,9 @@ export const useMessageStore = create()( backgroundMessageCount: 0, totalAvailableMessages: previousMemoryState?.totalAvailableMessages, hasMoreAbove, + historyLoading: false, + historyComplete: !hasMoreAbove, + historyLimit: targetLimit, trimmedHeadMaxId: previousMemoryState?.trimmedHeadMaxId, streamingCooldownUntil: undefined, }); @@ -2239,7 +2252,7 @@ export const useMessageStore = create()( }, trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => { - const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES; + const effectiveTargetSize = targetSize ?? getBackgroundTrimLimit(); const state = get(); const sessionMessages = state.messages.get(sessionId); if (!sessionMessages || sessionMessages.length <= effectiveTargetSize) { @@ -2428,44 +2441,67 @@ export const useMessageStore = create()( return; } - const memLimits = getMemoryLimits(); - // OpenCode may default to "last N" when limit is omitted. - // 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, - ); + if (memoryState.historyLoading) { + return; + } - const allMessages = await executeWithSessionDirectory( - sessionId, - () => opencodeClient.getSessionMessages(sessionId, desiredLimit) + const memLimits = getMemoryLimits(); + const baseLimit = memoryState.historyLimit ?? Math.max( + 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) { const dedupedMessages = dedupeMessagesById(allMessages); + const hasPotentialMore = allMessages.length >= fetchLimit; const firstCurrentMessage = currentMessages[0]; const indexInAll = dedupedMessages.findIndex((message) => message.info.id === firstCurrentMessage.info.id); 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); - set((state) => { - const updatedMessages = [...newMessages, ...currentMessages]; + set((snapshot) => { + const latestCurrent = snapshot.messages.get(sessionId) ?? currentMessages; + const latestMemory = snapshot.sessionMemoryState.get(sessionId) ?? memoryState; + const updatedMessages = [...newMessages, ...latestCurrent]; 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); - const newMemoryState = new Map(state.sessionMemoryState); + const newMemoryState = new Map(snapshot.sessionMemoryState); newMemoryState.set(sessionId, { - ...memoryState, - viewportAnchor: memoryState.viewportAnchor + addedCount, - hasMoreAbove: indexInAll - loadCount > 0, + ...latestMemory, + viewportAnchor: latestMemory.viewportAnchor + addedCount, + hasMoreAbove: indexInAll - loadCount > 0 || hasPotentialMore, + historyLoading: false, + historyLimit: desiredLimit, + historyComplete: !(indexInAll - loadCount > 0 || hasPotentialMore), totalAvailableMessages: Math.max( - memoryState.totalAvailableMessages ?? 0, - dedupedMessages.length + latestMemory.totalAvailableMessages ?? 0, + dedupedMessages.length, ), }); @@ -2474,21 +2510,83 @@ export const useMessageStore = create()( sessionMemoryState: newMemoryState, }; }); - } else if (indexInAll === 0) { - set((state) => { - const newMemoryState = new Map(state.sessionMemoryState); + return; + } + + if (indexInAll === 0) { + set((snapshot) => { + const latestMemory = snapshot.sessionMemoryState.get(sessionId) ?? memoryState; + const newMemoryState = new Map(snapshot.sessionMemoryState); newMemoryState.set(sessionId, { - ...memoryState, - hasMoreAbove: false, + ...latestMemory, + hasMoreAbove: hasPotentialMore, + historyLoading: false, + historyLimit: desiredLimit, + historyComplete: !hasPotentialMore, totalAvailableMessages: Math.max( - memoryState.totalAvailableMessages ?? 0, - dedupedMessages.length + latestMemory.totalAvailableMessages ?? 0, + dedupedMessages.length, ), }); 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) => { @@ -2526,6 +2624,9 @@ export const useMessageStore = create()( backgroundMessageCount: memory.backgroundMessageCount, totalAvailableMessages: memory.totalAvailableMessages, hasMoreAbove: memory.hasMoreAbove, + historyLoading: memory.historyLoading, + historyComplete: memory.historyComplete, + historyLimit: memory.historyLimit, trimmedHeadMaxId: memory.trimmedHeadMaxId, }, ]), @@ -2542,7 +2643,18 @@ export const useMessageStore = create()( let restoredMemoryState = currentState.sessionMemoryState; if (Array.isArray(persistedState.sessionMemoryState)) { restoredMemoryState = new Map( - 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]; + }) ); } diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 13d1b4db..89a7a01b 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -33,6 +33,9 @@ export interface SessionMemoryState { isZombie?: boolean; totalAvailableMessages?: number; hasMoreAbove?: boolean; + historyLoading?: boolean; + historyComplete?: boolean; + historyLimit?: number; trimmedHeadMaxId?: string; streamingCooldownUntil?: number; lastUserMessageAt?: number; // Timestamp when user last sent a message @@ -48,44 +51,57 @@ export interface SessionContextUsage { lastMessageId?: string; } -// Default memory limits (can be overridden via settings) -export const DEFAULT_MEMORY_LIMITS = { +// Default message limit (can be overridden via settings). +// 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, - VIEWPORT_MESSAGES: 120, - HISTORICAL_MESSAGES: 90, - FETCH_BUFFER: 20, - STREAMING_BUFFER: Infinity, BACKGROUND_STREAMING_BUFFER: 120, ZOMBIE_TIMEOUT: 10 * 60 * 1000, } as const; -export const DEFAULT_ACTIVE_SESSION_WINDOW = 180; - -// Dynamic memory limits accessor - reads directly from UI store. +// Dynamic accessors — read user setting from UI store. // NOTE: do not use require() here (breaks in browser/desktop runtime bundles). import { useUIStore } from "../useUIStore"; -export const getMemoryLimits = () => { +/** User-configured (or default) message limit. */ +export const getMessageLimit = (): number => { const state = useUIStore.getState?.(); - if (!state) { - return DEFAULT_MEMORY_LIMITS; - } + return state?.messageLimit ?? DEFAULT_MESSAGE_LIMIT; +}; + +/** 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 { ...DEFAULT_MEMORY_LIMITS, - HISTORICAL_MESSAGES: state.memoryLimitHistorical ?? DEFAULT_MEMORY_LIMITS.HISTORICAL_MESSAGES, - VIEWPORT_MESSAGES: state.memoryLimitViewport ?? DEFAULT_MEMORY_LIMITS.VIEWPORT_MESSAGES, + HISTORICAL_MESSAGES: limit, + VIEWPORT_MESSAGES: bgTrim, + HISTORY_CHUNK: limit, }; }; -export const getActiveSessionWindow = () => { - const state = useUIStore.getState?.(); - if (!state) { - return DEFAULT_ACTIVE_SESSION_WINDOW; - } - return state.memoryLimitActiveSession ?? DEFAULT_ACTIVE_SESSION_WINDOW; -}; +export const getActiveSessionWindow = () => getMessageLimit(); -// 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 ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index b43b4fc2..d80eb5bf 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -5,7 +5,7 @@ import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; 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 { useMessageStore } from "./messageStore"; @@ -292,7 +292,7 @@ export const useSessionStore = create()( get().updateViewportAnchor(previousSessionId, previousMessages.length - 1); } - get().trimToViewportWindow(previousSessionId, getMemoryLimits().VIEWPORT_MESSAGES); + get().trimToViewportWindow(previousSessionId, getBackgroundTrimLimit()); } } @@ -301,12 +301,17 @@ export const useSessionStore = create()( if (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); } - get().trimToViewportWindow(id, getActiveSessionWindow()); + get().trimToViewportWindow(id, getMessageLimit()); // Analyze session messages to extract agent/model/variant choices // This ensures context is available even when ModelControls isn't mounted diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 72fad371..1f00eaec 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -88,9 +88,7 @@ interface UIStore { autoDeleteEnabled: boolean; autoDeleteAfterDays: number; autoDeleteLastRunAt: number | null; - memoryLimitHistorical: number; - memoryLimitViewport: number; - memoryLimitActiveSession: number; + messageLimit: number; toolCallExpansion: 'collapsed' | 'activity' | 'detailed'; fontSize: number; @@ -172,9 +170,7 @@ interface UIStore { setAutoDeleteEnabled: (value: boolean) => void; setAutoDeleteAfterDays: (days: number) => void; setAutoDeleteLastRunAt: (timestamp: number | null) => void; - setMemoryLimitHistorical: (value: number) => void; - setMemoryLimitViewport: (value: number) => void; - setMemoryLimitActiveSession: (value: number) => void; + setMessageLimit: (value: number) => void; setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void; setFontSize: (size: number) => void; setTerminalFontSize: (size: number) => void; @@ -256,9 +252,7 @@ export const useUIStore = create()( autoDeleteEnabled: false, autoDeleteAfterDays: 30, autoDeleteLastRunAt: null, - memoryLimitHistorical: 90, - memoryLimitViewport: 120, - memoryLimitActiveSession: 180, + messageLimit: 200, toolCallExpansion: 'collapsed', fontSize: 100, terminalFontSize: 13, @@ -568,19 +562,9 @@ export const useUIStore = create()( set({ autoDeleteLastRunAt: timestamp }); }, - setMemoryLimitHistorical: (value) => { + setMessageLimit: (value) => { const clamped = Math.max(10, Math.min(500, Math.round(value))); - set({ memoryLimitHistorical: 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 }); + set({ messageLimit: clamped }); }, setToolCallExpansion: (value) => { @@ -881,24 +865,47 @@ export const useUIStore = create()( { name: 'ui-store', storage: createJSONStorage(() => getSafeStorage()), - version: 1, + version: 3, migrate: (persistedState, version) => { - if (version >= 1 || !persistedState || typeof persistedState !== 'object') { + if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; - 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, - notificationTemplates: { - completion: { ...EMPTY_NOTIFICATION_TEMPLATES.completion }, - error: { ...EMPTY_NOTIFICATION_TEMPLATES.error }, - question: { ...EMPTY_NOTIFICATION_TEMPLATES.question }, - subtask: { ...EMPTY_NOTIFICATION_TEMPLATES.subtask }, - }, - }; + + // v2 -> v3: collapse 3 memory-limit fields into single messageLimit. + // Pick the best user-customised value (prefer historical, fall back to active). + // Discard old defaults (90/120/180) — they become the new single default (200). + if (version < 3) { + const OLD_DEFAULTS = new Set([90, 120, 180, 220]); + 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) => ({ theme: state.theme, @@ -918,9 +925,7 @@ export const useUIStore = create()( autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, autoDeleteLastRunAt: state.autoDeleteLastRunAt, - memoryLimitHistorical: state.memoryLimitHistorical, - memoryLimitViewport: state.memoryLimitViewport, - memoryLimitActiveSession: state.memoryLimitActiveSession, + messageLimit: state.messageLimit, toolCallExpansion: state.toolCallExpansion, fontSize: state.fontSize, terminalFontSize: state.terminalFontSize, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index a00d9b59..b5134032 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1507,15 +1507,9 @@ const sanitizeSettingsUpdate = (payload) => { } } - // Memory limits for message viewport management - if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) { - result.memoryLimitHistorical = Math.max(10, Math.min(500, Math.round(candidate.memoryLimitHistorical))); - } - 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))); + // Message limit — single setting for fetch / trim / Load More chunk + if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) { + result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit))); } const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);