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:
committed by
GitHub
parent
3bf0843ebb
commit
252d4bd5ab
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user