1/* Admin-Ka v2 — client : chat Claude Code + sessions détachées + écosystème */2(() => {3 const $ = (id) => document.getElementById(id);4 const messagesEl = $('messages');5 const inputEl = $('input');6 const sendBtn = $('sendBtn');7 const stopBtn = $('stopBtn');8 const projectSel = $('projectSel');910 marked.setOptions({ breaks: true, mangle: false, headerIds: false });1112 // ---------- état ----------13 const store = JSON.parse(localStorage.getItem('adminka') || '{}');14 const settings = {15 model: store.model || 'fable',16 permMode: store.permMode || 'default',17 projectId: store.projectId || null,18 };19 function saveStore() { localStorage.setItem('adminka', JSON.stringify(settings)); }2021 let ws = null, wsRetry = 1000, wsWanted = true;22 let currentChat = null; // publicChat du serveur23 let isRunning = false;24 let live = null, liveThink = null;25 let curView = 'chat';26 let ecoData = null;27 const toolCards = new Map();28 const permCards = new Map();2930 const MODEL_LABEL = { fable: 'Fable 5', opus: 'Opus 5', sonnet: 'Sonnet 5', haiku: 'Haiku' };31 const PERM_LABEL = { default: 'normal', acceptEdits: 'acceptEdits', plan: 'plan', bypass: '⚠ bypass' };3233 // ---------- utilitaires ----------34 function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }35 function el(tag, cls, html) {36 const e = document.createElement(tag);37 if (cls) e.className = cls;38 if (html !== undefined) e.innerHTML = html;39 return e;40 }41 function fmt$(v) { return (v || 0) < 0.005 ? '<0,01 $' : (v).toFixed(2).replace('.', ',') + ' $'; }42 function fmtTok(v) { return v > 1000 ? Math.round(v / 1000) + 'k' : String(v || 0); }43 function timeAgo(ts) {44 if (!ts) return '—';45 const s = (Date.now() - ts) / 1000;46 if (s < 90) return 'à l’instant';47 if (s < 3600) return Math.round(s / 60) + ' min';48 if (s < 86400) return Math.round(s / 3600) + ' h';49 return Math.round(s / 86400) + ' j';50 }51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }5253 // ---------- navigation ----------54 const views = ['chat', 'sessions', 'eco', 'settings'];55 function switchView(name) {56 curView = name;57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);58 document.querySelectorAll('.nav-btn').forEach((b) => {59 b.classList.toggle('active', b.dataset.view === name);60 if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden');61 });62 if (name === 'sessions') loadSessions();63 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }64 if (name === 'settings') refreshSettingsView();65 }66 document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });67 function navBadge(view) {68 if (curView === view) return;69 const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`);70 if (b) b.classList.remove('hidden');71 }7273 // ---------- bannières ----------74 function banner(kind, html, onclick, ttl = 6000) {75 const b = el('div', 'banner ' + kind, html);76 if (onclick) b.onclick = () => { onclick(); b.remove(); };77 $('banners').appendChild(b);78 if (navigator.vibrate) navigator.vibrate(60);79 setTimeout(() => b.remove(), ttl);80 }8182 // ---------- scroll / messages ----------83 function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; }84 function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; }85 function addMsg(node) {86 $('welcome').classList.add('hidden');87 const wasNear = nearBottom();88 const w = workingEl(); if (w) messagesEl.insertBefore(node, w); else messagesEl.appendChild(node);89 scrollBottom(wasNear);90 }91 function renderMd(container, text) {92 container.innerHTML = marked.parse(text || '');93 container.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });94 }95 // bouton copier sur tous les blocs de code (délégué, marche pour tout <pre>)96 messagesEl.addEventListener('click', (e) => {97 const pre = e.target.closest('pre');98 if (!pre || e.target.closest('button') || pre.dataset.copying) return;99 if (window.getSelection().toString()) return;100 navigator.clipboard.writeText(pre.textContent).then(() => {101 pre.dataset.copying = '1';102 const tag = el('span', 'copied-tag', 'copié ✓');103 pre.appendChild(tag);104 setTimeout(() => { tag.remove(); delete pre.dataset.copying; }, 1200);105 }).catch(() => {});106 });107 function truncPre(text, limit = 3000) {108 const pre = el('pre');109 const full = String(text ?? '');110 if (full.length <= limit) { pre.textContent = full; return pre; }111 pre.textContent = full.slice(0, limit);112 const wrap = el('div');113 const btn = el('button', 'see-more', 'voir plus (' + Math.round((full.length - limit) / 1000) + ' k de plus)');114 btn.onclick = () => { pre.textContent = full; btn.remove(); };115 wrap.append(pre, btn);116 return wrap;117 }118119 function workingEl() { return messagesEl.querySelector('.working'); }120 let workTimer = null, workStart = 0;121 function setRunning(r) {122 isRunning = r;123 sendBtn.classList.toggle('hidden', r);124 stopBtn.classList.toggle('hidden', !r);125 let w = workingEl();126 if (r && !w) {127 $('welcome').classList.add('hidden');128 w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span><b class="work-time"></b>');129 messagesEl.appendChild(w);130 scrollBottom(true);131 workStart = Date.now();132 clearInterval(workTimer);133 workTimer = setInterval(() => {134 const t = w.querySelector('.work-time');135 if (!t) { clearInterval(workTimer); return; }136 const s = Math.floor((Date.now() - workStart) / 1000);137 t.textContent = s >= 60 ? Math.floor(s / 60) + ' min ' + (s % 60) + ' s' : s + ' s';138 }, 1000);139 } else if (!r && w) { w.remove(); clearInterval(workTimer); }140 if (!r) {141 finalizeLive(); finalizeThink();142 flushQueued();143 }144 }145 // file d'attente : un message tapé pendant l'exécution part dès que Claude a fini146 let queuedPrompt = null;147 function flushQueued() {148 if (!queuedPrompt) return;149 const q = queuedPrompt; queuedPrompt = null;150 const n = messagesEl.querySelector('.queued-notice'); if (n) n.remove();151 doSend(q);152 }153154 // ---------- streaming live ----------155 function ensureLive() {156 if (live) return live;157 const wrap = el('div', 'msg-claude');158 const md = el('div', 'md');159 wrap.appendChild(md);160 addMsg(wrap);161 live = { el: wrap, textEl: md, text: '' };162 return live;163 }164 function finalizeLive() { if (live) { live.el.remove(); live = null; } }165 function ensureThink() {166 if (liveThink) return liveThink;167 const box = el('div', 'thinking');168 addMsg(box);169 liveThink = { el: box, text: '' };170 return liveThink;171 }172 function finalizeThink() {173 if (!liveThink) return;174 const d = el('details', 'thinking-box', '<summary>réflexion</summary>');175 const inner = el('div', 'thinking'); inner.textContent = liveThink.text;176 d.appendChild(inner);177 liveThink.el.replaceWith(d);178 liveThink = null;179 }180181 // ---------- cartes outils ----------182 const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' };183 const TOOL_TINT = { Bash: 'bash', Edit: 'edit', MultiEdit: 'edit', Write: 'edit', NotebookEdit: 'edit', Read: 'read', Glob: 'read', Grep: 'read', LS: 'read', NotebookRead: 'read', WebFetch: 'web', WebSearch: 'web' };184 function toolSubtitle(name, input) {185 input = input || {};186 if (name === 'Bash') return input.command || '';187 if (input.file_path) return input.file_path.replace(/^\/Users\/[^/]+\//, '~/');188 if (input.pattern) return input.pattern;189 if (input.url) return input.url;190 if (name === 'Task') return input.description || '';191 if (name === 'TodoWrite') return 'liste de tâches';192 return '';193 }194 function diffBlock(oldStr, newStr) {195 const d = el('div', 'diff');196 for (const l of String(oldStr || '').split('\n')) d.appendChild(el('div', 'dl del', esc('- ' + l)));197 for (const l of String(newStr || '').split('\n')) d.appendChild(el('div', 'dl add', esc('+ ' + l)));198 return d;199 }200 function toolBody(name, input) {201 const body = el('div', 'tool-body');202 input = input || {};203 if (name === 'Bash') {204 body.appendChild(el('div', 'label', 'commande'));205 body.appendChild(truncPre(input.command));206 if (input.description) body.appendChild(el('div', 'meta-line', esc(input.description)));207 } else if (name === 'Edit') {208 body.appendChild(diffBlock(input.old_string, input.new_string));209 } else if (name === 'MultiEdit') {210 for (const e of input.edits || []) body.appendChild(diffBlock(e.old_string, e.new_string));211 } else if (name === 'Write') {212 body.appendChild(el('div', 'label', 'contenu'));213 const d = el('div', 'diff');214 const lines = String(input.content || '').split('\n');215 for (const l of lines.slice(0, 40)) d.appendChild(el('div', 'dl add', esc('+ ' + l)));216 if (lines.length > 40) d.appendChild(el('div', 'dl', '… ' + (lines.length - 40) + ' lignes de plus'));217 body.appendChild(d);218 } else if (name === 'TodoWrite') {219 const ul = el('ul', 'todos');220 for (const t of input.todos || []) {221 const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';222 ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)));223 }224 body.appendChild(ul);225 } else if (name === 'Task') {226 body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2)));227 } else if (name === 'ExitPlanMode') {228 const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md);229 } else {230 body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500));231 }232 return body;233 }234 function addToolCard(block) {235 finalizeLive(); finalizeThink();236 const name = block.name || '?';237 const shortName = name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name;238 const card = el('details', 'tool');239 const sum = el('summary');240 sum.innerHTML = `<span class="t-icon ${TOOL_TINT[name] || ''}">${TOOL_ICONS[name] || '⚙️'}</span><span class="t-name">${esc(shortName)}</span><span class="t-sub">${esc(toolSubtitle(name, block.input))}</span><span class="t-state pending">●</span>`;241 card.appendChild(sum);242 card.appendChild(toolBody(name, block.input));243 addMsg(card);244 toolCards.set(block.id, { card, stateEl: sum.querySelector('.t-state'), bodyEl: card.querySelector('.tool-body') });245 }246 function attachToolResult(block) {247 const t = toolCards.get(block.tool_use_id);248 if (!t) return;249 t.stateEl.className = 't-state ' + (block.is_error ? 'err' : 'ok');250 t.stateEl.textContent = block.is_error ? '✗' : '✓';251 let text = '';252 if (typeof block.content === 'string') text = block.content;253 else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n');254 if (text.trim()) {255 t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat'));256 t.bodyEl.appendChild(truncPre(text));257 }258 }259260 // ---------- permissions ----------261 function addPermCard(ev) {262 finalizeLive(); finalizeThink();263 const card = el('div', 'perm');264 const isPlan = ev.tool === 'ExitPlanMode';265 card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`;266 if (isPlan && ev.input?.plan) {267 const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md);268 } else {269 const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre);270 }271 const actions = el('div', 'perm-actions');272 const deny = el('button', 'perm-deny', 'Refuser');273 const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser');274 actions.append(deny, allow);275 if (!isPlan) {276 const always = el('button', 'perm-always', 'Toujours<br><small>cet outil</small>');277 always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true });278 actions.insertBefore(always, allow);279 // « Tout autoriser » : le serveur approuve TOUTES les permissions de la280 // session — la tâche va au bout même téléphone rangé281 const all = el('button', 'perm-allall', '🔓 Tout autoriser — finir la tâche sans redemander');282 all.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, allowAll: true });283 card.appendChild(actions);284 card.appendChild(all);285 } else {286 card.appendChild(actions);287 }288 deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false });289 allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true });290 addMsg(card);291 permCards.set(ev.requestId, card);292 scrollBottom(true);293 if (navigator.vibrate) navigator.vibrate([60, 60, 60]);294 }295 function resolvePermCard(ev) {296 const card = permCards.get(ev.requestId);297 if (!card) return;298 const actions = card.querySelector('.perm-actions');299 if (actions) actions.remove();300 const allBtn = card.querySelector('.perm-allall');301 if (allBtn) allBtn.remove();302 card.appendChild(el('div', 'perm-resolved',303 ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : ''))));304 }305306 // ---------- rendu des événements ----------307 function renderEvent(ev) {308 if (ev.type === 'user_prompt') {309 finalizeLive(); finalizeThink();310 const b = el('div', 'msg-user'); b.textContent = ev.text; addMsg(b);311 return;312 }313 if (ev.type === 'status') { setRunning(ev.state === 'running'); return; }314 if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; }315 if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; }316 if (ev.type === 'permission_request') { addPermCard(ev); return; }317 if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; }318 if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; }319 if (ev.type === 'claude') { renderClaude(ev.event); return; }320 }321322 function renderClaude(ev) {323 if (ev.type === 'system') {324 if (ev.subtype === 'init') {325 addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`)));326 } else if (ev.subtype === 'compact_boundary') {327 addMsg(el('div', 'meta-line', '⇣ contexte compacté'));328 } else if (ev.subtype && ev.subtype !== 'hook') {329 addMsg(el('div', 'meta-line', esc('⌁ ' + ev.subtype)));330 }331 return;332 }333 if (ev.type === 'stream_event' && ev.event) {334 const se = ev.event;335 if (se.type === 'content_block_delta' && se.delta) {336 if (se.delta.type === 'text_delta') {337 const l = ensureLive();338 l.text += se.delta.text;339 renderMd(l.textEl, l.text);340 scrollBottom();341 } else if (se.delta.type === 'thinking_delta') {342 const t = ensureThink();343 t.text += se.delta.thinking;344 t.el.textContent = t.text.slice(-600);345 scrollBottom();346 }347 }348 return;349 }350 if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {351 for (const block of ev.message.content) {352 if (block.type === 'text' && block.text && block.text.trim()) {353 finalizeLive(); finalizeThink();354 const wrap = el('div', 'msg-claude');355 const md = el('div', 'md');356 renderMd(md, block.text);357 wrap.appendChild(md);358 addMsg(wrap);359 } else if (block.type === 'tool_use') {360 addToolCard(block);361 } else if (block.type === 'thinking' && block.thinking) {362 if (liveThink) { liveThink.el.remove(); liveThink = null; }363 const d = el('details', 'thinking-box', '<summary>réflexion</summary>');364 const inner = el('div', 'thinking'); inner.textContent = block.thinking;365 d.appendChild(inner);366 addMsg(d);367 }368 }369 return;370 }371 if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {372 for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block);373 return;374 }375 if (ev.type === 'result') {376 finalizeLive(); finalizeThink();377 const dur = ev.duration_ms ? (ev.duration_ms / 1000).toFixed(1) + ' s' : '';378 const cost = ev.total_cost_usd ? ' · ' + ev.total_cost_usd.toFixed(3) + ' $' : '';379 const turns = ev.num_turns ? ' · ' + ev.num_turns + ' tours' : '';380 addMsg(el('div', 'meta-line', esc('— fin ' + dur + cost + turns)));381 if (ev.is_error && ev.result) addMsg(el('div', 'error-box', esc(String(ev.result))));382 return;383 }384 }385386 // ---------- méta de session (chips + bandeau) ----------387 function refreshChips() {388 const model = currentChat ? currentChat.model : settings.model;389 const perm = currentChat ? currentChat.permMode : settings.permMode;390 $('chipModel').textContent = MODEL_LABEL[model] || model;391 $('chipPerm').textContent = PERM_LABEL[perm] || perm;392 $('chipPerm').classList.toggle('warn', perm === 'bypass');393 $('chipModel').classList.add('orange');394 const meta = $('sessMeta');395 if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) {396 meta.classList.remove('hidden');397 $('metaModel').textContent = (currentChat.autoAllowAll ? '🔓 ' : '') + (currentChat.lastModel || MODEL_LABEL[model]);398 $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : '';399 $('metaCost').textContent = fmt$(currentChat.totalCost);400 $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok (' + Math.min(99, Math.round(currentChat.contextTokens / 2000)) + '%)' : '';401 } else meta.classList.add('hidden');402 }403404 // ---------- WebSocket ----------405 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }406 function setConn(state) {407 const pill = $('connPill');408 pill.className = 'conn-pill ' + state;409 $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';410 }411 function connect() {412 if (!wsWanted) return;413 setConn('mid');414 const proto = location.protocol === 'https:' ? 'wss' : 'ws';415 ws = new WebSocket(`${proto}://${location.host}/ws`);416 ws.onopen = () => {417 setConn('on');418 wsRetry = 1000;419 if (currentChat) send({ type: 'open', chatId: currentChat.id });420 if (curView === 'eco') send({ type: 'eco_sub' });421 };422 ws.onclose = () => {423 setConn('off');424 setTimeout(connect, wsRetry);425 wsRetry = Math.min(wsRetry * 1.6, 20000);426 };427 ws.onmessage = (e) => {428 let msg;429 try { msg = JSON.parse(e.data); } catch { return; }430 handleServer(msg);431 };432 }433 function rememberChat(id) { settings.lastChatId = id; saveStore(); }434 function handleServer(msg) {435 if (msg.type === 'chat_created') { currentChat = msg.chat; rememberChat(msg.chat.id); refreshChips(); return; }436 if (msg.type === 'chat_meta') {437 if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); }438 return;439 }440 if (msg.type === 'history') {441 currentChat = msg.chat;442 rememberChat(msg.chat.id);443 clearConversation(true);444 for (const ev of msg.events) { try { renderEvent(ev); } catch {} }445 setRunning(msg.running);446 for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;447 refreshChips();448 scrollBottom(true);449 return;450 }451 if (msg.type === 'notify') {452 if (msg.kind === 'done') {453 if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {454 banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId));455 navBadge('chat'); navBadge('sessions');456 }457 } else if (msg.kind === 'perm') {458 if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {459 banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000);460 navBadge('chat');461 }462 }463 return;464 }465 if (msg.type === 'eco_alert') {466 if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000);467 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));468 navBadge('eco');469 if (curView === 'eco') loadEco();470 return;471 }472 if (msg.type === 'eco') {473 if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items);474 return;475 }476 renderEvent(msg);477 }478479 function clearConversation(keepChat) {480 messagesEl.innerHTML = '';481 toolCards.clear(); permCards.clear();482 live = null; liveThink = null;483 if (!keepChat && !currentChat) $('welcome').classList.remove('hidden');484 }485 function openChat(chatId) {486 switchView('chat');487 currentChat = { id: chatId };488 clearConversation(true);489 send({ type: 'open', chatId });490 }491492 // ---------- composer ----------493 function doSend(text) {494 send({495 type: 'start',496 chatId: currentChat ? currentChat.id : null,497 project: projectSel.value,498 model: currentChat ? currentChat.model : settings.model,499 permMode: currentChat ? currentChat.permMode : settings.permMode,500 prompt: text,501 });502 }503 function sendPrompt() {504 const text = inputEl.value.trim();505 if (!text) return;506 // commandes slash locales507 if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; }508 if (text === '/cost') {509 if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`)));510 inputEl.value = ''; autoGrow(); return;511 }512 if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; }513 inputEl.value = '';514 autoGrow();515 hideSlash();516 if (isRunning) {517 // Claude travaille : le message part automatiquement dès la fin du tour518 queuedPrompt = queuedPrompt ? queuedPrompt + '\n' + text : text;519 let n = messagesEl.querySelector('.queued-notice');520 if (!n) { n = el('div', 'notice-line queued-notice'); messagesEl.appendChild(n); }521 n.textContent = '⏳ en attente de la fin du tour : « ' + queuedPrompt.slice(0, 80) + ' »';522 scrollBottom(true);523 return;524 }525 doSend(text);526 }527 sendBtn.onclick = sendPrompt;528 stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); };529 inputEl.addEventListener('keydown', (e) => {530 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); }531 });532 function autoGrow() {533 inputEl.style.height = 'auto';534 inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';535 }536 inputEl.addEventListener('input', () => {537 autoGrow();538 if (inputEl.value.startsWith('/')) showSlash(); else hideSlash();539 });540 inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300));541542 function showSlash() {543 const h = $('slashHint');544 h.classList.remove('hidden');545 if (!h.childElementCount) {546 for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) {547 const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`);548 b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); };549 h.appendChild(b);550 }551 }552 }553 function hideSlash() { $('slashHint').classList.add('hidden'); }554555 document.querySelectorAll('.hint').forEach((h) => {556 h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };557 });558559 // ---------- chips ----------560 $('chipModel').onclick = () => {561 const order = ['fable', 'opus', 'sonnet', 'haiku'];562 const cur = currentChat ? currentChat.model : settings.model;563 const next = order[(order.indexOf(cur) + 1) % order.length];564 applyOpts({ model: next });565 };566 $('chipPerm').onclick = () => {567 const order = ['default', 'acceptEdits', 'plan', 'bypass'];568 const cur = currentChat ? currentChat.permMode : settings.permMode;569 const next = order[(order.indexOf(cur) + 1) % 4];570 if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;571 applyOpts({ permMode: next });572 };573 function applyOpts(opts) {574 if (currentChat) {575 Object.assign(currentChat, opts);576 send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode });577 }578 if (opts.model) settings.model = opts.model;579 if (opts.permMode) settings.permMode = opts.permMode;580 saveStore();581 refreshChips();582 refreshSettingsView();583 }584 $('chipMore').onclick = () => {585 const c = currentChat;586 openSheet(`587 <div class="sheet-h"><h3>Session</h3></div>588 <div class="sheet-sec">589 <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div>590 <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div>591 <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div>592 <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div>593 <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div>594 </div>595 <div class="sheet-sec">596 <div class="set-actions">597 <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button>598 <button class="btn-ghost" id="shCompact">/compact</button>599 <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button>600 </div>601 </div>`);602 $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); };603 $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); };604 $('shNew').onclick = () => { closeSheet(); newChat(); };605 };606 function newChat() {607 currentChat = null;608 queuedPrompt = null;609 settings.lastChatId = null; saveStore();610 clearConversation();611 setRunning(false);612 refreshChips();613 switchView('chat');614 }615616 // ---------- sessions ----------617 async function loadSessions() {618 const r = await fetch('/api/chats');619 if (!r.ok) return;620 const { chats } = await r.json();621 const list = $('sessList');622 list.innerHTML = '';623 if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; }624 const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };625 for (const c of chats) {626 const card = el('div', 'sess-card');627 card.innerHTML = `628 <div class="sess-top">629 <span class="sess-title">${esc(c.title)}</span>630 <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span>631 </div>632 <div class="sess-info">633 <span>${esc(c.projectName || '')}</span>634 <span>${MODEL_LABEL[c.model] || c.model}</span>635 <span>${fmt$(c.totalCost)}</span>636 <span>${timeAgo(c.updated)}</span>637 </div>`;638 const actions = el('div', 'sess-actions');639 const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');640 open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };641 actions.appendChild(open);642 if (c.state === 'idle') {643 const del = el('button', 'del', 'Supprimer');644 del.onclick = async (e) => {645 e.stopPropagation();646 if (!confirm('Supprimer cette session ?')) return;647 await fetch('/api/chats/' + c.id, { method: 'DELETE' });648 card.remove();649 if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }650 };651 actions.appendChild(del);652 }653 card.appendChild(actions);654 card.onclick = () => openChat(c.id);655 list.appendChild(card);656 }657 }658 $('newSessBtn').onclick = newChat;659660 // incidents globaux (bouton en tête de l'onglet Écosystème)661 $('incBtn').onclick = async () => {662 const r = await fetch('/api/eco/incidents');663 if (!r.ok) return;664 const { incidents } = await r.json();665 const label = (app) => (ecoData?.sites.find(s => s.app === app)?.label) || app;666 openSheet(`667 <div class="sheet-h"><h3>Incidents</h3></div>668 <div class="sheet-sec">669 <h4>Historique — 50 derniers</h4>670 ${incidents.length ? incidents.map(i => `671 <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>672 <span><b>${esc(label(i.site))}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}673 ${i.ended ? ' → rétabli en ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — <b style="color:var(--bad)">EN COURS</b>'} · ${esc(i.reason || '')}</span></div>`).join('')674 : '<div style="color:var(--muted)">Aucun incident enregistré 🎉</div>'}675 </div>`);676 };677678 // ---------- écosystème ----------679 function siteStatus(s) {680 if (s.incident || (s.last && !s.last.ok)) return 'down';681 if (s.last && s.last.ms > 4000) return 'slow';682 return s.last ? 'up' : 'unknown';683 }684 const ST = {685 up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' },686 slow: { cls: 'slow', ico: '▲', lbl: 'LENT' },687 down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' },688 unknown: { cls: 'slow', ico: '▲', lbl: '…' },689 };690 function sparkline(spark, w = 120, h = 30) {691 if (!spark || spark.length < 2) return '<svg class="spark"></svg>';692 const ms = spark.map(p => p.ok ? p.ms : null);693 const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15;694 const step = w / (spark.length - 1);695 let d = '', pen = false;696 spark.forEach((p, i) => {697 if (!p.ok) { pen = false; return; }698 const x = i * step, y = h - 3 - (p.ms / max) * (h - 8);699 d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);700 pen = true;701 });702 const fails = spark.map((p, i) => !p.ok ? `<rect x="${(i * step - 1.5).toFixed(1)}" y="${h - 7}" width="3" height="5" fill="var(--bad)"/>` : '').join('');703 return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>${fails}</svg>`;704 }705 async function loadEco() {706 const [r, rn] = await Promise.all([fetch('/api/eco/summary'), fetch('/api/eco/nodes')]);707 if (!r.ok) return;708 ecoData = await r.json();709 renderEco();710 if (rn.ok) renderNodes(await rn.json());711 }712 function nbar(label, val, pct) {713 const c = pct > 88 ? 'var(--bad)' : pct > 70 ? 'var(--warn)' : 'var(--good)';714 return `<div class="nbar"><div class="nbar-l"><span>${label}</span><b>${val}</b></div>715 <div class="nbar-track"><div class="nbar-fill" style="width:${Math.max(2, Math.min(100, pct))}%;background:${c}"></div></div></div>`;716 }717 function renderNodes(data) {718 const grid = $('ecoNodes');719 if (!grid) return;720 grid.innerHTML = '';721 for (const n of data.nodes) {722 const L = n.last;723 const memPct = L && L.mem_total ? Math.round(L.mem_used / L.mem_total * 100) : null;724 const upApps = n.apps.filter(a => a.status === 'online').length;725 const badApp = n.apps.some(a => a.status && a.status !== 'online');726 const card = el('div', 'node-card' + (badApp ? ' warn' : ''));727 card.innerHTML = `728 <div class="node-top"><span class="node-name">${esc(n.node)}</span>729 <span class="node-apps${badApp ? ' bad' : ''}">${upApps}/${n.apps.length} apps</span></div>730 ${nbar('charge', L ? L.load1.toFixed(1) : '—', L ? Math.min(100, L.load1 / 32 * 100) : 0)}731 ${nbar('RAM', memPct !== null ? memPct + '%' : '—', memPct || 0)}732 ${nbar('disque', L ? L.disk_pct + '%' : '—', L ? L.disk_pct : 0)}`;733 grid.appendChild(card);734 }735 }736 function applyChecks(items) {737 if (!ecoData) return;738 for (const it of items) {739 const s = ecoData.sites.find(x => x.app === it.site);740 if (!s) continue;741 s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err };742 s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok });743 if (s.spark.length > 40) s.spark.shift();744 }745 renderEco();746 }747 function renderEco() {748 const sites = ecoData.sites;749 const up = sites.filter(s => siteStatus(s) === 'up').length;750 const down = sites.filter(s => siteStatus(s) === 'down').length;751 const lat = sites.filter(s => s.last?.ok).map(s => s.last.ms);752 const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : 0;753 $('ecoStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });754 $('ecoOverview').innerHTML = `755 <div class="eco-stat ok"><div class="v">${up}/${sites.length}</div><div class="l">en ligne</div></div>756 <div class="eco-stat ${down ? 'bad' : ''}"><div class="v">${down}</div><div class="l">hors ligne</div></div>757 <div class="eco-stat"><div class="v">${avg}<small style="font-size:12px"> ms</small></div><div class="l">latence moy.</div></div>`;758 const grid = $('ecoGrid');759 grid.innerHTML = '';760 const order = { down: 0, slow: 1, unknown: 2, up: 3 };761 for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)])) {762 const st = ST[siteStatus(s)];763 const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));764 card.innerHTML = `765 <div class="site-top">766 <span class="site-name">${esc(s.label)}</span>767 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>768 </div>769 <div class="site-mid">770 <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small></div>771 ${sparkline(s.spark)}772 </div>773 <div class="site-foot">774 <span>up 24h <b>${s.uptime24 ?? '—'}%</b></span>775 <span>p95 <b>${s.p95_24 ?? '—'} ms</b></span>776 <span>${esc(s.node)}</span>777 ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">pm2 <b>${esc(s.proc.status)}</b></span>` : ''}778 ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}779 </div>`;780 card.onclick = () => openSiteSheet(s.app);781 grid.appendChild(card);782 }783 }784785 async function openSiteSheet(app) {786 const r = await fetch('/api/eco/site/' + app);787 if (!r.ok) return;788 const d = await r.json();789 const s = ecoData.sites.find(x => x.app === app) || {};790 const st = ST[siteStatus(s)];791 const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;792 openSheet(`793 <div class="sheet-h">794 <h3>${esc(d.label)}</h3>795 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>796 </div>797 <div class="sheet-sec">798 <h4>Disponibilité</h4>799 <div class="tile-row">800 <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div>801 <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div>802 <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div>803 </div>804 <div style="margin-top:10px">${uptimeDays(d.days)}</div>805 </div>806 <div class="sheet-sec">807 <h4>Latence — 24 h</h4>808 ${latencyChart(d.series)}809 <div class="tile-row" style="margin-top:10px">810 <div class="tile"><div class="v">${s.avg24 ?? '—'}</div><div class="l">moy ms</div></div>811 <div class="tile"><div class="v">${s.p95_24 ?? '—'}</div><div class="l">p95 ms</div></div>812 <div class="tile"><div class="v">${s.last?.code || '—'}</div><div class="l">HTTP</div></div>813 </div>814 </div>815 <div class="sheet-sec">816 <h4>Service</h4>817 <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>818 <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>819 <div class="kv"><span>PM2</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>820 <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>821 <div class="kv"><span>Charge nœud</span><b>${s.nodeStat ? s.nodeStat.load1 + ' · RAM ' + Math.round(s.nodeStat.mem_used / s.nodeStat.mem_total * 100) + '% · disque ' + s.nodeStat.disk_pct + '%' : '—'}</b></div>822 <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>823 <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div>824 </div>825 ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => `826 <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>827 <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}828 ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''}829 ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => `830 <div class="inc-item"><span class="inc-dot">✗</span><span>${new Date(e.ts).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} · ${e.code ? 'HTTP ' + e.code : esc(e.err || '')}</span></div>`).join('')}</div>` : ''}831 <div class="sheet-sec">832 <h4>Actions d'administration</h4>833 <div class="admin-actions">834 <button class="btn-ghost" id="aRestart">↻ Redémarrer PM2</button>835 <button class="btn-ghost" id="aLogs">📜 Logs récents</button>836 <button class="btn-ghost" id="aCommits">⌥ Git & commits</button>837 <a class="btn-ghost" href="https://${esc(d.domain)}" target="_blank" rel="noopener">↗ Ouvrir le site</a>838 </div>839 <div id="aOut" class="admin-out hidden"></div>840 </div>841 <button class="btn-cta" style="width:100%" id="shChat">💬 Demander à Claude Code →</button>842 `);843 $('shChat').onclick = () => {844 closeSheet();845 newChat();846 for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value;847 };848 const out = () => $('aOut');849 const showOut = (html) => { out().classList.remove('hidden'); out().innerHTML = html; };850 $('aRestart').onclick = async () => {851 if (!confirm(`Redémarrer ${d.label} (pm2 restart) sur ${d.node} ?`)) return;852 showOut('<div class="admin-load">redémarrage…</div>');853 const r = await fetch('/api/admin/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: d.app, action: 'restart' }) });854 const j = await r.json();855 showOut(`<div class="${j.ok ? 'admin-ok' : 'admin-err'}">${j.ok ? '✓ redémarré' : '✗ échec'} — statut : ${esc(j.output || '')}</div>`);856 if (navigator.vibrate) navigator.vibrate(60);857 };858 $('aLogs').onclick = async () => {859 showOut('<div class="admin-load">chargement des logs…</div>');860 const r = await fetch('/api/admin/logs/' + d.app);861 const j = await r.json();862 showOut(`<pre>${esc((j.logs || '').slice(-4000))}</pre>`);863 };864 $('aCommits').onclick = async () => {865 showOut('<div class="admin-load">…</div>');866 const r = await fetch('/api/admin/commits/' + d.app);867 const j = await r.json();868 showOut(`<pre>${esc(j.text || '')}</pre>`);869 };870 attachChartTips();871 }872873 function uptimeDays(days) {874 if (!days) return '';875 const cells = days.map(dd => {876 const u = dd.uptime;877 const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)';878 return `<div title="${new Date(dd.day).toLocaleDateString('fr-CA')} — ${u === null ? 'aucune donnée' : u + '%'}" style="flex:1;height:22px;border-radius:5px;background:${c};border:1.5px solid var(--line)"></div>`;879 }).join('');880 return `<div style="display:flex;gap:4px">${cells}</div><div style="display:flex;justify-content:space-between;font-size:10px;color:var(--muted);font-family:var(--mono);margin-top:3px"><span>il y a 14 j</span><span>aujourd'hui</span></div>`;881 }882883 function latencyChart(series) {884 const W = 340, H = 130, PL = 38, PB = 18, PT = 8;885 if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>';886 const ok = series.filter(p => p.ok);887 const max = Math.max(...ok.map(p => p.ms), 100) * 1.15;888 const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6);889 const y = (ms) => PT + (1 - ms / max) * (H - PT - PB);890 let d = '', pen = false;891 const pts = [];892 for (const p of series) {893 if (!p.ok) { pen = false; continue; }894 const px = x(p.t), py = y(p.ms);895 d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1);896 pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t });897 pen = true;898 }899 const failMarks = series.filter(p => !p.ok).map(p =>900 `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join('');901 const gridLines = [0.25, 0.5, 0.75, 1].map(f => {902 const gy = y(max * f / 1.15);903 return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/>904 <text x="${PL - 5}" y="${gy + 3.5}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${Math.round(max * f / 1.15)}</text>`;905 }).join('');906 const lastPt = pts[pts.length - 1];907 const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/>908 <text x="${Math.min(lastPt.x, W - 40)}" y="${Math.max(lastPt.y - 8, 10)}" font-size="10" font-weight="800" fill="var(--ink)" font-family="var(--mono)">${lastPt.ms} ms</text>` : '';909 const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t);910 const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h';911 return `<div style="position:relative">912 <svg class="chart lat-chart" viewBox="0 0 ${W} ${H}" data-pts='${JSON.stringify(pts.map(p => ({ x: +p.x.toFixed(1), y: +p.y.toFixed(1), ms: p.ms, t: p.t })))}'>913 ${gridLines}914 <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>915 ${failMarks}${lastLbl}916 <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text>917 <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text>918 </svg></div>`;919 }920 function attachChartTips() {921 document.querySelectorAll('.lat-chart').forEach((svg) => {922 const pts = JSON.parse(svg.dataset.pts || '[]');923 if (!pts.length) return;924 const wrap = svg.parentElement;925 let tip = null;926 const show = (clientX) => {927 const rect = svg.getBoundingClientRect();928 const vx = (clientX - rect.left) / rect.width * 340;929 let best = pts[0];930 for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;931 if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); }932 tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`;933 tip.style.left = (best.x / 340 * rect.width) + 'px';934 tip.style.top = (best.y / 130 * rect.height) + 'px';935 };936 svg.addEventListener('pointerdown', (e) => show(e.clientX));937 svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); });938 svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } });939 });940 }941942 // rafraîchissement périodique de l'onglet éco (en plus du push)943 setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000);944 // retour au premier plan (iOS coupe le WS quand l'écran se verrouille) :945 // reconnexion IMMÉDIATE + resynchronisation de la conversation en cours946 document.addEventListener('visibilitychange', () => {947 if (document.hidden) return;948 if (!ws || ws.readyState !== 1) {949 wsRetry = 500;950 try { ws && ws.close(); } catch {}951 connect();952 } else if (currentChat) {953 send({ type: 'open', chatId: currentChat.id });954 }955 if (curView === 'eco') loadEco();956 });957 window.addEventListener('online', () => { wsRetry = 500; if (!ws || ws.readyState !== 1) connect(); });958959 // ---------- feuille ----------960 function openSheet(html) {961 $('sheetBody').innerHTML = html;962 $('sheet').classList.remove('hidden');963 }964 function closeSheet() { $('sheet').classList.add('hidden'); }965 $('sheetBack').onclick = closeSheet;966967 // ---------- réglages ----------968 function refreshSettingsView() {969 document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model));970 document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode));971 $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—';972 $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—';973 $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—';974 }975 document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); });976 document.querySelectorAll('#segPerm button').forEach(b => {977 b.onclick = () => {978 if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;979 applyOpts({ permMode: b.dataset.v });980 };981 });982 $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } };983 $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); };984 $('logoutBtn').onclick = async () => {985 wsWanted = false;986 await fetch('/api/logout', { method: 'POST' });987 location.reload();988 };989990 // ---------- login / init ----------991 async function init() {992 const me = await fetch('/api/me');993 if (!me.ok) {994 $('login').classList.remove('hidden');995 $('app').classList.add('hidden');996 return;997 }998 const info = await me.json();999 $('setHost').textContent = (info.node || 'M3U96a') + ':3300';1000 $('login').classList.add('hidden');1001 $('app').classList.remove('hidden');1002 const pr = await fetch('/api/projects');1003 const { projects } = await pr.json();1004 projectSel.innerHTML = '';1005 for (const p of projects) {1006 const o = document.createElement('option');1007 o.value = p.id; o.textContent = p.name + ' · ' + p.node;1008 projectSel.appendChild(o);1009 }1010 if (settings.projectId) projectSel.value = settings.projectId;1011 projectSel.onchange = () => { settings.projectId = projectSel.value; saveStore(); };1012 refreshChips();1013 refreshSettingsView();1014 // reprise automatique : on rouvre la dernière conversation (historique +1015 // live si une tâche tourne encore) même après fermeture complète de l'app1016 if (settings.lastChatId) currentChat = { id: settings.lastChatId };1017 connect();1018 }1019 $('loginBtn').onclick = doLogin;1020 $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });1021 async function doLogin() {1022 const r = await fetch('/api/login', {1023 method: 'POST',1024 headers: { 'Content-Type': 'application/json' },1025 body: JSON.stringify({ password: $('password').value }),1026 });1027 if (r.ok) { $('password').value = ''; init(); }1028 else {1029 const d = await r.json().catch(() => ({}));1030 $('loginError').textContent = d.error || 'Erreur';1031 }1032 }10331034 init();1035})();1036