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 {} }