From 6fa3735ddc45740232e19ec32476f1b5f852df72 Mon Sep 17 00:00:00 2001 From: Adam Kunicki Date: Sun, 15 Mar 2026 10:57:48 -0700 Subject: [PATCH] feat(viewer): show active observer auth path in settings UI Add an observer status banner at the top of the Connection settings panel showing the actual resolved auth state (provider, model, auth method, token validity) and available credentials per provider. Fetches from the new /api/observer-status endpoint on settings load. Closes: codemem-4uc --- codemem/viewer_static/app.js | 84 ++++++++++++++++++++++++++++- codemem/viewer_static/index.html | 30 +++++++++++ viewer_ui/src/lib/api.ts | 4 ++ viewer_ui/src/tabs/settings.ts | 91 +++++++++++++++++++++++++++++++- 4 files changed, 207 insertions(+), 2 deletions(-) diff --git a/codemem/viewer_static/app.js b/codemem/viewer_static/app.js index 3c431462..cb3605f4 100644 --- a/codemem/viewer_static/app.js +++ b/codemem/viewer_static/app.js @@ -248,6 +248,9 @@ const query = buildProjectParams(project, options?.limit, options?.offset, options?.scope); return fetchJson(`/api/summaries?${query}`); } + async function loadObserverStatus() { + return fetchJson("/api/observer-status"); + } async function loadConfig() { return fetchJson("/api/config"); } @@ -3452,11 +3455,90 @@ Global: ${Number(totalsGlobal.tokens_saved || 0).toLocaleString()} saved` : ""; saveBtn.disabled = !state.settingsDirty; } } + function formatAuthMethod(method) { + switch (method) { + case "anthropic_consumer": + return "OAuth (Claude Max/Pro)"; + case "codex_consumer": + return "OAuth (ChatGPT subscription)"; + case "sdk_client": + return "API key"; + case "claude_sidecar": + return "Local Claude session"; + case "opencode_run": + return "OpenCode sidecar"; + default: + return method; + } + } + function formatCredentialSources(creds) { + const parts = []; + if (creds.oauth) parts.push("OAuth"); + if (creds.api_key) parts.push("API key"); + if (creds.env_var) parts.push("env var"); + return parts.length ? parts.join(", ") : "none"; + } + function createEl(tag, className, text) { + const el2 = document.createElement(tag); + if (className) el2.className = className; + if (text) el2.textContent = text; + return el2; + } + function renderObserverStatusBanner(status) { + const banner = $("observerStatusBanner"); + if (!banner) return; + if (!status || typeof status !== "object") { + banner.hidden = true; + return; + } + banner.textContent = ""; + const active = status.active; + const available = status.available_credentials || {}; + if (active) { + const provider = String(active.provider || "unknown"); + const model = String(active.model || ""); + const method = formatAuthMethod(active.auth?.method || "none"); + const tokenOk = active.auth?.token_present === true; + banner.append(createEl("div", "status-label", "Active observer")); + const row = createEl("div", "status-active"); + row.textContent = `${provider} → ${model} via ${method} `; + const tokenSpan = createEl("span", tokenOk ? "cred-ok" : "cred-none", tokenOk ? "✓" : "✗"); + row.append(tokenSpan); + banner.append(row); + } else { + banner.append(createEl("div", "status-label", "Observer status")); + banner.append(createEl("div", "status-active", "Not yet initialized (waiting for first session)")); + } + const credEntries = Object.entries(available).filter( + ([, creds]) => creds && typeof creds === "object" + ); + if (credEntries.length) { + banner.append(createEl("div", "status-label", "Available credentials")); + const row = createEl("div"); + credEntries.forEach(([provider, creds], idx) => { + const c = creds; + const sources = formatCredentialSources(c); + const hasAny = Object.values(c).some(Boolean); + const span = createEl("span", "status-cred"); + const icon = createEl("span", hasAny ? "cred-ok" : "cred-none", hasAny ? "✓" : "–"); + span.append(icon); + span.append(` ${String(provider)}: ${sources}`); + if (idx > 0) row.append(" · "); + row.append(span); + }); + banner.append(row); + } + banner.hidden = false; + } async function loadConfigData() { if (settingsOpen) return; try { - const payload = await loadConfig(); + const [payload, status] = await Promise.all([ + loadConfig(), + loadObserverStatus().catch(() => null) + ]); renderConfigModal(payload); + renderObserverStatusBanner(status); } catch { } } diff --git a/codemem/viewer_static/index.html b/codemem/viewer_static/index.html index bdbd17d7..81d5e7da 100644 --- a/codemem/viewer_static/index.html +++ b/codemem/viewer_static/index.html @@ -1214,6 +1214,35 @@ } .settings-panel { display: none; flex-direction: column; gap: var(--sp-3); } .settings-panel.active { display: flex; } + .observer-status-banner { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-0); + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + .observer-status-banner .status-active { + color: var(--text-primary); + font-weight: 500; + } + .observer-status-banner .status-label { + color: var(--text-tertiary); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + } + .observer-status-banner .status-cred { + display: inline-flex; + align-items: center; + gap: 4px; + } + .observer-status-banner .cred-ok { color: var(--semantic-success, #4ade80); } + .observer-status-banner .cred-none { color: var(--text-tertiary); } .settings-group { display: flex; flex-direction: column; @@ -1466,6 +1495,7 @@

Memory & model settings

+

Connection

diff --git a/viewer_ui/src/lib/api.ts b/viewer_ui/src/lib/api.ts index a7b8647e..e1155521 100644 --- a/viewer_ui/src/lib/api.ts +++ b/viewer_ui/src/lib/api.ts @@ -72,6 +72,10 @@ export async function loadSummariesPage( return fetchJson(`/api/summaries?${query}`); } +export async function loadObserverStatus(): Promise { + return fetchJson('/api/observer-status'); +} + export async function loadConfig(): Promise { return fetchJson('/api/config'); } diff --git a/viewer_ui/src/tabs/settings.ts b/viewer_ui/src/tabs/settings.ts index 29d1158e..c64b2ba1 100644 --- a/viewer_ui/src/tabs/settings.ts +++ b/viewer_ui/src/tabs/settings.ts @@ -786,11 +786,100 @@ export async function saveSettings(startPolling: () => void, refreshCallback: () } } +function formatAuthMethod(method: string): string { + switch (method) { + case 'anthropic_consumer': + return 'OAuth (Claude Max/Pro)'; + case 'codex_consumer': + return 'OAuth (ChatGPT subscription)'; + case 'sdk_client': + return 'API key'; + case 'claude_sidecar': + return 'Local Claude session'; + case 'opencode_run': + return 'OpenCode sidecar'; + default: + return method || 'none'; + } +} + +function formatCredentialSources(creds: Record): string { + const parts: string[] = []; + if (creds.oauth) parts.push('OAuth'); + if (creds.api_key) parts.push('API key'); + if (creds.env_var) parts.push('env var'); + return parts.length ? parts.join(', ') : 'none'; +} + +function createEl(tag: string, className?: string, text?: string): HTMLElement { + const el = document.createElement(tag); + if (className) el.className = className; + if (text) el.textContent = text; + return el; +} + +function renderObserverStatusBanner(status: any) { + const banner = $('observerStatusBanner'); + if (!banner) return; + + if (!status || typeof status !== 'object') { + banner.hidden = true; + return; + } + + banner.textContent = ''; + const active = status.active; + const available = status.available_credentials || {}; + + if (active) { + const provider = String(active.provider || 'unknown'); + const model = String(active.model || ''); + const method = formatAuthMethod(active.auth?.method || 'none'); + const tokenOk = active.auth?.token_present === true; + + banner.append(createEl('div', 'status-label', 'Active observer')); + const row = createEl('div', 'status-active'); + row.textContent = `${provider} \u2192 ${model} via ${method} `; + const tokenSpan = createEl('span', tokenOk ? 'cred-ok' : 'cred-none', tokenOk ? '\u2713' : '\u2717'); + row.append(tokenSpan); + banner.append(row); + } else { + banner.append(createEl('div', 'status-label', 'Observer status')); + banner.append(createEl('div', 'status-active', 'Not yet initialized (waiting for first session)')); + } + + const credEntries = Object.entries(available).filter( + ([, creds]) => creds && typeof creds === 'object', + ); + if (credEntries.length) { + banner.append(createEl('div', 'status-label', 'Available credentials')); + const row = createEl('div'); + credEntries.forEach(([provider, creds], idx) => { + const c = creds as Record; + const sources = formatCredentialSources(c); + const hasAny = Object.values(c).some(Boolean); + const span = createEl('span', 'status-cred'); + const icon = createEl('span', hasAny ? 'cred-ok' : 'cred-none', hasAny ? '\u2713' : '\u2013'); + span.append(icon); + span.append(` ${String(provider)}: ${sources}`); + if (idx > 0) row.append(' \u00b7 '); + row.append(span); + }); + banner.append(row); + } + + banner.hidden = false; +} + export async function loadConfigData() { if (settingsOpen) return; try { - const payload = await api.loadConfig(); + const [payload, status] = await Promise.all([ + api.loadConfig(), + api.loadObserverStatus().catch(() => null), + ]); renderConfigModal(payload); + renderObserverStatusBanner(status); } catch {} }