diff --git a/docs/plans/state.json b/docs/plans/state.json
index 8f68fca..c0ff662 100644
--- a/docs/plans/state.json
+++ b/docs/plans/state.json
@@ -5658,6 +5658,17 @@
"docs/plans/state.json"
],
"test_status": "pnpm typecheck passing"
+ },
+ "dashboard-assistant-health-persistence-indicator": {
+ "status": "completed",
+ "date": "2026-02-19",
+ "updated": "2026-02-19",
+ "summary": "Added a persistent Assistant Health save-state indicator that survives rerenders and explicitly reports whether updates were saved runtime-only or persisted to config file, with timestamped status messages.",
+ "files_modified": [
+ "src/gateway/ui/pages/dashboard.js",
+ "docs/plans/state.json"
+ ],
+ "test_status": "pnpm typecheck passing"
}
},
"overall_progress": {
diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js
index bde4007..905510d 100644
--- a/src/gateway/ui/pages/dashboard.js
+++ b/src/gateway/ui/pages/dashboard.js
@@ -10,6 +10,7 @@ let _slowTimer = null;
let _dashboardClient = null;
let _lastPlaybookRollbackPatches = null;
let _lastBriefingTestAt = null;
+let _assistantSaveState = null;
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -122,6 +123,36 @@ function buildRollbackPatchesFromSnapshot(snapshot) {
};
}
+function setAssistantSaveState(message, tone = 'neutral') {
+ _assistantSaveState = {
+ message,
+ tone,
+ at: Date.now(),
+ };
+}
+
+function renderAssistantSaveState() {
+ if (!_assistantSaveState) {
+ return '
No recent save action.
';
+ }
+
+ const toneClass = _assistantSaveState.tone === 'success'
+ ? 'text-green-500'
+ : _assistantSaveState.tone === 'warning'
+ ? 'text-amber-500'
+ : _assistantSaveState.tone === 'error'
+ ? 'text-red-500'
+ : 'text-zinc-500';
+
+ const at = new Date(_assistantSaveState.at).toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+
+ return `${escapeHtml(_assistantSaveState.message)} (at ${escapeHtml(at)})
`;
+}
+
// ── Initial full render ─────────────────────────────────────────
function renderSkeleton(el) {
@@ -492,43 +523,49 @@ function updateContextHealth(contextData) {
async function applyAssistantPatch(patches, statusEl) {
if (!_dashboardClient) {
- console.warn('[Flynn] applyAssistantPatch: no client');
+ setAssistantSaveState('Save skipped: dashboard client unavailable.', 'error');
return;
}
- console.log('[Flynn] applyAssistantPatch:', JSON.stringify(patches));
if (statusEl) {
statusEl.textContent = 'Saving...';
statusEl.className = 'text-sm text-zinc-500';
}
try {
const result = await _dashboardClient.call('config.patch', { patches });
- console.log('[Flynn] config.patch result:', JSON.stringify(result));
const rejected = result?.rejected ?? [];
const persistError = result?.persistError;
const applied = result?.applied ?? [];
const persisted = result?.persisted === true;
+ let message = '';
+ let tone = 'neutral';
if (statusEl) {
if (persistError) {
- statusEl.textContent = `Save failed: ${persistError}`;
- statusEl.className = 'text-sm text-red-500';
+ message = `Save failed: ${persistError}`;
+ tone = 'error';
} else if (rejected.length > 0) {
- statusEl.textContent = `Rejected: ${rejected.join(', ')}`;
- statusEl.className = 'text-sm text-red-500';
+ message = `Rejected: ${rejected.join(', ')}`;
+ tone = 'error';
} else if (!persisted) {
- statusEl.textContent = `Runtime saved (${applied.length} updated)`;
- statusEl.className = 'text-sm text-amber-500';
+ message = `Runtime-only save (${applied.length} updated, file persistence unavailable)`;
+ tone = 'warning';
} else {
- statusEl.textContent = `Saved & persisted (${applied.length} updated)`;
- statusEl.className = 'text-sm text-green-500';
+ message = `Saved to runtime + config file (${applied.length} updated)`;
+ tone = 'success';
}
+ setAssistantSaveState(message, tone);
+ statusEl.textContent = message;
+ statusEl.className = `text-sm ${tone === 'success' ? 'text-green-500' : tone === 'warning' ? 'text-amber-500' : tone === 'error' ? 'text-red-500' : 'text-zinc-500'}`;
}
+ return { persisted, applied, rejected, persistError };
} catch (error) {
- console.error('[Flynn] config.patch error:', error);
+ const msg = `Save error: ${error instanceof Error ? error.message : String(error)}`;
+ setAssistantSaveState(msg, 'error');
if (statusEl) {
- statusEl.textContent = `Save error: ${error instanceof Error ? error.message : String(error)}`;
+ statusEl.textContent = msg;
statusEl.className = 'text-sm text-red-500';
}
+ return { persisted: false, applied: [], rejected: [], persistError: msg };
}
}
@@ -700,7 +737,7 @@ function updateAssistantHealth(configData) {
${escapeHtml(briefingPrompt || 'No daily briefing prompt configured.')}
${briefingReady ? '' : 'Enable daily briefing and set output channel/peer to test-send.
'}
-
+ ${renderAssistantSaveState()}
`;
const statusEl = el.querySelector('#ops-assistant-status');
@@ -765,15 +802,7 @@ function updateAssistantHealth(configData) {
updateSessionAnalytics(refreshed.sessionAnalytics);
updateContextHealth(refreshed.contextUsage);
// Capture status message before re-render destroys the DOM element
- const savedText = statusEl?.textContent ?? '';
- const savedClass = statusEl?.className ?? '';
updateAssistantHealth(refreshed.config);
- // Restore status message in the new DOM
- const newStatusEl = document.getElementById('ops-assistant-status');
- if (newStatusEl && savedText) {
- newStatusEl.textContent = savedText;
- newStatusEl.className = savedClass;
- }
}
});
});
@@ -963,5 +992,6 @@ export const DashboardPage = {
_dashboardClient = null;
_lastPlaybookRollbackPatches = null;
_lastBriefingTestAt = null;
+ _assistantSaveState = null;
},
};