feat(ui): blackflame theming pass — edit toggle, md tables, back button, Little Blue action cards
- markdown_editor Edit toggle uses themed ghost button - .md-preview gets full blackflame styling incl. tables (migrated BookStack tables now render as tables) - reusable back button on page/reference/project/resource reading views - Little Blue actions regrouped into themed cards, pairing Start/Stop per guest Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// #/little-blue — the caretaker page: Little Blue's chat (reuses agent_chat) +
|
||||
// a manual Actions panel (run whitelisted actions; approve/reject risky ones).
|
||||
// a manual Actions panel. Actions are grouped into cards (Start/Stop of the same
|
||||
// guest pair together); risky ones queue for owner approval.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { wireAgentChat } from '../components/agent_chat.js';
|
||||
@@ -7,6 +8,8 @@ import { wireAgentChat } from '../components/agent_chat.js';
|
||||
const BLUE_LABELS = {
|
||||
search: '🔍 looking', list_actions: '📋 checking what she can do', propose_action: '🔧 taking action'
|
||||
};
|
||||
const VERB = { start: 'Start', stop: 'Stop', shutdown: 'Shutdown', reboot: 'Reboot' };
|
||||
const stripVerb = (l) => ((l || '').replace(/^(Start|Stop|Restart|Reboot|Shutdown)\s+/i, '').trim() || l);
|
||||
|
||||
function toast(host, msg, kind) {
|
||||
const t = el('div', { class: 'lb-toast' + (kind === 'err' ? ' err' : '') }, msg);
|
||||
@@ -14,48 +17,70 @@ function toast(host, msg, kind) {
|
||||
setTimeout(() => t.remove(), 4000);
|
||||
}
|
||||
|
||||
function actionButton(a, panel, toastHost) {
|
||||
const verb = a.kind === 'guest_power' ? (VERB[a.op] || a.op) : 'Restart';
|
||||
const btn = el('button', {
|
||||
class: 'lb-act ' + (a.tier === 'risky' ? 'risky' : 'safe'),
|
||||
title: a.tier === 'risky' ? 'Needs your approval' : 'Runs immediately',
|
||||
onclick: async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await api.post(`/api/actions/${a.id}/run`);
|
||||
toast(toastHost, r.executed ? `Ran: ${a.label}` : `Queued for approval: ${a.label}`);
|
||||
renderActions(panel, toastHost);
|
||||
} catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); btn.disabled = false; }
|
||||
}
|
||||
}, verb);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function renderActions(panel, toastHost) {
|
||||
clear(panel);
|
||||
let actions = [], pending = [];
|
||||
try { ({ actions } = await api.get('/api/actions')); } catch (e) { actions = []; }
|
||||
try { ({ pending } = await api.get('/api/actions/pending')); } catch (e) { pending = []; }
|
||||
try { ({ actions } = await api.get('/api/actions')); } catch { /* */ }
|
||||
try { ({ pending } = await api.get('/api/actions/pending')); } catch { /* */ }
|
||||
|
||||
panel.appendChild(el('div', { class: 'lb-sec-t' }, 'Actions'));
|
||||
if (!actions.length) {
|
||||
panel.appendChild(el('div', { class: 'muted' }, 'No actions configured yet (populate config/actions.json).'));
|
||||
}
|
||||
panel.appendChild(el('div', { class: 'lb-sec' }, 'Actions'));
|
||||
if (!actions.length) panel.appendChild(el('p', { class: 'muted' }, 'No actions configured yet.'));
|
||||
|
||||
// Group guest power by guest (pairs Start/Stop); service restarts stand alone.
|
||||
const groups = new Map();
|
||||
for (const a of actions) {
|
||||
panel.appendChild(el('div', { class: 'lb-action' },
|
||||
el('span', { class: 'lb-a-label' }, a.label || a.id),
|
||||
el('span', { class: 'lb-a-tier ' + a.tier }, a.tier),
|
||||
el('button', { class: 'lb-run', onclick: async (ev) => {
|
||||
ev.target.disabled = true;
|
||||
try {
|
||||
const r = await api.post(`/api/actions/${a.id}/run`);
|
||||
toast(toastHost, r.executed ? `Ran "${a.label || a.id}"` : `Queued "${a.label || a.id}" for approval`);
|
||||
renderActions(panel, toastHost);
|
||||
} catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); ev.target.disabled = false; }
|
||||
} }, a.tier === 'risky' ? 'Request' : 'Run')));
|
||||
const key = a.kind === 'guest_power' ? `g:${a.node}:${a.vmid}` : `s:${a.id}`;
|
||||
if (!groups.has(key)) groups.set(key, { name: stripVerb(a.label), acts: [] });
|
||||
groups.get(key).acts.push(a);
|
||||
}
|
||||
if (actions.length) {
|
||||
const cards = el('div', { class: 'lb-cards' });
|
||||
for (const { name, acts } of groups.values()) {
|
||||
acts.sort((x, y) => (x.op === 'start' ? -1 : y.op === 'start' ? 1 : 0));
|
||||
cards.appendChild(el('div', { class: 'card lb-card' },
|
||||
el('div', { class: 'lb-card-title' }, name),
|
||||
el('div', { class: 'lb-btn-row' }, acts.map(a => actionButton(a, panel, toastHost)))));
|
||||
}
|
||||
panel.appendChild(cards);
|
||||
}
|
||||
|
||||
if (pending.length) {
|
||||
panel.appendChild(el('div', { class: 'lb-sec-t' }, 'Awaiting approval'));
|
||||
panel.appendChild(el('div', { class: 'lb-sec' }, 'Awaiting approval'));
|
||||
const resolve = async (id, verb) => {
|
||||
try { await api.post(`/api/actions/pending/${id}/${verb}`); toast(toastHost, verb === 'approve' ? 'Approved' : 'Rejected'); renderActions(panel, toastHost); }
|
||||
catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); }
|
||||
};
|
||||
const pcards = el('div', { class: 'lb-cards' });
|
||||
for (const p of pending) {
|
||||
const row = el('div', { class: 'lb-pending' },
|
||||
el('span', { class: 'lb-a-label' }, p.action_id),
|
||||
el('button', { class: 'ok', onclick: () => resolve(p.id, 'approve') }, 'Approve'),
|
||||
el('button', { class: 'no', onclick: () => resolve(p.id, 'reject') }, 'Reject'));
|
||||
panel.appendChild(row);
|
||||
async function resolve(id, verb) {
|
||||
try { await api.post(`/api/actions/pending/${id}/${verb}`); toast(toastHost, verb === 'approve' ? 'Approved' : 'Rejected'); renderActions(panel, toastHost); }
|
||||
catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); }
|
||||
}
|
||||
pcards.appendChild(el('div', { class: 'card lb-pending-card' },
|
||||
el('div', { class: 'lb-card-title' }, p.action_id),
|
||||
el('div', { class: 'lb-btn-row' },
|
||||
el('button', { class: 'lb-act safe', onclick: () => resolve(p.id, 'approve') }, 'Approve'),
|
||||
el('button', { class: 'lb-act risky', onclick: () => resolve(p.id, 'reject') }, 'Reject'))));
|
||||
}
|
||||
panel.appendChild(pcards);
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
const log = el('div', { class: 'rail-log sentinel-log' });
|
||||
const log = el('div', { class: 'rail-log lb-log' });
|
||||
const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Tell Little Blue what’s wrong…' });
|
||||
const actionsPanel = el('div', { class: 'lb-actions' });
|
||||
const toastHost = el('div', { class: 'lb-toasts' });
|
||||
|
||||
Reference in New Issue
Block a user