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

* feat: message list refactoring and optimizations

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

- Replace memoryLimitHistorical/Viewport/ActiveSession with one `messageLimit` (default 200)
- Background trim derived automatically (limit * 0.6), not user-facing
- Fix stale historyLimit overriding current messageLimit on session reload
- Remove streaming cap that reduced fetch to VIEWPORT_MESSAGES during active streams
- Deduplicate detectTurns() call in TurnGroupingContext (reuse staticValue.turns)
- Zustand v3 migration: collapse old fields, preserve user-customized values
- Server: sanitize single messageLimit field (10-500)
This commit is contained in:
Bohdan Triapitsyn
2026-02-12 20:36:02 +02:00
committed by GitHub
parent 3bf0843ebb
commit 252d4bd5ab
13 changed files with 627 additions and 330 deletions
@@ -6,24 +6,16 @@ import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } from '@/stores/types/sessionTypes';
import { DEFAULT_MESSAGE_LIMIT } from '@/stores/types/sessionTypes';
const MIN_HISTORICAL = 10;
const MAX_HISTORICAL = 500;
const MIN_VIEWPORT = 20;
const MAX_VIEWPORT = 500;
const MIN_ACTIVE = 30;
const MAX_ACTIVE = 1000;
const MIN_LIMIT = 10;
const MAX_LIMIT = 500;
export const MemoryLimitsSettings: React.FC = () => {
const { isMobile } = useDeviceInfo();
const memoryLimitHistorical = useUIStore((state) => state.memoryLimitHistorical);
const memoryLimitViewport = useUIStore((state) => state.memoryLimitViewport);
const memoryLimitActiveSession = useUIStore((state) => state.memoryLimitActiveSession);
const setMemoryLimitHistorical = useUIStore((state) => state.setMemoryLimitHistorical);
const setMemoryLimitViewport = useUIStore((state) => state.setMemoryLimitViewport);
const setMemoryLimitActiveSession = useUIStore((state) => state.setMemoryLimitActiveSession);
const messageLimit = useUIStore((state) => state.messageLimit);
const setMessageLimit = useUIStore((state) => state.setMessageLimit);
const [isLoading, setIsLoading] = React.useState(true);
@@ -31,7 +23,7 @@ export const MemoryLimitsSettings: React.FC = () => {
React.useEffect(() => {
const loadSettings = async () => {
try {
let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null;
let data: { messageLimit?: number } | null = null;
// 1. Runtime settings API (VSCode)
if (!data) {
@@ -42,9 +34,7 @@ export const MemoryLimitsSettings: React.FC = () => {
const settings = result?.settings as Record<string, unknown> | undefined;
if (settings) {
data = {
memoryLimitHistorical: typeof settings.memoryLimitHistorical === 'number' ? settings.memoryLimitHistorical : undefined,
memoryLimitViewport: typeof settings.memoryLimitViewport === 'number' ? settings.memoryLimitViewport : undefined,
memoryLimitActiveSession: typeof settings.memoryLimitActiveSession === 'number' ? settings.memoryLimitActiveSession : undefined,
messageLimit: typeof settings.messageLimit === 'number' ? settings.messageLimit : undefined,
};
}
} catch {
@@ -64,16 +54,8 @@ export const MemoryLimitsSettings: React.FC = () => {
}
}
if (data) {
if (typeof data.memoryLimitHistorical === 'number') {
setMemoryLimitHistorical(data.memoryLimitHistorical);
}
if (typeof data.memoryLimitViewport === 'number') {
setMemoryLimitViewport(data.memoryLimitViewport);
}
if (typeof data.memoryLimitActiveSession === 'number') {
setMemoryLimitActiveSession(data.memoryLimitActiveSession);
}
if (data && typeof data.messageLimit === 'number') {
setMessageLimit(data.messageLimit);
}
} catch (error) {
console.warn('Failed to load memory limits settings:', error);
@@ -82,35 +64,21 @@ export const MemoryLimitsSettings: React.FC = () => {
}
};
loadSettings();
}, [setMemoryLimitHistorical, setMemoryLimitViewport, setMemoryLimitActiveSession]);
}, [setMessageLimit]);
const persistSetting = React.useCallback(async (key: string, value: number) => {
try {
await updateDesktopSettings({ [key]: value });
} catch (error) {
console.warn(`Failed to save ${key}:`, error);
}
}, []);
const handleHistoricalChange = React.useCallback((value: number) => {
setMemoryLimitHistorical(value);
persistSetting('memoryLimitHistorical', value);
}, [setMemoryLimitHistorical, persistSetting]);
const handleViewportChange = React.useCallback((value: number) => {
setMemoryLimitViewport(value);
persistSetting('memoryLimitViewport', value);
}, [setMemoryLimitViewport, persistSetting]);
const handleActiveSessionChange = React.useCallback((value: number) => {
setMemoryLimitActiveSession(value);
persistSetting('memoryLimitActiveSession', value);
}, [setMemoryLimitActiveSession, persistSetting]);
const handleChange = React.useCallback((value: number) => {
setMessageLimit(value);
void updateDesktopSettings({ messageLimit: value }).catch((error: unknown) => {
console.warn('Failed to save messageLimit:', error);
});
}, [setMessageLimit]);
if (isLoading) {
return null;
}
const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT;
return (
<div className="space-y-4">
<div className="space-y-1">
@@ -121,71 +89,49 @@ export const MemoryLimitsSettings: React.FC = () => {
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Control how many messages are kept in memory for performance optimization.<br />
Lower values use less memory but may require reloading older messages.
How many messages to keep in view per session.<br />
Older messages are available via "Load more". Background sessions are trimmed automatically.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-3">
<MemoryLimitRow
label="Initial load limit"
description="Messages loaded when opening a session"
value={memoryLimitHistorical}
defaultValue={DEFAULT_MEMORY_LIMITS.HISTORICAL_MESSAGES}
min={MIN_HISTORICAL}
max={MAX_HISTORICAL}
onChange={handleHistoricalChange}
isMobile={isMobile}
/>
<MemoryLimitRow
label="Background trim limit"
description="Max messages kept when switching away"
value={memoryLimitViewport}
defaultValue={DEFAULT_MEMORY_LIMITS.VIEWPORT_MESSAGES}
min={MIN_VIEWPORT}
max={MAX_VIEWPORT}
onChange={handleViewportChange}
isMobile={isMobile}
/>
<MemoryLimitRow
label="Active session limit"
description="Max messages for active session"
value={memoryLimitActiveSession}
defaultValue={DEFAULT_ACTIVE_SESSION_WINDOW}
min={MIN_ACTIVE}
max={MAX_ACTIVE}
onChange={handleActiveSessionChange}
isMobile={isMobile}
/>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">Message limit</span>
<span className="typography-meta text-muted-foreground">Messages loaded per session</span>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="typography-meta text-muted-foreground/60">(default: {DEFAULT_MESSAGE_LIMIT})</span>
)}
{isMobile ? (
<MobileInput value={messageLimit} min={MIN_LIMIT} max={MAX_LIMIT} onChange={handleChange} />
) : (
<NumberInput
value={messageLimit}
onValueChange={handleChange}
min={MIN_LIMIT}
max={MAX_LIMIT}
step={10}
aria-label="Message limit"
/>
)}
</div>
</div>
</div>
</div>
</div>
);
};
interface MemoryLimitRowProps {
label: string;
description: string;
value: number;
defaultValue: number;
min: number;
max: number;
onChange: (value: number) => void;
isMobile: boolean;
}
const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
label,
description,
const MobileInput: React.FC<{ value: number; min: number; max: number; onChange: (v: number) => void }> = ({
value,
defaultValue,
min,
max,
onChange,
isMobile,
}) => {
const [draft, setDraft] = React.useState(String(value));
@@ -193,21 +139,16 @@ const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
setDraft(String(value));
}, [value]);
const handleMobileChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = e.target.value;
setDraft(nextValue);
if (nextValue.trim() === '') {
return;
}
if (nextValue.trim() === '') return;
const parsed = Number(nextValue);
if (!Number.isFinite(parsed)) {
return;
}
const clamped = Math.min(max, Math.max(min, Math.round(parsed)));
onChange(clamped);
if (!Number.isFinite(parsed)) return;
onChange(Math.min(max, Math.max(min, Math.round(parsed))));
}, [min, max, onChange]);
const handleMobileBlur = React.useCallback(() => {
const handleBlur = React.useCallback(() => {
if (draft.trim() === '') {
setDraft(String(value));
return;
@@ -222,41 +163,15 @@ const MemoryLimitRow: React.FC<MemoryLimitRowProps> = ({
setDraft(String(clamped));
}, [draft, value, min, max, onChange]);
const isDefault = value === defaultValue;
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">{label}</span>
<span className="typography-meta text-muted-foreground">{description}</span>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="typography-meta text-muted-foreground/60">(default: {defaultValue})</span>
)}
{isMobile ? (
<input
type="number"
inputMode="numeric"
value={draft}
onChange={handleMobileChange}
onBlur={handleMobileBlur}
aria-label={label}
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
) : (
<NumberInput
value={value}
onValueChange={onChange}
min={min}
max={max}
step={10}
aria-label={label}
/>
)}
</div>
</div>
</div>
<input
type="number"
inputMode="numeric"
value={draft}
onChange={handleChange}
onBlur={handleBlur}
aria-label="Message limit"
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
);
};