robin/dist/index.html
pyr0ball 42472ee024 feat(ui): replace Vue/Vite frontend with self-contained static HTML
The UI was previously a Vue 3 SPA served via a Vite dev server on :1420.
This had two problems:
  - Building from source required Node, npm, and the Tauri CLI
  - Running required starting a Vite dev server alongside the binary

Replace with a single self-contained dist/index.html using vanilla JS
and the Tauri 2 withGlobalTauri IPC global. No build step, no npm, no
dev server — the binary loads the static file directly.

Changes:
  - dist/index.html: full Robin UI (chat, events, debug tabs) in ~750 LOC
      - Chat tab: streaming LLM responses via robin:chat-token events
      - Events tab: live matched system events with severity badges
      - Debug tab: migration config, Ollama status probe, notif level picker
      - Onboarding form shown on first run (calls complete_onboarding IPC)
      - All user/LLM text via textContent/DOM construction, no innerHTML
      - Markdown renderer (fenced code, inline code) built from DOM nodes
  - tauri.conf.json: add withGlobalTauri: true, remove devUrl and Node hooks
  - tauri.conf.json: update CSP to allow inline scripts (desktop app)
  - manage.sh: remove Vite dev server auto-start and kill logic
  - manage.sh: build/dev now use cargo directly, add bundle command for
    full .deb/.rpm/.AppImage (still requires Tauri CLI)
  - .gitignore: track dist/index.html, only ignore dist/assets/ (Vite output)
2026-05-20 12:23:04 -07:00

