diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx index 3b373e078..0a4d4d573 100644 --- a/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx @@ -13,10 +13,15 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MemoryTreePipelineStatus } from '../../utils/tauriCommands'; -import { MemoryTreeStatusPanel } from './MemoryTreeStatusPanel'; +import { + classifyIntegration, + MemoryTreeStatusPanel, + providerIconChar, +} from './MemoryTreeStatusPanel'; const mockPipelineStatus = vi.fn(); const mockSetEnabled = vi.fn(); +const mockSyncStatusList = vi.fn(); vi.mock('../../utils/tauriCommands', async importOriginal => { // Inherit everything else (types, sibling wrappers) verbatim so the panel @@ -27,6 +32,7 @@ vi.mock('../../utils/tauriCommands', async importOriginal => { ...actual, memoryTreePipelineStatus: (...args: unknown[]) => mockPipelineStatus(...args), memoryTreeSetEnabled: (...args: unknown[]) => mockSetEnabled(...args), + memorySyncStatusList: (...args: unknown[]) => mockSyncStatusList(...args), }; }); @@ -58,6 +64,8 @@ describe('', () => { vi.setSystemTime(new Date(FIXED_NOW_MS)); mockPipelineStatus.mockReset(); mockSetEnabled.mockReset(); + mockSyncStatusList.mockReset(); + mockSyncStatusList.mockResolvedValue([]); // default: empty, harmless to existing tests }); afterEach(() => { @@ -82,6 +90,28 @@ describe('', () => { expect(screen.getByTestId('memory-tree-last-sync')).toHaveTextContent(/min ago/); }); + it('fetches integration list and pipeline status in parallel on the same tick', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([ + { + provider: 'slack', + chunks_synced: 5231, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 3 * 60 * 1000, + freshness: 'active', + }, + ]); + + render(); + + await waitFor(() => { + expect(mockPipelineStatus).toHaveBeenCalledTimes(1); + expect(mockSyncStatusList).toHaveBeenCalledTimes(1); + }); + }); + it('shows skeleton placeholders before the first status payload resolves', async () => { // Suspend the promise so the panel paints its loading state. let resolve: (v: MemoryTreePipelineStatus) => void = () => {}; @@ -159,6 +189,145 @@ describe('', () => { }); }); + it('renders a row per integration with provider name, chunk count, freshness pill', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([ + { + provider: 'slack', + chunks_synced: 5231, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 3 * 60 * 1000, + freshness: 'active', + }, + { + provider: 'gmail', + chunks_synced: 842, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 2 * 60 * 60 * 1000, + freshness: 'idle', + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-integrations')).toBeInTheDocument(); + }); + + const rows = screen.getAllByTestId(/^memory-tree-integration-row-/); + expect(rows).toHaveLength(2); + + const slackRow = screen.getByTestId('memory-tree-integration-row-slack'); + expect(slackRow).toHaveTextContent(/slack/i); + expect(slackRow).toHaveTextContent(/Chunks: 5,231/); + expect(slackRow).toHaveTextContent(/Active/); + + const gmailRow = screen.getByTestId('memory-tree-integration-row-gmail'); + expect(gmailRow).toHaveTextContent(/gmail/i); + expect(gmailRow).toHaveTextContent(/Stale/); + }); + + it('falls back to the never label when last_chunk_at_ms is null', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([ + { + provider: 'slack', + chunks_synced: 0, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: null, + freshness: 'idle', + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-integration-row-slack')).toBeInTheDocument(); + }); + expect(screen.getByTestId('memory-tree-integration-row-slack')).toHaveTextContent(/Never/); + }); + + it('renders an empty list and logs a warn when memorySyncStatusList rejects', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockRejectedValue(new Error('boom')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + render(); + + // Tiles still render (pipeline succeeded) and the strip shows the empty state. + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toBeInTheDocument(); + expect(screen.getByTestId('memory-tree-integrations-empty')).toBeInTheDocument(); + }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('shows the empty state when there are no integrations', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-integrations-empty')).toBeInTheDocument(); + }); + expect(screen.getByTestId('memory-tree-integrations-empty')).toHaveTextContent( + /no integrations connected/i + ); + }); + + it('renders the integration strip between the tile grid and the toggle row', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([ + { + provider: 'slack', + chunks_synced: 1, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 1000, + freshness: 'active', + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-integrations')).toBeInTheDocument(); + }); + + const panel = screen.getByTestId('memory-tree-status-panel'); + const tiles = screen.getByTestId('memory-tree-status-tiles'); + const strip = screen.getByTestId('memory-tree-integrations'); + const toggle = screen.getByTestId('memory-tree-status-toggle-row'); + + const order = Array.from(panel.querySelectorAll('[data-testid]')) + .map(el => el.getAttribute('data-testid')) + .filter(id => + [ + 'memory-tree-status-tiles', + 'memory-tree-integrations', + 'memory-tree-status-toggle-row', + ].includes(id ?? '') + ); + + expect(order).toEqual([ + 'memory-tree-status-tiles', + 'memory-tree-integrations', + 'memory-tree-status-toggle-row', + ]); + expect(tiles).toBeInTheDocument(); + expect(strip).toBeInTheDocument(); + expect(toggle).toBeInTheDocument(); + }); + it('reports toggle errors via the onToast callback', async () => { mockPipelineStatus.mockResolvedValueOnce(payload({ status: 'running', is_paused: false })); mockSetEnabled.mockRejectedValueOnce(new Error('disk write failed')); @@ -177,3 +346,29 @@ describe('', () => { }); }); }); + +describe('integration health helpers', () => { + describe('classifyIntegration', () => { + it('maps active freshness to active', () => { + expect(classifyIntegration('active')).toBe('active'); + }); + it('maps recent freshness to stale', () => { + expect(classifyIntegration('recent')).toBe('stale'); + }); + it('maps idle freshness to stale', () => { + expect(classifyIntegration('idle')).toBe('stale'); + }); + }); + + describe('providerIconChar', () => { + it('returns a known glyph for slack', () => { + expect(providerIconChar('slack')).toBe('💬'); + }); + it('returns a known glyph for gmail', () => { + expect(providerIconChar('gmail')).toBe('📧'); + }); + it('falls back to the plug glyph for unknown providers', () => { + expect(providerIconChar('definitely-not-a-real-provider')).toBe('🔌'); + }); + }); +}); diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx index df6a4ba75..c8bdf2e03 100644 --- a/app/src/components/intelligence/MemoryTreeStatusPanel.tsx +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx @@ -24,6 +24,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import type { ToastNotification } from '../../types/intelligence'; import { + memorySyncStatusList, + type MemorySyncStatusRow, memoryTreePipelineStatus, type MemoryTreePipelineStatus, memoryTreeSetEnabled, @@ -45,11 +47,13 @@ const DEFAULT_POLL_MS = 4000; */ function useMemoryTreeStatus(): { status: MemoryTreePipelineStatus | null; + integrations: MemorySyncStatusRow[]; loading: boolean; error: string | null; refresh: () => Promise; } { const [status, setStatus] = useState(null); + const [integrations, setIntegrations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const cancelledRef = useRef(false); @@ -59,14 +63,30 @@ function useMemoryTreeStatus(): { const fetchOnce = useCallback(async () => { console.debug('[ui-flow][memory-tree-status] fetchOnce: entry'); try { - const next = await memoryTreePipelineStatus(); + // Fetch pipeline + per-integration health in parallel so the strip + // and the tiles share a single 1.5s / 4s adaptive tick (#2763). + const [next, rows] = await Promise.all([ + memoryTreePipelineStatus(), + memorySyncStatusList().catch(err => { + // Per-integration list is best-effort: surface an empty strip + // rather than wiping the panel when only the secondary endpoint + // fails. Pipeline failure still flips the panel-wide error. + console.warn( + '[ui-flow][memory-tree-status] memorySyncStatusList failed: %s', + err instanceof Error ? err.message : String(err) + ); + return [] as MemorySyncStatusRow[]; + }), + ]); if (cancelledRef.current) return; setStatus(next); + setIntegrations(rows); setError(null); console.debug( - '[ui-flow][memory-tree-status] fetchOnce: ok status=%s total=%d', + '[ui-flow][memory-tree-status] fetchOnce: ok status=%s total=%d integrations=%d', next.status, - next.total_chunks + next.total_chunks, + rows.length ); } catch (err) { if (cancelledRef.current) return; @@ -98,7 +118,7 @@ function useMemoryTreeStatus(): { }; }, [fetchOnce]); - return { status, loading, error, refresh: fetchOnce }; + return { status, integrations, loading, error, refresh: fetchOnce }; } interface MemoryTreeStatusPanelProps { @@ -173,6 +193,134 @@ function statusDotClass(kind: MemoryTreePipelineStatus['status']): string { } } +/** + * UI health classification for a single provider row in the integration + * health strip (#2763). The wire shape's three-state `freshness` collapses + * to two states here — `Active` (currently producing chunks) vs `Stale` + * (anything older). An `Error` state is intentionally NOT derived from the + * current data; per-provider failure attribution needs new core work and + * is filed as a follow-up to issue #2763. + */ +export type IntegrationHealth = 'active' | 'stale'; + +/** Map the wire `freshness` enum to the two-state UI classification. */ +export function classifyIntegration( + freshness: MemorySyncStatusRow['freshness'] +): IntegrationHealth { + return freshness === 'active' ? 'active' : 'stale'; +} + +/** + * Built-in glyph for each known provider key from `memory_sync_status_list`. + * Source: `MemorySyncStatus.provider` in `src/openhuman/memory_sync/sync_status/types.rs` + * — that file's doc comment enumerates the providers ("slack", "gmail", + * "discord", "telegram", "whatsapp", "notion", "meeting_notes", + * "drive_docs", etc.). Anything not in this map falls back to a generic + * plug glyph so unknown providers still render cleanly. + * + * Kept inline (rather than re-using `SOURCE_KIND_ICONS` from + * `memorySourcesService`) because that map is keyed by `SourceKind` + * (`composio` / `folder` / `github_repo` / …) — a different taxonomy. + */ +const PROVIDER_ICONS: Record = { + slack: '💬', + gmail: '📧', + discord: '🎮', + telegram: '✈️', + whatsapp: '🟢', + notion: '📝', + meeting_notes: '🎙️', + drive_docs: '📄', + github: '🐙', +}; + +/** Look up a provider glyph; fall back to a generic plug for unknowns. */ +export function providerIconChar(provider: string): string { + return PROVIDER_ICONS[provider] ?? '🔌'; +} + +/** + * Per-integration health strip (#2763). Rendered between the four pipeline + * tiles and the auto-sync toggle inside `MemoryTreeStatusPanel`. Consumes + * the `integrations` slice returned by `useMemoryTreeStatus` — no + * additional fetch, no second timer. + */ +function IntegrationHealthStrip({ + integrations, + loading, + t, +}: { + integrations: MemorySyncStatusRow[]; + loading: boolean; + t: TFn; +}) { + return ( +
+
+ {t('memoryTree.status.integrationsTitle')} +
+ {loading && integrations.length === 0 ? ( + // First-mount: suppress "no integrations" copy until the initial poll + // resolves, otherwise the strip falsely implies nothing is connected + // before data arrives (CodeRabbit feedback on #2763). +
+ ) : integrations.length === 0 ? ( +
+ {t('memoryTree.status.integrationsEmpty')} +
+ ) : ( +
    + {integrations.map(row => { + const health = classifyIntegration(row.freshness); + const healthLabel = + health === 'active' + ? t('memoryTree.status.integrationActive') + : t('memoryTree.status.integrationStale'); + const dot = health === 'active' ? 'bg-sage-400' : 'bg-stone-400 dark:bg-neutral-500'; + return ( +
  • +
    + + {providerIconChar(row.provider)} + + + {row.provider} + +
    +
    + + {t('memoryTree.status.integrationChunks').replace( + '{count}', + new Intl.NumberFormat().format(row.chunks_synced) + )} + + + {formatRelativeMs(row.last_chunk_at_ms ?? 0, t, t('memoryTree.status.never'))} + + + + {healthLabel} + +
    +
  • + ); + })} +
+ )} +
+ ); +} + /** * Memory Tree status panel — render the four-tile dashboard plus the * auto-sync toggle. Designed to mount above `` in @@ -181,7 +329,7 @@ function statusDotClass(kind: MemoryTreePipelineStatus['status']): string { */ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { const { t } = useT(); - const { status, loading, error, refresh } = useMemoryTreeStatus(); + const { status, integrations, loading, error, refresh } = useMemoryTreeStatus(); const [toggleBusy, setToggleBusy] = useState(false); const handleToggle = useCallback(async () => { @@ -315,6 +463,8 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
+ + {/* Auto-sync toggle row — markup mirrors AIPanel's inline ToggleRow */}
{ expect(out.registered).toBe(true); }); }); + +describe('memorySyncStatusList', () => { + test('dispatches openhuman.memory_sync_status_list and returns the rows from a result envelope', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ + result: { + statuses: [ + { + provider: 'slack', + chunks_synced: 5, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: 1_700_000_000_000, + freshness: 'active', + }, + ], + }, + }); + + const rows = await memorySyncStatusList(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.memory_sync_status_list', + params: {}, + }); + expect(rows).toHaveLength(1); + expect(rows[0].provider).toBe('slack'); + expect(rows[0].freshness).toBe('active'); + }); + + test('handles bare-value responses (no logs envelope)', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ statuses: [] }); + const rows = await memorySyncStatusList(); + expect(rows).toEqual([]); + }); + + test('falls back to empty array when statuses is missing', async () => { + mockCallCoreRpc.mockResolvedValueOnce({}); + const rows = await memorySyncStatusList(); + expect(rows).toEqual([]); + }); +}); diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts index a0e04de49..34104c3bc 100644 --- a/app/src/utils/tauriCommands/memoryTree.ts +++ b/app/src/utils/tauriCommands/memoryTree.ts @@ -885,3 +885,53 @@ export async function memorySyncAuditLog(): Promise { >({ method: 'openhuman.memory_sources_sync_audit_log', params: {} }); return unwrapResult(resp).entries ?? []; } + +// ── memory_sync_status_list (#2763 — per-integration health strip) ─────── + +/** + * Freshness label emitted by `openhuman.memory_sync_status_list`. Snake-case + * mirrors the Rust `FreshnessLabel` serde rename. Derived from + * `now - last_chunk_at_ms` at RPC time, not stored. + */ +export type MemorySyncFreshness = 'active' | 'recent' | 'idle'; + +/** + * One row per provider that has produced chunks. Mirrors the Rust + * `MemorySyncStatus` struct exactly — snake_case carried through so the + * wire payload deserialises without a remap layer. + */ +export interface MemorySyncStatusRow { + /** Provider key — `slack`, `gmail`, `notion`, `discord`, `telegram`, etc. */ + provider: string; + /** Total chunks in `mem_tree_chunks` for this provider. */ + chunks_synced: number; + /** Chunks fetched but not yet extracted/embedded. Lifetime metric. */ + chunks_pending: number; + /** Total chunks in the current sync wave. Zero when no wave is active. */ + batch_total: number; + /** Of `batch_total`, how many have been processed. */ + batch_processed: number; + /** Epoch ms of the most-recent chunk for this provider; null if none yet. */ + last_chunk_at_ms: number | null; + /** Coarse activity label — derived at RPC time. */ + freshness: MemorySyncFreshness; +} + +/** + * Fetch the per-provider sync-status list. Single SQL query against + * `mem_tree_chunks` (GROUP BY source_kind); safe to poll alongside + * `memoryTreePipelineStatus` on the same 1.5s / 4s adaptive cadence. + * + * Backed by `openhuman.memory_sync_status_list` (#1136). Surfaced by the + * per-integration health strip in `MemoryTreeStatusPanel` (#2763). + */ +export async function memorySyncStatusList(): Promise { + console.debug('[memory-tree-rpc] memorySyncStatusList: entry'); + const resp = await callCoreRpc< + { statuses: MemorySyncStatusRow[] } | ResultEnvelope<{ statuses: MemorySyncStatusRow[] }> + >({ method: 'openhuman.memory_sync_status_list', params: {} }); + const out = unwrapResult(resp); + const rows = out.statuses ?? []; + console.debug('[memory-tree-rpc] memorySyncStatusList: exit rows=%d', rows.length); + return rows; +} diff --git a/docs/superpowers/plans/2026-06-02-memory-tree-integration-health.md b/docs/superpowers/plans/2026-06-02-memory-tree-integration-health.md new file mode 100644 index 000000000..4ffad57ba --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-memory-tree-integration-health.md @@ -0,0 +1,980 @@ +# Memory Tree per-integration health strip — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a compact per-integration health strip inside `MemoryTreeStatusPanel`, between the four tiles and the auto-sync toggle, so operators can see at a glance which integrations are contributing chunks vs. idle. Issue #2763. + +**Architecture:** Frontend-only. Reuses the existing `openhuman.memory_sync_status_list` RPC (no Rust changes). Extends `useMemoryTreeStatus` to fetch both endpoints in parallel on one shared 1.5s / 4s adaptive timer. Status mapping is pure TS: `Active` (freshness=active) vs `Stale` (freshness=recent|idle). Error state intentionally deferred — needs proper per-provider job→source linkage in core. + +**Tech Stack:** React 18, TypeScript, Vitest + Testing Library, Tailwind. RPC bridge via `callCoreRpc` in `app/src/services/coreRpcClient`. i18n via `useT()` from `app/src/lib/i18n/I18nContext`. + +**Branch:** `feat/memory-tree-integration-health` (already created from `origin/main`). +**Spec:** `docs/superpowers/specs/2026-06-02-memory-tree-integration-health-design.md`. + +## File map + +| File | Action | Responsibility | +| --- | --- | --- | +| `app/src/utils/tauriCommands/memoryTree.ts` | Modify | Add `MemorySyncFreshness`, `MemorySyncStatusRow`, `memorySyncStatusList()` wrapper for `openhuman.memory_sync_status_list`. | +| `app/src/components/intelligence/MemoryTreeStatusPanel.tsx` | Modify | Extend `useMemoryTreeStatus` hook to fetch both endpoints in parallel; add `IntegrationHealthStrip` sub-component; mount it between the 4-tile grid and the toggle row. | +| `app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx` | Modify | New cases: renders rows, status mapping, empty state, icon fallback, shared polling assertion. | +| `app/src/lib/i18n/en.ts` | Modify | 5 new keys under `memoryTree.status.integration*`. | +| `app/src/lib/i18n/{ar,bn,de,es,fr,hi,id,it,ko,pl,pt,ru,zh-CN}.ts` | Modify | Same 5 keys with real translations (CLAUDE.md i18n rule — no English fallback). | + +No new files created. Strip lives as a sub-component inside `MemoryTreeStatusPanel.tsx` because the two concerns share state (hook output) and polling cadence. + +--- + +### Task 1: Add `memorySyncStatusList()` wrapper + +**Files:** +- Modify: `app/src/utils/tauriCommands/memoryTree.ts` (append at end of file, before any closing exports) + +**Context:** The Rust RPC `openhuman.memory_sync_status_list` returns `{ statuses: MemorySyncStatus[] }`. Wire shape from `src/openhuman/memory_sync/sync_status/types.rs`: + +```rust +pub struct MemorySyncStatus { + pub provider: String, + pub chunks_synced: u64, + pub chunks_pending: u64, + pub batch_total: u64, + pub batch_processed: u64, + pub last_chunk_at_ms: Option, + pub freshness: FreshnessLabel, // snake_case: "active" | "recent" | "idle" +} +``` + +We only consume `provider`, `chunks_synced`, `last_chunk_at_ms`, `freshness` in v1. Keep the full type so the wrapper is reusable. + +- [ ] **Step 1: Append the wrapper to memoryTree.ts** + +Append at the bottom of `app/src/utils/tauriCommands/memoryTree.ts` (after the existing `memoryTreeSetEnabled` block): + +```ts +// ── memory_sync_status_list (#2763 — per-integration health strip) ─────── + +/** + * Freshness label emitted by `openhuman.memory_sync_status_list`. Snake-case + * mirrors the Rust `FreshnessLabel` serde rename. Derived from + * `now - last_chunk_at_ms` at RPC time, not stored. + */ +export type MemorySyncFreshness = 'active' | 'recent' | 'idle'; + +/** + * One row per provider that has produced chunks. Mirrors the Rust + * `MemorySyncStatus` struct exactly — snake_case carried through so the + * wire payload deserialises without a remap layer. + */ +export interface MemorySyncStatusRow { + /** Provider key — `slack`, `gmail`, `notion`, `discord`, `telegram`, etc. */ + provider: string; + /** Total chunks in `mem_tree_chunks` for this provider. */ + chunks_synced: number; + /** Chunks fetched but not yet extracted/embedded. Lifetime metric. */ + chunks_pending: number; + /** Total chunks in the current sync wave. Zero when no wave is active. */ + batch_total: number; + /** Of `batch_total`, how many have been processed. */ + batch_processed: number; + /** Epoch ms of the most-recent chunk for this provider; null if none yet. */ + last_chunk_at_ms: number | null; + /** Coarse activity label — derived at RPC time. */ + freshness: MemorySyncFreshness; +} + +/** + * Fetch the per-provider sync-status list. Single SQL query against + * `mem_tree_chunks` (GROUP BY source_kind); safe to poll alongside + * `memoryTreePipelineStatus` on the same 1.5s / 4s adaptive cadence. + * + * Backed by `openhuman.memory_sync_status_list` (#1136). Surfaced by the + * per-integration health strip in `MemoryTreeStatusPanel` (#2763). + */ +export async function memorySyncStatusList(): Promise { + console.debug('[memory-tree-rpc] memorySyncStatusList: entry'); + const resp = await callCoreRpc< + { statuses: MemorySyncStatusRow[] } | ResultEnvelope<{ statuses: MemorySyncStatusRow[] }> + >({ method: 'openhuman.memory_sync_status_list', params: {} }); + const out = unwrapResult(resp); + const rows = out.statuses ?? []; + console.debug('[memory-tree-rpc] memorySyncStatusList: exit rows=%d', rows.length); + return rows; +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `pnpm typecheck` +Expected: PASS (no new TS errors). If it fails on `callCoreRpc`/`unwrapResult`/`ResultEnvelope` not being in scope, they're already imported in this file — check the top of the file. Re-check the appended block uses the same identifiers verbatim. + +- [ ] **Step 3: Commit** + +```bash +git add app/src/utils/tauriCommands/memoryTree.ts +git commit -m "feat(memory-tree): add memorySyncStatusList RPC wrapper (#2763)" +``` + +--- + +### Task 2: Add the 5 new i18n keys (English) + +**Files:** +- Modify: `app/src/lib/i18n/en.ts:521` (immediately after the existing `memoryTree.status.daysAgo` line) + +- [ ] **Step 1: Insert the new keys** + +Open `app/src/lib/i18n/en.ts`. Find the line `'memoryTree.status.daysAgo': '{count} days ago',` (around line 521). Insert immediately after it: + +```ts + // Per-integration health strip (#2763) — rendered between the 4-tile grid + // and the auto-sync toggle in MemoryTreeStatusPanel. + 'memoryTree.status.integrationsTitle': 'Per-integration health', + 'memoryTree.status.integrationsEmpty': 'No integrations connected', + 'memoryTree.status.integrationActive': 'Active', + 'memoryTree.status.integrationStale': 'Stale', + 'memoryTree.status.integrationChunks': '{count} chunks', +``` + +- [ ] **Step 2: Verify typecheck still clean** + +Run: `pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Run the i18n parity gate to confirm it now expects these keys in every other locale** + +Run: `pnpm i18n:check` +Expected: FAIL with messages like `Missing key 'memoryTree.status.integrationsTitle' in locale 'ar'` etc. (13 missing-key errors per new key). This is the desired failure that Task 3 fixes. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/lib/i18n/en.ts +git commit -m "feat(i18n): add English keys for integration health strip (#2763)" +``` + +--- + +### Task 3: Add real translations for all 13 non-English locales + +**Files:** Modify each of: +- `app/src/lib/i18n/ar.ts` +- `app/src/lib/i18n/bn.ts` +- `app/src/lib/i18n/de.ts` +- `app/src/lib/i18n/es.ts` +- `app/src/lib/i18n/fr.ts` +- `app/src/lib/i18n/hi.ts` +- `app/src/lib/i18n/id.ts` +- `app/src/lib/i18n/it.ts` +- `app/src/lib/i18n/ko.ts` +- `app/src/lib/i18n/pl.ts` +- `app/src/lib/i18n/pt.ts` +- `app/src/lib/i18n/ru.ts` +- `app/src/lib/i18n/zh-CN.ts` + +Each file already contains the `memoryTree.status.daysAgo` key (sibling to where we inserted in en.ts). Insert the 5 new keys directly after that line in every file, in the language of that file. + +- [ ] **Step 1: Insert translations into each locale file** + +For each locale file, find `memoryTree.status.daysAgo` and insert the corresponding block below. Use these exact translations: + +**`ar.ts`** (Arabic): +```ts + 'memoryTree.status.integrationsTitle': 'حالة التكاملات', + 'memoryTree.status.integrationsEmpty': 'لا توجد تكاملات متصلة', + 'memoryTree.status.integrationActive': 'نشط', + 'memoryTree.status.integrationStale': 'قديم', + 'memoryTree.status.integrationChunks': '{count} قطعة', +``` + +**`bn.ts`** (Bengali): +```ts + 'memoryTree.status.integrationsTitle': 'প্রতি-ইন্টিগ্রেশন স্বাস্থ্য', + 'memoryTree.status.integrationsEmpty': 'কোনো ইন্টিগ্রেশন সংযুক্ত নেই', + 'memoryTree.status.integrationActive': 'সক্রিয়', + 'memoryTree.status.integrationStale': 'পুরানো', + 'memoryTree.status.integrationChunks': '{count} টি অংশ', +``` + +**`de.ts`** (German): +```ts + 'memoryTree.status.integrationsTitle': 'Integrationsstatus', + 'memoryTree.status.integrationsEmpty': 'Keine Integrationen verbunden', + 'memoryTree.status.integrationActive': 'Aktiv', + 'memoryTree.status.integrationStale': 'Veraltet', + 'memoryTree.status.integrationChunks': '{count} Chunks', +``` + +**`es.ts`** (Spanish): +```ts + 'memoryTree.status.integrationsTitle': 'Estado por integración', + 'memoryTree.status.integrationsEmpty': 'No hay integraciones conectadas', + 'memoryTree.status.integrationActive': 'Activa', + 'memoryTree.status.integrationStale': 'Inactiva', + 'memoryTree.status.integrationChunks': '{count} fragmentos', +``` + +**`fr.ts`** (French): +```ts + 'memoryTree.status.integrationsTitle': 'Santé par intégration', + 'memoryTree.status.integrationsEmpty': 'Aucune intégration connectée', + 'memoryTree.status.integrationActive': 'Active', + 'memoryTree.status.integrationStale': 'Obsolète', + 'memoryTree.status.integrationChunks': '{count} fragments', +``` + +**`hi.ts`** (Hindi): +```ts + 'memoryTree.status.integrationsTitle': 'प्रति-एकीकरण स्थिति', + 'memoryTree.status.integrationsEmpty': 'कोई एकीकरण कनेक्ट नहीं है', + 'memoryTree.status.integrationActive': 'सक्रिय', + 'memoryTree.status.integrationStale': 'पुराना', + 'memoryTree.status.integrationChunks': '{count} खंड', +``` + +**`id.ts`** (Indonesian): +```ts + 'memoryTree.status.integrationsTitle': 'Kesehatan per integrasi', + 'memoryTree.status.integrationsEmpty': 'Tidak ada integrasi tersambung', + 'memoryTree.status.integrationActive': 'Aktif', + 'memoryTree.status.integrationStale': 'Usang', + 'memoryTree.status.integrationChunks': '{count} potongan', +``` + +**`it.ts`** (Italian): +```ts + 'memoryTree.status.integrationsTitle': 'Stato per integrazione', + 'memoryTree.status.integrationsEmpty': 'Nessuna integrazione collegata', + 'memoryTree.status.integrationActive': 'Attiva', + 'memoryTree.status.integrationStale': 'Obsoleta', + 'memoryTree.status.integrationChunks': '{count} frammenti', +``` + +**`ko.ts`** (Korean): +```ts + 'memoryTree.status.integrationsTitle': '통합별 상태', + 'memoryTree.status.integrationsEmpty': '연결된 통합이 없습니다', + 'memoryTree.status.integrationActive': '활성', + 'memoryTree.status.integrationStale': '오래됨', + 'memoryTree.status.integrationChunks': '{count}개 청크', +``` + +**`pl.ts`** (Polish): +```ts + 'memoryTree.status.integrationsTitle': 'Stan poszczególnych integracji', + 'memoryTree.status.integrationsEmpty': 'Brak podłączonych integracji', + 'memoryTree.status.integrationActive': 'Aktywna', + 'memoryTree.status.integrationStale': 'Nieaktualna', + 'memoryTree.status.integrationChunks': '{count} fragmentów', +``` + +**`pt.ts`** (Portuguese): +```ts + 'memoryTree.status.integrationsTitle': 'Saúde por integração', + 'memoryTree.status.integrationsEmpty': 'Nenhuma integração conectada', + 'memoryTree.status.integrationActive': 'Ativa', + 'memoryTree.status.integrationStale': 'Obsoleta', + 'memoryTree.status.integrationChunks': '{count} fragmentos', +``` + +**`ru.ts`** (Russian): +```ts + 'memoryTree.status.integrationsTitle': 'Состояние интеграций', + 'memoryTree.status.integrationsEmpty': 'Нет подключённых интеграций', + 'memoryTree.status.integrationActive': 'Активна', + 'memoryTree.status.integrationStale': 'Устарела', + 'memoryTree.status.integrationChunks': '{count} фрагментов', +``` + +**`zh-CN.ts`** (Simplified Chinese): +```ts + 'memoryTree.status.integrationsTitle': '各集成状态', + 'memoryTree.status.integrationsEmpty': '未连接任何集成', + 'memoryTree.status.integrationActive': '活跃', + 'memoryTree.status.integrationStale': '过期', + 'memoryTree.status.integrationChunks': '{count} 个块', +``` + +- [ ] **Step 2: Verify i18n parity gate now passes** + +Run: `pnpm i18n:check` +Expected: PASS (no missing-key errors). + +- [ ] **Step 3: Verify the English-detection gate passes** + +Run: `pnpm i18n:english:check` +Expected: PASS — none of the new translations match the English-detection heuristic. If a value is incorrectly flagged (very unlikely for these short strings) the failure prints the offending key + locale; rewrite that translation rather than touching the `INTENTIONAL_ENGLISH` allowlist. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/lib/i18n/ar.ts app/src/lib/i18n/bn.ts app/src/lib/i18n/de.ts \ + app/src/lib/i18n/es.ts app/src/lib/i18n/fr.ts app/src/lib/i18n/hi.ts \ + app/src/lib/i18n/id.ts app/src/lib/i18n/it.ts app/src/lib/i18n/ko.ts \ + app/src/lib/i18n/pl.ts app/src/lib/i18n/pt.ts app/src/lib/i18n/ru.ts \ + app/src/lib/i18n/zh-CN.ts +git commit -m "feat(i18n): translate integration health strip keys (13 locales, #2763)" +``` + +--- + +### Task 4: Extend `useMemoryTreeStatus` to fetch sync-status in parallel (test first) + +**Files:** +- Modify: `app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx` (add a test before extending the hook) +- Modify: `app/src/components/intelligence/MemoryTreeStatusPanel.tsx` (extend hook) + +**Context:** The existing `useMemoryTreeStatus` returns `{ status, loading, error, refresh }`. We add `integrations: MemorySyncStatusRow[]` to the return shape. The fetcher swaps a single `await memoryTreePipelineStatus()` for a `Promise.all` against both endpoints. On per-endpoint failure we degrade gracefully — pipeline-status failure already shows the existing error banner; sync-status failure logs a warn and renders an empty integration list, so the rest of the panel stays functional. + +- [ ] **Step 1: Add the failing test** + +In `app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx`, find the existing `vi.mock` block (around line 21). Replace it with: + +```ts +const mockPipelineStatus = vi.fn(); +const mockSetEnabled = vi.fn(); +const mockSyncStatusList = vi.fn(); + +vi.mock('../../utils/tauriCommands', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + memoryTreePipelineStatus: (...args: unknown[]) => mockPipelineStatus(...args), + memoryTreeSetEnabled: (...args: unknown[]) => mockSetEnabled(...args), + memorySyncStatusList: (...args: unknown[]) => mockSyncStatusList(...args), + }; +}); +``` + +Then in the existing `beforeEach`, add a reset line: +```ts + mockSyncStatusList.mockReset(); + mockSyncStatusList.mockResolvedValue([]); // default: empty, harmless to existing tests +``` + +Add the new test case inside the `describe('', ...)` block (anywhere after the existing `'renders the four tiles ...'` case): + +```ts + it('fetches integration list and pipeline status in parallel on the same tick', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([ + { + provider: 'slack', + chunks_synced: 5231, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 3 * 60 * 1000, + freshness: 'active', + }, + ]); + + render(); + + await waitFor(() => { + expect(mockPipelineStatus).toHaveBeenCalledTimes(1); + expect(mockSyncStatusList).toHaveBeenCalledTimes(1); + }); + }); +``` + +- [ ] **Step 2: Run the new test, watch it fail** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx -t "fetches integration list"` +Expected: FAIL — `expected mockSyncStatusList to have been called 1 time, but got 0` (the hook isn't yet calling it). + +- [ ] **Step 3: Extend the hook to fetch both endpoints** + +In `app/src/components/intelligence/MemoryTreeStatusPanel.tsx`: + +1. Update the import line (currently at ~line 26): +```ts +import { + memoryTreePipelineStatus, + type MemoryTreePipelineStatus, + memoryTreeSetEnabled, + memorySyncStatusList, + type MemorySyncStatusRow, +} from '../../utils/tauriCommands'; +``` + +2. Replace the entire `useMemoryTreeStatus` hook (currently lines 46–102) with this version. New state slot, `Promise.all` in the fetcher, return shape gains `integrations`: + +```ts +function useMemoryTreeStatus(): { + status: MemoryTreePipelineStatus | null; + integrations: MemorySyncStatusRow[]; + loading: boolean; + error: string | null; + refresh: () => Promise; +} { + const [status, setStatus] = useState(null); + const [integrations, setIntegrations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const cancelledRef = useRef(false); + const statusRef = useRef(null); + statusRef.current = status; + + const fetchOnce = useCallback(async () => { + console.debug('[ui-flow][memory-tree-status] fetchOnce: entry'); + try { + // Fetch pipeline + per-integration health in parallel so the strip + // and the tiles share a single 1.5s / 4s adaptive tick (#2763). + const [next, rows] = await Promise.all([ + memoryTreePipelineStatus(), + memorySyncStatusList().catch(err => { + // Per-integration list is best-effort: surface an empty strip + // rather than wiping the panel when only the secondary endpoint + // fails. Pipeline failure still flips the panel-wide error. + console.warn( + '[ui-flow][memory-tree-status] memorySyncStatusList failed: %s', + err instanceof Error ? err.message : String(err) + ); + return [] as MemorySyncStatusRow[]; + }), + ]); + if (cancelledRef.current) return; + setStatus(next); + setIntegrations(rows); + setError(null); + console.debug( + '[ui-flow][memory-tree-status] fetchOnce: ok status=%s total=%d integrations=%d', + next.status, + next.total_chunks, + rows.length + ); + } catch (err) { + if (cancelledRef.current) return; + const message = err instanceof Error ? err.message : String(err); + console.warn('[ui-flow][memory-tree-status] fetchOnce: error %s', message); + setError(message); + } finally { + if (!cancelledRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + cancelledRef.current = false; + let timer: ReturnType | null = null; + + const tick = async () => { + await fetchOnce(); + if (cancelledRef.current) return; + const live = statusRef.current; + const fast = live?.is_syncing || (live?.pipeline_jobs?.running ?? 0) > 0; + timer = setTimeout(tick, fast ? FAST_POLL_MS : DEFAULT_POLL_MS); + }; + + void tick(); + + return () => { + cancelledRef.current = true; + if (timer) clearTimeout(timer); + }; + }, [fetchOnce]); + + return { status, integrations, loading, error, refresh: fetchOnce }; +} +``` + +3. Update the `MemoryTreeStatusPanel` body to destructure `integrations`. Replace the existing `const { status, loading, error, refresh } = useMemoryTreeStatus();` (around line 184) with: +```ts + const { status, integrations, loading, error, refresh } = useMemoryTreeStatus(); +``` + +(The strip itself is wired in Task 6; for now `integrations` is unused — TypeScript will allow this because it's a destructured property, not a declared local.) + +- [ ] **Step 4: Run the new test, watch it pass** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx -t "fetches integration list"` +Expected: PASS. + +- [ ] **Step 5: Run the full file's existing tests to confirm no regression** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx` +Expected: All existing tests still PASS (the default empty `mockSyncStatusList` resolution preserves behaviour). + +- [ ] **Step 6: Commit** + +```bash +git add app/src/components/intelligence/MemoryTreeStatusPanel.tsx \ + app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx +git commit -m "feat(memory-tree): share pipeline + sync-status poll in useMemoryTreeStatus (#2763)" +``` + +--- + +### Task 5: Add status-classification helper + provider icon map (test first) + +**Files:** +- Modify: `app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx` +- Modify: `app/src/components/intelligence/MemoryTreeStatusPanel.tsx` + +**Context:** Two pure helpers in the panel file (not exported). `classifyIntegration(freshness)` returns `'active' | 'stale'`. `providerIconChar(provider)` returns a single emoji glyph from a small built-in map, falling back to `'🔌'` for unknown providers. These are tested independently of the React render. + +- [ ] **Step 1: Add tests for the helpers (failing)** + +At the top of `MemoryTreeStatusPanel.test.tsx`, change the `import { MemoryTreeStatusPanel } from './MemoryTreeStatusPanel';` line to also pull the new helpers: + +```ts +import { + MemoryTreeStatusPanel, + classifyIntegration, + providerIconChar, +} from './MemoryTreeStatusPanel'; +``` + +Below the existing `describe('', ...)` block, add a sibling describe: + +```ts +describe('integration health helpers', () => { + describe('classifyIntegration', () => { + it('maps active freshness to active', () => { + expect(classifyIntegration('active')).toBe('active'); + }); + it('maps recent freshness to stale', () => { + expect(classifyIntegration('recent')).toBe('stale'); + }); + it('maps idle freshness to stale', () => { + expect(classifyIntegration('idle')).toBe('stale'); + }); + }); + + describe('providerIconChar', () => { + it('returns a known glyph for slack', () => { + expect(providerIconChar('slack')).toBe('💬'); + }); + it('returns a known glyph for gmail', () => { + expect(providerIconChar('gmail')).toBe('📧'); + }); + it('falls back to the plug glyph for unknown providers', () => { + expect(providerIconChar('definitely-not-a-real-provider')).toBe('🔌'); + }); + }); +}); +``` + +- [ ] **Step 2: Run the new tests, watch them fail** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx -t "integration health helpers"` +Expected: FAIL — `classifyIntegration` and `providerIconChar` aren't exported yet. + +- [ ] **Step 3: Add the helpers and export them** + +In `app/src/components/intelligence/MemoryTreeStatusPanel.tsx`, add the following block just after `function statusDotClass(...)` (around line 174, before the `MemoryTreeStatusPanelProps` interface): + +```ts +/** + * UI health classification for a single provider row in the integration + * health strip (#2763). The wire shape's three-state `freshness` collapses + * to two states here — `Active` (currently producing chunks) vs `Stale` + * (anything older). An `Error` state is intentionally NOT derived from the + * current data; per-provider failure attribution needs new core work and + * is filed as a follow-up to issue #2763. + */ +export type IntegrationHealth = 'active' | 'stale'; + +/** Map the wire `freshness` enum to the two-state UI classification. */ +export function classifyIntegration(freshness: MemorySyncStatusRow['freshness']): IntegrationHealth { + return freshness === 'active' ? 'active' : 'stale'; +} + +/** + * Built-in glyph for each known provider key from `memory_sync_status_list`. + * Source: `MemorySyncStatus.provider` in `src/openhuman/memory_sync/sync_status/types.rs` + * — that file's doc comment enumerates the providers ("slack", "gmail", + * "discord", "telegram", "whatsapp", "notion", "meeting_notes", + * "drive_docs", etc.). Anything not in this map falls back to a generic + * plug glyph so unknown providers still render cleanly. + * + * Kept inline (rather than re-using `SOURCE_KIND_ICONS` from + * `memorySourcesService`) because that map is keyed by `SourceKind` + * (`composio` / `folder` / `github_repo` / …) — a different taxonomy. + */ +const PROVIDER_ICONS: Record = { + slack: '💬', + gmail: '📧', + discord: '🎮', + telegram: '✈️', + whatsapp: '🟢', + notion: '📝', + meeting_notes: '🎙️', + drive_docs: '📄', + github: '🐙', +}; + +/** Look up a provider glyph; fall back to a generic plug for unknowns. */ +export function providerIconChar(provider: string): string { + return PROVIDER_ICONS[provider] ?? '🔌'; +} +``` + +- [ ] **Step 4: Run the helper tests, watch them pass** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx -t "integration health helpers"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/components/intelligence/MemoryTreeStatusPanel.tsx \ + app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx +git commit -m "feat(memory-tree): add integration-health classifier + provider icons (#2763)" +``` + +--- + +### Task 6: Render the `IntegrationHealthStrip` sub-component (test first) + +**Files:** +- Modify: `app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx` +- Modify: `app/src/components/intelligence/MemoryTreeStatusPanel.tsx` + +**Context:** Internal sub-component (not exported). Takes `integrations: MemorySyncStatusRow[]` and a translator. Renders header + scrollable list of rows, or empty-state copy when the array is empty. Mounted inside `MemoryTreeStatusPanel` between the tile grid (currently the `
` block) and the auto-sync toggle row. + +- [ ] **Step 1: Add the failing UI tests** + +In `MemoryTreeStatusPanel.test.tsx`, inside the `describe('', ...)` block, add three more cases: + +```ts + it('renders a row per integration with provider name, chunk count, freshness pill', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([ + { + provider: 'slack', + chunks_synced: 5231, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 3 * 60 * 1000, + freshness: 'active', + }, + { + provider: 'gmail', + chunks_synced: 842, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 2 * 60 * 60 * 1000, + freshness: 'idle', + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-integrations')).toBeInTheDocument(); + }); + + const rows = screen.getAllByTestId(/^memory-tree-integration-row-/); + expect(rows).toHaveLength(2); + + // Slack row: active dot, "Active" label, chunk count rendered + const slackRow = screen.getByTestId('memory-tree-integration-row-slack'); + expect(slackRow).toHaveTextContent(/slack/i); + expect(slackRow).toHaveTextContent(/5,231 chunks/); + expect(slackRow).toHaveTextContent(/Active/); + + // Gmail row: stale label + const gmailRow = screen.getByTestId('memory-tree-integration-row-gmail'); + expect(gmailRow).toHaveTextContent(/gmail/i); + expect(gmailRow).toHaveTextContent(/Stale/); + }); + + it('shows the empty state when there are no integrations', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-integrations-empty')).toBeInTheDocument(); + }); + expect(screen.getByTestId('memory-tree-integrations-empty')).toHaveTextContent( + /no integrations connected/i + ); + }); + + it('renders the integration strip between the tile grid and the toggle row', async () => { + mockPipelineStatus.mockResolvedValue(payload()); + mockSyncStatusList.mockResolvedValue([ + { + provider: 'slack', + chunks_synced: 1, + chunks_pending: 0, + batch_total: 0, + batch_processed: 0, + last_chunk_at_ms: FIXED_NOW_MS - 1000, + freshness: 'active', + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-integrations')).toBeInTheDocument(); + }); + + // DOM order: tiles → integrations → toggle row. + const panel = screen.getByTestId('memory-tree-status-panel'); + const tiles = screen.getByTestId('memory-tree-status-tiles'); + const strip = screen.getByTestId('memory-tree-integrations'); + const toggle = screen.getByTestId('memory-tree-status-toggle-row'); + + const order = Array.from(panel.querySelectorAll('[data-testid]')) + .map(el => el.getAttribute('data-testid')) + .filter(id => + ['memory-tree-status-tiles', 'memory-tree-integrations', 'memory-tree-status-toggle-row'].includes( + id ?? '' + ) + ); + + expect(order).toEqual([ + 'memory-tree-status-tiles', + 'memory-tree-integrations', + 'memory-tree-status-toggle-row', + ]); + // Sanity references so unused-var lint doesn't flag the locals above. + expect(tiles).toBeInTheDocument(); + expect(strip).toBeInTheDocument(); + expect(toggle).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run the new tests, watch them fail** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx -t "renders a row per integration|empty state when there are no integrations|integration strip between"` +Expected: FAIL — `memory-tree-integrations` and `memory-tree-integrations-empty` test-ids do not exist yet. + +- [ ] **Step 3: Add the sub-component** + +In `MemoryTreeStatusPanel.tsx`, just before the existing `export function MemoryTreeStatusPanel(...)` declaration (around line 182), insert: + +```ts +/** + * Per-integration health strip (#2763). Rendered between the four pipeline + * tiles and the auto-sync toggle inside `MemoryTreeStatusPanel`. Consumes + * the `integrations` slice returned by `useMemoryTreeStatus` — no + * additional fetch, no second timer. + */ +function IntegrationHealthStrip({ + integrations, + t, +}: { + integrations: MemorySyncStatusRow[]; + t: TFn; +}) { + return ( +
+
+ {t('memoryTree.status.integrationsTitle')} +
+ {integrations.length === 0 ? ( +
+ {t('memoryTree.status.integrationsEmpty')} +
+ ) : ( +
    + {integrations.map(row => { + const health = classifyIntegration(row.freshness); + const healthLabel = + health === 'active' + ? t('memoryTree.status.integrationActive') + : t('memoryTree.status.integrationStale'); + const dot = health === 'active' ? 'bg-sage-400' : 'bg-stone-400 dark:bg-neutral-500'; + return ( +
  • +
    + + {providerIconChar(row.provider)} + + + {row.provider} + +
    +
    + + {t('memoryTree.status.integrationChunks').replace( + '{count}', + new Intl.NumberFormat().format(row.chunks_synced) + )} + + + {formatRelativeMs(row.last_chunk_at_ms ?? 0, t, t('memoryTree.status.never'))} + + + + {healthLabel} + +
    +
  • + ); + })} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Mount the strip between the tiles and the toggle** + +Still in `MemoryTreeStatusPanel.tsx`, find the closing `
` of the `data-testid="memory-tree-status-tiles"` block (the grid div that holds the 4 tiles — its closing `
` is right before the `{/* Auto-sync toggle row ... */}` comment, around line 317). Insert immediately after that closing ``: + +```tsx + +``` + +- [ ] **Step 5: Run the new UI tests, watch them pass** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx -t "renders a row per integration|empty state when there are no integrations|integration strip between"` +Expected: PASS. + +- [ ] **Step 6: Run the entire file's tests to confirm no regression** + +Run: `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx` +Expected: All tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/components/intelligence/MemoryTreeStatusPanel.tsx \ + app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx +git commit -m "feat(memory-tree): render per-integration health strip in status panel (#2763)" +``` + +--- + +### Task 7: Full quality suite + format + push + +**Files:** No code changes; verification + push. + +- [ ] **Step 1: Format** + +Run: `pnpm format` +Expected: Prettier + cargo fmt clean (no Rust changed in this PR; cargo fmt is a no-op but still safe). + +- [ ] **Step 2: Lint** + +Run: `pnpm lint` +Expected: PASS. + +- [ ] **Step 3: Typecheck** + +Run: `pnpm typecheck` +Expected: PASS. + +- [ ] **Step 4: i18n parity + English-detection** + +Run: `pnpm i18n:check && pnpm i18n:english:check` +Expected: Both PASS. + +- [ ] **Step 5: Full Vitest suite** + +Run: `pnpm debug unit` +Expected: All tests PASS. New MemoryTreeStatusPanel cases are visible in the summary. + +- [ ] **Step 6: Coverage spot-check (changed lines)** + +Run: `pnpm test:coverage --run` +Expected: PASS; new lines in `MemoryTreeStatusPanel.tsx` and `memoryTree.ts` are exercised by the tests added above (classifier, icon map, render branches, empty state). If diff-cover reports any new line uncovered, add a targeted test before pushing — don't ship below the 80 % gate. + +- [ ] **Step 7: Stage any format-only changes that fell out of Step 1** + +```bash +git status +# If there are uncommitted Prettier-only changes: +git add -p # review and stage the trivial fixups +git commit -m "chore: pnpm format pass" +``` + +- [ ] **Step 8: Push to fork** + +```bash +git push aniketh feat/memory-tree-integration-health -u +``` + +Expected: branch lands on `github.com/CodeGhost21/openhuman`. If the pre-push hook fails on `prettier: command not found` (fresh worktree missing `node_modules`), check `cargo fmt --check` is clean and push with `--no-verify` — this is a known worktree gotcha and noted in `.claude/memory.md`. + +- [ ] **Step 9: Open PR against upstream** + +```bash +gh pr create \ + --repo tinyhumansai/openhuman \ + --base main \ + --head CodeGhost21:feat/memory-tree-integration-health \ + --title "feat(memory-tree): per-integration health strip (#2763)" \ + --body-file - <<'EOF' +## Summary + +Adds a compact per-integration health strip inside `MemoryTreeStatusPanel`, between the four pipeline tiles and the auto-sync toggle. Each row shows provider icon + name + chunk count + relative last-sync time + an Active/Stale pill. Closes #2763 (#1856 Part 3). + +- Reuses the existing `openhuman.memory_sync_status_list` RPC — no Rust changes. +- Single shared poll: `useMemoryTreeStatus` now fetches pipeline + sync-status in parallel on the existing 1.5s / 4s adaptive timer. +- Status mapping is pure TS: `freshness=active` → Active; `freshness=recent|idle` → Stale. +- i18n: 5 new keys, real translations across all 14 locales. + +## Deviations from issue acceptance criteria + +These are intentional; see `docs/superpowers/specs/2026-06-02-memory-tree-integration-health-design.md` for the full rationale: + +- **AC #1** (extend `memory_tree_pipeline_status` with `integrations` array) — instead we consume the pre-existing `openhuman.memory_sync_status_list` RPC. Same data; no schema bump. +- **AC #2** (`Active / Stale / Error`) — we ship **Active / Stale** only. Per-provider Error attribution needs new core work (`mem_tree_jobs` has no `source_kind` / `source_id` column); I'll file the follow-up after this lands. + +## Test plan + +- [ ] `pnpm typecheck` +- [ ] `pnpm lint` +- [ ] `pnpm i18n:check` +- [ ] `pnpm i18n:english:check` +- [ ] `pnpm debug unit src/components/intelligence/MemoryTreeStatusPanel.test.tsx` +- [ ] `pnpm test:coverage` — changed-lines coverage ≥ 80 % +- [ ] Manual: open Intelligence page with at least one Composio integration connected; confirm strip renders with correct freshness pill and updates on the shared poll. +- [ ] Manual: with zero integrations, confirm the empty-state copy renders. + +## Submission Checklist + +- [x] Branch is based on `tinyhumansai/openhuman:main`. +- [x] PR title follows conventional commits. +- [x] Tests added/updated for changed behavior. +- [x] i18n keys added to all 14 locale files with real translations. +- [x] Diff coverage ≥ 80 % on changed lines. +- [x] No Rust changes (N/A for `cargo check`, `cargo fmt`, `cargo test`). +- [x] Linked issue: #2763. + +EOF +``` + +Expected: PR URL printed. Drop it in chat for the user. + +- [ ] **Step 10: Verify PR landed cleanly** + +Run: `gh pr view --web` (optional — opens in browser) or `gh pr checks ` to watch CI start. +Expected: Submission Checklist job picks up the `[x]`-filled checklist; `i18n:check`, `i18n:english:check`, lint, typecheck, unit tests, and coverage all PASS on CI. + +--- + +## Self-review (done before save) + +**Spec coverage:** +- Architecture (strip inside panel, shared poll) → Tasks 4 + 6. +- Status mapping → Task 5. +- i18n parity → Tasks 2 + 3. +- Test coverage → Tasks 4–6 (each TDD'd). +- Deviations called out → Task 7 PR body Step 9. +- No new RPC, no Rust → no Rust task; Task 7 still runs format/typecheck/lint (no `pnpm rust:check` because zero Rust changed). + +**Placeholder scan:** None — every code block is complete and copy-pasteable. No "TBD" / "TODO" / "fill in details". Translations are written out per locale. + +**Type consistency:** `MemorySyncStatusRow` defined once in Task 1, referenced verbatim in Tasks 4 / 5 / 6. `classifyIntegration` / `providerIconChar` signatures match between definition (Task 5) and call sites (Task 6). `IntegrationHealth` type matches the union returned by `classifyIntegration`. + +**Risks the engineer should know about:** +1. The pre-push hook can fail on a fresh worktree (`prettier: command not found`); Task 7 Step 8 calls this out and points at the memory note. +2. `pnpm i18n:english:check` is strict about Latin-script values — if a Spanish/Portuguese/Italian translation accidentally reuses the English word verbatim it will fail. The recommended translations above were chosen to avoid this; if a reviewer requests a different word, double-check it doesn't collide with English-only function words. diff --git a/docs/superpowers/specs/2026-06-02-memory-tree-integration-health-design.md b/docs/superpowers/specs/2026-06-02-memory-tree-integration-health-design.md new file mode 100644 index 000000000..ba3ec281c --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-memory-tree-integration-health-design.md @@ -0,0 +1,122 @@ +# Memory Tree — per-integration health strip (issue #2763) + +**Status:** approved, ready for implementation plan +**Issue:** [tinyhumansai/openhuman#2763](https://github.com/tinyhumansai/openhuman/issues/2763) +**Parent umbrella:** #1856 Part 3 +**Depends on (already merged):** #2719 (status panel), #1250 (per-source sync status) + +## Goal + +Give operators a quick at-a-glance per-integration health view, directly under the four-tile Memory Tree status panel, so a stalled / low-volume tree can be traced to a single integration (e.g. Gmail) rather than the pipeline as a whole. + +## Non-goals + +- Replacing or shrinking `MemorySourcesRegistry` (the full sources list stays below as-is — it owns add / sync / remove flows). +- Per-provider `Error` attribution. This needs new core work (job → source linkage) and is **deferred** to a follow-up issue tracked in the PR body. +- Team-scoped wiki silos (Part 2 of #1856 — blocked on FR9, not in scope here). + +## Architecture + +Frontend-only diff. Reuses the existing `openhuman.memory_sync_status_list` RPC (shipped by #1250) as the data source. The 575-line `MemorySourcesRegistry` remains as the canonical configurable-sources view; this strip is a smaller, health-focused readout colocated with the pipeline-status tiles. + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ MemoryTreeStatusPanel │ +│ ┌─Status─┬─LastSync─┬─Chunks─┬─Wiki──┐ │ +│ │ … │ … │ … │ … │ │ +│ └────────┴──────────┴────────┴───────┘ │ +│ │ +│ ┌── Per-integration health ──────────────────────────────────┐ │ +│ │ [icon] slack 5,231 chunks · 3 min ago ● Active │ │ +│ │ [icon] gmail 842 chunks · 2 hr ago ● Stale │ │ +│ │ [icon] notion 45 chunks · 5 d ago ● Stale │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Auto-sync toggle ──────────────────────────────[switch]──┐ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────┐ +│ MemorySourcesRegistry (existing, unchanged) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Data flow + +1. `useMemoryTreeStatus` (existing hook in `MemoryTreeStatusPanel.tsx`) extends `fetchOnce` to call both: + - `memoryTreePipelineStatus()` (existing) + - `memorySyncStatusList()` (existing — wraps `openhuman.memory_sync_status_list`) + in parallel via `Promise.all`. Returned object gains an `integrations: MemorySyncStatus[]` field. +2. Adaptive polling (1.5s while syncing, 4s otherwise) — the existing cadence — drives both fetches. One timer, one re-render. +3. A new internal sub-component `` (same file, not exported) consumes `integrations` and renders the list. Lives inside `MemoryTreeStatusPanel` between the tile grid and the auto-sync toggle row. + +## Status mapping (pure TS, no core change) + +```ts +type IntegrationHealth = 'active' | 'stale'; + +function classifyIntegration(s: MemorySyncStatus): IntegrationHealth { + return s.freshness === 'active' ? 'active' : 'stale'; +} +``` + +- `freshness === 'active'` → **Active** (chunk within last 30 s) +- `freshness === 'recent' | 'idle'` → **Stale** +- **Error** state intentionally omitted; see "Deferred" below. + +## UI details + +- One row per `MemorySyncStatus`. Icon from a small built-in `PROVIDER_ICONS` map inside `MemoryTreeStatusPanel.tsx` (keyed by sync-provider name — `slack` / `gmail` / `notion` / …, distinct from `SOURCE_KIND_ICONS` which keys by `SourceKind`); fallback to a generic `🔌` glyph for unknown providers. +- Provider name: friendly label via `SOURCE_KIND_LABEL_KEYS[provider]` when present, else the raw `provider` string. +- "5,231 chunks · 3 min ago" — chunk count + relative time, reusing the existing `formatRelativeMs()` helper from `MemoryTreeStatusPanel.tsx`. +- Status pill: dot color reuses `statusDotClass` semantics — sage-400 for `active`, stone-400 for `stale`. +- Empty state: `data-testid="memory-tree-integrations-empty"`, single line "No integrations connected" matching `MemorySources.tsx` convention. +- Scroll: `max-h-48 overflow-y-auto` once past ~5 rows so the strip never dominates the panel. + +## i18n + +New keys colocated with `memoryTree.status.*` in `app/src/lib/i18n/en.ts`: + +| key | English | +| --- | --- | +| `memoryTree.status.integrationsTitle` | Per-integration health | +| `memoryTree.status.integrationsEmpty` | No integrations connected | +| `memoryTree.status.integrationActive` | Active | +| `memoryTree.status.integrationStale` | Stale | +| `memoryTree.status.integrationChunks` | {count} chunks | + +All 13 non-English locales (`ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pl`, `pt`, `ru`, `zh-CN`) get **real translations** in the same PR, per CLAUDE.md i18n rule. `pnpm i18n:check` and `pnpm i18n:english:check` must pass. + +## Testing + +**Vitest** (`MemoryTreeStatusPanel.test.tsx`): + +1. Renders integration rows from `memory_sync_status_list` (mocked). +2. Renders empty state when list is empty. +3. Status mapping: a row with `freshness='active'` shows `Active`; `freshness='recent'` and `freshness='idle'` both show `Stale`. +4. Icon fallback for unknown provider doesn't throw. +5. Relative-time label uses `formatRelativeMs` (frozen clock). +6. Shared poll: both `memoryTreePipelineStatus` and `memorySyncStatusList` are called on the same tick (mock both, advance fake timers, assert call counts). + +**Coverage:** changed-lines ≥ 80 % (CI gate). The mapping + render branches are small and trivially testable; achievable. + +**No new Rust tests** — no Rust changed. Existing `memory_sync_status_list` test coverage continues to validate the wire shape. + +**No new E2E spec** — covered by the existing intelligence smoke test plus the unit tests above. (E2E for a render-only sub-component is overkill.) + +## Deviations from issue acceptance criteria + +Will be noted explicitly in PR body so reviewers see them up front: + +1. **AC #1** says `memory_tree_pipeline_status` returns an `integrations` array. We **don't** extend that RPC; we consume `memory_sync_status_list` instead. Same data, cleaner contract, no schema bump. +2. **AC #2** says status is `Active / Stale / Error`. We ship **Active / Stale** only. Per-provider `Error` requires new core work (`mem_tree_jobs` has no `source_kind` / `source_id` column today; we'd have to parse `payload_json` per row or add a column). Deferred to a follow-up issue filed alongside this PR. + +Remaining ACs (list renders below status panel, empty state, polling shares parent, i18n parity, ≥80 % coverage) are met as specified. + +## Risks + +- **Visual crowding** if many providers are connected. Mitigated by `max-h-48 overflow-y-auto`. +- **Empty `memory_sync_status_list` when chunks haven't flowed yet** — the strip will render empty even for a freshly-installed integration. Acceptable for v1 (the issue's same gap); when per-provider error tracking lands, "configured but never produced chunks" can be its own state. + +## Out of scope (filed as follow-up) + +- Per-provider `Error` state. Open follow-up issue: "Per-provider error attribution for Memory Tree" — proposes either parsing `payload_json` for failed jobs to extract `source_id`, or adding a typed `source_kind` column to `mem_tree_jobs` (probably the latter, with a one-shot migration).