d0ff9738eb
- Removed MCP toggle state and related logic from ContextSettingsModal. - Eliminated the Sidebar component entirely, consolidating its functionality elsewhere. - Cleaned up unused imports and effects related to MCP status fetching and toggling.
503 lines
17 KiB
TypeScript
503 lines
17 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;
|
|
}
|
|
|
|
// Simple debounce helper
|
|
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
|
|
let timeoutId: NodeJS.Timeout;
|
|
return ((...args: any[]) => {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => fn(...args), ms);
|
|
}) as T;
|
|
}
|
|
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
// Chip group with select all/none
|
|
function ChipGroup({
|
|
label,
|
|
options,
|
|
selectedValues,
|
|
onToggle,
|
|
onSelectAll,
|
|
onSelectNone
|
|
}: {
|
|
label: string;
|
|
options: string[];
|
|
selectedValues: string[];
|
|
onToggle: (value: string) => void;
|
|
onSelectAll: () => void;
|
|
onSelectNone: () => void;
|
|
}) {
|
|
const allSelected = options.every(opt => selectedValues.includes(opt));
|
|
const noneSelected = options.every(opt => !selectedValues.includes(opt));
|
|
|
|
return (
|
|
<div className="chip-group">
|
|
<div className="chip-group-header">
|
|
<span className="chip-group-label">{label}</span>
|
|
<div className="chip-group-actions">
|
|
<button
|
|
type="button"
|
|
className={`chip-action ${allSelected ? 'active' : ''}`}
|
|
onClick={onSelectAll}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`chip-action ${noneSelected ? 'active' : ''}`}
|
|
onClick={onSelectNone}
|
|
>
|
|
None
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="chips-container">
|
|
{options.map(option => (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
className={`chip ${selectedValues.includes(option) ? 'selected' : ''}`}
|
|
onClick={() => onToggle(option)}
|
|
>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</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);
|
|
|
|
// Create debounced save function
|
|
const debouncedSave = useCallback(
|
|
debounce((newSettings: Settings) => {
|
|
onSave(newSettings);
|
|
}, 300),
|
|
[onSave]
|
|
);
|
|
|
|
// Update form state when settings prop changes
|
|
useEffect(() => {
|
|
setFormState(settings);
|
|
}, [settings]);
|
|
|
|
// Get context preview based on current form state
|
|
const { preview, isLoading, error, projects, selectedProject, setSelectedProject } = useContextPreview(formState);
|
|
|
|
const updateSetting = useCallback((key: keyof Settings, value: string) => {
|
|
const newState = { ...formState, [key]: value };
|
|
setFormState(newState);
|
|
debouncedSave(newState);
|
|
}, [formState, debouncedSave]);
|
|
|
|
const toggleBoolean = useCallback((key: keyof Settings) => {
|
|
const currentValue = formState[key];
|
|
const newValue = currentValue === 'true' ? 'false' : 'true';
|
|
updateSetting(key, newValue);
|
|
}, [formState, updateSetting]);
|
|
|
|
const toggleArrayValue = useCallback((key: keyof Settings, value: string) => {
|
|
const currentValue = formState[key] || '';
|
|
const currentArray = currentValue ? currentValue.split(',') : [];
|
|
const newArray = currentArray.includes(value)
|
|
? currentArray.filter(v => v !== value)
|
|
: [...currentArray, value];
|
|
updateSetting(key, newArray.join(','));
|
|
}, [formState, updateSetting]);
|
|
|
|
const getArrayValues = useCallback((key: keyof Settings): string[] => {
|
|
const currentValue = formState[key] || '';
|
|
return currentValue ? currentValue.split(',') : [];
|
|
}, [formState]);
|
|
|
|
const setAllArrayValues = useCallback((key: keyof Settings, values: string[]) => {
|
|
updateSetting(key, values.join(','));
|
|
}, [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;
|
|
|
|
const observationTypes = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change'];
|
|
const observationConcepts = ['how-it-works', 'why-it-exists', 'what-changed', 'problem-solution', 'gotcha', 'pattern', 'trade-off'];
|
|
|
|
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">
|
|
<a
|
|
href="https://docs.claude-mem.ai"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="Documentation"
|
|
className="modal-icon-link"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
|
</svg>
|
|
</a>
|
|
<a
|
|
href="https://x.com/Claude_Memory"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="X (Twitter)"
|
|
className="modal-icon-link"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
|
</svg>
|
|
</a>
|
|
<label className="preview-selector">
|
|
Preview for:
|
|
<select
|
|
value={selectedProject || ''}
|
|
onChange={(e) => setSelectedProject(e.target.value)}
|
|
>
|
|
{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: Filters */}
|
|
<CollapsibleSection
|
|
title="Filters"
|
|
description="Which observation types to include"
|
|
>
|
|
<ChipGroup
|
|
label="Type"
|
|
options={observationTypes}
|
|
selectedValues={getArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES')}
|
|
onToggle={(value) => toggleArrayValue('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', value)}
|
|
onSelectAll={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', observationTypes)}
|
|
onSelectNone={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', [])}
|
|
/>
|
|
<ChipGroup
|
|
label="Concept"
|
|
options={observationConcepts}
|
|
selectedValues={getArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS')}
|
|
onToggle={(value) => toggleArrayValue('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', value)}
|
|
onSelectAll={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', observationConcepts)}
|
|
onSelectNone={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', [])}
|
|
/>
|
|
</CollapsibleSection>
|
|
|
|
{/* Section 3: 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="Model selection and integrations"
|
|
defaultOpen={false}
|
|
>
|
|
<FormField
|
|
label="Model"
|
|
tooltip="AI model used for generating observations"
|
|
>
|
|
<select
|
|
value={formState.CLAUDE_MEM_MODEL || 'haiku'}
|
|
onChange={(e) => updateSetting('CLAUDE_MEM_MODEL', e.target.value)}
|
|
>
|
|
{/* Shorthand names forward to latest model version */}
|
|
<option value="haiku">haiku (fastest)</option>
|
|
<option value="sonnet">sonnet (balanced)</option>
|
|
<option value="opus">opus (highest quality)</option>
|
|
</select>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|