753 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Robin</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface2: #334155;
--border: #475569;
--text: #f1f5f9;
--muted: #94a3b8;
--accent: #f97316;
--accent-bg: #1c0a00;
--info: #3b82f6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--radius: 6px;
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px; line-height: 1.5; overflow: hidden;
}
#app { display: flex; flex-direction: column; height: 100vh; }
header {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; background: var(--surface);
border-bottom: 1px solid var(--border); flex-shrink: 0; user-select: none;
}
header .logo { font-size: 18px; line-height: 1; }
header h1 { font-size: 15px; font-weight: 600; color: var(--text); flex: 1; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--muted); flex-shrink: 0; transition: background 0.3s;
}
.status-dot.ok { background: var(--success); }
.status-dot.warn { background: var(--warning); }
.status-dot.error { background: var(--error); }
.status-label { font-size: 11px; color: var(--muted); }
.tabs {
display: flex; background: var(--surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.tab-btn {
padding: 7px 14px; background: none;
border: none; border-bottom: 2px solid transparent;
color: var(--muted); cursor: pointer; font-size: 13px;
font-family: var(--font); transition: color 0.15s, border-color 0.15s;
display: flex; align-items: center; gap: 5px;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.badge {
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
border-radius: 999px; min-width: 16px; height: 16px; padding: 0 4px; line-height: 1;
}
.badge.hidden { display: none; }
.pane { display: none; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
.pane.active { display: flex; }
#onboarding {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: flex-start;
padding: 24px 20px; gap: 14px; text-align: center; overflow-y: auto;
}
#onboarding.hidden { display: none; }
#onboarding .bird { font-size: 48px; line-height: 1; }
#onboarding h2 { font-size: 18px; font-weight: 600; }
#onboarding p { font-size: 13px; color: var(--muted); max-width: 300px; }
.form-group { width: 100%; max-width: 320px; text-align: left; }
.form-group label {
display: block; font-size: 12px; color: var(--muted);
margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em;
}
.form-group select, .form-group input {
width: 100%; padding: 8px 10px; background: var(--surface2);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-size: 13px; font-family: var(--font);
outline: none; transition: border-color 0.15s;
}
.form-group select:focus, .form-group input:focus { border-color: var(--accent); }
.form-group select option { background: var(--surface2); }
.btn {
padding: 8px 16px; border-radius: var(--radius); border: none;
font-size: 13px; font-family: var(--font); font-weight: 500;
cursor: pointer; transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; } .btn:active { opacity: 0.7; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-ghost:hover { color: var(--text); border-color: var(--text); }
.btn-icon {
width: 34px; height: 34px; padding: 0; display: flex;
align-items: center; justify-content: center; font-size: 16px;
background: var(--accent); color: #fff; border-radius: var(--radius);
border: none; cursor: pointer; flex-shrink: 0; transition: opacity 0.15s;
}
.btn-icon:hover { opacity: 0.85; } .btn-icon:active { opacity: 0.7; }
.btn-icon:disabled { opacity: 0.4; cursor: not-allowed; }
#messages {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 10px; min-height: 0;
}
#messages::-webkit-scrollbar { width: 4px; }
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.msg {
max-width: 90%; padding: 8px 12px; border-radius: var(--radius);
font-size: 13px; line-height: 1.55; word-break: break-word;
}
.msg.user {
background: var(--accent-bg); border: 1px solid var(--accent);
align-self: flex-end;
}
.msg.assistant {
background: var(--surface); border: 1px solid var(--border);
align-self: flex-start;
}
.msg.assistant pre {
background: var(--surface2); padding: 8px 10px; border-radius: 4px;
font-family: var(--mono); font-size: 12px;
overflow-x: auto; margin: 6px 0; white-space: pre-wrap;
}
.msg.assistant code {
background: var(--surface2); padding: 1px 4px;
border-radius: 3px; font-family: var(--mono); font-size: 12px;
}
.msg-role {
font-size: 10px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 3px;
}
.msg.user .msg-role { color: var(--accent); }
.msg.system-msg {
background: transparent; border: 1px dashed var(--border);
align-self: center; color: var(--muted);
font-size: 12px; max-width: 100%; text-align: center;
}
.typing-cursor {
display: inline-block; width: 7px; height: 13px;
background: var(--accent); border-radius: 1px;
vertical-align: middle; animation: blink 0.9s step-end infinite; margin-left: 2px;
}
@keyframes blink { 50% { opacity: 0; } }
.chat-input-row {
display: flex; gap: 8px; padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface); flex-shrink: 0; align-items: flex-end;
}
#chat-input {
flex: 1; padding: 8px 10px; background: var(--surface2);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-size: 13px; font-family: var(--font);
resize: none; outline: none; line-height: 1.4;
max-height: 100px; overflow-y: auto; transition: border-color 0.15s;
}
#chat-input:focus { border-color: var(--accent); }
#chat-input::placeholder { color: var(--muted); }
#events-list {
flex: 1; overflow-y: auto; padding: 10px;
display: flex; flex-direction: column; gap: 8px; min-height: 0;
}
#events-list::-webkit-scrollbar { width: 4px; }
#events-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.event-card {
padding: 10px 12px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius);
border-left: 3px solid var(--muted);
}
.event-card.severity-warn { border-left-color: var(--warning); }
.event-card.severity-crit { border-left-color: var(--error); }
.event-card.severity-info { border-left-color: var(--info); }
.event-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.event-title { font-size: 13px; font-weight: 600; flex: 1; }
.severity-badge {
font-size: 10px; font-weight: 700; text-transform: uppercase;
padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em;
}
.severity-badge.warn { background: #422006; color: var(--warning); }
.severity-badge.crit { background: #450a0a; color: var(--error); }
.severity-badge.info { background: #0c1a3a; color: var(--info); }
.event-body { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.event-meta { font-size: 11px; color: var(--border); font-family: var(--mono); }
.events-toolbar {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface); flex-shrink: 0;
}
.events-toolbar span { flex: 1; font-size: 12px; color: var(--muted); }
.empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 8px; color: var(--muted); font-size: 13px; padding: 24px; text-align: center;
}
.empty-state .icon { font-size: 32px; opacity: 0.4; }
#pane-debug {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 12px; min-height: 0;
}
#pane-debug::-webkit-scrollbar { width: 4px; }
#pane-debug::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.debug-section {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
}
.debug-section-header {
padding: 8px 12px; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.07em;
color: var(--muted); background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.debug-rows { padding: 8px 0; }
.debug-row { display: flex; align-items: center; padding: 5px 12px; gap: 8px; font-size: 12px; }
.debug-row .key { width: 130px; flex-shrink: 0; color: var(--muted); }
.debug-row .val { color: var(--text); font-family: var(--mono); font-size: 12px; word-break: break-all; }
.debug-row .val.ok { color: var(--success); }
.debug-row .val.warn { color: var(--warning); }
.debug-row .val.err { color: var(--error); }
.notif-select {
background: var(--surface2); border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-size: 12px; font-family: var(--font);
padding: 2px 6px; outline: none; cursor: pointer;
}
.notif-select:focus { border-color: var(--accent); }
.notif-select option { background: var(--surface2); }
.hidden { display: none !important; }
.scroll-anchor { height: 1px; flex-shrink: 0; }
</style>
</head>
<body>
<div id="app">
<header>
<span class="logo">&#x1F426;</span>
<h1>Robin</h1>
<span class="status-label" id="status-label">connecting&#8230;</span>
<span class="status-dot" id="status-dot"></span>
</header>
<div id="onboarding">
<div class="bird">&#x1F426;</div>
<h2>Welcome to Robin</h2>
<p>Set up your migration profile so Robin can give you personalised help.</p>
<div class="form-group">
<label for="src-os">Coming from</label>
<select id="src-os">
<option value="macos">macOS</option>
<option value="windows" selected>Windows</option>
<option value="linux">Linux (different distro)</option>
<option value="android">Android</option>
<option value="ipad">iPad / iOS</option>
</select>
</div>
<div class="form-group">
<label for="distro">Your Linux distro (blank to auto-detect)</label>
<input type="text" id="distro" placeholder="e.g. linuxmint, ubuntu, arch" />
</div>
<div class="form-group">
<label for="dual-boot">Dual-booting with (optional)</label>
<select id="dual-boot">
<option value="">None</option>
<option value="windows">Windows</option>
<option value="macos">macOS</option>
</select>
</div>
<button class="btn btn-primary" id="onboarding-save" style="width:100%;max-width:320px">Get started</button>
<p id="onboarding-error" class="hidden" style="color:var(--error);font-size:12px"></p>
</div>
<div class="tabs hidden" id="tabs">
<button class="tab-btn active" data-tab="chat">Chat</button>
<button class="tab-btn" data-tab="events">Events <span class="badge hidden" id="events-badge">0</span></button>
<button class="tab-btn" data-tab="debug">Debug</button>
</div>
<div class="pane active" id="pane-chat">
<div id="messages">
<div class="msg system-msg" id="welcome-msg">Robin is watching your system logs. Ask me anything about Linux!</div>
<div class="scroll-anchor" id="chat-bottom"></div>
</div>
<div class="chat-input-row">
<textarea id="chat-input" rows="1" placeholder="Ask Robin&#8230; (Enter to send, Shift+Enter for newline)" aria-label="Chat message"></textarea>
<button class="btn-icon" id="send-btn" title="Send (Enter)" aria-label="Send">&#x25B6;</button>
</div>
</div>
<div class="pane" id="pane-events">
<div class="events-toolbar">
<span id="events-count">No events yet</span>
<button class="btn btn-ghost" id="clear-events-btn" style="font-size:12px;padding:4px 10px">Clear</button>
</div>
<div id="events-list">
<div class="empty-state" id="events-empty">
<span class="icon">&#x1F4CB;</span>
<span>No system events matched yet.<br>Robin is watching your logs.</span>
</div>
</div>
</div>
<div class="pane" id="pane-debug">
<div class="debug-section">
<div class="debug-section-header">Migration Profile</div>
<div class="debug-rows">
<div class="debug-row"><span class="key">Source OS</span><span class="val" id="dbg-source-os">&#8212;</span></div>
<div class="debug-row"><span class="key">Distro</span><span class="val" id="dbg-distro">&#8212;</span></div>
<div class="debug-row"><span class="key">Distro family</span><span class="val" id="dbg-distro-family">&#8212;</span></div>
<div class="debug-row"><span class="key">Dual boot</span><span class="val" id="dbg-dual-boot">none</span></div>
<div class="debug-row"><span class="key">Fluency level</span><span class="val" id="dbg-fluency">0 / 5</span></div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">LLM Config</div>
<div class="debug-rows">
<div class="debug-row"><span class="key">Ollama URL</span><span class="val" id="dbg-ollama-url">&#8212;</span></div>
<div class="debug-row"><span class="key">Model</span><span class="val" id="dbg-ollama-model">&#8212;</span></div>
<div class="debug-row"><span class="key">Connection</span><span class="val" id="dbg-ollama-status">&#8212;</span></div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">Notifications</div>
<div class="debug-rows">
<div class="debug-row">
<span class="key">Level</span>
<select class="notif-select" id="notif-level-select" aria-label="Notification level">
<option value="off">Off</option>
<option value="badge_only">Badge only</option>
<option value="badge_and_toast">Badge + toast</option>
</select>
</div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">App Info</div>
<div class="debug-rows">
<div class="debug-row"><span class="key">Tier</span><span class="val" id="dbg-tier">free</span></div>
<div class="debug-row"><span class="key">Events captured</span><span class="val" id="dbg-event-count">0</span></div>
</div>
</div>
<div class="debug-section">
<div class="debug-section-header">Profile</div>
<div class="debug-rows">
<div class="debug-row">
<button class="btn btn-ghost" id="reset-onboarding-btn" style="font-size:12px;padding:4px 10px">Change migration profile&#8230;</button>
</div>
</div>
</div>
</div>
</div>
<script>
"use strict";
/* Robin frontend -- vanilla JS, Tauri 2 IPC (withGlobalTauri: true)
No build step, no npm, no Node required.
Security note: All user-supplied and LLM-supplied text goes through
setTextContent() / DOM text nodes rather than innerHTML.
The chat markdown renderer builds the DOM tree element-by-element so
no unsanitised HTML is ever passed to innerHTML.
*/
var _invoke = null;
var _listen = null;
// State
var currentConfig = null;
var streamBuf = '';
var streamMsgEl = null;
var isSending = false;
var eventCount = 0;
var newEventCount = 0;
function $(id) { return document.getElementById(id); }
// ── DOM refs
var statusDot = $('status-dot');
var statusLabel = $('status-label');
var onboardingEl = $('onboarding');
var tabsEl = $('tabs');
var messagesEl = $('messages');
var chatBottom = $('chat-bottom');
var chatInput = $('chat-input');
var sendBtn = $('send-btn');
var eventsList = $('events-list');
var eventsEmpty = $('events-empty');
var eventsCountEl = $('events-count');
var eventsBadge = $('events-badge');
var onbError = $('onboarding-error');
var dbgEventCount = $('dbg-event-count');
// ── Status
function setStatus(state, label) {
statusDot.className = 'status-dot ' + state;
statusLabel.textContent = label;
}
// ── Timestamp
function fmtTs(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
var p = function(n) { return String(n).padStart(2,'0'); };
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
// ── Scroll
function scrollBottom() { chatBottom.scrollIntoView({ behavior: 'smooth' }); }
// ── Tabs
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var tab = btn.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.pane').forEach(function(p) { p.classList.remove('active'); });
btn.classList.add('active');
$('pane-' + tab).classList.add('active');
if (tab === 'chat') {
if (_invoke) _invoke('panel_opened').catch(function(){});
clearBadge(); scrollBottom();
} else {
if (_invoke) _invoke('panel_closed').catch(function(){});
if (tab === 'events') clearBadge();
}
});
});
// ── Badge
function bumpBadge() {
newEventCount++;
var chatActive = document.querySelector('[data-tab="chat"]').classList.contains('active');
if (!chatActive) {
eventsBadge.textContent = newEventCount > 99 ? '99+' : String(newEventCount);
eventsBadge.classList.remove('hidden');
}
}
function clearBadge() { newEventCount = 0; eventsBadge.classList.add('hidden'); }
// ── Event cards -- built with DOM methods; no innerHTML for user data
function addEventCard(ev) {
eventsEmpty.classList.add('hidden');
var sev = (ev.severity || 'info').toLowerCase();
var card = document.createElement('div');
card.className = 'event-card severity-' + sev;
var header = document.createElement('div');
header.className = 'event-card-header';
var titleEl = document.createElement('span');
titleEl.className = 'event-title';
titleEl.textContent = ev.title; // textContent: safe
var sevBadge = document.createElement('span');
sevBadge.className = 'severity-badge ' + sev;
sevBadge.textContent = sev; // textContent: safe
header.appendChild(titleEl);
header.appendChild(sevBadge);
var bodyEl = document.createElement('div');
bodyEl.className = 'event-body';
bodyEl.textContent = ev.body; // textContent: safe
var metaEl = document.createElement('div');
metaEl.className = 'event-meta';
metaEl.textContent = ev.pattern_id + ' \xB7 ' + fmtTs(ev.timestamp); // textContent: safe
card.appendChild(header);
card.appendChild(bodyEl);
card.appendChild(metaEl);
var first = eventsList.querySelector('.event-card');
if (first) { eventsList.insertBefore(card, first); } else { eventsList.appendChild(card); }
eventCount++;
dbgEventCount.textContent = String(eventCount);
eventsCountEl.textContent = eventCount + ' event' + (eventCount !== 1 ? 's' : '');
bumpBadge();
}
// ── Chat: add a bubble
function addMsg(role, text) {
var div = document.createElement('div');
div.className = 'msg ' + role;
if (role !== 'system-msg') {
var lbl = document.createElement('div');
lbl.className = 'msg-role';
lbl.textContent = role === 'user' ? 'You' : 'Robin';
div.appendChild(lbl);
}
var body = document.createElement('div');
body.className = 'msg-content';
if (role === 'system-msg') {
body.textContent = text; // textContent: safe
} else if (role === 'user') {
body.textContent = text; // user text: no formatting needed
} else {
renderMdInto(body, text); // markdown renderer builds DOM nodes
}
div.appendChild(body);
messagesEl.insertBefore(div, chatBottom);
scrollBottom();
return div;
}
// ── Markdown renderer: builds DOM nodes, never assigns innerHTML with user data
// Supports: ```code blocks``` and `inline code`. All other text is text nodes.
function renderMdInto(container, text) {
// Split on fenced code blocks
var parts = text.split(/(```[\s\S]*?```)/g);
parts.forEach(function(part) {
if (part.startsWith('```')) {
var code = part.slice(3, -3).replace(/^\n/, '');
var pre = document.createElement('pre');
var codeEl = document.createElement('code');
codeEl.textContent = code; // textContent: safe, displays < > & correctly
pre.appendChild(codeEl);
container.appendChild(pre);
} else {
// Handle inline code within this segment
var inlineParts = part.split(/(`[^`\n]+`)/g);
inlineParts.forEach(function(seg) {
if (seg.startsWith('`') && seg.endsWith('`') && seg.length > 2) {
var codeEl = document.createElement('code');
codeEl.textContent = seg.slice(1, -1); // textContent: safe
container.appendChild(codeEl);
} else {
// Plain text: convert newlines to <br> using text nodes + br elements
var lines = seg.split('\n');
lines.forEach(function(line, i) {
container.appendChild(document.createTextNode(line));
if (i < lines.length - 1) container.appendChild(document.createElement('br'));
});
}
});
}
});
}
// ── Streaming
function startStream() {
var div = document.createElement('div');
div.className = 'msg assistant';
var lbl = document.createElement('div');
lbl.className = 'msg-role';
lbl.textContent = 'Robin';
div.appendChild(lbl);
var body = document.createElement('div');
body.className = 'msg-content';
var cursor = document.createElement('span');
cursor.className = 'typing-cursor';
body.appendChild(cursor);
div.appendChild(body);
messagesEl.insertBefore(div, chatBottom);
streamMsgEl = div;
streamBuf = '';
scrollBottom();
}
function appendToken(token) {
if (!streamMsgEl) startStream();
streamBuf += token;
// Rebuild the content node from scratch on each token.
// This is simple and correct; for very long responses it could be optimised
// by appending incrementally, but correctness wins here.
var body = streamMsgEl.querySelector('.msg-content');
while (body.firstChild) body.removeChild(body.firstChild);
renderMdInto(body, streamBuf);
var cursor = document.createElement('span');
cursor.className = 'typing-cursor';
body.appendChild(cursor);
scrollBottom();
}
function endStream() {
if (!streamMsgEl) return;
var body = streamMsgEl.querySelector('.msg-content');
while (body.firstChild) body.removeChild(body.firstChild);
renderMdInto(body, streamBuf);
streamMsgEl = null; streamBuf = '';
isSending = false;
sendBtn.disabled = false;
chatInput.disabled = false;
chatInput.focus();
}
// ── Send
async function sendMessage() {
var text = chatInput.value.trim();
if (!text || isSending || !_invoke) return;
isSending = true;
sendBtn.disabled = true; chatInput.disabled = true;
chatInput.value = ''; chatInput.style.height = 'auto';
addMsg('user', text);
try {
await _invoke('chat', { message: text });
} catch(err) {
isSending = false; sendBtn.disabled = false; chatInput.disabled = false;
addMsg('system-msg', 'Error: ' + String(err));
}
}
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
sendBtn.addEventListener('click', sendMessage);
chatInput.addEventListener('input', function() {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 100) + 'px';
});
// ── Clear events
$('clear-events-btn').addEventListener('click', function() {
eventsList.querySelectorAll('.event-card').forEach(function(c) { c.remove(); });
eventsEmpty.classList.remove('hidden');
eventCount = 0; dbgEventCount.textContent = '0';
eventsCountEl.textContent = 'No events yet'; clearBadge();
});
// ── Notification level
$('notif-level-select').addEventListener('change', function() {
if (_invoke) _invoke('update_notification_level', { level: this.value }).catch(function(e) {
console.error('update_notification_level:', e);
});
});
// ── Debug panel
function populateDebug(cfg) {
if (!cfg) return;
var m = cfg.migration;
if (m) {
$('dbg-source-os').textContent = m.source_os || '—';
$('dbg-distro').textContent = m.distro || '—';
$('dbg-distro-family').textContent = m.source_distro_family || '(auto)';
$('dbg-dual-boot').textContent = m.dual_boot_with || 'none';
$('dbg-fluency').textContent = (m.fluency_level || 0) + ' / 5';
} else {
$('dbg-source-os').textContent = '(not configured)';
}
$('dbg-ollama-url').textContent = (cfg.ollama && cfg.ollama.base_url) || '—';
$('dbg-ollama-model').textContent = (cfg.ollama && cfg.ollama.model) || '—';
$('dbg-tier').textContent = cfg.tier || 'free';
var lvl = (cfg.display && cfg.display.notification_level) || 'badge_and_toast';
$('notif-level-select').value = lvl;
probeOllama(cfg.ollama && cfg.ollama.base_url);
}
async function probeOllama(url) {
var el = $('dbg-ollama-status');
if (!url) { el.textContent = 'no URL'; el.className = 'val err'; return; }
try {
var res = await fetch(url + '/api/tags', { signal: AbortSignal.timeout(3000) });
if (res.ok) {
var data = await res.json();
var count = (data.models && data.models.length) || 0;
el.textContent = 'reachable (' + count + ' model' + (count !== 1 ? 's' : '') + ')';
el.className = 'val ok';
} else {
el.textContent = 'HTTP ' + res.status; el.className = 'val warn';
}
} catch(e) {
el.textContent = 'unreachable'; el.className = 'val err';
}
}
// ── Onboarding
function showOnboarding() {
onboardingEl.classList.remove('hidden'); tabsEl.classList.add('hidden');
document.querySelectorAll('.pane').forEach(function(p) { p.classList.remove('active'); });
setStatus('warn', 'setup needed');
}
function showMain() {
onboardingEl.classList.add('hidden'); tabsEl.classList.remove('hidden');
$('pane-chat').classList.add('active');
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelector('[data-tab="chat"]').classList.add('active');
if (_invoke) _invoke('panel_opened').catch(function(){});
setStatus('ok', 'ready');
}
$('onboarding-save').addEventListener('click', async function() {
onbError.classList.add('hidden');
var srcOs = $('src-os').value;
var distro = $('distro').value.trim() || 'unknown';
var dualBoot = $('dual-boot').value || null;
try {
await _invoke('complete_onboarding', {
sourceOs: srcOs, distro: distro, sourceDistro: null, dualBootWith: dualBoot
});
currentConfig = await _invoke('get_config');
populateDebug(currentConfig);
showMain();
addMsg('system-msg', '✓ Profile saved. Robin is watching your logs.');
} catch(err) {
onbError.textContent = String(err); onbError.classList.remove('hidden');
}
});
$('reset-onboarding-btn').addEventListener('click', showOnboarding);
// ── Init
async function init() {
_invoke = window.__TAURI__.core.invoke;
_listen = window.__TAURI__.event.listen;
await _listen('robin:chat-token', function(e) { appendToken(e.payload); });
await _listen('robin:chat-done', function() { endStream(); });
await _listen('robin:chat-error', function(e) {
endStream(); addMsg('system-msg', 'LLM error: ' + e.payload);
});
await _listen('robin:event', function(e) { addEventCard(e.payload); });
try {
currentConfig = await _invoke('get_config');
populateDebug(currentConfig);
} catch(e) { console.error('get_config:', e); setStatus('error', 'config error'); }
var needsSetup = true;
try { needsSetup = await _invoke('needs_onboarding'); } catch(e) {}
if (needsSetup) {
showOnboarding();
} else {
showMain();
try {
var pending = await _invoke('get_pending_events');
pending.forEach(addEventCard);
} catch(e) {}
}
}
function waitAndInit(tries) {
if (window.__TAURI__ && window.__TAURI__.core) {
init().catch(function(e) { setStatus('error', 'init error'); console.error('Robin init:', e); });
} else if (tries > 0) {
setTimeout(function() { waitAndInit(tries - 1); }, 100);
} else {
setStatus('error', 'IPC unavailable');
$('welcome-msg').textContent = 'Could not connect to the Robin backend — try restarting the app.';
}
}
waitAndInit(30);
</script>
</body>
</html>