feat: add memory limits UI settings for messages in session
This commit is contained in:
@@ -300,6 +300,38 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memory limit fields
|
||||||
|
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
|
||||||
|
let parsed = n
|
||||||
|
.as_u64()
|
||||||
|
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||||
|
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||||
|
if let Some(value) = parsed {
|
||||||
|
let clamped = value.max(10).min(500);
|
||||||
|
result_obj.insert("memoryLimitHistorical".to_string(), json!(clamped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
|
||||||
|
let parsed = n
|
||||||
|
.as_u64()
|
||||||
|
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||||
|
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||||
|
if let Some(value) = parsed {
|
||||||
|
let clamped = value.max(20).min(500);
|
||||||
|
result_obj.insert("memoryLimitViewport".to_string(), json!(clamped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
|
||||||
|
let parsed = n
|
||||||
|
.as_u64()
|
||||||
|
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||||
|
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||||
|
if let Some(value) = parsed {
|
||||||
|
let clamped = value.max(30).min(1000);
|
||||||
|
result_obj.insert("memoryLimitActiveSession".to_string(), json!(clamped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Array fields
|
// Array fields
|
||||||
if let Some(arr) = obj.get("approvedDirectories") {
|
if let Some(arr) = obj.get("approvedDirectories") {
|
||||||
result_obj.insert(
|
result_obj.insert(
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiInformationLine } from '@remixicon/react';
|
||||||
|
import { NumberInput } from '@/components/ui/number-input';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop';
|
||||||
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
|
import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } 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;
|
||||||
|
|
||||||
|
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 [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
|
// Load settings from server on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null;
|
||||||
|
|
||||||
|
// 1. Desktop runtime (Tauri)
|
||||||
|
if (isDesktopRuntime()) {
|
||||||
|
data = await getDesktopSettings();
|
||||||
|
} else {
|
||||||
|
// 2. Runtime settings API (VSCode)
|
||||||
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
|
if (runtimeSettings) {
|
||||||
|
try {
|
||||||
|
const result = await runtimeSettings.load();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to fetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch API (Web)
|
||||||
|
if (!data) {
|
||||||
|
const response = await fetch('/api/config/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load memory limits settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSettings();
|
||||||
|
}, [setMemoryLimitHistorical, setMemoryLimitViewport, setMemoryLimitActiveSession]);
|
||||||
|
|
||||||
|
const persistSetting = React.useCallback(async (key: string, value: number) => {
|
||||||
|
try {
|
||||||
|
await updateDesktopSettings({ [key]: value });
|
||||||
|
|
||||||
|
if (!isDesktopRuntime()) {
|
||||||
|
const response = await fetch('/api/config/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [key]: value }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Failed to save ${key} to server:`, response.status, response.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground">Message Memory</h3>
|
||||||
|
<Tooltip delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<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.
|
||||||
|
</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>
|
||||||
|
</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,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onChange,
|
||||||
|
isMobile,
|
||||||
|
}) => {
|
||||||
|
const [draft, setDraft] = React.useState(String(value));
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDraft(String(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleMobileChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const nextValue = e.target.value;
|
||||||
|
setDraft(nextValue);
|
||||||
|
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);
|
||||||
|
}, [min, max, onChange]);
|
||||||
|
|
||||||
|
const handleMobileBlur = React.useCallback(() => {
|
||||||
|
if (draft.trim() === '') {
|
||||||
|
setDraft(String(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = Number(draft);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
setDraft(String(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clamped = Math.min(max, Math.max(min, Math.round(parsed)));
|
||||||
|
onChange(clamped);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||||
|
import { MemoryLimitsSettings } from './MemoryLimitsSettings';
|
||||||
import { DefaultsSettings } from './DefaultsSettings';
|
import { DefaultsSettings } from './DefaultsSettings';
|
||||||
import { GitSettings } from './GitSettings';
|
import { GitSettings } from './GitSettings';
|
||||||
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
||||||
@@ -83,7 +84,7 @@ const ChatSectionContent: React.FC = () => {
|
|||||||
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning', 'queueMode']} />;
|
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning', 'queueMode']} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sessions section: Default model & agent, Session retention
|
// Sessions section: Default model & agent, Session retention, Memory limits
|
||||||
const SessionsSectionContent: React.FC = () => {
|
const SessionsSectionContent: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -91,6 +92,9 @@ const SessionsSectionContent: React.FC = () => {
|
|||||||
<div className="border-t border-border/40 pt-6">
|
<div className="border-t border-border/40 pt-6">
|
||||||
<SessionRetentionSettings />
|
<SessionRetentionSettings />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-t border-border/40 pt-6">
|
||||||
|
<MemoryLimitsSettings />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { AssistantMessage, Message, Part } from '@opencode-ai/sdk/v2';
|
import type { AssistantMessage, Message, Part } from '@opencode-ai/sdk/v2';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { MEMORY_LIMITS } from '@/stores/types/sessionTypes';
|
import { getMemoryLimits } from '@/stores/types/sessionTypes';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { readSessionCursor } from '@/lib/messageCursorPersistence';
|
import { readSessionCursor } from '@/lib/messageCursorPersistence';
|
||||||
import { extractTextFromPart } from '@/stores/utils/messageUtils';
|
import { extractTextFromPart } from '@/stores/utils/messageUtils';
|
||||||
@@ -126,8 +126,9 @@ export const useMessageSync = () => {
|
|||||||
const currentMessages = (messages.get(currentSessionId) || []) as SessionMessageRecord[];
|
const currentMessages = (messages.get(currentSessionId) || []) as SessionMessageRecord[];
|
||||||
|
|
||||||
const memoryState = useSessionStore.getState().sessionMemoryState.get(currentSessionId);
|
const memoryState = useSessionStore.getState().sessionMemoryState.get(currentSessionId);
|
||||||
const targetLimit = memoryState?.isStreaming ? MEMORY_LIMITS.VIEWPORT_MESSAGES : MEMORY_LIMITS.HISTORICAL_MESSAGES;
|
const memLimits = getMemoryLimits();
|
||||||
const fetchLimit = targetLimit + MEMORY_LIMITS.FETCH_BUFFER;
|
const targetLimit = memoryState?.isStreaming ? memLimits.VIEWPORT_MESSAGES : memLimits.HISTORICAL_MESSAGES;
|
||||||
|
const fetchLimit = targetLimit + memLimits.FETCH_BUFFER;
|
||||||
const latestMessages = (await opencodeClient.getSessionMessages(currentSessionId, fetchLimit)) as SessionMessageRecord[];
|
const latestMessages = (await opencodeClient.getSessionMessages(currentSessionId, fetchLimit)) as SessionMessageRecord[];
|
||||||
const cursorRecord = await readSessionCursor(currentSessionId);
|
const cursorRecord = await readSessionCursor(currentSessionId);
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export type DesktopSettings = {
|
|||||||
queueModeEnabled?: boolean;
|
queueModeEnabled?: boolean;
|
||||||
gitmojiEnabled?: boolean;
|
gitmojiEnabled?: boolean;
|
||||||
|
|
||||||
|
// Memory limits for message viewport management
|
||||||
|
memoryLimitHistorical?: number; // Default fetch limit when loading/syncing (default: 90)
|
||||||
|
memoryLimitViewport?: number; // Trim target when leaving session (default: 120)
|
||||||
|
memoryLimitActiveSession?: number; // Trim target for active session (default: 180)
|
||||||
|
|
||||||
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
||||||
skillCatalogs?: SkillCatalogConfig[];
|
skillCatalogs?: SkillCatalogConfig[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -185,6 +185,17 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
store.setAutoDeleteAfterDays(normalized);
|
store.setAutoDeleteAfterDays(normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof settings.memoryLimitHistorical === 'number' && Number.isFinite(settings.memoryLimitHistorical)) {
|
||||||
|
store.setMemoryLimitHistorical(settings.memoryLimitHistorical);
|
||||||
|
}
|
||||||
|
if (typeof settings.memoryLimitViewport === 'number' && Number.isFinite(settings.memoryLimitViewport)) {
|
||||||
|
store.setMemoryLimitViewport(settings.memoryLimitViewport);
|
||||||
|
}
|
||||||
|
if (typeof settings.memoryLimitActiveSession === 'number' && Number.isFinite(settings.memoryLimitActiveSession)) {
|
||||||
|
store.setMemoryLimitActiveSession(settings.memoryLimitActiveSession);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
|
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
|
||||||
queueStore.setQueueMode(settings.queueModeEnabled);
|
queueStore.setQueueMode(settings.queueModeEnabled);
|
||||||
}
|
}
|
||||||
@@ -273,6 +284,16 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) {
|
||||||
|
result.memoryLimitHistorical = candidate.memoryLimitHistorical;
|
||||||
|
}
|
||||||
|
if (typeof candidate.memoryLimitViewport === 'number' && Number.isFinite(candidate.memoryLimitViewport)) {
|
||||||
|
result.memoryLimitViewport = candidate.memoryLimitViewport;
|
||||||
|
}
|
||||||
|
if (typeof candidate.memoryLimitActiveSession === 'number' && Number.isFinite(candidate.memoryLimitActiveSession)) {
|
||||||
|
result.memoryLimitActiveSession = candidate.memoryLimitActiveSession;
|
||||||
|
}
|
||||||
|
|
||||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||||
if (skillCatalogs) {
|
if (skillCatalogs) {
|
||||||
result.skillCatalogs = skillCatalogs;
|
result.skillCatalogs = 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 } from "./types/sessionTypes";
|
import { MEMORY_LIMITS, getMemoryLimits } from "./types/sessionTypes";
|
||||||
import {
|
import {
|
||||||
touchStreamingLifecycle,
|
touchStreamingLifecycle,
|
||||||
removeLifecycleEntries,
|
removeLifecycleEntries,
|
||||||
@@ -385,10 +385,12 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
pendingAssistantHeaderSessions: new Set(),
|
pendingAssistantHeaderSessions: new Set(),
|
||||||
pendingUserMessageMetaBySession: new Map(),
|
pendingUserMessageMetaBySession: new Map(),
|
||||||
|
|
||||||
loadMessages: async (sessionId: string, limit: number = MEMORY_LIMITS.HISTORICAL_MESSAGES) => {
|
loadMessages: async (sessionId: string, limit?: number) => {
|
||||||
|
const memLimits = getMemoryLimits();
|
||||||
|
const effectiveLimit = limit ?? memLimits.HISTORICAL_MESSAGES;
|
||||||
const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming;
|
const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming;
|
||||||
const targetLimit = isStreaming ? MEMORY_LIMITS.VIEWPORT_MESSAGES : limit;
|
const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit;
|
||||||
const fetchLimit = isStreaming ? undefined : targetLimit + MEMORY_LIMITS.FETCH_BUFFER;
|
const fetchLimit = isStreaming ? undefined : targetLimit + memLimits.FETCH_BUFFER;
|
||||||
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
|
||||||
@@ -2227,10 +2229,11 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
trimToViewportWindow: (sessionId: string, targetSize: number = MEMORY_LIMITS.VIEWPORT_MESSAGES, currentSessionId?: string) => {
|
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => {
|
||||||
|
const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES;
|
||||||
const state = get();
|
const state = get();
|
||||||
const sessionMessages = state.messages.get(sessionId);
|
const sessionMessages = state.messages.get(sessionId);
|
||||||
if (!sessionMessages || sessionMessages.length <= targetSize) {
|
if (!sessionMessages || sessionMessages.length <= effectiveTargetSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2246,11 +2249,11 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const anchor = memoryState.viewportAnchor || sessionMessages.length - 1;
|
const anchor = memoryState.viewportAnchor || sessionMessages.length - 1;
|
||||||
let start = Math.max(0, anchor - Math.floor(targetSize / 2));
|
let start = Math.max(0, anchor - Math.floor(effectiveTargetSize / 2));
|
||||||
const end = Math.min(sessionMessages.length, start + targetSize);
|
const end = Math.min(sessionMessages.length, start + effectiveTargetSize);
|
||||||
|
|
||||||
if (end === sessionMessages.length && end - start < targetSize) {
|
if (end === sessionMessages.length && end - start < effectiveTargetSize) {
|
||||||
start = Math.max(0, end - targetSize);
|
start = Math.max(0, end - effectiveTargetSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedMessages = sessionMessages.slice(start, end);
|
const trimmedMessages = sessionMessages.slice(start, end);
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export interface SessionContextUsage {
|
|||||||
lastMessageId?: string;
|
lastMessageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MEMORY_LIMITS = {
|
// Default memory limits (can be overridden via settings)
|
||||||
|
export const DEFAULT_MEMORY_LIMITS = {
|
||||||
MAX_SESSIONS: 3,
|
MAX_SESSIONS: 3,
|
||||||
VIEWPORT_MESSAGES: 120,
|
VIEWPORT_MESSAGES: 120,
|
||||||
HISTORICAL_MESSAGES: 90,
|
HISTORICAL_MESSAGES: 90,
|
||||||
@@ -61,7 +62,35 @@ export const MEMORY_LIMITS = {
|
|||||||
ZOMBIE_TIMEOUT: 10 * 60 * 1000,
|
ZOMBIE_TIMEOUT: 10 * 60 * 1000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ACTIVE_SESSION_WINDOW = 180;
|
export const DEFAULT_ACTIVE_SESSION_WINDOW = 180;
|
||||||
|
|
||||||
|
// Dynamic memory limits accessor - reads directly from UI store.
|
||||||
|
// NOTE: do not use require() here (breaks in browser/desktop runtime bundles).
|
||||||
|
import { useUIStore } from "../useUIStore";
|
||||||
|
|
||||||
|
export const getMemoryLimits = () => {
|
||||||
|
const state = useUIStore.getState?.();
|
||||||
|
if (!state) {
|
||||||
|
return DEFAULT_MEMORY_LIMITS;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...DEFAULT_MEMORY_LIMITS,
|
||||||
|
HISTORICAL_MESSAGES: state.memoryLimitHistorical ?? DEFAULT_MEMORY_LIMITS.HISTORICAL_MESSAGES,
|
||||||
|
VIEWPORT_MESSAGES: state.memoryLimitViewport ?? DEFAULT_MEMORY_LIMITS.VIEWPORT_MESSAGES,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveSessionWindow = () => {
|
||||||
|
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 MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS;
|
||||||
|
export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW;
|
||||||
|
|
||||||
export type NewSessionDraftState = {
|
export type NewSessionDraftState = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -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 } from "./types/sessionTypes";
|
import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
|
||||||
import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes";
|
import { getActiveSessionWindow, getMemoryLimits } from "./types/sessionTypes";
|
||||||
|
|
||||||
import { useSessionStore as useSessionManagementStore } from "./sessionStore";
|
import { useSessionStore as useSessionManagementStore } from "./sessionStore";
|
||||||
import { useMessageStore } from "./messageStore";
|
import { useMessageStore } from "./messageStore";
|
||||||
@@ -273,7 +273,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
get().updateViewportAnchor(previousSessionId, previousMessages.length - 1);
|
get().updateViewportAnchor(previousSessionId, previousMessages.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
get().trimToViewportWindow(previousSessionId, MEMORY_LIMITS.VIEWPORT_MESSAGES);
|
get().trimToViewportWindow(previousSessionId, getMemoryLimits().VIEWPORT_MESSAGES);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
await get().loadMessages(id);
|
await get().loadMessages(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
get().trimToViewportWindow(id, ACTIVE_SESSION_WINDOW);
|
get().trimToViewportWindow(id, getActiveSessionWindow());
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ interface UIStore {
|
|||||||
autoDeleteEnabled: boolean;
|
autoDeleteEnabled: boolean;
|
||||||
autoDeleteAfterDays: number;
|
autoDeleteAfterDays: number;
|
||||||
autoDeleteLastRunAt: number | null;
|
autoDeleteLastRunAt: number | null;
|
||||||
|
memoryLimitHistorical: number;
|
||||||
|
memoryLimitViewport: number;
|
||||||
|
memoryLimitActiveSession: number;
|
||||||
|
|
||||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -87,6 +90,9 @@ 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;
|
||||||
|
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;
|
||||||
setPadding: (size: number) => void;
|
setPadding: (size: number) => void;
|
||||||
@@ -141,6 +147,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
autoDeleteEnabled: false,
|
autoDeleteEnabled: false,
|
||||||
autoDeleteAfterDays: 30,
|
autoDeleteAfterDays: 30,
|
||||||
autoDeleteLastRunAt: null,
|
autoDeleteLastRunAt: null,
|
||||||
|
memoryLimitHistorical: 90,
|
||||||
|
memoryLimitViewport: 120,
|
||||||
|
memoryLimitActiveSession: 180,
|
||||||
toolCallExpansion: 'collapsed',
|
toolCallExpansion: 'collapsed',
|
||||||
fontSize: 100,
|
fontSize: 100,
|
||||||
padding: 100,
|
padding: 100,
|
||||||
@@ -296,6 +305,21 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ autoDeleteLastRunAt: timestamp });
|
set({ autoDeleteLastRunAt: timestamp });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setMemoryLimitHistorical: (value) => {
|
||||||
|
const clamped = Math.max(10, Math.min(500, Math.round(value)));
|
||||||
|
set({ memoryLimitHistorical: clamped });
|
||||||
|
},
|
||||||
|
|
||||||
|
setMemoryLimitViewport: (value) => {
|
||||||
|
const clamped = Math.max(20, Math.min(500, Math.round(value)));
|
||||||
|
set({ memoryLimitViewport: clamped });
|
||||||
|
},
|
||||||
|
|
||||||
|
setMemoryLimitActiveSession: (value) => {
|
||||||
|
const clamped = Math.max(30, Math.min(1000, Math.round(value)));
|
||||||
|
set({ memoryLimitActiveSession: clamped });
|
||||||
|
},
|
||||||
|
|
||||||
setToolCallExpansion: (value) => {
|
setToolCallExpansion: (value) => {
|
||||||
set({ toolCallExpansion: value });
|
set({ toolCallExpansion: value });
|
||||||
},
|
},
|
||||||
@@ -527,6 +551,9 @@ 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,
|
||||||
|
memoryLimitViewport: state.memoryLimitViewport,
|
||||||
|
memoryLimitActiveSession: state.memoryLimitActiveSession,
|
||||||
toolCallExpansion: state.toolCallExpansion,
|
toolCallExpansion: state.toolCallExpansion,
|
||||||
fontSize: state.fontSize,
|
fontSize: state.fontSize,
|
||||||
padding: state.padding,
|
padding: state.padding,
|
||||||
|
|||||||
@@ -642,6 +642,17 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memory limits for message viewport management
|
||||||
|
if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) {
|
||||||
|
result.memoryLimitHistorical = Math.max(10, Math.min(500, Math.round(candidate.memoryLimitHistorical)));
|
||||||
|
}
|
||||||
|
if (typeof candidate.memoryLimitViewport === 'number' && Number.isFinite(candidate.memoryLimitViewport)) {
|
||||||
|
result.memoryLimitViewport = Math.max(20, Math.min(500, Math.round(candidate.memoryLimitViewport)));
|
||||||
|
}
|
||||||
|
if (typeof candidate.memoryLimitActiveSession === 'number' && Number.isFinite(candidate.memoryLimitActiveSession)) {
|
||||||
|
result.memoryLimitActiveSession = Math.max(30, Math.min(1000, Math.round(candidate.memoryLimitActiveSession)));
|
||||||
|
}
|
||||||
|
|
||||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||||
if (skillCatalogs) {
|
if (skillCatalogs) {
|
||||||
result.skillCatalogs = skillCatalogs;
|
result.skillCatalogs = skillCatalogs;
|
||||||
|
|||||||
Reference in New Issue
Block a user