diff --git a/README.md b/README.md
index 16ccf08..88fb836 100644
--- a/README.md
+++ b/README.md
@@ -491,7 +491,7 @@ Flynn includes a built-in web control dashboard served by the WebSocket gateway.
| **Chat** | Session selector, streaming tool events, markdown rendering with syntax highlighting |
| **Sessions** | Browse all sessions, view message history, delete sessions |
| **Usage** | Token usage summary cards, per-session breakdown table, auto-refresh |
-| **Settings** | Edit hook patterns (confirm/log/silent), view tools, channels, and redacted config |
+| **Settings** | Edit hook patterns (confirm/log/silent), Personal Assistant Mode toggles, view tools/services, and redacted config |
The dashboard is a vanilla JS SPA with no build step — hash-based routing, ES modules, and the existing WebSocket JSON-RPC protocol.
diff --git a/docs/plans/state.json b/docs/plans/state.json
index 4a3bea2..33b8151 100644
--- a/docs/plans/state.json
+++ b/docs/plans/state.json
@@ -5467,6 +5467,19 @@
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts + pnpm typecheck passing"
+ },
+ "dashboard-assistant-health-quick-actions": {
+ "status": "completed",
+ "date": "2026-02-18",
+ "updated": "2026-02-18",
+ "summary": "Extended Live Ops Dashboard with an Assistant Health section that surfaces product-feel runtime state (announce mode, daily briefing, memory cadence, TTS) and adds one-click quick actions that apply safe runtime config.patch updates and refresh status immediately.",
+ "files_modified": [
+ "src/gateway/ui/pages/dashboard.js",
+ "src/gateway/ui/style.css",
+ "README.md",
+ "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 9991e25..1eda2e8 100644
--- a/src/gateway/ui/pages/dashboard.js
+++ b/src/gateway/ui/pages/dashboard.js
@@ -7,6 +7,7 @@
let _fastTimer = null;
let _slowTimer = null;
+let _dashboardClient = null;
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -80,6 +81,11 @@ function renderSkeleton(el) {
Loading...
+ Assistant Health
+
+
Event Stream
Loading events...
@@ -411,6 +417,128 @@ function updateContextHealth(contextData) {
`;
}
+async function applyAssistantPatch(patches, statusEl) {
+ if (!_dashboardClient) {return;}
+ if (statusEl) {
+ statusEl.textContent = 'Saving...';
+ statusEl.className = 'text-sm text-muted';
+ }
+ try {
+ const result = await _dashboardClient.call('config.patch', { patches });
+ const rejected = result?.rejected ?? [];
+ const persistError = result?.persistError;
+ const applied = result?.applied ?? [];
+ const persisted = result?.persisted === true;
+
+ if (statusEl) {
+ if (persistError) {
+ statusEl.textContent = `Save failed: ${persistError}`;
+ statusEl.className = 'text-sm text-error';
+ } else if (rejected.length > 0) {
+ statusEl.textContent = `Rejected: ${rejected.join(', ')}`;
+ statusEl.className = 'text-sm text-error';
+ } else if (!persisted) {
+ statusEl.textContent = `Runtime saved (${applied.length} updated)`;
+ statusEl.className = 'text-sm text-muted';
+ } else {
+ statusEl.textContent = `Saved (${applied.length} updated)`;
+ statusEl.className = 'text-sm text-success';
+ }
+ }
+ } catch (error) {
+ if (statusEl) {
+ statusEl.textContent = `Save error: ${error instanceof Error ? error.message : String(error)}`;
+ statusEl.className = 'text-sm text-error';
+ }
+ }
+}
+
+function updateAssistantHealth(configData) {
+ const el = document.getElementById('ops-assistant-health');
+ if (!el) {return;}
+
+ const automation = configData?.automation ?? {};
+ const memory = configData?.memory ?? {};
+ const tts = configData?.tts ?? {};
+
+ const deliveryMode = automation.delivery_mode ?? 'shared_session';
+ const announce = deliveryMode === 'announce';
+ const dailyBriefing = Boolean(automation.daily_briefing?.enabled);
+ const memoryDaily = Boolean(memory.daily_log?.enabled);
+ const memoryProactive = Boolean(memory.proactive_extract?.enabled);
+ const proactiveThreshold = Number(memory.proactive_extract?.min_tool_calls ?? 1);
+ const ttsEnabled = Boolean(tts.enabled);
+
+ const chip = (label, value) => `
+
+ ${escapeHtml(label)}
+ ${value ? 'ON' : 'OFF'}
+
+ `;
+
+ el.innerHTML = `
+
+ ${chip('Announce Mode', announce)}
+ ${chip('Daily Briefing', dailyBriefing)}
+ ${chip('Memory Daily Log', memoryDaily)}
+ ${chip('Proactive Extract', memoryProactive)}
+ ${chip('TTS Replies', ttsEnabled)}
+
+ Extract Threshold
+ ${Number.isFinite(proactiveThreshold) ? proactiveThreshold : 1}
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const statusEl = el.querySelector('#ops-assistant-status');
+ const buttons = el.querySelectorAll('.assistant-action-btn');
+ buttons.forEach((button) => {
+ button.addEventListener('click', async () => {
+ const action = button.getAttribute('data-action');
+ let patches = null;
+ if (action === 'toggle-announce') {
+ patches = { 'automation.delivery_mode': announce ? 'shared_session' : 'announce' };
+ } else if (action === 'toggle-daily-briefing') {
+ patches = { 'automation.daily_briefing.enabled': !dailyBriefing };
+ } else if (action === 'toggle-memory-daily') {
+ patches = { 'memory.daily_log.enabled': !memoryDaily };
+ } else if (action === 'toggle-memory-proactive') {
+ patches = { 'memory.proactive_extract.enabled': !memoryProactive };
+ } else if (action === 'toggle-tts') {
+ patches = { 'tts.enabled': !ttsEnabled };
+ }
+ if (!patches) {return;}
+ await applyAssistantPatch(patches, statusEl);
+ // Force immediate refresh of slow sections after applying.
+ const refreshed = await fetchSlow(_dashboardClient);
+ if (refreshed) {
+ updateServices(refreshed.services);
+ updateSessionAnalytics(refreshed.sessionAnalytics);
+ updateContextHealth(refreshed.contextUsage);
+ updateAssistantHealth(refreshed.config);
+ }
+ });
+ });
+}
+
function _updateChannels(channelsData) {
const el = document.getElementById('ops-channels');
if (!el) {return;}
@@ -479,13 +607,14 @@ async function fetchFast(client) {
async function fetchSlow(client) {
try {
- const [health, services, sessionAnalytics, contextUsage] = await Promise.all([
+ const [health, services, sessionAnalytics, contextUsage, config] = await Promise.all([
client.call('system.health'),
client.call('system.services'),
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
client.call('system.contextUsage'),
+ client.call('config.get'),
]);
- return { health, services, sessionAnalytics, contextUsage };
+ return { health, services, sessionAnalytics, contextUsage, config };
} catch {
return null;
}
@@ -497,6 +626,7 @@ let _lastHealth = null;
let _lastMetrics = null;
async function loadDashboard(el, client) {
+ _dashboardClient = client;
renderSkeleton(el);
// Fetch everything initially
@@ -518,6 +648,7 @@ async function loadDashboard(el, client) {
updateServices(slow.services);
updateSessionAnalytics(slow.sessionAnalytics);
updateContextHealth(slow.contextUsage);
+ updateAssistantHealth(slow.config);
}
// Fast refresh: 3 seconds for metrics, events, requests
@@ -541,6 +672,7 @@ async function loadDashboard(el, client) {
updateServices(data.services);
updateSessionAnalytics(data.sessionAnalytics);
updateContextHealth(data.contextUsage);
+ updateAssistantHealth(data.config);
}
}, 10000);
}
@@ -561,5 +693,6 @@ export const DashboardPage = {
}
_lastHealth = null;
_lastMetrics = null;
+ _dashboardClient = null;
},
};
diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css
index 636948c..4d9fa97 100644
--- a/src/gateway/ui/style.css
+++ b/src/gateway/ui/style.css
@@ -1544,6 +1544,33 @@ tr:hover td {
color: var(--text-primary);
}
+.assistant-health-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.assistant-chip {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: var(--font-size-sm);
+}
+
+.assistant-chip-label {
+ color: var(--text-secondary);
+}
+
+.assistant-chip-value {
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
/* ── Responsive: Mobile ─────────────────────────────────────── */
@media (max-width: 768px) {