-
-
- Request ingestion from all connected channels.
+ {/* Detail overlay — fills the entire workspace card */}
+ {selectedChunk && (
+
- )}
-
-
- {/* Collapsible: Learn */}
-
-
setLearnOpen(!learnOpen)}
- className="w-full flex items-center justify-between p-4 text-left hover:bg-stone-100 transition-colors rounded-xl">
-
-
-
-
-
Learn
- {learnResult && (
-
- Last run: {learnResult.namespaces_processed} namespace(s)
-
- )}
-
-
-
- {learnOpen && (
-
-
- Runs the summarizer tree over raw memory namespaces, condensing buffered content into
- hour-level summaries and propagating them upward. Requires local AI to be enabled.
-
-
void handleLearnAll()}
- disabled={learning}
- className="px-3 py-1.5 text-xs rounded-lg border border-primary-500/40 bg-primary-500/20 hover:bg-primary-500/30 text-primary-700 disabled:opacity-40">
- {learning ? (
-
-
-
-
-
- Learning...
-
- ) : (
- 'Learn all'
- )}
+ onClick={handleCloseDetail}
+ aria-label="Close detail (Esc)"
+ title="Close (Esc)"
+ className="rounded-lg p-1.5 text-stone-500 transition-colors
+ hover:bg-stone-100 hover:text-stone-900
+ focus:outline-none focus:ring-2 focus:ring-ocean-200">
+
+
+
+
+
- {learnResult && (
-
-
-
- {learnResult.namespaces_processed} processed
-
- ·
-
- {learnResult.results.filter(r => r.status === 'ok').length} ok
-
- {learnResult.results.some(r => r.status === 'error') && (
- <>
- ·
- setLearnErrorOpen(!learnErrorOpen)}
- className="text-coral-500 underline decoration-dotted">
- {learnResult.results.filter(r => r.status === 'error').length} error(s)
-
- >
- )}
-
-
- {learnErrorOpen && (
-
- {learnResult.results
- .filter(r => r.status === 'error')
- .map(r => (
-
- {r.namespace} :{' '}
- {r.error ?? 'unknown error'}
-
- ))}
-
- )}
-
- )}
+
- )}
-
-
- {/* Warnings */}
- {(memoryWorkspaceError || (!isTauri() && !memoryWorkspaceLoading)) && (
-
- {memoryWorkspaceError ||
- 'Memory workspace requires the desktop Tauri runtime to load real data.'}
)}
diff --git a/app/src/components/intelligence/ModelAssignment.tsx b/app/src/components/intelligence/ModelAssignment.tsx
new file mode 100644
index 000000000..d2b0ec5da
--- /dev/null
+++ b/app/src/components/intelligence/ModelAssignment.tsx
@@ -0,0 +1,150 @@
+import {
+ DEFAULT_EXTRACT_MODEL,
+ DEFAULT_SUMMARISER_MODEL,
+ type ModelDescriptor,
+ RECOMMENDED_MODEL_CATALOG,
+ REQUIRED_EMBEDDER_MODEL,
+} from '../../lib/intelligence/settingsApi';
+
+interface ModelAssignmentProps {
+ /** Names of models that are already installed on the user's machine. */
+ installedModelIds: ReadonlyArray
;
+ /** Currently chosen memory LLM (used for both extract + summarise). */
+ memoryModel: string;
+ /** Called when the user picks a different memory LLM. The setting fans
+ * out to both `llm_extractor_model` and `llm_summariser_model` in
+ * config.toml — most users want one model for both roles, and the
+ * cognitive load of two dropdowns isn't worth the rare power-user
+ * case of mixing them. */
+ onChangeMemory: (id: string) => void;
+}
+
+/**
+ * Per-role assignment table — two rows: Memory LLM (covers both extract
+ * and summarise), and Embedder.
+ *
+ * The embedder row is locked to `bge-m3` for v1 (the spec says we never
+ * round-trip embeddings through the cloud). The Memory LLM dropdown is
+ * populated from the recommended catalog filtered to models that can
+ * serve both extract AND summarise roles, plus any locally-installed
+ * models the user has pulled outside the curated catalog.
+ */
+export default function ModelAssignment({
+ installedModelIds,
+ memoryModel,
+ onChangeMemory,
+}: ModelAssignmentProps) {
+ // Ollama returns tags as `:latest` for default-tag models. The
+ // catalog stores bare names (e.g. `bge-m3`). Strip the `:latest` suffix
+ // on the installed side so the bare-name comparison matches.
+ const normalizedInstalled = installedModelIds.map(id =>
+ id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id
+ );
+ const memoryOptions = memoryLlmOptions(normalizedInstalled);
+ const embedderDescriptor = RECOMMENDED_MODEL_CATALOG.find(m => m.id === REQUIRED_EMBEDDER_MODEL);
+ const embedderInstalled = normalizedInstalled.includes(REQUIRED_EMBEDDER_MODEL);
+
+ return (
+
+
opt.id === memoryModel))}>
+ onChangeMemory(e.target.value)}
+ className="w-full sm:w-64 px-3 py-1.5 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors"
+ aria-label="Memory LLM (extract + summarise)">
+ {memoryOptions.map(opt => (
+
+ {opt.label ?? opt.id}
+
+ ))}
+
+
+
+
+
+
{REQUIRED_EMBEDDER_MODEL}
+ {embedderInstalled ? (
+
+
+
+
+ loaded
+
+ ) : (
+
not downloaded
+ )}
+
+
+
+ );
+}
+
+interface RowProps {
+ label: string;
+ sublabel: string;
+ last?: boolean;
+ children: React.ReactNode;
+}
+
+function Row({ label, sublabel, last, children }: RowProps) {
+ return (
+
+ );
+}
+
+function describeMemory(model?: ModelDescriptor): string {
+ if (!model) return 'used for extract + summarise';
+ return `${model.size} · ${model.ramHint} · ${model.category}`;
+}
+
+/**
+ * Build the Memory LLM dropdown options. A model qualifies if it can serve
+ * BOTH extract and summarise roles. Catalog entries come first; locally
+ * installed extras (pulled outside the curated catalog) are appended so
+ * they remain selectable.
+ */
+function memoryLlmOptions(installedModelIds: ReadonlyArray): ModelDescriptor[] {
+ const catalog = RECOMMENDED_MODEL_CATALOG.filter(
+ m => m.roles.includes('extract') && m.roles.includes('summariser')
+ );
+ const known = new Set(catalog.map(m => m.id));
+ const extras = installedModelIds
+ .filter(id => !known.has(id) && id !== REQUIRED_EMBEDDER_MODEL)
+ .map(id => ({
+ id,
+ size: '—',
+ approxBytes: 0,
+ ramHint: '—',
+ category: 'balanced',
+ note: 'locally installed',
+ roles: ['extract', 'summariser'],
+ }));
+ return [...catalog, ...extras];
+}
+
+// Re-export defaults so callers can still seed initial state via these
+// constants without chasing them through the API module.
+export { DEFAULT_EXTRACT_MODEL, DEFAULT_SUMMARISER_MODEL, REQUIRED_EMBEDDER_MODEL };
diff --git a/app/src/components/intelligence/ModelCatalog.tsx b/app/src/components/intelligence/ModelCatalog.tsx
new file mode 100644
index 000000000..005a8d3d9
--- /dev/null
+++ b/app/src/components/intelligence/ModelCatalog.tsx
@@ -0,0 +1,252 @@
+import { useState } from 'react';
+
+import {
+ capabilityForModel,
+ type ModelDescriptor,
+ RECOMMENDED_MODEL_CATALOG,
+} from '../../lib/intelligence/settingsApi';
+
+interface ModelCatalogProps {
+ /** Names of models that are already installed on the user's machine. */
+ installedModelIds: ReadonlyArray;
+ /** Models in active use right now (assigned to a role). */
+ activeModelIds: ReadonlyArray;
+ /** Called when the user kicks off a download for a catalog entry. */
+ onDownload: (model: ModelDescriptor) => Promise;
+ /** Called when the user wants to assign an installed model to its role. */
+ onUse: (model: ModelDescriptor) => void;
+ /** Called when the user removes an installed model. */
+ onDelete?: (model: ModelDescriptor) => Promise;
+}
+
+type RowState = 'idle' | 'downloading' | 'error';
+
+/**
+ * Single-column list of curated models. Each row is one card showing
+ * [action]
+ * The action button changes by state:
+ * - not installed → "Download" (clicks fire the per-capability RPC)
+ * - installed but unused → "Use"
+ * - installed and active → "Active"
+ * - downloading → inline progress bar (mocked client-side animation
+ * since the per-asset RPC is fire-and-forget; the real progress
+ * stream is wired in `local_ai_downloads_progress` polling — out of
+ * scope for v1)
+ */
+// Ollama reports tags as `:` (e.g. `bge-m3:latest`,
+// `gemma3:1b-it-qat`). The recommended catalog uses bare names for the
+// default-`:latest` case (e.g. `bge-m3`) and full `:` for
+// non-default tags. Normalize both sides by stripping the `:latest`
+// suffix before comparing — that way `bge-m3` matches `bge-m3:latest`,
+// while `gemma3:1b-it-qat` still requires the explicit tag.
+function normalizeModelId(id: string): string {
+ return id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id;
+}
+
+export default function ModelCatalog({
+ installedModelIds,
+ activeModelIds,
+ onDownload,
+ onUse,
+ onDelete,
+}: ModelCatalogProps) {
+ const installedSet = new Set(installedModelIds.map(normalizeModelId));
+ const activeSet = new Set(activeModelIds.map(normalizeModelId));
+
+ return (
+
+ {RECOMMENDED_MODEL_CATALOG.map(model => (
+
+ ))}
+
+ );
+}
+
+interface CatalogRowProps {
+ model: ModelDescriptor;
+ installed: boolean;
+ active: boolean;
+ onDownload: ModelCatalogProps['onDownload'];
+ onUse: ModelCatalogProps['onUse'];
+ onDelete: ModelCatalogProps['onDelete'];
+}
+
+function CatalogRow({ model, installed, active, onDownload, onUse, onDelete }: CatalogRowProps) {
+ const [state, setState] = useState('idle');
+ const [progress, setProgress] = useState(0);
+
+ const status: 'active' | 'installed' | 'available' = active
+ ? 'active'
+ : installed
+ ? 'installed'
+ : 'available';
+
+ const handleDownload = async () => {
+ setState('downloading');
+ setProgress(8);
+ // Animated mock progress while the real per-capability RPC is in flight.
+ // The real download progress stream comes from
+ // `openhumanLocalAiDownloadsProgress` polling — wiring that in is
+ // tracked separately and out of scope for v1.
+ const tick = setInterval(() => {
+ setProgress(prev => {
+ if (prev >= 90) return prev;
+ return prev + Math.max(2, Math.round((100 - prev) * 0.06));
+ });
+ }, 220);
+ let didFail = false;
+ try {
+ await onDownload(model);
+ setProgress(100);
+ } catch (err) {
+ console.debug('[intelligence-settings] catalog download failed', { id: model.id, err });
+ setState('error');
+ didFail = true;
+ } finally {
+ clearInterval(tick);
+ // Hold the terminal state long enough for the user to actually read
+ // it. Success collapses fast (~600 ms) so the row settles back to
+ // its post-install state without a long pause; error lingers ~3s
+ // so an unsuccessful pull doesn't snap back before the user has
+ // a chance to notice. Tracked via a local flag because `state` is
+ // React state and won't reflect the just-issued `setState('error')`
+ // until the next render.
+ const settleMs = didFail ? 3000 : 600;
+ window.setTimeout(() => {
+ setState('idle');
+ setProgress(0);
+ }, settleMs);
+ }
+ };
+
+ return (
+
+
+
+
{model.id}
+
{model.size}
+
+
+
+ {state === 'downloading' ? (
+
+ ) : (
+
onUse(model)}
+ onDelete={onDelete ? () => onDelete(model) : undefined}
+ />
+ )}
+
+
+
+ {model.ramHint}
+ ·
+ {model.category}
+ ·
+ {model.note}
+ {capabilityForModel(model) === null && (
+ no capability binding
+ )}
+
+ {state === 'error' && (
+
+ Download failed — check Ollama is running and try again.
+
+ )}
+
+ );
+}
+
+function StatusChip({ status }: { status: 'active' | 'installed' | 'available' }) {
+ if (status === 'active') {
+ return (
+
+ active
+
+ );
+ }
+ if (status === 'installed') {
+ return (
+
+ installed
+
+ );
+ }
+ return (
+
+ not downloaded
+
+ );
+}
+
+interface ActionButtonProps {
+ status: 'active' | 'installed' | 'available';
+ hasDelete: boolean;
+ onDownload: () => void;
+ onUse: () => void;
+ onDelete?: () => void;
+}
+
+function ActionButton({ status, hasDelete, onDownload, onUse, onDelete }: ActionButtonProps) {
+ if (status === 'active') {
+ return (
+ in use
+ );
+ }
+ if (status === 'installed') {
+ return (
+
+
+ Use
+
+ {hasDelete && onDelete && (
+
+ Delete
+
+ )}
+
+ );
+ }
+ return (
+
+ Download
+
+ );
+}
+
+function ProgressBar({ progress }: { progress: number }) {
+ return (
+
+ );
+}
diff --git a/app/src/components/intelligence/__tests__/IntelligenceSettingsTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSettingsTab.test.tsx
new file mode 100644
index 000000000..eb7cd286f
--- /dev/null
+++ b/app/src/components/intelligence/__tests__/IntelligenceSettingsTab.test.tsx
@@ -0,0 +1,257 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
+
+import { renderWithProviders } from '../../../test/test-utils';
+import IntelligenceSettingsTab from '../IntelligenceSettingsTab';
+
+// The orchestrator hits these RPCs on mount; the global tauriCommands mock
+// in setup.ts only stubs auth/service helpers, so we extend it here with
+// the local-AI surface the Settings tab uses, plus the new memory_tree
+// LLM-selector RPCs that replaced the dev-time mock backend.
+vi.mock('../../../utils/tauriCommands', () => ({
+ isTauri: vi.fn(() => true),
+ // memory_tree LLM selector — the BackendChooser polls these on mount and
+ // again on every backend toggle. We track the value in a closure so the
+ // set→get round-trip behaves like the real persistent core.
+ memoryTreeGetLlm: vi.fn(),
+ memoryTreeSetLlm: vi.fn(),
+ openhumanLocalAiAssetsStatus: vi
+ .fn()
+ .mockResolvedValue({
+ result: {
+ chat: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ embedding: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ quantization: 'q4_k_m',
+ },
+ }),
+ openhumanLocalAiDiagnostics: vi.fn().mockResolvedValue({
+ ollama_running: true,
+ ollama_binary_path: '/usr/local/bin/ollama',
+ installed_models: [
+ { name: 'gemma3:1b-it-qat', size: 1_700_000_000, modified_at: null },
+ { name: 'bge-m3', size: 1_300_000_000, modified_at: null },
+ ],
+ expected: {
+ chat_model: 'gemma3:1b-it-qat',
+ chat_found: true,
+ embedding_model: 'bge-m3',
+ embedding_found: true,
+ vision_model: '',
+ vision_found: false,
+ },
+ issues: [],
+ ok: true,
+ }),
+ openhumanLocalAiStatus: vi
+ .fn()
+ .mockResolvedValue({
+ result: {
+ state: 'Ready',
+ model_id: 'gemma3:1b-it-qat',
+ chat_model_id: 'gemma3:1b-it-qat',
+ vision_model_id: '',
+ embedding_model_id: 'bge-m3',
+ stt_model_id: '',
+ tts_voice_id: '',
+ quantization: 'q4_k_m',
+ vision_state: 'idle',
+ vision_mode: 'off',
+ embedding_state: 'Ready',
+ stt_state: 'idle',
+ tts_state: 'idle',
+ provider: 'ollama',
+ active_backend: 'cpu',
+ last_latency_ms: 142,
+ },
+ }),
+ openhumanLocalAiPresets: vi
+ .fn()
+ .mockResolvedValue({
+ presets: [],
+ recommended_tier: 'minimal',
+ current_tier: 'minimal',
+ device: {
+ total_ram_bytes: 16_000_000_000,
+ cpu_count: 8,
+ cpu_brand: 'Test CPU',
+ os_name: 'macos',
+ os_version: '14',
+ has_gpu: false,
+ gpu_description: null,
+ },
+ local_ai_enabled: false,
+ }),
+ openhumanLocalAiDownloadAsset: vi
+ .fn()
+ .mockResolvedValue({
+ result: {
+ chat: { state: 'Ready', id: 'gemma3:1b-it-qat', provider: 'ollama' },
+ vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ embedding: { state: 'Ready', id: 'bge-m3', provider: 'ollama' },
+ stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
+ quantization: 'q4_k_m',
+ },
+ }),
+}));
+
+// Pull mocked references after vi.mock() has hoisted. Cast through unknown
+// because the import here is the typed wrapper module shape.
+const { memoryTreeGetLlm, memoryTreeSetLlm } =
+ (await import('../../../utils/tauriCommands')) as unknown as {
+ memoryTreeGetLlm: Mock;
+ memoryTreeSetLlm: Mock;
+ };
+
+describe('IntelligenceSettingsTab', () => {
+ beforeEach(() => {
+ let backend: 'cloud' | 'local' = 'cloud';
+ memoryTreeGetLlm.mockReset();
+ memoryTreeSetLlm.mockReset();
+ memoryTreeGetLlm.mockImplementation(async () => ({ current: backend }));
+ // Accept both legacy (bare string) and the new request-object shape so
+ // tests can assert on either call form.
+ memoryTreeSetLlm.mockImplementation(
+ async (req: 'cloud' | 'local' | { backend: 'cloud' | 'local' }) => {
+ backend = typeof req === 'string' ? req : req.backend;
+ return { current: backend };
+ }
+ );
+ });
+
+ // Helper: bootstrap into Local mode so the model assignment + catalog
+ // render. Cloud is the default; clicking the Advanced radio flips to
+ // local and renders the Ollama-related sections.
+ async function flipToLocal() {
+ await waitFor(() => {
+ expect(screen.getByText('AI backend')).toBeInTheDocument();
+ });
+ const radios = screen.getAllByRole('radio');
+ const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
+ expect(localCard).toBeDefined();
+ fireEvent.click(localCard!);
+ await waitFor(() => {
+ expect(screen.getByText('Model assignment')).toBeInTheDocument();
+ });
+ }
+
+ it('renders the AI backend section in cloud mode (no local sections)', async () => {
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByText('AI backend')).toBeInTheDocument();
+ });
+ // Cloud is default — local-only sections are hidden so cloud users
+ // never see Ollama-related UI.
+ expect(screen.queryByText('Model assignment')).not.toBeInTheDocument();
+ expect(screen.queryByText('Model catalog')).not.toBeInTheDocument();
+ // Currently-loaded panel was removed entirely (was dev-debug noise).
+ expect(screen.queryByText('Currently loaded')).not.toBeInTheDocument();
+ });
+
+ it('hides Model assignment in Cloud mode and reveals it in Local mode', async () => {
+ renderWithProviders( );
+ await flipToLocal();
+
+ // The new UI consolidates Extract + Summariser LLM into a single
+ // Memory LLM picker (the underlying RPC still fans out to both
+ // extract_model and summariser_model in config.toml).
+ expect(screen.getByText('Memory LLM')).toBeInTheDocument();
+ expect(screen.getByText('Embedder')).toBeInTheDocument();
+ // Old separate dropdowns must be absent.
+ expect(screen.queryByText('Extract LLM')).not.toBeInTheDocument();
+ expect(screen.queryByText('Summariser LLM')).not.toBeInTheDocument();
+ });
+
+ it('shows model catalog rows with sizes (in local mode)', async () => {
+ renderWithProviders( );
+ await flipToLocal();
+
+ await waitFor(() => {
+ expect(screen.getAllByText('qwen2.5:0.5b').length).toBeGreaterThanOrEqual(1);
+ });
+ // Each model can appear in the Memory LLM dropdown AND the catalog,
+ // so use getAllByText. Just confirm the catalog has at least one of
+ // each curated entry rendered somewhere on the screen.
+ expect(screen.getAllByText('gemma3:1b-it-qat').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('llama3.1:8b').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('bge-m3').length).toBeGreaterThanOrEqual(1);
+
+ // 4.9 GB is unique to llama3.1:8b in the catalog row meta.
+ expect(screen.getByText('4.9 GB')).toBeInTheDocument();
+ });
+
+ it('renders a Download action for models that are not installed', async () => {
+ renderWithProviders( );
+ await flipToLocal();
+
+ // qwen2.5:0.5b is NOT in the diagnostics installed list, so it shows
+ // a Download button.
+ await waitFor(() => {
+ expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
+ });
+
+ const downloadButtons = screen.getAllByRole('button', { name: 'Download' });
+ expect(downloadButtons.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('reads the backend via memoryTreeGetLlm on mount and persists toggles via memoryTreeSetLlm', async () => {
+ renderWithProviders( );
+
+ // Bootstrap: getMemoryTreeLlm must run once on mount.
+ await waitFor(() => {
+ expect(memoryTreeGetLlm).toHaveBeenCalled();
+ });
+
+ // Click Local — setMemoryTreeLlm must be called with the request
+ // object form `{ backend: 'local' }`. settingsApi.ts always normalizes
+ // to the request-object shape because the wrapper now accepts both
+ // forms but the API layer translates camelCase options through the
+ // object shape. Model fields are absent so the corresponding
+ // config keys stay untouched.
+ const radios = screen.getAllByRole('radio');
+ const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
+ fireEvent.click(localCard!);
+
+ await waitFor(() => {
+ expect(memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'local' });
+ });
+
+ // The mocked setter persists state in the closure, so the bootstrap
+ // value of any subsequent get_llm call would now be 'local' — sanity
+ // check that the closure flipped.
+ const after = await memoryTreeGetLlm();
+ expect(after.current).toBe('local');
+ });
+
+ it('persists Memory LLM dropdown changes via memoryTreeSetLlm with both extract_model and summariser_model', async () => {
+ // The single Memory LLM picker fans out to BOTH extract_model and
+ // summariser_model in one atomic write — the underlying schema keeps
+ // the two keys separate so power users can split via the RPC, but the
+ // UI consolidates them into one cognitive unit.
+ renderWithProviders( );
+ await flipToLocal();
+
+ // Reset call history so the assertion below is scoped to the
+ // dropdown change, not the earlier backend toggle.
+ memoryTreeSetLlm.mockClear();
+
+ // Pick a different memory LLM. `llama3.1:8b` is in the curated
+ // catalog with both `extract` and `summariser` roles.
+ const memorySelect = screen.getByLabelText(
+ 'Memory LLM (extract + summarise)'
+ ) as HTMLSelectElement;
+ fireEvent.change(memorySelect, { target: { value: 'llama3.1:8b' } });
+
+ await waitFor(() => {
+ expect(memoryTreeSetLlm).toHaveBeenCalledWith({
+ backend: 'local',
+ extract_model: 'llama3.1:8b',
+ summariser_model: 'llama3.1:8b',
+ });
+ });
+ });
+});
diff --git a/app/src/components/intelligence/__tests__/MemoryChunkLetterhead.test.tsx b/app/src/components/intelligence/__tests__/MemoryChunkLetterhead.test.tsx
new file mode 100644
index 000000000..2ff25b2f0
--- /dev/null
+++ b/app/src/components/intelligence/__tests__/MemoryChunkLetterhead.test.tsx
@@ -0,0 +1,60 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import type { Chunk } from '../../../utils/tauriCommands';
+import { MemoryChunkLetterhead } from '../MemoryChunkLetterhead';
+
+const BASE_CHUNK: Chunk = {
+ id: 'chunk-letterhead-01',
+ source_kind: 'email',
+ source_id: 'gmail:steve@example.com|sanil@vezures.xyz',
+ source_ref: 'gmail://msg/abc',
+ owner: 'sanil@vezures.xyz',
+ timestamp_ms: Date.UTC(2026, 4, 4, 9, 14, 0),
+ token_count: 100,
+ lifecycle_status: 'admitted',
+ content_preview: 'hello',
+ has_embedding: true,
+ tags: [],
+};
+
+describe('MemoryChunkLetterhead', () => {
+ it('renders the from/to/date frontmatter from a personalized email source', () => {
+ const chunk: Chunk = { ...BASE_CHUNK, tags: ['person/Steven-Enamakel'] };
+ render( );
+
+ expect(screen.getByText('from')).toBeInTheDocument();
+ expect(screen.getByText('to')).toBeInTheDocument();
+ // Person tag wins over the raw email handle as the display name.
+ expect(screen.getByText('Steven Enamakel')).toBeInTheDocument();
+ // The raw address is rendered as secondary text.
+ expect(screen.getByText('steve@example.com')).toBeInTheDocument();
+ expect(screen.getByText('sanil@vezures.xyz')).toBeInTheDocument();
+ // Date formatted as YYYY·MM·DD · HH:MM utc (UTC components).
+ expect(screen.getByText('2026·05·04 · 09:14 utc')).toBeInTheDocument();
+ });
+
+ it('falls back to the raw email when no person/* tag is present', () => {
+ render( );
+ // Without a person tag, fromName === the raw email.
+ expect(screen.getByText('steve@example.com')).toBeInTheDocument();
+ });
+
+ it('falls back to the chunk owner when the source_id has no recipient half', () => {
+ const chunk: Chunk = {
+ ...BASE_CHUNK,
+ source_id: 'notion:launch-plan',
+ owner: 'sanil@vezures.xyz',
+ };
+ render( );
+ // No `|` → recipient defaults to owner.
+ expect(screen.getByText('sanil@vezures.xyz')).toBeInTheDocument();
+ });
+
+ it('uses source_kind as the display when source_id is bare', () => {
+ const chunk: Chunk = { ...BASE_CHUNK, source_kind: 'doc', source_id: '', tags: [] };
+ render( );
+ // Empty source_id → fromName falls back to the source_kind label.
+ expect(screen.getByText('doc')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/intelligence/__tests__/MemoryChunkMentioned.test.tsx b/app/src/components/intelligence/__tests__/MemoryChunkMentioned.test.tsx
new file mode 100644
index 000000000..db28ab1f0
--- /dev/null
+++ b/app/src/components/intelligence/__tests__/MemoryChunkMentioned.test.tsx
@@ -0,0 +1,39 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { EntityRef } from '../../../utils/tauriCommands';
+import { MemoryChunkMentioned } from '../MemoryChunkMentioned';
+
+const ENTITIES: EntityRef[] = [
+ { entity_id: 'person:steve', kind: 'person', surface: 'Steven Enamakel', count: 4 },
+ { entity_id: 'org:tinyhumans', kind: 'organization', surface: 'TinyHumans', count: 1 },
+ { entity_id: 'event:launch', kind: 'event', surface: 'Phoenix launch', count: 7 },
+];
+
+describe('MemoryChunkMentioned', () => {
+ it('renders nothing when the entity list is empty', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders one row per entity with kind, surface, and a pluralised count', () => {
+ render( );
+ expect(screen.getByText('Steven Enamakel')).toBeInTheDocument();
+ expect(screen.getByText('TinyHumans')).toBeInTheDocument();
+ expect(screen.getByText('Phoenix launch')).toBeInTheDocument();
+
+ // Singular vs plural — the surface display has to switch on count.
+ expect(screen.getByText('1 chunk')).toBeInTheDocument();
+ expect(screen.getByText('4 chunks')).toBeInTheDocument();
+ expect(screen.getByText('7 chunks')).toBeInTheDocument();
+ });
+
+ it('fires onSelectEntity with the clicked entity', () => {
+ const onSelectEntity = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByText('TinyHumans').closest('button')!);
+ expect(onSelectEntity).toHaveBeenCalledTimes(1);
+ expect(onSelectEntity).toHaveBeenCalledWith(ENTITIES[1]);
+ });
+});
diff --git a/app/src/components/intelligence/__tests__/MemoryChunkScoreBars.test.tsx b/app/src/components/intelligence/__tests__/MemoryChunkScoreBars.test.tsx
new file mode 100644
index 000000000..7535dfb64
--- /dev/null
+++ b/app/src/components/intelligence/__tests__/MemoryChunkScoreBars.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import type { ScoreBreakdown } from '../../../utils/tauriCommands';
+import { MemoryChunkScoreBars } from '../MemoryChunkScoreBars';
+
+describe('MemoryChunkScoreBars', () => {
+ it('renders one row per signal with a clamped, formatted value', () => {
+ const breakdown: ScoreBreakdown = {
+ total: 0.65,
+ threshold: 0.5,
+ kept: true,
+ llm_consulted: false,
+ signals: [
+ { name: 'recency', weight: 0.5, value: 0.83 },
+ { name: 'salience', weight: 0.3, value: 0.4 },
+ // Out-of-range and NaN both clamp to 0..1 — the bar must not crash
+ // or render past the track.
+ { name: 'pinned', weight: 0.1, value: 1.7 },
+ { name: 'broken', weight: 0.1, value: Number.NaN },
+ ],
+ };
+ render( );
+
+ expect(screen.getByText('recency')).toBeInTheDocument();
+ expect(screen.getByText('0.83')).toBeInTheDocument();
+ expect(screen.getByText('0.40')).toBeInTheDocument();
+ // Clamped to 1.00 (over-range) and 0.00 (NaN).
+ expect(screen.getByText('1.00')).toBeInTheDocument();
+ expect(screen.getByText('0.00')).toBeInTheDocument();
+
+ // ARIA labels on the bars are how a screen reader would surface the
+ // percentage; check the over-range one collapsed to "100 percent".
+ expect(screen.getByLabelText('pinned score 100 percent')).toBeInTheDocument();
+ expect(screen.getByLabelText('broken score 0 percent')).toBeInTheDocument();
+ });
+
+ it('shows the threshold footer with kept/dropped state', () => {
+ const breakdown: ScoreBreakdown = {
+ total: 0.2,
+ threshold: 0.5,
+ kept: false,
+ llm_consulted: false,
+ signals: [{ name: 'recency', weight: 1, value: 0.2 }],
+ };
+ render( );
+ expect(screen.getByText(/dropped at 0\.50/)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx
index f4fe539da..1c2f5cd9a 100644
--- a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx
+++ b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx
@@ -1,257 +1,279 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
+import type { Chunk, EntityRef, ScoreBreakdown, Source } from '../../../utils/tauriCommands';
import { MemoryWorkspace } from '../MemoryWorkspace';
-// Mock useIntelligenceStats — the hook used by MemoryWorkspace
-vi.mock('../../../hooks/useIntelligenceStats', () => ({
- useIntelligenceStats: () => ({
- sessions: { total: 5, totalTokens: 1200 },
- memoryFiles: 3,
- entities: { contact: 2, message: 10 },
- isLoading: false,
- refetch: vi.fn(),
- }),
-}));
-
-// Mock channelConnectionsApi so listStatus doesn't hit the network
-vi.mock('../../../services/api/channelConnectionsApi', () => ({
- channelConnectionsApi: {
- listStatus: vi.fn().mockResolvedValue([
- {
- channel_id: 'telegram-main',
- auth_mode: 'managed_dm',
- connected: true,
- has_credentials: true,
- },
- { channel_id: 'discord-bot', auth_mode: 'bot_token', connected: true, has_credentials: true },
- ]),
- },
-}));
-
-// Override the global tauriCommands mock from setup.ts with memory-specific stubs
+// The MemoryWorkspace orchestrator + its detail-pane child both fan out
+// to the `memory_tree_*` JSON-RPC wrappers. The setup.ts global mock
+// stubs auth helpers; we extend it here with the read-side surface so
+// the workspace can render against a deterministic fixture set.
vi.mock('../../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
- memoryListDocuments: vi.fn().mockResolvedValue({
- documents: [
- { documentId: 'doc-1', namespace: 'research', title: 'Paper A' },
- { documentId: 'doc-2', namespace: 'research', title: 'Paper B' },
- ],
- }),
- memoryListNamespaces: vi.fn().mockResolvedValue(['research', 'conversations']),
- aiListMemoryFiles: vi.fn().mockResolvedValue(['2026-03-31.md']),
- aiReadMemoryFile: vi.fn().mockResolvedValue('# Memory\nSome content'),
- aiWriteMemoryFile: vi.fn().mockResolvedValue(undefined),
- memoryDeleteDocument: vi.fn().mockResolvedValue(undefined),
- memoryQueryNamespace: vi.fn().mockResolvedValue({ text: 'query result', entities: [] }),
- memoryRecallNamespace: vi.fn().mockResolvedValue({ text: 'recall result', entities: [] }),
- memorySyncAll: vi.fn().mockResolvedValue({ requested: true }),
- memorySyncChannel: vi.fn().mockResolvedValue({ requested: true, channel_id: 'telegram-main' }),
- memoryLearnAll: vi.fn().mockResolvedValue({
- namespaces_processed: 2,
- results: [
- { namespace: 'research', status: 'ok' },
- { namespace: 'conversations', status: 'ok' },
- ],
- }),
- memoryGraphQuery: vi.fn().mockResolvedValue([
- {
- namespace: 'research',
- subject: 'Alice',
- predicate: 'AUTHORED',
- object: 'Paper A',
- attrs: { entity_types: { subject: 'person', object: 'document' } },
- updatedAt: 1700000000,
- evidenceCount: 3,
- orderIndex: null,
- documentIds: ['doc-1'],
- chunkIds: ['doc-1#chunk-1'],
- },
- {
- namespace: 'research',
- subject: 'Bob',
- predicate: 'REVIEWED',
- object: 'Paper A',
- attrs: { entity_types: { subject: 'person', object: 'document' } },
- updatedAt: 1700000001,
- evidenceCount: 1,
- orderIndex: null,
- documentIds: ['doc-1'],
- chunkIds: [],
- },
- ]),
+ memoryTreeListChunks: vi.fn(),
+ memoryTreeListSources: vi.fn(),
+ memoryTreeTopEntities: vi.fn(),
+ memoryTreeEntityIndexFor: vi.fn(),
+ memoryTreeChunkScore: vi.fn(),
}));
-describe('MemoryWorkspace', () => {
- const onToast = vi.fn();
+const {
+ memoryTreeListChunks,
+ memoryTreeListSources,
+ memoryTreeTopEntities,
+ memoryTreeEntityIndexFor,
+ memoryTreeChunkScore,
+} = (await import('../../../utils/tauriCommands')) as unknown as {
+ memoryTreeListChunks: Mock;
+ memoryTreeListSources: Mock;
+ memoryTreeTopEntities: Mock;
+ memoryTreeEntityIndexFor: Mock;
+ memoryTreeChunkScore: Mock;
+};
- it('renders the Memory heading', async () => {
- renderWithProviders( );
- expect(screen.getByText('Memory')).toBeInTheDocument();
+// ── Fixtures — small but realistic ───────────────────────────────────────
+
+const NOW_MS = Date.UTC(2026, 4, 4, 9, 14, 0);
+const HOUR = 60 * 60 * 1000;
+
+const FIXTURE_CHUNKS: Chunk[] = [
+ {
+ id: 'chunk-today-01',
+ source_kind: 'email',
+ source_id: 'gmail:enamakel@mail.tinyhumans.ai|sanil@vezures.xyz',
+ source_ref: 'gmail://msg/aaa',
+ owner: 'sanil@vezures.xyz',
+ timestamp_ms: NOW_MS,
+ token_count: 312,
+ lifecycle_status: 'admitted',
+ content_preview:
+ 'welcome to the future of ai assistants — openhuman. hey hey Sanil Jain! steve here.',
+ has_embedding: true,
+ tags: ['person/Steven-Enamakel', 'organization/TinyHumans', 'product/openhuman'],
+ },
+ {
+ id: 'chunk-today-02',
+ source_kind: 'email',
+ source_id: 'gmail:notifications@github.com|sanil@vezures.xyz',
+ source_ref: 'gmail://msg/bbb',
+ owner: 'sanil@vezures.xyz',
+ timestamp_ms: NOW_MS - 90 * 60 * 1000,
+ token_count: 94,
+ lifecycle_status: 'admitted',
+ content_preview: '[tinyhumansai/openhuman] PR #1175 merged.',
+ has_embedding: true,
+ tags: ['organization/GitHub', 'product/openhuman', 'event/pr-merged'],
+ },
+ {
+ id: 'chunk-today-03',
+ source_kind: 'chat',
+ source_id: 'slack:T0123|C-engineering',
+ source_ref: 'slack://channel/eng/p1',
+ owner: 'sanil@vezures.xyz',
+ timestamp_ms: NOW_MS - 3 * HOUR,
+ token_count: 47,
+ lifecycle_status: 'admitted',
+ content_preview: 'maya patel: pushed the staging chart fix',
+ has_embedding: true,
+ tags: ['person/Maya-Patel', 'organization/TinyHumans'],
+ },
+];
+
+const FIXTURE_SOURCES: Source[] = [
+ {
+ source_id: 'gmail:enamakel@mail.tinyhumans.ai|sanil@vezures.xyz',
+ display_name: 'Steven Enamakel',
+ source_kind: 'email',
+ chunk_count: 1,
+ most_recent_ms: NOW_MS,
+ lifecycle_status: 'admitted',
+ },
+ {
+ source_id: 'gmail:notifications@github.com|sanil@vezures.xyz',
+ display_name: 'GitHub notifications',
+ source_kind: 'email',
+ chunk_count: 1,
+ most_recent_ms: NOW_MS - 90 * 60 * 1000,
+ lifecycle_status: 'admitted',
+ },
+ {
+ source_id: 'slack:T0123|C-engineering',
+ display_name: 'Slack: #engineering',
+ source_kind: 'chat',
+ chunk_count: 1,
+ most_recent_ms: NOW_MS - 3 * HOUR,
+ lifecycle_status: 'admitted',
+ },
+];
+
+const FIXTURE_PEOPLE: EntityRef[] = [
+ { entity_id: 'person:Steven Enamakel', kind: 'person', surface: 'Steven Enamakel', count: 2 },
+ { entity_id: 'person:Maya Patel', kind: 'person', surface: 'Maya Patel', count: 1 },
+];
+
+const FIXTURE_TOPICS: EntityRef[] = [
+ { entity_id: 'product:openhuman', kind: 'product', surface: 'openhuman', count: 3 },
+ { entity_id: 'event:pr-merged', kind: 'event', surface: 'pr-merged', count: 1 },
+];
+
+const FIXTURE_SCORE: ScoreBreakdown = {
+ signals: [
+ { name: 'source', weight: 0.3, value: 0.8 },
+ { name: 'entities', weight: 0.4, value: 0.7 },
+ { name: 'recency', weight: 0.3, value: 0.9 },
+ ],
+ total: 0.79,
+ threshold: 0.85,
+ kept: true,
+ llm_consulted: false,
+};
+
+beforeEach(() => {
+ memoryTreeListChunks.mockReset();
+ memoryTreeListSources.mockReset();
+ memoryTreeTopEntities.mockReset();
+ memoryTreeEntityIndexFor.mockReset();
+ memoryTreeChunkScore.mockReset();
+
+ memoryTreeListChunks.mockResolvedValue({ chunks: FIXTURE_CHUNKS, total: FIXTURE_CHUNKS.length });
+ memoryTreeListSources.mockResolvedValue(FIXTURE_SOURCES);
+ // The workspace calls topEntities twice: ('person', 12) and (undefined, 40).
+ memoryTreeTopEntities.mockImplementation((kind?: string) => {
+ if (kind === 'person') return Promise.resolve(FIXTURE_PEOPLE);
+ return Promise.resolve([...FIXTURE_PEOPLE, ...FIXTURE_TOPICS]);
});
+ memoryTreeEntityIndexFor.mockResolvedValue([
+ { entity_id: 'person:Steven Enamakel', kind: 'person', surface: 'Steven Enamakel', count: 1 },
+ { entity_id: 'organization:TinyHumans', kind: 'organization', surface: 'TinyHumans', count: 1 },
+ ]);
+ memoryTreeChunkScore.mockResolvedValue(FIXTURE_SCORE);
+});
- it('displays graph relations after loading', async () => {
- renderWithProviders( );
-
+describe('MemoryWorkspace — 2-pane + overlay browser', () => {
+ it('renders the navigator + result list scaffold and the search box', async () => {
+ renderWithProviders( );
+ // Workspace renders the empty placeholder until the first fixture
+ // round-trip lands — the full 2-pane shell only mounts once allChunks
+ // is populated. Wait for the post-load state, then assert all four
+ // anchors exist together.
await waitFor(() => {
- expect(screen.getByText('Alice', { selector: 'span' })).toBeInTheDocument();
+ expect(screen.getByTestId('memory-workspace')).toBeInTheDocument();
});
-
- expect(screen.getByText('AUTHORED', { selector: 'span' })).toBeInTheDocument();
- expect(screen.getByText('Bob', { selector: 'span' })).toBeInTheDocument();
- expect(screen.getByText('REVIEWED', { selector: 'span' })).toBeInTheDocument();
- // "Paper A" appears in both graph relations and documents list,
- // so just verify at least one instance is present
- expect(screen.getAllByText('Paper A').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByTestId('memory-navigator')).toBeInTheDocument();
+ expect(screen.getByTestId('memory-result-list')).toBeInTheDocument();
+ expect(screen.getByLabelText('Search memory')).toBeInTheDocument();
});
- it('shows evidence count badge when > 1', async () => {
- renderWithProviders( );
-
+ it('calls the canonical memory_tree_* RPCs on mount', async () => {
+ renderWithProviders( );
await waitFor(() => {
- expect(screen.getByText('x3')).toBeInTheDocument();
- });
-
- // Bob's relation has evidenceCount 1 — should NOT show a badge
- expect(screen.queryByText('x1')).not.toBeInTheDocument();
- });
-
- it('shows Relations stat in the stats bar', async () => {
- renderWithProviders( );
-
- // The stats bar has a "Relations" label
- await waitFor(() => {
- expect(screen.getByText('Relations')).toBeInTheDocument();
+ expect(memoryTreeListChunks).toHaveBeenCalledWith({ limit: 500 });
+ expect(memoryTreeListSources).toHaveBeenCalled();
+ expect(memoryTreeTopEntities).toHaveBeenCalledWith('person', 12);
+ expect(memoryTreeTopEntities).toHaveBeenCalledWith(undefined, 40);
});
});
- it('renders the Memory Graph section', async () => {
- renderWithProviders( );
+ it('renders navigator section headings (recent, sources, people, topics)', async () => {
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByText('recent')).toBeInTheDocument();
+ });
+ expect(screen.getByText('sources')).toBeInTheDocument();
+ expect(screen.getByText('people')).toBeInTheDocument();
+ expect(screen.getByText('topics')).toBeInTheDocument();
+ });
+
+ it('does NOT auto-open the detail overlay on mount (2-pane is the default rest state)', async () => {
+ renderWithProviders( );
+ // Wait for fixtures to land so we know the workspace is fully rendered.
+ await waitFor(() => screen.getByTestId('memory-result-list'));
+ // The new layout opens detail only on row click; no overlay until then.
+ expect(screen.queryByTestId('memory-chunk-detail')).toBeNull();
+ });
+
+ it('renders the Sources section count + per-kind nesting after the load resolves', async () => {
+ renderWithProviders( );
+ // Inside the Sources NavSection, fixtures group into Email (2) + Chat (1).
+ // Each per-kind sub-section is rendered as its own NavSection — closed by
+ // default, but the labels are visible.
+ await waitFor(() => {
+ expect(screen.getByText('Email')).toBeInTheDocument();
+ expect(screen.getByText('Chat')).toBeInTheDocument();
+ });
+ // Person entities (from FIXTURE_PEOPLE) ARE visible by default — the
+ // people NavSection is `defaultOpen`.
+ expect(screen.getAllByText('Steven Enamakel').length).toBeGreaterThan(0);
+ expect(screen.getByText('Maya Patel')).toBeInTheDocument();
+ });
+
+ it('typing in the search box narrows the result-list rows', async () => {
+ renderWithProviders( );
+ await waitFor(() => {
+ const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
+ expect(rows.length).toBe(FIXTURE_CHUNKS.length);
+ });
+
+ const search = screen.getByLabelText('Search memory') as HTMLInputElement;
+ fireEvent.change(search, { target: { value: 'PR #1175' } });
await waitFor(() => {
- expect(screen.getByText('Memory Graph')).toBeInTheDocument();
+ const visible = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
+ expect(visible.length).toBe(1);
+ expect(visible[0]?.textContent ?? '').toMatch(/PR #1175|github/i);
+ });
+ });
+
+ it('opens the detail overlay when a result row is clicked', async () => {
+ renderWithProviders( );
+ await waitFor(() => {
+ const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
+ expect(rows.length).toBeGreaterThan(0);
+ });
+
+ const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
+ fireEvent.click(rows[0]!);
+
+ await waitFor(() => {
+ // Detail overlay mounts the ChunkDetail (data-testid="memory-chunk-detail")
+ // along with the letterhead — both show only after a row click in the
+ // 2-pane + overlay layout.
+ expect(screen.getByTestId('memory-chunk-detail')).toBeInTheDocument();
+ expect(screen.getByTestId('memory-chunk-letterhead')).toBeInTheDocument();
+ });
+ });
+
+ it('closes the detail overlay on Escape key', async () => {
+ renderWithProviders( );
+ await waitFor(() => {
+ const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
+ expect(rows.length).toBeGreaterThan(0);
+ });
+
+ const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
+ fireEvent.click(rows[0]!);
+ await waitFor(() => screen.getByTestId('memory-chunk-detail'));
+
+ fireEvent.keyDown(window, { key: 'Escape' });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('memory-chunk-detail')).toBeNull();
});
});
});
-describe('MemoryWorkspace – no graph relations', () => {
- const onToast = vi.fn();
+describe('MemoryWorkspace — empty state', () => {
+ it('renders the empty placeholder when the core returns zero chunks', async () => {
+ memoryTreeListChunks.mockResolvedValueOnce({ chunks: [], total: 0 });
+ memoryTreeListSources.mockResolvedValueOnce([]);
+ memoryTreeTopEntities.mockResolvedValue([]);
- it('shows empty-state message when no relations exist', async () => {
- // Override only memoryGraphQuery to return empty
- const tauriMod = await import('../../../utils/tauriCommands');
- vi.mocked(tauriMod.memoryGraphQuery).mockResolvedValueOnce([]);
-
- renderWithProviders( );
+ renderWithProviders( );
await waitFor(() => {
- expect(screen.getByText('No memory graph data yet')).toBeInTheDocument();
- });
- });
-});
-
-describe('MemoryWorkspace – non-Tauri environment', () => {
- const onToast = vi.fn();
-
- it('shows Tauri-required warning when not running in Tauri', async () => {
- const tauriMod = await import('../../../utils/tauriCommands');
- vi.mocked(tauriMod.isTauri).mockReturnValue(false);
-
- renderWithProviders( );
-
- await waitFor(() => {
- expect(
- screen.getByText('Memory workspace requires the desktop Tauri runtime to load real data.')
- ).toBeInTheDocument();
- });
-
- // Restore for other tests
- vi.mocked(tauriMod.isTauri).mockReturnValue(true);
- });
-});
-
-describe('MemoryWorkspace – Sync section', () => {
- const onToast = vi.fn();
-
- it('renders the Sync collapsible button', async () => {
- renderWithProviders( );
- await waitFor(() => {
- expect(screen.getByText('Sync')).toBeInTheDocument();
- });
- });
-
- it('expands and shows Sync all button when toggled', async () => {
- renderWithProviders( );
- await waitFor(() => {
- expect(screen.getByText('Sync')).toBeInTheDocument();
- });
- fireEvent.click(screen.getByText('Sync').closest('button')!);
- await waitFor(() => {
- expect(screen.getByText('Sync all')).toBeInTheDocument();
- });
- });
-
- it('calls memorySyncAll when Sync all button clicked', async () => {
- const tauriMod = await import('../../../utils/tauriCommands');
- renderWithProviders( );
- await waitFor(() => screen.getByText('Sync'));
- fireEvent.click(screen.getByText('Sync').closest('button')!);
- await waitFor(() => screen.getByText('Sync all'));
- fireEvent.click(screen.getByText('Sync all'));
- await waitFor(() => {
- expect(vi.mocked(tauriMod.memorySyncAll)).toHaveBeenCalled();
- });
- });
-
- it('calls memorySyncChannel when per-channel Sync button clicked', async () => {
- const tauriMod = await import('../../../utils/tauriCommands');
- renderWithProviders( );
- await waitFor(() => screen.getByText('Sync'));
- fireEvent.click(screen.getByText('Sync').closest('button')!);
- await waitFor(() => screen.getAllByText('Sync').length > 1);
- // The per-channel sync buttons appear after channels load
- await waitFor(() => screen.getByText('telegram-main'));
- const syncBtns = screen.getAllByText('Sync');
- // Last Sync buttons are per-channel (first is the header)
- fireEvent.click(syncBtns[syncBtns.length - 1]);
- await waitFor(() => {
- expect(vi.mocked(tauriMod.memorySyncChannel)).toHaveBeenCalledWith('discord-bot');
- });
- });
-});
-
-describe('MemoryWorkspace – Learn section', () => {
- const onToast = vi.fn();
-
- it('renders the Learn collapsible button', async () => {
- renderWithProviders( );
- await waitFor(() => {
- expect(screen.getByText('Learn')).toBeInTheDocument();
- });
- });
-
- it('expands and shows Learn all button when toggled', async () => {
- renderWithProviders( );
- await waitFor(() => screen.getByText('Learn'));
- fireEvent.click(screen.getByText('Learn').closest('button')!);
- await waitFor(() => {
- expect(screen.getByText('Learn all')).toBeInTheDocument();
- });
- });
-
- it('calls memoryLearnAll and shows result summary', async () => {
- const tauriMod = await import('../../../utils/tauriCommands');
- renderWithProviders( );
- await waitFor(() => screen.getByText('Learn'));
- fireEvent.click(screen.getByText('Learn').closest('button')!);
- await waitFor(() => screen.getByText('Learn all'));
- fireEvent.click(screen.getByText('Learn all'));
- await waitFor(() => {
- expect(vi.mocked(tauriMod.memoryLearnAll)).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(screen.getByText(/2 processed/)).toBeInTheDocument();
+ expect(screen.getByTestId('memory-empty-placeholder')).toBeInTheDocument();
});
+ expect(screen.getByText('Nothing yet.')).toBeInTheDocument();
});
});
diff --git a/app/src/components/intelligence/__tests__/ModelCatalog.test.tsx b/app/src/components/intelligence/__tests__/ModelCatalog.test.tsx
new file mode 100644
index 000000000..03b0ff91a
--- /dev/null
+++ b/app/src/components/intelligence/__tests__/ModelCatalog.test.tsx
@@ -0,0 +1,141 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import ModelCatalog from '../ModelCatalog';
+
+describe('ModelCatalog', () => {
+ it('renders one row per recommended model', () => {
+ render(
+
+ );
+ // Each id from RECOMMENDED_MODEL_CATALOG appears as a row title.
+ expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
+ expect(screen.getByText('gemma3:1b-it-qat')).toBeInTheDocument();
+ expect(screen.getByText('llama3.1:8b')).toBeInTheDocument();
+ expect(screen.getByText('bge-m3')).toBeInTheDocument();
+ });
+
+ it('shows "Download" for models that are not installed', () => {
+ render(
+
+ );
+ // Four models, all available → four Download buttons.
+ expect(screen.getAllByRole('button', { name: /download/i })).toHaveLength(4);
+ expect(screen.getAllByText('not downloaded').length).toBeGreaterThan(0);
+ });
+
+ it('shows "Use" for installed-but-not-active models, "in use" for active', () => {
+ render(
+
+ );
+ // bge-m3 is installed AND active → "in use" pill, no Use button for it.
+ expect(screen.getAllByText('in use').length).toBeGreaterThan(0);
+ // gemma3 is installed but not active → Use button visible.
+ expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
+ });
+
+ it('matches `bge-m3` against `bge-m3:latest` via the :latest normalization', () => {
+ // Ollama tags everything as `:latest` by default; the catalog uses bare
+ // names. The component must treat them as the same id.
+ render(
+
+ );
+ // bge-m3 row is now in the "installed" state — at least one Use button
+ // appears (for bge-m3 specifically).
+ expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
+ });
+
+ it('fires onUse with the matching model when the Use button is clicked', () => {
+ const onUse = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Use' }));
+ expect(onUse).toHaveBeenCalledTimes(1);
+ expect(onUse.mock.calls[0][0]).toMatchObject({ id: 'llama3.1:8b' });
+ });
+
+ it('renders Delete buttons only when onDelete is provided', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.queryByLabelText('Delete model')).toBeNull();
+
+ rerender(
+
+ );
+ expect(screen.getByLabelText('Delete model')).toBeInTheDocument();
+ });
+
+ it('shows a progress bar while a download is in flight, then clears it', async () => {
+ let resolveDownload!: () => void;
+ const onDownload = vi.fn(
+ () =>
+ new Promise(resolve => {
+ resolveDownload = resolve;
+ })
+ );
+ render(
+
+ );
+
+ fireEvent.click(screen.getAllByRole('button', { name: /download/i })[0]);
+
+ // Mid-flight: a progressbar is rendered for that row.
+ await waitFor(() => {
+ expect(screen.queryAllByRole('progressbar').length).toBeGreaterThan(0);
+ });
+
+ resolveDownload();
+ // After settle (~600 ms on success), the bar disappears and the row
+ // returns to its post-install state. We just confirm the state
+ // eventually clears — not the exact timing.
+ await waitFor(
+ () => {
+ expect(screen.queryByRole('progressbar')).toBeNull();
+ },
+ { timeout: 2000 }
+ );
+ });
+});
diff --git a/app/src/components/intelligence/memory-workspace.css b/app/src/components/intelligence/memory-workspace.css
new file mode 100644
index 000000000..694cd50c2
--- /dev/null
+++ b/app/src/components/intelligence/memory-workspace.css
@@ -0,0 +1,617 @@
+/**
+ * Locked design tokens for the three-pane MemoryWorkspace browser.
+ *
+ * Scoped under `.memory-workspace-root` so the rest of the app's surfaces
+ * (which use the global stone/primary palette) are unaffected.
+ */
+.memory-workspace-root {
+ /* Match the rest of the app's stone palette — dropped the cream tones
+ (#faf7f2 / #f4f0e9) that read yellowish next to the bottom nav bar. */
+ --paper: #fafaf9; /* stone-50 — base surface */
+ --paper-elevated: #ffffff; /* white — active/selected/list */
+ --paper-recessed: #f5f5f4; /* stone-100 — hover */
+ --paper-recessed-darker: #e7e5e4; /* stone-200 — pressed */
+ --hairline: #e7e5e4; /* stone-200 — borders */
+ --ink: #1c1917;
+ --ink-soft: #44403c;
+ --ink-whisper: #78716c;
+ --ocean: #4a83dd;
+ --ocean-deep: #2e5baa;
+ --ocean-mist: rgba(74, 131, 221, 0.08);
+ --sage: #7a9b7e;
+ --amber: #c8954d;
+ --coral: #c26b5c;
+ --shadow-rest: 0 1px 2px rgba(26, 31, 46, 0.04);
+ --r-tight: 4px;
+ --r-card: 8px;
+ --r-room: 12px;
+}
+
+.memory-workspace-root,
+.memory-workspace-root * {
+ box-sizing: border-box;
+}
+
+.memory-workspace-root {
+ background: var(--paper);
+ color: var(--ink);
+ border-radius: var(--r-room);
+ border: 1px solid var(--hairline);
+ box-shadow: var(--shadow-rest);
+ overflow: hidden;
+ font-family:
+ Inter,
+ -apple-system,
+ BlinkMacSystemFont,
+ system-ui,
+ sans-serif;
+}
+
+.memory-workspace-grid {
+ display: grid;
+ grid-template-columns: 280px 380px 1fr;
+ min-height: 640px;
+ height: calc(100vh - 240px);
+ max-height: 900px;
+}
+
+.memory-workspace-grid > * + * {
+ border-left: 1px solid var(--hairline);
+}
+
+@media (max-width: 1100px) {
+ .memory-workspace-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .memory-workspace-grid .mw-pane-detail {
+ display: none;
+ }
+ .memory-workspace-grid.mw-show-detail {
+ grid-template-columns: 1fr;
+ }
+ .memory-workspace-grid.mw-show-detail .mw-pane-navigator,
+ .memory-workspace-grid.mw-show-detail .mw-pane-results {
+ display: none;
+ }
+ .memory-workspace-grid.mw-show-detail .mw-pane-detail {
+ display: flex;
+ }
+}
+
+.mw-pane-navigator {
+ background: var(--paper-recessed);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.mw-pane-results {
+ background: var(--paper);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.mw-pane-detail {
+ background: var(--paper-elevated);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.mw-pane-scroll {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+/* Section headings — Inter uppercase, modern app-rail style. Replaces
+ the Cabinet Grotesk lowercase tracked treatment which read like a
+ magazine masthead in a left-rail context. */
+.mw-section-heading {
+ font-family: Inter, system-ui, sans-serif;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-whisper);
+ padding: 10px 16px 6px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ user-select: none;
+ background: transparent;
+ border: none;
+ width: 100%;
+ text-align: left;
+ transition: color 120ms ease;
+}
+.mw-section-heading:hover {
+ color: var(--ink-soft);
+}
+.mw-section-chev {
+ margin-left: auto;
+ font-size: 11px;
+ opacity: 0.5;
+ transition: transform 120ms ease;
+}
+.mw-section-chev.open {
+ transform: rotate(90deg);
+}
+
+.mw-section {
+ border-bottom: 1px solid var(--hairline);
+}
+.mw-section:last-child {
+ border-bottom: none;
+}
+
+/* Nested sections — sources expanded shows Email/Slack/etc. as
+ sub-collapsibles. Indent + tone-shift the nested level so the
+ hierarchy reads visually. */
+.mw-section .mw-section {
+ border-bottom: 1px solid var(--hairline);
+ border-left: 2px solid var(--hairline);
+ margin: 0 0 0 12px;
+ background: var(--paper-recessed);
+}
+.mw-section .mw-section:last-child {
+ border-bottom: none;
+}
+.mw-section .mw-section .mw-section-heading {
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ padding: 6px 12px 4px;
+ color: var(--ink-soft);
+}
+.mw-section .mw-section .mw-section-heading:hover {
+ color: var(--ink);
+}
+.mw-section .mw-section .mw-list-item {
+ /* Source rows under a kind heading need a stronger indent so the
+ hierarchy is unambiguous: kind heading at one level, individual
+ senders deeper. Bump left padding so each row sits visibly under
+ the kind's chevron, with a sub-rail line to continue the nesting. */
+ padding: 5px 12px 5px 26px;
+ position: relative;
+}
+.mw-section .mw-section .mw-list-item::before {
+ content: '';
+ position: absolute;
+ left: 14px;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: var(--hairline);
+ opacity: 0.7;
+}
+.mw-section .mw-section .mw-list-item.is-active::before {
+ background: var(--ocean);
+ opacity: 1;
+}
+
+.mw-search-row {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--hairline);
+}
+.mw-search-input {
+ width: 100%;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--hairline);
+ padding: 6px 0;
+ font-size: 14px;
+ color: var(--ink);
+ outline: none;
+ font-style: italic;
+}
+.mw-search-input:focus {
+ border-bottom-color: var(--ocean);
+}
+.mw-search-input::placeholder {
+ color: var(--ink-whisper);
+ font-style: italic;
+}
+
+.mw-heatmap-host {
+ padding: 8px 12px 12px;
+ border-bottom: 1px solid var(--hairline);
+}
+.mw-heatmap-host > div {
+ background: transparent !important;
+ border: none !important;
+ padding: 0 !important;
+}
+.mw-heatmap-host h3 {
+ display: none;
+}
+.mw-heatmap-host p {
+ font-size: 10px !important;
+ color: var(--ink-whisper) !important;
+ margin: 0 !important;
+}
+
+.mw-recent-summary {
+ padding: 8px 16px 14px;
+ font-size: 12px;
+ color: var(--ink-soft);
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+}
+.mw-recent-summary span + span {
+ margin-left: 8px;
+ color: var(--ink-whisper);
+}
+
+.mw-list {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 8px;
+}
+.mw-list-item {
+ display: grid;
+ grid-template-columns: 8px 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 6px 16px;
+ cursor: pointer;
+ border-left: 2px solid transparent;
+ transition:
+ background 80ms,
+ border-color 80ms;
+ background: transparent;
+ border-top: none;
+ border-right: none;
+ border-bottom: none;
+ width: 100%;
+ text-align: left;
+}
+.mw-list-item:hover {
+ background: var(--paper-recessed-darker);
+}
+.mw-list-item.is-active {
+ background: var(--paper-elevated); /* white — matches bottom nav active state */
+ border-left-color: var(--ocean);
+ box-shadow: 0 1px 2px rgba(28, 25, 23, 0.04);
+}
+.mw-list-item.is-active .mw-list-name {
+ font-weight: 600;
+ color: var(--ink);
+}
+.mw-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: var(--ink-whisper);
+}
+.mw-dot.dot-admitted {
+ background: var(--sage);
+}
+.mw-dot.dot-pending {
+ background: var(--amber);
+}
+.mw-dot.dot-buffered {
+ background: var(--ocean);
+}
+.mw-dot.dot-dropped {
+ background: var(--coral);
+}
+.mw-list-name {
+ font-size: 14px;
+ color: var(--ink-soft);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+.mw-list-count {
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ color: var(--ink-whisper);
+}
+
+/* Result list */
+.mw-results-empty,
+.mw-detail-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 32px;
+ color: var(--ink-soft);
+}
+.mw-results-empty {
+ font-size: 13px;
+ color: var(--ink-whisper);
+}
+.mw-detail-empty {
+ font-family: 'Cabinet Grotesk', Inter, system-ui, sans-serif;
+}
+.mw-detail-empty .mw-empty-title {
+ font-size: 22px;
+ margin: 0 0 12px;
+ color: var(--ink-soft);
+}
+.mw-detail-empty .mw-empty-body {
+ font-size: 14px;
+ color: var(--ink-whisper);
+ max-width: 320px;
+ line-height: 1.6;
+}
+
+.mw-results-section {
+ border-bottom: 1px solid var(--hairline);
+}
+.mw-results-section-header {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--paper);
+ font-family: 'Cabinet Grotesk', Inter, sans-serif;
+ font-size: 11px;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--ink-whisper);
+ padding: 10px 16px 8px;
+ border-bottom: 1px solid var(--hairline);
+}
+.mw-result-row {
+ display: grid;
+ grid-template-columns: 56px 1fr;
+ gap: 12px;
+ padding: 12px 16px;
+ cursor: pointer;
+ border-left: 2px solid transparent;
+ border-bottom: 1px solid var(--hairline);
+ transition: background 80ms;
+ background: transparent;
+ border-right: none;
+ border-top: none;
+ width: 100%;
+ text-align: left;
+}
+.mw-result-row:last-child {
+ border-bottom: none;
+}
+.mw-result-row:hover {
+ background: var(--paper-recessed);
+}
+.mw-result-row.is-active {
+ background: var(--ocean-mist);
+ border-left-color: var(--ocean);
+}
+.mw-result-row.is-active .mw-result-subject {
+ font-weight: 600;
+ color: var(--ink);
+}
+.mw-result-time {
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 12px;
+ color: var(--ink-whisper);
+ padding-top: 1px;
+}
+.mw-result-content {
+ min-width: 0;
+}
+.mw-result-subject {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--ink);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ line-height: 1.4;
+}
+.mw-result-meta {
+ font-size: 12px;
+ color: var(--ink-soft);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.mw-result-kind {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--ink-whisper);
+ padding: 1px 6px;
+ border: 1px solid var(--hairline);
+ border-radius: 999px;
+ background: var(--paper);
+ margin-right: 2px;
+}
+
+/* Chunk detail letter */
+.mw-detail-scroll {
+ padding: 40px 48px;
+}
+.mw-letter {
+ max-width: 720px;
+ margin: 0 auto;
+}
+.mw-letterhead {
+ margin-bottom: 24px;
+}
+.mw-letterhead-row {
+ display: grid;
+ grid-template-columns: 80px 1fr;
+ gap: 8px;
+ margin-bottom: 8px;
+ align-items: baseline;
+}
+.mw-letterhead-label {
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ text-transform: lowercase;
+ color: var(--ink-whisper);
+ letter-spacing: 0.05em;
+}
+.mw-letterhead-label::after {
+ content: '';
+ display: block;
+ border-bottom: 1px solid var(--hairline);
+ width: 40px;
+ margin-top: 2px;
+}
+.mw-letterhead-value {
+ font-size: 14px;
+ color: var(--ink);
+ line-height: 1.5;
+}
+.mw-letterhead-value-secondary {
+ font-size: 12px;
+ color: var(--ink-soft);
+ display: block;
+ margin-top: 2px;
+}
+.mw-letterhead-date {
+ margin-top: 12px;
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 12px;
+ color: var(--ink-soft);
+ letter-spacing: 0.05em;
+}
+
+.mw-rule {
+ border: none;
+ border-top: 1px solid var(--hairline);
+ margin: 24px 0;
+}
+
+.mw-letter-subject {
+ font-family: 'Cabinet Grotesk', Inter, system-ui, sans-serif;
+ font-size: 22px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+ margin: 0 0 16px;
+ line-height: 1.35;
+}
+
+.mw-letter-body {
+ font-size: 15px;
+ line-height: 1.7;
+ color: var(--ink);
+ white-space: pre-wrap;
+ /* `word-wrap` is the legacy alias of `overflow-wrap`; modern engines
+ respect `overflow-wrap` directly without the legacy fallback. */
+ overflow-wrap: break-word;
+}
+.mw-letter-body p {
+ margin: 0 0 12px;
+}
+
+/* Mentioned section */
+.mw-mentioned-heading,
+.mw-whykept-heading {
+ font-family: 'Cabinet Grotesk', Inter, sans-serif;
+ font-size: 12px;
+ letter-spacing: 0.3em;
+ color: var(--ink-whisper);
+ text-transform: lowercase;
+ margin: 16px 0 12px;
+ font-weight: 500;
+}
+
+.mw-mentioned-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+.mw-mentioned-row {
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ /* Single padding declaration — was duplicated (`padding: 0` then
+ `padding: 6px 0` later in the block). Combined into the right
+ value below. */
+ padding: 6px 0;
+ width: 100%;
+ display: grid;
+ grid-template-columns: 110px 1fr auto;
+ gap: 12px;
+ font-size: 13px;
+ color: var(--ink);
+ align-items: baseline;
+ text-align: left;
+ border-bottom: 1px dotted var(--hairline);
+}
+.mw-mentioned-row:last-child {
+ border-bottom: none;
+}
+.mw-mentioned-row:hover {
+ background: var(--paper-recessed);
+}
+.mw-mentioned-kind {
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ color: var(--ink-whisper);
+ text-transform: lowercase;
+}
+.mw-mentioned-surface {
+ color: var(--ink);
+ font-size: 13px;
+}
+.mw-mentioned-count {
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ color: var(--ink-whisper);
+}
+
+/* Score bars */
+.mw-scorebar-row {
+ display: grid;
+ grid-template-columns: 110px 1fr 60px;
+ gap: 12px;
+ align-items: center;
+ padding: 6px 0;
+ font-size: 13px;
+}
+.mw-scorebar-label {
+ font-size: 13px;
+ color: var(--ink-soft);
+}
+.mw-scorebar-value {
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 12px;
+ color: var(--ink);
+ text-align: right;
+}
+.mw-scorebar-threshold {
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ color: var(--ink-whisper);
+ text-align: center;
+ margin-top: 12px;
+ letter-spacing: 0.05em;
+}
+
+/* Footer */
+.mw-letter-footer {
+ margin-top: 32px;
+ padding-top: 16px;
+ border-top: 1px solid var(--hairline);
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ color: var(--ink-whisper);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 12px;
+}
+.mw-letter-footer button {
+ background: transparent;
+ border: none;
+ padding: 0;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: underline dotted;
+ text-underline-offset: 2px;
+}
+.mw-letter-footer button:hover {
+ color: var(--ocean);
+}
diff --git a/app/src/lib/intelligence/__tests__/settingsApi.test.ts b/app/src/lib/intelligence/__tests__/settingsApi.test.ts
new file mode 100644
index 000000000..ab3f3104f
--- /dev/null
+++ b/app/src/lib/intelligence/__tests__/settingsApi.test.ts
@@ -0,0 +1,174 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ capabilityForModel,
+ downloadAsset,
+ fetchInstalledAssets,
+ fetchInstalledModels,
+ fetchLocalAiStatus,
+ fetchPresets,
+ formatBytes,
+ getMemoryTreeLlm,
+ type ModelDescriptor,
+ setMemoryTreeLlm,
+} from '../settingsApi';
+
+// Stub the underlying tauri-command wrappers; we're testing the
+// camelCase→snake_case translation + simple try/catch shells, not the
+// RPC plumbing.
+vi.mock('../../../utils/tauriCommands', () => ({
+ isTauri: vi.fn(() => true),
+ memoryTreeGetLlm: vi.fn(),
+ memoryTreeSetLlm: vi.fn(),
+ openhumanLocalAiAssetsStatus: vi.fn(),
+ openhumanLocalAiStatus: vi.fn(),
+ openhumanLocalAiDiagnostics: vi.fn(),
+ openhumanLocalAiPresets: vi.fn(),
+ openhumanLocalAiDownloadAsset: vi.fn(),
+}));
+
+const tauri = (await import('../../../utils/tauriCommands')) as unknown as {
+ memoryTreeGetLlm: ReturnType;
+ memoryTreeSetLlm: ReturnType;
+ openhumanLocalAiAssetsStatus: ReturnType;
+ openhumanLocalAiStatus: ReturnType;
+ openhumanLocalAiDiagnostics: ReturnType;
+ openhumanLocalAiPresets: ReturnType;
+ openhumanLocalAiDownloadAsset: ReturnType;
+};
+
+beforeEach(() => {
+ Object.values(tauri).forEach(fn => fn.mockReset());
+});
+
+describe('getMemoryTreeLlm', () => {
+ it('returns the current backend value from the RPC', async () => {
+ tauri.memoryTreeGetLlm.mockResolvedValueOnce({ current: 'cloud' });
+ await expect(getMemoryTreeLlm()).resolves.toBe('cloud');
+ expect(tauri.memoryTreeGetLlm).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('setMemoryTreeLlm', () => {
+ it('passes only the backend when no options are supplied', async () => {
+ tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
+ await setMemoryTreeLlm('cloud');
+ expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'cloud' });
+ });
+
+ it('translates camelCase options to snake_case wire fields and passes only those that are set', async () => {
+ tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'local' });
+ await setMemoryTreeLlm('local', { extractModel: 'a:b', summariserModel: 'c:d' });
+ expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({
+ backend: 'local',
+ extract_model: 'a:b',
+ summariser_model: 'c:d',
+ });
+ // cloudModel was unset → cloud_model must NOT be on the wire payload.
+ expect(tauri.memoryTreeSetLlm.mock.calls[0][0]).not.toHaveProperty('cloud_model');
+ });
+
+ it('returns the effective backend value the core decided on', async () => {
+ tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
+ const out = await setMemoryTreeLlm('local');
+ expect(out).toEqual({ effective: 'cloud' });
+ });
+});
+
+describe('fetchInstalledAssets', () => {
+ it('unwraps the `result` field on success', async () => {
+ tauri.openhumanLocalAiAssetsStatus.mockResolvedValueOnce({ result: { foo: 1 } });
+ await expect(fetchInstalledAssets()).resolves.toEqual({ foo: 1 });
+ });
+ it('swallows RPC errors and returns null', async () => {
+ tauri.openhumanLocalAiAssetsStatus.mockRejectedValueOnce(new Error('boom'));
+ await expect(fetchInstalledAssets()).resolves.toBeNull();
+ });
+});
+
+describe('fetchLocalAiStatus', () => {
+ it('returns null on RPC failure', async () => {
+ tauri.openhumanLocalAiStatus.mockRejectedValueOnce(new Error('nope'));
+ await expect(fetchLocalAiStatus()).resolves.toBeNull();
+ });
+});
+
+describe('fetchInstalledModels', () => {
+ it('returns the installed_models array', async () => {
+ tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({
+ installed_models: [{ name: 'bge-m3', size_bytes: 1 }],
+ });
+ const got = await fetchInstalledModels();
+ expect(got).toHaveLength(1);
+ expect(got[0]?.name).toBe('bge-m3');
+ });
+ it('returns [] when the RPC rejects', async () => {
+ tauri.openhumanLocalAiDiagnostics.mockRejectedValueOnce(new Error('rpc down'));
+ await expect(fetchInstalledModels()).resolves.toEqual([]);
+ });
+ it('returns [] when installed_models is missing', async () => {
+ tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({});
+ await expect(fetchInstalledModels()).resolves.toEqual([]);
+ });
+});
+
+describe('fetchPresets', () => {
+ it('forwards the response on success', async () => {
+ tauri.openhumanLocalAiPresets.mockResolvedValueOnce({ presets: [] });
+ await expect(fetchPresets()).resolves.toEqual({ presets: [] });
+ });
+ it('returns null on failure', async () => {
+ tauri.openhumanLocalAiPresets.mockRejectedValueOnce(new Error('x'));
+ await expect(fetchPresets()).resolves.toBeNull();
+ });
+});
+
+describe('downloadAsset', () => {
+ it('returns the result envelope on success', async () => {
+ tauri.openhumanLocalAiDownloadAsset.mockResolvedValueOnce({ result: { ok: true } });
+ await expect(downloadAsset('chat')).resolves.toEqual({ ok: true });
+ expect(tauri.openhumanLocalAiDownloadAsset).toHaveBeenCalledWith('chat');
+ });
+ it('returns null on failure (and does not throw)', async () => {
+ tauri.openhumanLocalAiDownloadAsset.mockRejectedValueOnce(new Error('disconnected'));
+ await expect(downloadAsset('embedding')).resolves.toBeNull();
+ });
+});
+
+describe('capabilityForModel', () => {
+ const make = (roles: ModelDescriptor['roles']): ModelDescriptor => ({
+ id: 'x',
+ size: '0',
+ approxBytes: 0,
+ ramHint: '0',
+ category: 'fast',
+ note: '',
+ roles,
+ });
+ it('maps embedder → embedding', () => {
+ expect(capabilityForModel(make(['embedder']))).toBe('embedding');
+ });
+ it('maps extract / summariser → chat', () => {
+ expect(capabilityForModel(make(['extract']))).toBe('chat');
+ expect(capabilityForModel(make(['summariser']))).toBe('chat');
+ expect(capabilityForModel(make(['extract', 'summariser']))).toBe('chat');
+ });
+ it('returns null when no role binds to a known capability', () => {
+ expect(capabilityForModel(make([]))).toBeNull();
+ });
+});
+
+describe('formatBytes', () => {
+ it('falls back to em-dash for non-finite or zero', () => {
+ expect(formatBytes(Number.NaN)).toBe('—');
+ expect(formatBytes(0)).toBe('—');
+ expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('—');
+ });
+ it('formats GB for >= 1 GiB', () => {
+ expect(formatBytes(2.5 * 1024 ** 3)).toBe('2.5 GB');
+ });
+ it('formats MB (rounded) for sub-GB inputs', () => {
+ expect(formatBytes(150 * 1024 ** 2)).toBe('150 MB');
+ expect(formatBytes(1024 ** 2 + 1)).toBe('1 MB');
+ });
+});
diff --git a/app/src/lib/intelligence/settingsApi.ts b/app/src/lib/intelligence/settingsApi.ts
new file mode 100644
index 000000000..adda1f5f5
--- /dev/null
+++ b/app/src/lib/intelligence/settingsApi.ts
@@ -0,0 +1,260 @@
+/**
+ * Settings tab API layer for the Intelligence page.
+ *
+ * Wraps the existing `local_ai_*` core RPCs (re-exported with cleaner names)
+ * and the canonical `openhuman.memory_tree_get_llm` / `set_llm` JSON-RPC
+ * methods that drive the AI-backend selector. Both come from the shared
+ * `utils/tauriCommands` barrel.
+ *
+ * Logging convention: `[intelligence-settings-api]` prefix for grep-friendly
+ * tracing of the new flow per the project debug-logging rule.
+ */
+import {
+ type LlmBackend,
+ type LocalAiAssetsStatus,
+ type LocalAiDiagnostics,
+ type LocalAiStatus,
+ memoryTreeGetLlm,
+ memoryTreeSetLlm,
+ openhumanLocalAiAssetsStatus,
+ openhumanLocalAiDiagnostics,
+ openhumanLocalAiDownloadAsset,
+ openhumanLocalAiPresets,
+ openhumanLocalAiStatus,
+ type PresetsResponse,
+} from '../../utils/tauriCommands';
+
+/**
+ * AI backend the assistant is currently using for chat. Re-exports the
+ * canonical `LlmBackend` from the wrapper so both names remain valid as
+ * call-sites migrate.
+ */
+export type Backend = LlmBackend;
+
+/** Static descriptor used by ModelAssignment + ModelCatalog. */
+export interface ModelDescriptor {
+ /** Ollama-style identifier (e.g. `qwen2.5:0.5b`). */
+ id: string;
+ /** Pretty label shown in the UI (defaults to `id` when omitted). */
+ label?: string;
+ /** Human-readable disk size, e.g. `400 MB`. */
+ size: string;
+ /** Bytes — approximate; surfaced for sort / filter. */
+ approxBytes: number;
+ /** Approx RAM hint, e.g. `≤4 GB RAM`. */
+ ramHint: string;
+ /** Speed / quality tier — used for the inline annotation under each row. */
+ category: 'fast' | 'balanced' | 'high quality' | 'embedder';
+ /** One-sentence note about when to pick this model. */
+ note: string;
+ /** Role(s) this model is suitable for. */
+ roles: ReadonlyArray<'extract' | 'summariser' | 'embedder'>;
+}
+
+export type ModelRole = 'extract' | 'summariser' | 'embedder';
+
+/**
+ * Hard-coded recommended catalog. In a future wave this should come from
+ * a `local_ai.recommended_catalog` RPC; for v1 we ship a curated list so
+ * the UI is fully populated without a server roundtrip.
+ */
+export const RECOMMENDED_MODEL_CATALOG: ReadonlyArray = [
+ {
+ id: 'qwen2.5:0.5b',
+ size: '400 MB',
+ approxBytes: 400 * 1024 * 1024,
+ ramHint: '≤4 GB RAM',
+ category: 'fast',
+ note: 'compact, lower quality',
+ roles: ['extract'],
+ },
+ {
+ id: 'gemma3:1b-it-qat',
+ size: '1.7 GB',
+ approxBytes: Math.round(1.7 * 1024 * 1024 * 1024),
+ ramHint: '≤8 GB RAM',
+ category: 'balanced',
+ note: 'default summariser',
+ roles: ['extract', 'summariser'],
+ },
+ {
+ id: 'llama3.1:8b',
+ size: '4.9 GB',
+ approxBytes: Math.round(4.9 * 1024 * 1024 * 1024),
+ ramHint: '≥8 GB RAM',
+ category: 'high quality',
+ note: 'for capable machines',
+ roles: ['extract', 'summariser'],
+ },
+ {
+ id: 'bge-m3',
+ size: '1.3 GB',
+ approxBytes: Math.round(1.3 * 1024 * 1024 * 1024),
+ ramHint: '≥4 GB RAM',
+ category: 'embedder',
+ note: 'required for embeddings',
+ roles: ['embedder'],
+ },
+];
+
+export const DEFAULT_EXTRACT_MODEL = 'qwen2.5:0.5b';
+export const DEFAULT_SUMMARISER_MODEL = 'gemma3:1b-it-qat';
+export const REQUIRED_EMBEDDER_MODEL = 'bge-m3';
+
+/**
+ * Reads the currently configured chat backend from the core.
+ *
+ * Backed by `openhuman.memory_tree_get_llm` — the value persists across
+ * sidecar restarts via `config.toml`.
+ */
+export async function getMemoryTreeLlm(): Promise {
+ console.debug('[intelligence-settings-api] getMemoryTreeLlm: entry');
+ const resp = await memoryTreeGetLlm();
+ console.debug('[intelligence-settings-api] getMemoryTreeLlm: exit current=%s', resp.current);
+ return resp.current;
+}
+
+/**
+ * Optional per-role model picks for {@link setMemoryTreeLlm}. Field names
+ * are camelCase here to match TS conventions; the wrapper translates them
+ * to the snake_case wire shape the Rust `SetLlmRequest` expects:
+ *
+ * | TS option | Rust / wire field | Targets `memory_tree.*` |
+ * | ----------------- | ------------------- | ----------------------- |
+ * | `cloudModel` | `cloud_model` | `cloud_llm_model` |
+ * | `extractModel` | `extract_model` | `llm_extractor_model` |
+ * | `summariserModel` | `summariser_model` | `llm_summariser_model` |
+ *
+ * Each field follows "absent → unchanged, present → overwritten" so a
+ * caller flipping just the backend doesn't have to re-supply every model
+ * id, and a caller persisting just one role doesn't have to re-supply
+ * the others.
+ */
+export interface SetMemoryTreeLlmOptions {
+ cloudModel?: string;
+ extractModel?: string;
+ summariserModel?: string;
+}
+
+/**
+ * Switches the chat backend and (optionally) persists per-role model
+ * choices in the same atomic `config.toml` write. Returns the effective
+ * value the core agreed on — today the handler accepts the input
+ * verbatim, but a future revision may downgrade `local` → `cloud` when
+ * the host can't satisfy the local minimums.
+ *
+ * Backed by `openhuman.memory_tree_set_llm`.
+ *
+ * Existing one-arg callers — `setMemoryTreeLlm('cloud')` — keep working
+ * unchanged because `options` is optional.
+ */
+export async function setMemoryTreeLlm(
+ next: Backend,
+ options?: SetMemoryTreeLlmOptions
+): Promise<{ effective: Backend }> {
+ console.debug(
+ '[intelligence-settings-api] setMemoryTreeLlm: entry next=%s cloudModel=%s extractModel=%s summariserModel=%s',
+ next,
+ options?.cloudModel ?? '',
+ options?.extractModel ?? '',
+ options?.summariserModel ?? ''
+ );
+ // camelCase → snake_case translation lives here, in one place. The
+ // wrapper layer just forwards the snake_case shape to the wire.
+ const resp = await memoryTreeSetLlm({
+ backend: next,
+ ...(options?.cloudModel !== undefined && { cloud_model: options.cloudModel }),
+ ...(options?.extractModel !== undefined && { extract_model: options.extractModel }),
+ ...(options?.summariserModel !== undefined && { summariser_model: options.summariserModel }),
+ });
+ console.debug('[intelligence-settings-api] setMemoryTreeLlm: exit effective=%s', resp.current);
+ return { effective: resp.current };
+}
+
+/** Re-export the existing assets status fetch with a friendlier name. */
+export async function fetchInstalledAssets(): Promise {
+ try {
+ const response = await openhumanLocalAiAssetsStatus();
+ return response.result;
+ } catch (err) {
+ console.debug('[intelligence-settings-api] fetchInstalledAssets failed', err);
+ return null;
+ }
+}
+
+/**
+ * Fetch local AI status (includes per-capability state + last latency).
+ * Used by `CurrentlyLoaded` to render Ollama-side telemetry.
+ */
+export async function fetchLocalAiStatus(): Promise {
+ try {
+ const response = await openhumanLocalAiStatus();
+ return response.result;
+ } catch (err) {
+ console.debug('[intelligence-settings-api] fetchLocalAiStatus failed', err);
+ return null;
+ }
+}
+
+/**
+ * Reach into the existing diagnostics RPC for the list of installed Ollama
+ * models. The diagnostics endpoint already enumerates them and is the
+ * cleanest single source of truth — we do not duplicate the model table.
+ */
+export async function fetchInstalledModels(): Promise {
+ try {
+ const response = await openhumanLocalAiDiagnostics();
+ return response.installed_models ?? [];
+ } catch (err) {
+ console.debug('[intelligence-settings-api] fetchInstalledModels failed', err);
+ return [];
+ }
+}
+
+export async function fetchPresets(): Promise {
+ try {
+ return await openhumanLocalAiPresets();
+ } catch (err) {
+ console.debug('[intelligence-settings-api] fetchPresets failed', err);
+ return null;
+ }
+}
+
+/**
+ * Trigger a download for a capability (chat / vision / embedding / stt / tts).
+ * Used by ModelCatalog when the user clicks "Download".
+ *
+ * NOTE: the real RPC is per-capability, not per-model-id, so the catalog
+ * picks the closest matching capability. This is acceptable for v1; future
+ * iterations can swap in a per-model RPC.
+ */
+export async function downloadAsset(
+ capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
+): Promise {
+ try {
+ const response = await openhumanLocalAiDownloadAsset(capability);
+ return response.result;
+ } catch (err) {
+ console.debug('[intelligence-settings-api] downloadAsset failed', { capability, err });
+ return null;
+ }
+}
+
+/** Map a model descriptor to the closest capability bucket the core exposes. */
+export function capabilityForModel(model: ModelDescriptor): 'chat' | 'embedding' | null {
+ if (model.roles.includes('embedder')) return 'embedding';
+ if (model.roles.includes('extract') || model.roles.includes('summariser')) return 'chat';
+ return null;
+}
+
+/**
+ * Cheap pretty-printer for a byte count. Mirrors the `JetBrains Mono`-style
+ * compact format we want in the technical-readout sections.
+ */
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) return '—';
+ const gb = bytes / (1024 * 1024 * 1024);
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
+ const mb = bytes / (1024 * 1024);
+ return `${Math.round(mb)} MB`;
+}
diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx
index 4e8d70845..c36d3d457 100644
--- a/app/src/pages/Intelligence.tsx
+++ b/app/src/pages/Intelligence.tsx
@@ -1,55 +1,45 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
-import IntelligenceMemoryTab from '../components/intelligence/IntelligenceMemoryTab';
+import IntelligenceSettingsTab from '../components/intelligence/IntelligenceSettingsTab';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
+import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
import { ToastContainer } from '../components/intelligence/Toast';
-import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils';
import PillTabBar from '../components/PillTabBar';
import { useConsciousItems } from '../hooks/useConsciousItems';
-import {
- useSnoozeActionableItem,
- useUpdateActionableItem,
-} from '../hooks/useIntelligenceApiFallback';
import {
useIntelligenceSocket,
useIntelligenceSocketManager,
} from '../hooks/useIntelligenceSocket';
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
import { useMemoryIngestionStatus } from '../hooks/useMemoryIngestionStatus';
-import { useScreenIntelligenceItems } from '../hooks/useScreenIntelligenceItems';
import { useSubconscious } from '../hooks/useSubconscious';
import type {
- ActionableItem,
- ActionableItemSource,
- ActionableItemStatus,
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../types/intelligence';
-type IntelligenceTab = 'memory' | 'subconscious' | 'dreams';
+type IntelligenceTab = 'memory' | 'subconscious' | 'dreams' | 'settings';
export default function Intelligence() {
const { aiStatus } = useIntelligenceStats();
const { status: ingestionStatus } = useMemoryIngestionStatus();
const [activeTab, setActiveTab] = useState('memory');
- const [sourceFilter, setSourceFilter] = useState('all');
- const [priorityFilter] = useState<'critical' | 'important' | 'normal' | 'all'>('all');
- const [searchFilter, setSearchFilter] = useState('');
- // Conscious memory items (real data from the background analysis loop)
- const {
- items: consciousItems,
- loading: consciousLoading,
- isRunning,
- refresh: refreshConscious,
- triggerAnalysis,
- } = useConsciousItems();
+ // `useConsciousItems` is kept solely for the `isRunning` signal that
+ // drives the system-status pill in the Memory-tab header. The items
+ // themselves used to feed the actionable-cards count badge (now hidden,
+ // and the rendering surface — IntelligenceMemoryTab — is gone). When
+ // the status pill is rewired to a memory_tree-native source, drop this
+ // hook entirely.
+ const { isRunning } = useConsciousItems();
- const { mutateAsync: updateItemStatus } = useUpdateActionableItem();
- const { mutateAsync: snoozeItem } = useSnoozeActionableItem();
+ // useUpdateActionableItem / useSnoozeActionableItem hooks were the
+ // mutations behind handleComplete / Dismiss / Snooze. Removed along
+ // with those handlers since the Memory tab no longer renders the
+ // actionable-card surface.
// Subconscious engine data
const {
@@ -92,17 +82,6 @@ export default function Intelligence() {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
- const { items: screenIntelligenceItems, loading: screenIntelligenceLoading } =
- useScreenIntelligenceItems();
-
- const usingMemoryData = consciousItems.length > 0 || screenIntelligenceItems.length > 0;
- const items: ActionableItem[] = useMemo(
- () => [...consciousItems, ...screenIntelligenceItems],
- [consciousItems, screenIntelligenceItems]
- );
-
- const itemsLoading = consciousLoading || screenIntelligenceLoading;
-
// Initialize socket connection
useEffect(() => {
if (!socketConnected) {
@@ -110,131 +89,18 @@ export default function Intelligence() {
}
}, [socketConnected, socketManager]);
- // Filter and group items
- const filteredItems = useMemo(() => {
- const activeItems = items.filter(item => item.status === 'active');
- return filterItems(activeItems, {
- source: sourceFilter,
- priority: priorityFilter,
- searchTerm: searchFilter,
- });
- }, [items, priorityFilter, searchFilter, sourceFilter]);
-
- const timeGroups = useMemo(() => groupItemsByTime(filteredItems), [filteredItems]);
- const stats = useMemo(() => getItemStats(filteredItems), [filteredItems]);
-
- // Item action handlers
- const handleUpdateItemStatus = useCallback(
- async (itemId: string, status: ActionableItemStatus) => {
- try {
- await updateItemStatus({ itemId, status });
-
- let message = '';
- switch (status) {
- case 'completed':
- message = 'Task marked as completed';
- break;
- case 'dismissed':
- message = 'Task dismissed';
- break;
- case 'active':
- message = 'Task reactivated';
- break;
- default:
- message = 'Status updated';
- }
-
- addToast({ type: 'success', title: 'Status Updated', message });
- } catch (error) {
- console.error('Failed to update item status:', error);
- addToast({
- type: 'error',
- title: 'Update Failed',
- message: error instanceof Error ? error.message : 'Failed to update item status',
- });
- }
- },
- [updateItemStatus, addToast]
- );
-
- const handleComplete = useCallback(
- async (item: ActionableItem) => {
- await handleUpdateItemStatus(item.id, 'completed');
- },
- [handleUpdateItemStatus]
- );
-
- const handleDismiss = useCallback(
- (item: ActionableItem) => {
- setConfirmationModal({
- isOpen: true,
- title: 'Dismiss item?',
- message: `Are you sure you want to dismiss "${item.title}"?`,
- confirmText: 'Dismiss',
- cancelText: 'Cancel',
- destructive: item.priority === 'critical',
- showDontShowAgain: !item.requiresConfirmation,
- onConfirm: async () => {
- try {
- await handleUpdateItemStatus(item.id, 'dismissed');
- addToast({
- type: 'info',
- title: 'Dismissed',
- message: item.title.length > 40 ? `${item.title.substring(0, 40)}...` : item.title,
- action: { label: 'Undo', handler: () => handleUpdateItemStatus(item.id, 'active') },
- });
- } catch (error) {
- console.error('Failed to dismiss item:', error);
- }
- },
- onCancel: () => {},
- });
- },
- [handleUpdateItemStatus, addToast]
- );
-
- const handleSnooze = useCallback(
- async (item: ActionableItem, duration: number) => {
- try {
- const snoozeUntil = new Date(Date.now() + duration);
- await snoozeItem({ itemId: item.id, snoozeUntil });
- const hours = Math.round(duration / (1000 * 60 * 60));
- addToast({
- type: 'info',
- title: 'Snoozed',
- message: `Reminded in ${hours === 1 ? '1 hour' : `${hours} hours`}`,
- });
- } catch (error) {
- console.error('Failed to snooze item:', error);
- addToast({
- type: 'error',
- title: 'Snooze Failed',
- message: 'Failed to snooze item. Please try again.',
- });
- }
- },
- [snoozeItem, addToast]
- );
-
- const handleAnalyzeNow = useCallback(async () => {
- await triggerAnalysis();
- addToast({
- type: 'info',
- title: 'Analysis Started',
- message: 'Analyzing your connected skills for actionable items…',
- });
- }, [triggerAnalysis, addToast]);
-
- // System status
+ // System status — `itemsLoading` (the actionable-items + screen-items
+ // loading flag) used to feed the "loading" branch here, but both feeds
+ // are gone now. `isRunning` from useConsciousItems still surfaces the
+ // background analysis loop signal until that pill is rewired to
+ // memory_tree.
const systemStatus = isRunning
? 'loading'
: socketConnected && aiStatus === 'ready'
? 'ready'
- : itemsLoading
- ? 'loading'
- : !socketConnected
- ? 'disconnected'
- : aiStatus;
+ : !socketConnected
+ ? 'disconnected'
+ : aiStatus;
const systemStatusLabel = isRunning
? 'Analyzing…'
@@ -265,6 +131,7 @@ export default function Intelligence() {
{ id: 'memory', label: 'Memory' },
{ id: 'subconscious', label: 'Subconscious' },
{ id: 'dreams', label: 'Dreams', comingSoon: true },
+ { id: 'settings', label: 'Settings' },
];
return (
@@ -301,11 +168,14 @@ export default function Intelligence() {
Intelligence
- {activeTab === 'memory' && stats.total > 0 && (
-
- {stats.total}
-
- )}
+ {/* Header count badge was sourced from `stats.total` which
+ in turn came from the legacy actionable-items pipeline
+ (`filterItems(items, ...)`). The Memory tab now mounts
+ `MemoryWorkspace`, which renders chunks from
+ `memory_tree` and has nothing to do with that pipeline,
+ so the badge would have shown a count that no longer
+ matches anything visible. Hidden until a memory_tree
+ -native count signal is exposed. */}
{activeTab === 'memory' && (
@@ -332,51 +202,17 @@ export default function Intelligence() {
)}
- {activeTab === 'memory' && (
-
- {isRunning || itemsLoading ? (
-
- ) : (
-
-
-
- )}
- {usingMemoryData ? 'Refresh' : 'Analyze Now'}
-
- )}
+ {/* Analyze Now / Refresh button removed — the new
+ MemoryWorkspace fetches via memory_tree RPCs that
+ don't need a manual trigger. The actionable-cards
+ flow (handleAnalyzeNow) is no longer reachable from
+ the Memory tab; left in scope only for the legacy
+ subconscious/dreams tabs that still use it. */}
{/* Tab content */}
- {activeTab === 'memory' && (
-
- )}
+ {activeTab === 'memory' &&
}
{activeTab === 'subconscious' && (
}
+
+ {activeTab === 'settings' &&
}