Files
claude-mem/src/ui/viewer/components/Sidebar.tsx
T
Alex Newman 8caf159d99 feat: Add branch-based beta toggle for switching between stable and beta versions
Adds Version Channel section to Settings sidebar allowing users to:
- See current branch (main or beta/7.0) and stability status
- Switch to beta branch to access Endless Mode features
- Switch back to stable for production use
- Pull updates for current branch

Implementation:
- BranchManager.ts: Git operations for branch detection/switching
- worker-service.ts: /api/branch/* endpoints (status, switch, update)
- Sidebar.tsx: Version Channel UI with branch state and handlers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 14:12:49 -05:00

413 lines
16 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Settings, Stats } from '../types';
import { DEFAULT_SETTINGS } from '../constants/settings';
import { formatUptime, formatBytes } from '../utils/formatters';
interface SidebarProps {
isOpen: boolean;
settings: Settings;
stats: Stats;
isSaving: boolean;
saveStatus: string;
isConnected: boolean;
onSave: (settings: Settings) => void;
onClose: () => void;
onRefreshStats: () => void;
}
export function Sidebar({ isOpen, settings, stats, isSaving, saveStatus, isConnected, onSave, onClose, onRefreshStats }: SidebarProps) {
// Settings form state
const [model, setModel] = useState(settings.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL);
const [contextObs, setContextObs] = useState(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS);
const [workerPort, setWorkerPort] = useState(settings.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT);
// MCP toggle state (separate from settings)
const [mcpEnabled, setMcpEnabled] = useState(true);
const [mcpToggling, setMcpToggling] = useState(false);
const [mcpStatus, setMcpStatus] = useState('');
// Branch switching state
interface BranchInfo {
branch: string | null;
isBeta: boolean;
isGitRepo: boolean;
isDirty: boolean;
canSwitch: boolean;
error?: string;
}
const [branchInfo, setBranchInfo] = useState<BranchInfo | null>(null);
const [branchSwitching, setBranchSwitching] = useState(false);
const [branchStatus, setBranchStatus] = useState('');
// Update settings form state when settings change
useEffect(() => {
setModel(settings.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL);
setContextObs(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS);
setWorkerPort(settings.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT);
}, [settings]);
// Fetch MCP status on mount
useEffect(() => {
fetch('/api/mcp/status')
.then(res => res.json())
.then(data => setMcpEnabled(data.enabled))
.catch(error => console.error('Failed to load MCP status:', error));
}, []);
// Fetch branch status on mount
useEffect(() => {
fetch('/api/branch/status')
.then(res => res.json())
.then(data => setBranchInfo(data))
.catch(error => console.error('Failed to load branch status:', error));
}, []);
// Refresh stats when sidebar opens
useEffect(() => {
if (isOpen) {
onRefreshStats();
}
}, [isOpen, onRefreshStats]);
const handleSave = () => {
onSave({
CLAUDE_MEM_MODEL: model,
CLAUDE_MEM_CONTEXT_OBSERVATIONS: contextObs,
CLAUDE_MEM_WORKER_PORT: workerPort
});
};
const handleMcpToggle = async (enabled: boolean) => {
setMcpToggling(true);
setMcpStatus('Toggling...');
try {
const response = await fetch('/api/mcp/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const result = await response.json();
if (result.success) {
setMcpEnabled(result.enabled);
setMcpStatus('✓ Updated (restart Claude Code to apply)');
setTimeout(() => setMcpStatus(''), 3000);
} else {
setMcpStatus(`✗ Error: ${result.error}`);
setTimeout(() => setMcpStatus(''), 3000);
}
} catch (error) {
setMcpStatus(`✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setTimeout(() => setMcpStatus(''), 3000);
} finally {
setMcpToggling(false);
}
};
const handleBranchSwitch = async (targetBranch: string) => {
setBranchSwitching(true);
setBranchStatus('Switching branches...');
try {
const response = await fetch('/api/branch/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: targetBranch })
});
const result = await response.json();
if (result.success) {
setBranchStatus(`${result.message}`);
// Worker will restart, page will refresh
setTimeout(() => {
setBranchStatus('Restarting worker...');
}, 1000);
} else {
setBranchStatus(`✗ Error: ${result.error}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
} catch (error) {
setBranchStatus(`✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
};
const handleBranchUpdate = async () => {
setBranchSwitching(true);
setBranchStatus('Checking for updates...');
try {
const response = await fetch('/api/branch/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
setBranchStatus(`${result.message}`);
// Worker will restart, page will refresh
setTimeout(() => {
setBranchStatus('Restarting worker...');
}, 1000);
} else {
setBranchStatus(`✗ Error: ${result.error}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
} catch (error) {
setBranchStatus(`✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
};
return (
<div className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<h1>Settings</h1>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className={`status-dot ${isConnected ? 'connected' : ''}`} />
<span style={{ fontSize: '11px', opacity: 0.5, fontWeight: 300 }}>{isConnected ? 'Connected' : 'Disconnected'}</span>
</div>
<button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={onClose}
title="Close settings"
style={{
background: 'transparent',
border: '1px solid #404040',
padding: '8px',
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div className="stats-scroll">
<div className="settings-section">
<h3>Environment Variables</h3>
<div className="form-group">
<label htmlFor="model">CLAUDE_MEM_MODEL</label>
<div className="setting-description">
Model used for AI compression of tool observations. Haiku is fast and cheap, Sonnet offers better quality, Opus is most capable but expensive.
</div>
<select
id="model"
value={model}
onChange={e => setModel(e.target.value)}
>
<option value="claude-haiku-4-5">claude-haiku-4-5</option>
<option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
<option value="claude-opus-4">claude-opus-4</option>
</select>
</div>
<div className="form-group">
<label htmlFor="contextObs">CLAUDE_MEM_CONTEXT_OBSERVATIONS</label>
<div className="setting-description">
Number of recent observations to inject at session start. Higher values provide more context but increase token usage. Default: 50
</div>
<input
type="number"
id="contextObs"
min="1"
max="200"
value={contextObs}
onChange={e => setContextObs(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="workerPort">CLAUDE_MEM_WORKER_PORT</label>
<div className="setting-description">
Port number for the background worker service. Change only if port 37777 conflicts with another service.
</div>
<input
type="number"
id="workerPort"
min="1024"
max="65535"
value={workerPort}
onChange={e => setWorkerPort(e.target.value)}
/>
</div>
{saveStatus && (
<div className="save-status">{saveStatus}</div>
)}
</div>
<div className="settings-section">
<h3>MCP Search Server</h3>
<div className="form-group">
<label htmlFor="mcpEnabled" style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
id="mcpEnabled"
checked={mcpEnabled}
onChange={e => handleMcpToggle(e.target.checked)}
disabled={mcpToggling}
style={{ cursor: mcpToggling ? 'not-allowed' : 'pointer' }}
/>
Enable MCP Search Server
</label>
<div className="setting-description">
claude-mem suggests using skill-based search (saves ~2,500 tokens at session start), but some users prefer MCP. Disable to only use skill-based search. Requires Claude Code restart to apply changes.
</div>
{mcpStatus && (
<div className="save-status">{mcpStatus}</div>
)}
</div>
</div>
<div className="settings-section">
<h3>Version Channel</h3>
<div className="form-group">
{branchInfo ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: 600,
background: branchInfo.isBeta ? '#6b4500' : '#1a4d1a',
color: branchInfo.isBeta ? '#ffb84d' : '#4ade80',
border: `1px solid ${branchInfo.isBeta ? '#ffb84d' : '#4ade80'}`
}}>
{branchInfo.isBeta ? 'Beta' : 'Stable'}
</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>
{branchInfo.branch || 'main'}
</span>
</div>
{branchInfo.isBeta ? (
<>
<div className="setting-description" style={{ marginBottom: '12px' }}>
You're running the beta with Endless Mode. Your memory data is preserved when switching versions.
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => handleBranchSwitch('main')}
disabled={branchSwitching}
style={{
background: '#2a2a2a',
border: '1px solid #404040',
padding: '8px 16px',
cursor: branchSwitching ? 'not-allowed' : 'pointer',
opacity: branchSwitching ? 0.5 : 1
}}
>
Switch to Stable
</button>
<button
onClick={handleBranchUpdate}
disabled={branchSwitching}
style={{
background: '#2a2a2a',
border: '1px solid #404040',
padding: '8px 16px',
cursor: branchSwitching ? 'not-allowed' : 'pointer',
opacity: branchSwitching ? 0.5 : 1
}}
>
Check for Updates
</button>
</div>
</>
) : (
<>
<div className="setting-description" style={{ marginBottom: '12px' }}>
Try the beta to access experimental features like Endless Mode. Your memory data is preserved when switching.
</div>
<button
onClick={() => handleBranchSwitch('beta/7.0')}
disabled={branchSwitching}
style={{
background: '#4a3500',
border: '1px solid #ffb84d',
color: '#ffb84d',
padding: '8px 16px',
cursor: branchSwitching ? 'not-allowed' : 'pointer',
opacity: branchSwitching ? 0.5 : 1
}}
>
Try Beta (Endless Mode)
</button>
</>
)}
{branchStatus && (
<div className="save-status" style={{ marginTop: '8px' }}>{branchStatus}</div>
)}
</>
) : (
<div style={{ fontSize: '12px', opacity: 0.5 }}>Loading branch info...</div>
)}
</div>
</div>
<div className="settings-section">
<h3>Worker Stats</h3>
<div className="stats-grid">
<div className="stat">
<div className="stat-label">Version</div>
<div className="stat-value">{stats.worker?.version || '-'}</div>
</div>
<div className="stat">
<div className="stat-label">Uptime</div>
<div className="stat-value">{formatUptime(stats.worker?.uptime)}</div>
</div>
<div className="stat">
<div className="stat-label">Active Sessions</div>
<div className="stat-value">{stats.worker?.activeSessions || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">SSE Clients</div>
<div className="stat-value">{stats.worker?.sseClients || '0'}</div>
</div>
</div>
</div>
<div className="settings-section">
<h3>Database Stats</h3>
<div className="stats-grid">
<div className="stat">
<div className="stat-label">DB Size</div>
<div className="stat-value">{formatBytes(stats.database?.size)}</div>
</div>
<div className="stat">
<div className="stat-label">Observations</div>
<div className="stat-value">{stats.database?.observations || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">Sessions</div>
<div className="stat-value">{stats.database?.sessions || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">Summaries</div>
<div className="stat-value">{stats.database?.summaries || '0'}</div>
</div>
</div>
</div>
</div>
</div>
);
}