Files
claude-mem/src/ui/viewer/components/ContextSettingsModal.tsx
T
huakson 2b60dd2932 feat: isolate Claude and Codex session sources
Persist platform_source across session creation, transcript ingestion, API query paths, and viewer state so Claude and Codex data can coexist without bleeding into each other.

- add platform-source normalization helpers and persist platform_source in sdk_sessions via migration 24 with backfill and indexing
- thread platformSource through CLI hooks, transcript processing, context generation, pagination, search routes, SSE payloads, and session management
- expose source-aware project catalogs, viewer tabs, context preview selectors, and source badges for observations, prompts, and summaries
- start the transcript watcher from the worker for transcript-based clients and preserve platform source during Codex ingestion
- auto-start the worker from the MCP server for MCP-only clients and tighten stdio-driven cleanup during shutdown
- keep createSDKSession backward compatible with existing custom-title callers while allowing explicit platform source forwarding
2026-03-24 08:46:18 -03:00

505 lines
18 KiB
TypeScript

import React, { useState, useCallback, useEffect } from 'react';
import type { Settings } from '../types';
import { TerminalPreview } from './TerminalPreview';
import { useContextPreview } from '../hooks/useContextPreview';
interface ContextSettingsModalProps {
isOpen: boolean;
onClose: () => void;
settings: Settings;
onSave: (settings: Settings) => void;
isSaving: boolean;
saveStatus: string;
}
// Collapsible section component
function CollapsibleSection({
title,
description,
children,
defaultOpen = true
}: {
title: string;
description?: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className={`settings-section-collapsible ${isOpen ? 'open' : ''}`}>
<button
className="section-header-btn"
onClick={() => setIsOpen(!isOpen)}
type="button"
>
<div className="section-header-content">
<span className="section-title">{title}</span>
{description && <span className="section-description">{description}</span>}
</div>
<svg
className={`chevron-icon ${isOpen ? 'rotated' : ''}`}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{isOpen && <div className="section-content">{children}</div>}
</div>
);
}
// Form field with optional tooltip
function FormField({
label,
tooltip,
children
}: {
label: string;
tooltip?: string;
children: React.ReactNode;
}) {
return (
<div className="form-field">
<label className="form-field-label">
{label}
{tooltip && (
<span className="tooltip-trigger" title={tooltip}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
)}
</label>
{children}
</div>
);
}
// Toggle switch component
function ToggleSwitch({
id,
label,
description,
checked,
onChange,
disabled
}: {
id: string;
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="toggle-row">
<div className="toggle-info">
<label htmlFor={id} className="toggle-label">{label}</label>
{description && <span className="toggle-description">{description}</span>}
</div>
<button
type="button"
id={id}
role="switch"
aria-checked={checked}
className={`toggle-switch ${checked ? 'on' : ''} ${disabled ? 'disabled' : ''}`}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
>
<span className="toggle-knob" />
</button>
</div>
);
}
export function ContextSettingsModal({
isOpen,
onClose,
settings,
onSave,
isSaving,
saveStatus
}: ContextSettingsModalProps) {
const [formState, setFormState] = useState<Settings>(settings);
// Update form state when settings prop changes
useEffect(() => {
setFormState(settings);
}, [settings]);
// Get context preview based on current form state
const {
preview,
isLoading,
error,
projects,
sources,
selectedSource,
setSelectedSource,
selectedProject,
setSelectedProject
} = useContextPreview(formState);
const updateSetting = useCallback((key: keyof Settings, value: string) => {
const newState = { ...formState, [key]: value };
setFormState(newState);
}, [formState]);
const handleSave = useCallback(() => {
onSave(formState);
}, [formState, onSave]);
const toggleBoolean = useCallback((key: keyof Settings) => {
const currentValue = formState[key];
const newValue = currentValue === 'true' ? 'false' : 'true';
updateSetting(key, newValue);
}, [formState, updateSetting]);
// Handle ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="context-settings-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="modal-header">
<h2>Settings</h2>
<div className="header-controls">
<label className="preview-selector">
Source:
<select
value={selectedSource || ''}
onChange={(e) => setSelectedSource(e.target.value)}
disabled={sources.length === 0}
>
{sources.map(source => (
<option key={source} value={source}>{source}</option>
))}
</select>
</label>
<label className="preview-selector">
Project:
<select
value={selectedProject || ''}
onChange={(e) => setSelectedProject(e.target.value)}
disabled={projects.length === 0}
>
{projects.map(project => (
<option key={project} value={project}>{project}</option>
))}
</select>
</label>
<button
onClick={onClose}
className="modal-close-btn"
title="Close (Esc)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/* Body - 2 columns */}
<div className="modal-body">
{/* Left column - Terminal Preview */}
<div className="preview-column">
<div className="preview-content">
{error ? (
<div style={{ color: '#ff6b6b' }}>
Error loading preview: {error}
</div>
) : (
<TerminalPreview content={preview} isLoading={isLoading} />
)}
</div>
</div>
{/* Right column - Settings Panel */}
<div className="settings-column">
{/* Section 1: Loading */}
<CollapsibleSection
title="Loading"
description="How many observations to inject"
>
<FormField
label="Observations"
tooltip="Number of recent observations to include in context (1-200)"
>
<input
type="number"
min="1"
max="200"
value={formState.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_OBSERVATIONS', e.target.value)}
/>
</FormField>
<FormField
label="Sessions"
tooltip="Number of recent sessions to pull observations from (1-50)"
>
<input
type="number"
min="1"
max="50"
value={formState.CLAUDE_MEM_CONTEXT_SESSION_COUNT || '10'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_SESSION_COUNT', e.target.value)}
/>
</FormField>
</CollapsibleSection>
{/* Section 2: Display */}
<CollapsibleSection
title="Display"
description="What to show in context tables"
>
<div className="display-subsection">
<span className="subsection-label">Full Observations</span>
<FormField
label="Count"
tooltip="How many observations show expanded details (0-20)"
>
<input
type="number"
min="0"
max="20"
value={formState.CLAUDE_MEM_CONTEXT_FULL_COUNT || '5'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_FULL_COUNT', e.target.value)}
/>
</FormField>
<FormField
label="Field"
tooltip="Which field to expand for full observations"
>
<select
value={formState.CLAUDE_MEM_CONTEXT_FULL_FIELD || 'narrative'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_FULL_FIELD', e.target.value)}
>
<option value="narrative">Narrative</option>
<option value="facts">Facts</option>
</select>
</FormField>
</div>
<div className="display-subsection">
<span className="subsection-label">Token Economics</span>
<div className="toggle-group">
<ToggleSwitch
id="show-read-tokens"
label="Read cost"
description="Tokens to read this observation"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS')}
/>
<ToggleSwitch
id="show-work-tokens"
label="Work investment"
description="Tokens spent creating this observation"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS')}
/>
<ToggleSwitch
id="show-savings-amount"
label="Savings"
description="Total tokens saved by reusing context"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT')}
/>
</div>
</div>
</CollapsibleSection>
{/* Section 4: Advanced */}
<CollapsibleSection
title="Advanced"
description="AI provider and model selection"
defaultOpen={false}
>
<FormField
label="AI Provider"
tooltip="Choose between Claude (via Agent SDK) or Gemini (via REST API)"
>
<select
value={formState.CLAUDE_MEM_PROVIDER || 'claude'}
onChange={(e) => updateSetting('CLAUDE_MEM_PROVIDER', e.target.value)}
>
<option value="claude">Claude (uses your Claude account)</option>
<option value="gemini">Gemini (uses API key)</option>
<option value="openrouter">OpenRouter (multi-model)</option>
</select>
</FormField>
{formState.CLAUDE_MEM_PROVIDER === 'claude' && (
<FormField
label="Claude Model"
tooltip="Claude model used for generating observations"
>
<select
value={formState.CLAUDE_MEM_MODEL || 'haiku'}
onChange={(e) => updateSetting('CLAUDE_MEM_MODEL', e.target.value)}
>
<option value="haiku">haiku (fastest)</option>
<option value="sonnet">sonnet (balanced)</option>
<option value="opus">opus (highest quality)</option>
</select>
</FormField>
)}
{formState.CLAUDE_MEM_PROVIDER === 'gemini' && (
<>
<FormField
label="Gemini API Key"
tooltip="Your Google AI Studio API key (or set GEMINI_API_KEY env var)"
>
<input
type="password"
value={formState.CLAUDE_MEM_GEMINI_API_KEY || ''}
onChange={(e) => updateSetting('CLAUDE_MEM_GEMINI_API_KEY', e.target.value)}
placeholder="Enter Gemini API key..."
/>
</FormField>
<FormField
label="Gemini Model"
tooltip="Gemini model used for generating observations"
>
<select
value={formState.CLAUDE_MEM_GEMINI_MODEL || 'gemini-2.5-flash-lite'}
onChange={(e) => updateSetting('CLAUDE_MEM_GEMINI_MODEL', e.target.value)}
>
<option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite (10 RPM free)</option>
<option value="gemini-2.5-flash">gemini-2.5-flash (5 RPM free)</option>
<option value="gemini-3-flash-preview">gemini-3-flash-preview (5 RPM free)</option>
</select>
</FormField>
<div className="toggle-group" style={{ marginTop: '8px' }}>
<ToggleSwitch
id="gemini-rate-limiting"
label="Rate Limiting"
description="Enable for free tier (10-30 RPM). Disable if you have billing set up (1000+ RPM)."
checked={formState.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED === 'true'}
onChange={(checked) => updateSetting('CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED', checked ? 'true' : 'false')}
/>
</div>
</>
)}
{formState.CLAUDE_MEM_PROVIDER === 'openrouter' && (
<>
<FormField
label="OpenRouter API Key"
tooltip="Your OpenRouter API key from openrouter.ai (or set OPENROUTER_API_KEY env var)"
>
<input
type="password"
value={formState.CLAUDE_MEM_OPENROUTER_API_KEY || ''}
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_API_KEY', e.target.value)}
placeholder="Enter OpenRouter API key..."
/>
</FormField>
<FormField
label="OpenRouter Model"
tooltip="Model identifier from OpenRouter (e.g., anthropic/claude-3.5-sonnet, google/gemini-2.0-flash-thinking-exp)"
>
<input
type="text"
value={formState.CLAUDE_MEM_OPENROUTER_MODEL || 'xiaomi/mimo-v2-flash:free'}
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_MODEL', e.target.value)}
placeholder="e.g., xiaomi/mimo-v2-flash:free"
/>
</FormField>
<FormField
label="Site URL (Optional)"
tooltip="Your site URL for OpenRouter analytics (optional)"
>
<input
type="text"
value={formState.CLAUDE_MEM_OPENROUTER_SITE_URL || ''}
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_SITE_URL', e.target.value)}
placeholder="https://yoursite.com"
/>
</FormField>
<FormField
label="App Name (Optional)"
tooltip="Your app name for OpenRouter analytics (optional)"
>
<input
type="text"
value={formState.CLAUDE_MEM_OPENROUTER_APP_NAME || 'claude-mem'}
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_APP_NAME', e.target.value)}
placeholder="claude-mem"
/>
</FormField>
</>
)}
<FormField
label="Worker Port"
tooltip="Port for the background worker service"
>
<input
type="number"
min="1024"
max="65535"
value={formState.CLAUDE_MEM_WORKER_PORT || '37777'}
onChange={(e) => updateSetting('CLAUDE_MEM_WORKER_PORT', e.target.value)}
/>
</FormField>
<div className="toggle-group" style={{ marginTop: '12px' }}>
<ToggleSwitch
id="show-last-summary"
label="Include last summary"
description="Add previous session's summary to context"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY')}
/>
<ToggleSwitch
id="show-last-message"
label="Include last message"
description="Add previous session's final message"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE')}
/>
</div>
</CollapsibleSection>
</div>
</div>
{/* Footer with Save button */}
<div className="modal-footer">
<div className="save-status">
{saveStatus && <span className={saveStatus.includes('✓') ? 'success' : saveStatus.includes('✗') ? 'error' : ''}>{saveStatus}</span>}
</div>
<button
className="save-btn"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
);
}