bridgerator 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +76 -14
  2. package/assets/bridgerator.ico +0 -0
  3. package/assets/icon-16.png +0 -0
  4. package/assets/icon-20.png +0 -0
  5. package/assets/icon-24.png +0 -0
  6. package/assets/icon-256.png +0 -0
  7. package/assets/icon-32.png +0 -0
  8. package/assets/icon-48.png +0 -0
  9. package/assets/icon-64.png +0 -0
  10. package/assets/logo.svg +1 -0
  11. package/install.ps1 +455 -56
  12. package/npm-shrinkwrap.json +8621 -0
  13. package/package.json +55 -49
  14. package/recipes/backup-folder.json +31 -0
  15. package/recipes/clean-downloads.json +48 -0
  16. package/recipes/disk-report.json +66 -0
  17. package/recipes/sort-folder.json +49 -0
  18. package/recipes/update-apps.json +25 -0
  19. package/src/ask-lib.ps1 +230 -0
  20. package/src/ask.mjs +51 -0
  21. package/src/ask.ps1 +17 -0
  22. package/src/automations.mjs +673 -0
  23. package/src/batch.mjs +221 -0
  24. package/src/bridge.mjs +720 -29
  25. package/src/cli.mjs +529 -74
  26. package/src/confirm.mjs +418 -0
  27. package/src/dc-config-hooks.mjs +18 -8
  28. package/src/dc-isolate.mjs +5 -0
  29. package/src/delegate.mjs +555 -0
  30. package/src/docs-tool.mjs +494 -0
  31. package/src/doctor.mjs +200 -0
  32. package/src/events.mjs +21 -0
  33. package/src/extra-tools.mjs +122 -0
  34. package/src/guard.mjs +34 -0
  35. package/src/notice.mjs +84 -23
  36. package/src/pair.mjs +27 -4
  37. package/src/pc-check.mjs +112 -0
  38. package/src/pc-check.ps1 +103 -0
  39. package/src/pc-text.mjs +94 -0
  40. package/src/pdf-font.mjs +245 -0
  41. package/src/photos-tool.mjs +187 -0
  42. package/src/photos.ps1 +145 -0
  43. package/src/plan.mjs +90 -0
  44. package/src/policy.mjs +293 -124
  45. package/src/queue.mjs +194 -0
  46. package/src/recent.mjs +48 -0
  47. package/src/recipes.mjs +108 -0
  48. package/src/recycle.mjs +185 -0
  49. package/src/recycle.ps1 +110 -0
  50. package/src/screen-tool.mjs +185 -0
  51. package/src/screen.ps1 +285 -0
  52. package/src/search-tool.mjs +189 -0
  53. package/src/search.ps1 +129 -0
  54. package/src/settings-tool.mjs +186 -0
  55. package/src/setup-tool.mjs +129 -0
  56. package/src/software-tool.mjs +185 -0
  57. package/src/telemetry.mjs +86 -0
  58. package/src/tool-lib.mjs +177 -0
  59. package/src/tool-text.mjs +45 -0
  60. package/src/tray.mjs +97 -0
  61. package/src/tray.ps1 +359 -0
  62. package/src/undo.mjs +313 -0
  63. package/src/win.mjs +17 -0
  64. package/ui/app.css +388 -0
  65. package/ui/app.js +868 -0
  66. package/ui/i18n/en.json +598 -0
  67. package/ui/i18n/ru.json +598 -0
  68. package/ui/index.html +26 -0
  69. package/vendor/webview2/LICENSE.txt +27 -0
  70. package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
  71. package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
  72. package/vendor/webview2/NOTICE.txt +85 -0
  73. package/vendor/webview2/SHA256SUMS.txt +4 -0
  74. package/vendor/webview2/SOURCE.txt +1 -0
  75. package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
  76. package/vendor/webview2/x64/WebView2Loader.dll +0 -0
package/ui/app.js ADDED
@@ -0,0 +1,868 @@
1
+ // Bridgerator tray window: one page app, no framework. Talks to the tray helper / bridge only over
2
+ // window.chrome.webview.postMessage ({id, method, params} → {id, result | error}).
3
+ 'use strict';
4
+
5
+ const $ = (s, el = document) => el.querySelector(s);
6
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
+ const wv = window.chrome && window.chrome.webview;
8
+
9
+ // ---- host link ---------------------------------------------------------------------------------
10
+ let seq = 0;
11
+ const waiting = new Map();
12
+ function rpc(method, params = {}, files, timeout = 180000) {
13
+ if (!wv) return Promise.reject(new Error('not in the Bridgerator window'));
14
+ const id = `u${++seq}`;
15
+ return new Promise((resolve, reject) => {
16
+ const timer = timeout > 0 ? setTimeout(() => { if (waiting.delete(id)) reject(new Error('no answer')); }, timeout) : null;
17
+ waiting.set(id, { resolve, reject, timer });
18
+ const msg = { id, method, params };
19
+ if (files && files.length && wv.postMessageWithAdditionalObjects) wv.postMessageWithAdditionalObjects(msg, files); else wv.postMessage(msg);
20
+ });
21
+ }
22
+ if (wv) wv.addEventListener('message', (e) => {
23
+ const m = e.data;
24
+ if (m && m.t === 'nav' && typeof m.page === 'string') { go(m.page); return; }
25
+ const w = m && waiting.get(m.id);
26
+ if (!w) return;
27
+ waiting.delete(m.id); clearTimeout(w.timer);
28
+ if (m.error) w.reject(new Error(m.error)); else w.resolve(m.result);
29
+ });
30
+
31
+ // ---- strings -------------------------------------------------------------------------------------
32
+ let S = {};
33
+ const t = (key, vars = {}) => {
34
+ let v = key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), S);
35
+ if (typeof v !== 'string') return key;
36
+ return v.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? vars[k] : `{${k}}`));
37
+ };
38
+ const nf = () => new Intl.NumberFormat(state.settings?.language === 'ru' ? 'ru-RU' : 'en-US');
39
+ async function loadLang(lang) {
40
+ S = await (await fetch(`i18n/${lang === 'ru' ? 'ru' : 'en'}.json`)).json();
41
+ document.documentElement.lang = lang === 'ru' ? 'ru' : 'en';
42
+ document.querySelectorAll('[data-i18n-aria]').forEach((el) => el.setAttribute('aria-label', t(el.dataset.i18nAria)));
43
+ }
44
+
45
+ // ---- state -------------------------------------------------------------------------------------
46
+ let state = { state: 'offline', name: '', version: '', settings: { confirm: { enabled: false, ask: {}, timeoutSec: 120 }, allowedDirectories: [], readOnlyDirectories: [] }, queue: {} };
47
+ let queueItems = [], recentItems = [], undoInfo = null;
48
+ let shownSetup = null, shownBatch = null;
49
+ let page = 'home', qFilter = 'all', rSearch = '', rResult = 'all', helpTopic = 'talk', previewTheme = null, updateInfo = null, safetyTab = 'access';
50
+ const sysDark = window.matchMedia('(prefers-color-scheme: dark)');
51
+ function applyTheme() {
52
+ const th = state.settings?.theme || 'system';
53
+ document.documentElement.dataset.theme = th === 'system' ? (sysDark.matches ? 'dark' : 'light') : th;
54
+ }
55
+ sysDark.addEventListener('change', applyTheme);
56
+
57
+ // One refresh at a time (the 2 s poll never stacks on a slow answer); a call made meanwhile (the
58
+ // person's action, refresh(true)) is not dropped: ONE more run follows the current one (full wins).
59
+ let refreshing = null, refreshNext = null;
60
+ function refresh(full) {
61
+ if (refreshing) {
62
+ if (!refreshNext) { let done; const p = new Promise((r) => { done = r; }); refreshNext = { full: false, p, done }; }
63
+ refreshNext.full ||= !!full;
64
+ return refreshNext.p;
65
+ }
66
+ refreshing = refreshOnce(full).finally(() => {
67
+ refreshing = null;
68
+ const n = refreshNext; refreshNext = null;
69
+ if (n) refresh(n.full).then(n.done, n.done);
70
+ });
71
+ return refreshing;
72
+ }
73
+ async function refreshOnce(full) {
74
+ try {
75
+ const prevLang = state.settings?.language;
76
+ const next = await rpc('getState');
77
+ if (next.settings.language !== prevLang) await loadLang(next.settings.language);
78
+ state = next;
79
+ applyTheme();
80
+ if (full || page === 'queue' || page === 'home') queueItems = await rpc('queue.list');
81
+ if (full || page === 'recent' || page === 'home') recentItems = await rpc('recent.list');
82
+ if (full || page === 'recent' || page === 'safety') { const u = await rpc('undo.list'); undoInfo = u && Array.isArray(u.actions) ? u : null; }
83
+ } catch { /* keep the last view */ }
84
+ if (state.pendingSetup && state.pendingSetup.id !== shownSetup && !document.querySelector('.overlay')) { shownSetup = state.pendingSetup.id; setupDialog(state.pendingSetup); }
85
+ if (state.pendingBatch && state.pendingBatch.id !== shownBatch && !document.querySelector('.overlay')) { shownBatch = state.pendingBatch.id; batchDialog(state.pendingBatch); }
86
+ // Don't redraw under the person's hands: an open dialog / menu or a field being typed in.
87
+ const typing = document.activeElement && document.activeElement.matches('#main input, #main textarea, #main select');
88
+ if (!document.querySelector('.overlay, .menu') && !typing) render();
89
+ else renderNav();
90
+ }
91
+
92
+ // A path shows its last segment; a command line or a URL shows whole (its last "segment" is a flag or a piece).
93
+ function lastTarget(x) {
94
+ const s = String(x ?? '');
95
+ return /^([A-Za-z]:[\\/]|\\\\|\/)/.test(s) ? (s.split(/[\\/]/).filter(Boolean).pop() || s) : s;
96
+ }
97
+
98
+ // ---- icons ----------------------------------------------------------------------------------------
99
+ const I = {
100
+ home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20h14V9.5"></path><path d="M10 20v-6h4v6"></path>',
101
+ queue: '<path d="m4 6 1.5 1.5L8 5"></path><path d="m4 12 1.5 1.5L8 11"></path><path d="M4 18h4"></path><path d="M11 6h9"></path><path d="M11 12h9"></path><path d="M11 18h9"></path>',
102
+ recent: '<circle cx="12" cy="12" r="8.5"></circle><path d="M12 7.5V12l3 2"></path>',
103
+ safety: '<path d="M12 3 5 6v5.5c0 4.3 2.9 8 7 9.5 4.1-1.5 7-5.2 7-9.5V6l-7-3Z"></path><path d="m9 12 2 2 4-4"></path>',
104
+ notifications: '<path d="M6 9a6 6 0 0 1 12 0c0 6 2.5 7.5 2.5 7.5h-17S6 15 6 9Z"></path><path d="M10 20a2 2 0 0 0 4 0"></path>',
105
+ settings: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"></path>',
106
+ help: '<circle cx="12" cy="12" r="8.5"></circle><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.3-2.4 3.8"></path><path d="M12 17h.01"></path>',
107
+ automations: '<rect x="3.5" y="5" width="17" height="15" rx="2.5"></rect><path d="M3.5 9.5h17"></path><path d="M8 3v4M16 3v4"></path><path d="m10.5 12.5 4 2.25-4 2.25z"></path>',
108
+ delegated: '<path d="M4 6h10"></path><path d="M4 12h7"></path><path d="M4 18h10"></path><path d="M16 10l4 4-4 4"></path><path d="M13 14h7"></path>',
109
+ agents: '<circle cx="7" cy="8" r="3"></circle><circle cx="17" cy="8" r="3"></circle><path d="M2.5 19a4.5 4.5 0 0 1 9 0"></path><path d="M12.5 19a4.5 4.5 0 0 1 9 0"></path><path d="M10 8h4"></path>',
110
+ };
111
+ const ico = (k, size = 18, w = 1.8) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${I[k]}</svg>`;
112
+ const CHECK = (size, w, color = 'currentColor') => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"></path></svg>`;
113
+ const LOGO16 = '<svg width="16" height="16" viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" rx="15" fill="#2251E6"></rect><path d="M17 42 Q32 12 47 42" fill="none" stroke="#fff" stroke-width="5.5" stroke-linecap="round"></path><circle cx="17" cy="44" r="6.5" fill="#fff"></circle><circle cx="47" cy="44" r="6.5" fill="#7fe0ff"></circle></svg>';
114
+ const LOCK = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="lock"><rect x="5" y="11" width="14" height="9" rx="2"></rect><path d="M8 11V8a4 4 0 0 1 8 0v3"></path></svg>';
115
+ const PAGES = ['home', 'queue', 'automations', 'agents', 'delegated', 'recent', 'safety', 'notifications', 'settings', 'help'];
116
+
117
+ // ---- sidebar --------------------------------------------------------------------------------------
118
+ function renderNav() {
119
+ const todo = (state.queue && state.queue.todo) || 0;
120
+ const st = state.state || 'offline';
121
+ $('#nav').innerHTML = PAGES.map((k) => `<a href="#${k}" ${k === page ? 'aria-current="page"' : ''}>${ico(k)}<span class="grow" data-t="${esc(t(`nav.${k}`))}">${esc(t(`nav.${k}`))}</span>${k === 'queue' && todo ? `<span class="badge">${todo}</span>` : ''}</a>`).join('')
122
+ + `<div class="grow"></div><div class="nav-status"><div class="line"><span class="dot ${esc(st)}"></span>${esc(t(`state.${st}`))}</div><div class="sub">${esc(state.name || '—')} · v${esc(state.version)}</div></div>`;
123
+ }
124
+
125
+ // ---- plan banner ------------------------------------------------------------------------------------
126
+ const BICO = {
127
+ pro_ending: '<circle cx="12" cy="12" r="8.5"></circle><path d="M12 7.5V12l3 2"></path>',
128
+ pro_ended: '<circle cx="12" cy="12" r="8.5"></circle><path d="M12 8v5M12 16h.01"></path>',
129
+ free_high: '<path d="M4 19h16"></path><path d="M7 15v-4M12 15V7M17 15v-6"></path>',
130
+ limit: '<circle cx="12" cy="12" r="8.5"></circle><path d="M6 6l12 12"></path>',
131
+ };
132
+ function bannerHtml() {
133
+ const b = state.banner;
134
+ if (!b || !b.visible) return '';
135
+ const loc = state.settings.language === 'ru' ? 'ru-RU' : 'en-US';
136
+ const date = (d) => new Date(d.length === 10 ? `${d}T00:00:00` : d).toLocaleDateString(loc, { day: 'numeric', month: 'short' });
137
+ const text = b.kind === 'pro_ending' ? (b.days <= 1 ? t('plan.proEnding1', { date: date(state.plan?.until || new Date().toISOString()) }) : t('plan.proEnding', { days: b.days, date: date(state.plan?.until || new Date().toISOString()) }))
138
+ : b.kind === 'pro_ended' ? t('plan.proEnded')
139
+ : b.kind === 'free_high' ? t('plan.freeHigh', { calls: nf().format(b.calls), limit: nf().format(b.limit) })
140
+ : t('plan.limit', { date: date(b.reset) });
141
+ return `<div class="banner ${b.tone}" role="${b.kind === 'limit' ? 'alert' : 'status'}"><svg class="bico" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">${BICO[b.kind] || ''}</svg><span class="txt">${esc(text)}</span>`
142
+ + `<button class="btn bbtn" data-act="plan">${esc(t(b.action === 'renew' ? 'plan.renew' : 'plan.upgrade'))}</button>`
143
+ + (b.dismissible ? `<button class="icon-btn bx" data-act="dismissBanner" aria-label="${esc(t('plan.dismiss'))}" title="${esc(t('plan.dismiss'))}">✕</button>` : '<span class="bspace"></span>') + '</div>';
144
+ }
145
+
146
+ // ---- pages ------------------------------------------------------------------------------------------
147
+ function ago(ts) {
148
+ const m = Math.round((Date.now() - ts) / 60000);
149
+ if (m < 1) return t('status.agoNow');
150
+ if (m < 60) return t('status.agoMin', { n: m });
151
+ if (m < 1440) return t('status.agoHour', { n: Math.round(m / 60) });
152
+ return t('status.agoDay', { n: Math.round(m / 1440) });
153
+ }
154
+ const toolName = (k) => { const v = t(`tools.${k}`); return v === `tools.${k}` ? k : v; };
155
+ function planLine() {
156
+ const p = state.plan;
157
+ const parts = [];
158
+ if (state.email) parts.push(state.email);
159
+ if (p) {
160
+ if (p.plan === 'pro') parts.push(p.until ? t('status.proUntil', { date: new Date(p.until).toLocaleDateString(state.settings.language === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }) }) : t('status.proPlan'));
161
+ else parts.push(t('status.freePlan'));
162
+ }
163
+ return parts.join(' · ');
164
+ }
165
+
166
+ const P = {};
167
+ // Keep-awake (on by default, mains power only): what the tray helper is doing right now.
168
+ function awakeLine() {
169
+ const a = state.awake, s = state.settings;
170
+ if (!a) return '';
171
+ const k = s.keepAwake === false ? 'status.awakeOff' : a.on ? 'status.awakeOn' : a.want && !a.ac ? 'status.awakeBattery' : 'status.awakeIdle';
172
+ return `<div class="awake-line ${a.on ? 'on' : ''}"><span class="dot ${a.on ? 'online' : 'offline'}"></span>${esc(t(k))}</div>`;
173
+ }
174
+ P.home = () => {
175
+ const st = state.state || 'offline';
176
+ const c = state.queue || {};
177
+ const ro = (state.settings.readOnlyDirectories || []).length;
178
+ const last = recentItems[0];
179
+ const p = state.plan;
180
+ const usage = (p && p.plan === 'free' && p.limit ? `<div class="usage"><div class="bar"><div class="b-blue" data-w="${Math.min(100, Math.round(((p.calls || 0) / p.limit) * 100))}"></div></div><span>${esc(t('status.calls', { calls: nf().format(p.calls || 0), limit: nf().format(p.limit) }))}</span></div>` : '') + forecastLine();
181
+ const anyTodo = queueItems.length > 0;
182
+ return `<main class="home">${bannerHtml()}
183
+ <div class="head"><h1>${esc(t('status.title'))}</h1><div class="sub">${esc(t('status.sub'))}</div></div>
184
+ <section class="card conn" aria-label="${esc(t('aria.connection'))}">
185
+ <div class="conn-icon ${st}">${st === 'online' ? CHECK(22, 2) : st === 'paused' ? '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="7" y="6" width="3.5" height="12" rx="1"></rect><rect x="13.5" y="6" width="3.5" height="12" rx="1"></rect></svg>' : '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"></path></svg>'}</div>
186
+ <div class="conn-main"><div class="title">${esc(st === 'online' ? t('status.online', { name: state.name }) : st === 'paused' ? t('status.paused') : t('status.offline'))}</div>
187
+ <div class="sub">${esc(planLine())}</div>${usage}${awakeLine()}</div>
188
+ <div class="conn-btns"><button class="btn btn-primary w148" data-act="openAccount">${esc(t('status.openAccount'))}</button><button class="btn btn-secondary w148" data-act="${state.paused ? 'resume' : 'pause'}">${esc(t(state.paused ? 'status.resume' : 'status.pause'))}</button></div>
189
+ </section>
190
+ <div class="tiles">
191
+ <a class="card tile" href="#queue"><span class="k">${esc(t('status.cardQueue'))}</span><span class="v">${esc(t('status.toDo', { n: c.todo || 0 }))}</span><span class="s">${esc(t('status.queueSub', { doing: c.doing || 0, done: c.done || 0 }))}</span></a>
192
+ <a class="card tile" href="#safety"><span class="k">${esc(t('status.cardConfirm'))}</span><span class="v">${esc(t(state.settings.confirm.enabled ? 'status.on' : 'status.off'))}</span><span class="s">${esc(ro === 1 ? t('status.roFolder1') : t('status.roFolders', { n: ro }))}</span></a>
193
+ <a class="card tile" href="#recent"><span class="k">${esc(t('status.cardLast'))}</span><span class="v">${esc(last ? ago(last.t) : t('status.never'))}</span><span class="s" title="${esc(last ? `${last.tool} · ${last.target}` : '')}">${esc(last ? [last.tool, lastTarget(last.target)].filter(Boolean).join(' · ') : t('status.noActions'))}</span></a>
194
+ </div>
195
+ <section class="card steps" aria-label="${esc(t('aria.getStarted'))}">
196
+ <div class="step ok"><span class="check ok">${CHECK(12, 3.5)}</span><span class="grow t">${esc(t('status.step1'))}</span></div>
197
+ <div class="step ${state.setupDone ? 'ok' : ''}"><span class="check ${state.setupDone ? 'ok' : 'next'}">${state.setupDone ? CHECK(12, 3.5) : ''}</span><div class="stack"><span class="t">${esc(t('status.step2'))}</span>${state.setupDone ? '' : `<span class="h">${esc(t('status.step2Hint'))} <span class="mono">${esc(t('status.step2Phrase'))}</span></span>`}</div>${state.setupDone ? '' : `<button class="btn btn-secondary w112" data-copy="${esc(t('status.step2Phrase'))}">${esc(t('status.copyPhrase'))}</button>`}</div>
198
+ <div class="step ${anyTodo ? 'ok' : ''}"><span class="check ${anyTodo ? 'ok' : state.setupDone ? 'next' : ''}">${anyTodo ? CHECK(12, 3.5) : ''}</span><div class="stack"><span class="t">${esc(t('status.step3'))}</span>${anyTodo ? '' : `<span class="h">${esc(t('status.step3Hint'))} <span class="mono">${esc(t('talk.work'))}</span></span>`}</div>${anyTodo ? '' : `<a class="btn btn-secondary w112" href="#queue">${esc(t('status.openQueue'))}</a>`}</div>
199
+ </section></main>`;
200
+ };
201
+
202
+ const STATUSES = ['todo', 'doing', 'done', 'failed', 'skipped'];
203
+ P.queue = () => {
204
+ const items = queueItems;
205
+ const count = (s) => items.filter((i) => i.status === s).length;
206
+ const total = items.length;
207
+ const pct = (n) => (total ? Math.round((n / total) * 100) : 0);
208
+ const rows = items.filter((i) => qFilter === 'all' || i.status === qFilter).map((i) => {
209
+ const note = i.status === 'doing' ? (i.note || (i.worker ? `ChatGPT (${i.worker}) · ${ago(Date.parse(i.updatedAt))}` : t('queue.noteDoing', { ago: ago(Date.parse(i.updatedAt)) }))) : (i.note || (i.paths || []).join(', ') || i.details || '');
210
+ return `<div class="qrow" role="listitem" draggable="true" data-id="${esc(i.id)}">
211
+ <span class="grip" title="${esc(t('queue.drag'))}"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="9" cy="6" r="1.6"></circle><circle cx="15" cy="6" r="1.6"></circle><circle cx="9" cy="12" r="1.6"></circle><circle cx="15" cy="12" r="1.6"></circle><circle cx="9" cy="18" r="1.6"></circle><circle cx="15" cy="18" r="1.6"></circle></svg></span>
212
+ <span class="sdot ${i.status}" aria-hidden="true"></span>
213
+ <div class="qtext"><span class="t ${i.status === 'done' ? 'done' : ''}">${esc(i.title)}</span><span class="n">${esc(i.from ? `${t('queue.fromAi', { from: i.from })} · ${note}` : note)}</span></div>
214
+ <span class="qpill"><span class="pill t-${i.status}">${esc(t(`queue.${i.status}`))}</span></span>
215
+ <button class="icon-btn" data-act="qmenu" data-id="${esc(i.id)}" aria-label="${esc(t('queue.more'))}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"></circle><circle cx="12" cy="12" r="1.8"></circle><circle cx="19" cy="12" r="1.8"></circle></svg></button></div>`;
216
+ }).join('');
217
+ const filters = ['all', 'todo', 'doing', 'done', 'failed'].map((k) => `<button role="tab" aria-selected="${k === qFilter}" data-filter="${k}">${esc(t(`queue.${k}`))}<span class="n">${k === 'all' ? total : count(k)}</span></button>`).join('');
218
+ return `<main class="queue">${bannerHtml()}
219
+ <div class="head-row"><div class="head grow"><h1>${esc(t('queue.title'))}</h1><div class="sub">${esc(t('queue.sub'))}</div></div>
220
+ <button class="btn btn-secondary w96" data-act="clearMenu" aria-haspopup="menu">${esc(t('queue.clear'))}</button><button class="btn btn-primary w128" data-act="addTask"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg>${esc(t('queue.add'))}</button></div>
221
+ <div class="usage"><div class="bar"><div class="b-green" data-w="${pct(count('done'))}"></div><div class="b-blue" data-w="${pct(count('doing'))}"></div><div class="b-red" data-w="${pct(count('failed'))}"></div></div><span>${esc(t('queue.progress', { done: count('done'), total }))}</span></div>
222
+ <div role="tablist" class="filters" aria-label="${esc(t('aria.filter'))}">${filters}</div>
223
+ <div role="list" class="card list" id="qlist">${rows || `<div class="empty">${esc(t('queue.empty'))}</div>`}</div>
224
+ <div class="hintbar"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hint-ico"><path d="M21 12a8.5 8.5 0 0 1-12.4 7.6L3 21l1.4-5.6A8.5 8.5 0 1 1 21 12Z"></path></svg>
225
+ <div class="hint-rows">${[['queue.hint', 'talk.work'], ['queue.hintContinue', 'talk.continue']].map(([l, ph]) => `<span class="txt">${esc(t(l))}</span><span class="mono phrase-chip">${esc(t(ph))}</span><button class="btn btn-hint" data-copy="${esc(t(ph))}">${esc(t('queue.copyPhrase'))}</button>`).join('')}</div></div></main>`;
226
+ };
227
+
228
+ P.recent = () => {
229
+ const cls = (o) => (o === 'ok' ? 'done' : o === 'not confirmed' ? 'denied' : o === 'paused' ? 'paused' : 'error');
230
+ const label = (o) => t(o === 'ok' ? 'recent.rDone' : o === 'not confirmed' ? 'recent.rDenied' : o === 'paused' ? 'recent.rPaused' : 'recent.rError');
231
+ const q = rSearch.toLowerCase();
232
+ const rows = recentItems.filter((e) => (rResult === 'all' || cls(e.outcome) === rResult) && (!q || `${e.tool} ${e.target}`.toLowerCase().includes(q))).map((e) => {
233
+ const d = new Date(e.t);
234
+ const u = e.undo && undoInfo ? undoInfo.actions.find((a) => a.id === e.undo) : null;
235
+ const ub = u && u.noUndo && !u.undone ? `<span class="hint">${esc(t('recent.noUndo'))}</span>` : u && !u.undone ? `<button class="linkbtn" data-undo="${esc(u.id)}">${esc(t('recent.undo'))}</button>` : u && u.undone ? `<span class="hint">${esc(t('recent.undone'))}</span>` : '<span></span>';
236
+ return `<div class="trow"><span class="time">${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}</span><span>${esc(toolName(e.tool))}</span><span class="cmd" title="${esc(e.target)}">${esc(e.target)}</span><span class="pill t-${cls(e.outcome)}">${esc(label(e.outcome))}</span>${ub}</div>`;
237
+ }).join('');
238
+ const opt = (v, k) => `<option value="${v}" ${rResult === v ? 'selected' : ''}>${esc(t(k))}</option>`;
239
+ return `<main class="recent">${bannerHtml()}
240
+ <div class="head"><h1>${esc(t('recent.title'))}</h1><div class="sub">${esc(t('recent.sub'))}</div></div>
241
+ <div class="head-row"><div class="searchbox"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" class="muted"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg>
242
+ <label for="r-search" class="sr">${esc(t('recent.search'))}</label><input id="r-search" placeholder="${esc(t('recent.search'))}" value="${esc(rSearch)}"></div>
243
+ <label for="r-result" class="sr">${esc(t('recent.result'))}</label><select id="r-result" class="select w150">${opt('all', 'recent.allResults')}${opt('done', 'recent.rDone')}${opt('error', 'recent.rError')}${opt('denied', 'recent.rDenied')}</select>
244
+ <button class="btn btn-secondary" data-act="undoDialog">${esc(t('recent.undoAll'))}</button><button class="btn btn-secondary w96" data-act="clearRecent">${esc(t('recent.clear'))}</button></div>
245
+ <div class="card table"><div class="trow th"><span>${esc(t('recent.time'))}</span><span>${esc(t('recent.action'))}</span><span>${esc(t('recent.target'))}</span><span>${esc(t('recent.result'))}</span><span></span></div>
246
+ <div class="tbody">${rows || `<div class="empty">${esc(t('recent.empty'))}</div>`}</div></div>
247
+ <div class="toggle-line"><button class="switch" role="switch" aria-checked="${!!state.settings.localHistory}" data-set="localHistory" aria-label="${esc(t('recent.keep'))}"></button><span>${esc(t('recent.keep'))}</span></div></main>`;
248
+ };
249
+
250
+ const CATS = ['delete', 'dangerous_commands', 'write_files', 'move', 'any_command', 'install', 'scheduled_scripts'];
251
+ P.safety = () => {
252
+ const c = state.settings.confirm;
253
+ const on = !!c.enabled;
254
+ const cats = CATS.map((k) => `<div class="cat ${on ? '' : 'dim'}"><div class="stack"><span>${esc(t(`safety.${k}`))}</span><span class="hint">${esc(t(`safety.${k}Hint`))}</span></div>
255
+ <button class="switch" role="switch" aria-checked="${!!c.ask[k]}" aria-label="${esc(t(`safety.${k}`))}" data-set="confirm.${k}" ${on ? '' : 'disabled'}></button></div>`).join('');
256
+ const sec = c.timeoutSec || 120;
257
+ const opt = (v, k) => `<option value="${v}" ${sec === v ? 'selected' : ''}>${esc(t(k))}</option>`;
258
+ const chip = (p, key, lock) => `<div class="chip">${lock ? LOCK : ''}<span title="${esc(p)}">${esc(p)}</span><button aria-label="${esc(t('safety.remove', { path: p }))}" data-rmdir="${esc(key)}" data-path="${esc(p)}">✕</button></div>`;
259
+ const s = state.settings;
260
+ const pre = s.preset || 'full';
261
+ const tabs = SAFETY_TABS.map((k) => `<button role="tab" aria-selected="${k === safetyTab}" data-tab="${k}">${esc(t(`safety.tab_${k}`))}</button>`).join('');
262
+ const presets = PRESETS.map((k) => `<button role="radio" aria-checked="${pre === k}" data-set="preset" data-value="${k}">${esc(t(`safety.${k}`))}</button>`).join('');
263
+ const head = `<main class="safety">${bannerHtml()}
264
+ <div class="head"><h1>${esc(t('safety.title'))}</h1><div class="sub">${esc(t('safety.sub'))}</div></div>
265
+ <div class="head-row"><div role="tablist" class="filters" aria-label="${esc(t('safety.title'))}">${tabs}</div><div class="grow"></div>
266
+ <span class="flabel" id="pr-l">${esc(t('safety.preset'))}</span><div class="seg w240" role="radiogroup" aria-labelledby="pr-l">${presets}</div></div>
267
+ <div class="hint preset-hint">${esc(t(`safety.${pre}Hint`))}</div>`;
268
+ if (safetyTab === 'undo') return head + undoTab(s);
269
+ if (safetyTab === 'limits') return head + limitsTab(s);
270
+ return `${head}
271
+ <div class="two">
272
+ <section class="card confirm-card" aria-labelledby="c-h"><div class="top"><div class="stack"><h2 id="c-h">${esc(t('safety.confirm'))}</h2><span class="hint">${esc(t('safety.confirmSub'))}</span></div>
273
+ <button class="switch" role="switch" aria-checked="${on}" aria-label="${esc(t('safety.confirm'))}" data-set="confirm.enabled"></button></div>
274
+ ${cats}<div class="grow"></div>
275
+ <div class="foot-row"><label for="s-timeout">${esc(t('safety.autoDeny'))}</label><select id="s-timeout" class="select w132">${opt(60, 'safety.min1')}${opt(120, 'safety.min2')}${opt(300, 'safety.min5')}</select></div></section>
276
+ <section class="card folders" aria-labelledby="f-h"><h2 id="f-h">${esc(t('safety.folders'))}</h2>
277
+ ${(state.settings.allowedDirectories || []).length
278
+ ? `<div class="fgroup"><div class="head-row"><span class="flabel grow">${esc(t('safety.onlyThese'))}</span><button class="linkbtn" data-act="allowAll">${esc(t('safety.allowAll'))}</button></div>${state.settings.allowedDirectories.map((p) => chip(p, 'allowedDirectories')).join('')}
279
+ <button class="btn btn-dashed" data-adddir="allowedDirectories">${esc(t('safety.addFolder'))}</button></div>`
280
+ : `<div class="fgroup"><div class="allfolders"><span class="dot online"></span><span class="grow">${esc(t('safety.allFolders'))}</span></div>
281
+ <button class="btn btn-dashed" data-adddir="allowedDirectories">${esc(t('safety.limit'))}</button></div>`}
282
+ <div class="fgroup"><span class="flabel">${esc(t('safety.readOnly'))} <span class="opt">${esc(t('safety.readOnlySub'))}</span></span>${(state.settings.readOnlyDirectories || []).map((p) => chip(p, 'readOnlyDirectories', true)).join('')}
283
+ <button class="btn btn-dashed" data-adddir="readOnlyDirectories">${esc(t('safety.addReadOnly'))}</button></div>
284
+ <div class="grow"></div>
285
+ <div class="cat"><div class="stack"><span>${esc(t('safety.sensitive'))}</span><span class="hint">${esc(t('safety.sensitiveHint'))}</span></div><button class="switch" role="switch" aria-checked="${s.protectSensitive === true}" aria-label="${esc(t('safety.sensitive'))}" data-set="protectSensitive"></button></div>
286
+ <div class="note"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" class="note-ico"><path d="M12 3 5 6v5.5c0 4.3 2.9 8 7 9.5 4.1-1.5 7-5.2 7-9.5V6l-7-3Z"></path></svg><span>${esc(t('safety.always'))}</span></div>
287
+ </section></div></main>`;
288
+ };
289
+ const SAFETY_TABS = ['access', 'undo', 'limits'];
290
+ const PRESETS = ['read_only', 'files', 'full'];
291
+ // A number setting as a select; a value set by ChatGPT outside the list is shown too.
292
+ const numSel = (key, cur, list, k) => `<select class="select w132" data-num="${key}" aria-label="${esc(t(k, { n: cur }))}">${[...new Set([...list, cur])].sort((a, b) => a - b).map((v) => `<option value="${v}" ${v === cur ? 'selected' : ''}>${esc(t(k, { n: v }))}</option>`).join('')}</select>`;
293
+ function undoTab(s) {
294
+ const sn = s.snapshots || { enabled: true, days: 7, maxMb: 500, maxFileMb: 50 };
295
+ const u = undoInfo || { count: 0, mb: 0 };
296
+ return `<div class="two">
297
+ <section class="card confirm-card" aria-labelledby="rb-h"><div class="top"><div class="stack"><h2 id="rb-h">${esc(t('safety.recycle'))}</h2><span class="hint">${esc(t('safety.recycleHint'))}</span></div>
298
+ <button class="switch" role="switch" aria-checked="${s.recycleBin !== false}" aria-label="${esc(t('safety.recycle'))}" data-set="recycleBin"></button></div>
299
+ <div class="cat"><div class="stack"><span>${esc(t('safety.recycleNoBin'))}</span><span class="hint">${esc(t('safety.recycleNoBinHint'))}</span></div></div>
300
+ <div class="grow"></div><div class="note"><span>${esc(t('safety.recycleNet'))}</span></div></section>
301
+ <section class="card confirm-card" aria-labelledby="sn-h"><div class="top"><div class="stack"><h2 id="sn-h">${esc(t('safety.snapshots'))}</h2><span class="hint">${esc(t('safety.snapshotsHint'))}</span></div>
302
+ <button class="switch" role="switch" aria-checked="${sn.enabled !== false}" aria-label="${esc(t('safety.snapshots'))}" data-set="snapshots.enabled"></button></div>
303
+ <div class="cat ${sn.enabled === false ? 'dim' : ''}"><span class="grow">${esc(t('safety.keepFor'))}</span>${numSel('snapshots.days', sn.days, [1, 3, 7, 14, 30], 'safety.days')}</div>
304
+ <div class="cat ${sn.enabled === false ? 'dim' : ''}"><span class="grow">${esc(t('safety.upTo'))}</span>${numSel('snapshots.maxMb', sn.maxMb, [100, 250, 500, 1000, 2000], 'safety.mb')}</div>
305
+ <div class="cat ${sn.enabled === false ? 'dim' : ''}"><div class="stack grow"><span>${esc(t('safety.bigFile'))}</span><span class="hint">${esc(t('safety.bigFileHint'))}</span></div>${numSel('snapshots.maxFileMb', sn.maxFileMb, [10, 25, 50, 100, 200], 'safety.mb')}</div>
306
+ <div class="cat"><span class="hint grow">${esc(t('safety.used', { n: u.count || 0, mb: u.mb || 0 }))}</span></div>
307
+ <div class="grow"></div>
308
+ <div class="foot-row"><div class="grow"></div><button class="btn btn-secondary" data-act="clearSnapshots">${esc(t('safety.clearSnaps'))}</button><button class="btn btn-primary" data-act="undoDialog">${esc(t('recent.undoAll'))}</button></div></section>
309
+ </div></main>`;
310
+ }
311
+ function limitsTab(s) {
312
+ const f = s.fuse || { enabled: false, limit: 20 };
313
+ return `<div class="two">
314
+ <section class="card confirm-card" aria-labelledby="fu-h"><div class="top"><div class="stack"><h2 id="fu-h">${esc(t('safety.fuse'))}</h2><span class="hint">${esc(t('safety.fuseHint'))}</span></div>
315
+ <button class="switch" role="switch" aria-checked="${f.enabled === true}" aria-label="${esc(t('safety.fuse'))}" data-set="fuse.enabled"></button></div>
316
+ <div class="cat ${f.enabled ? '' : 'dim'}"><span class="grow">${esc(t('safety.fuseLimit'))}</span>${numSel('fuse.limit', f.limit, [10, 20, 50, 100, 200], 'safety.perMinute')}</div>
317
+ <div class="grow"></div></section>
318
+ <section class="card confirm-card" aria-labelledby="aw-h"><div class="top"><div class="stack"><h2 id="aw-h">${esc(t('settings.keepAwake'))}</h2><span class="hint">${esc(t('settings.keepAwakeHint'))}</span></div>
319
+ <button class="switch" role="switch" aria-checked="${s.keepAwake !== false}" aria-label="${esc(t('settings.keepAwake'))}" data-set="keepAwake"></button></div>
320
+ <div class="grow"></div></section>
321
+ </div></main>`;
322
+ }
323
+
324
+ P.notifications = () => {
325
+ const s = state.settings;
326
+ if (!previewTheme) previewTheme = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
327
+ const st = s.popupStyle || 'toast';
328
+ const style = (k) => `<button class="style-card" role="radio" aria-checked="${st === k}" data-set="popupStyle" data-value="${k}"><span class="radio" aria-hidden="true"></span><span class="stack"><span>${esc(t(`notify.${k}`))}</span><span class="hint">${esc(t(`notify.${k}Hint`))}</span></span></button>`;
329
+ const sw = (k, key) => `<div class="row"><span class="grow">${esc(t(`notify.${k}`))}</span><button class="switch" role="switch" aria-checked="${s.notify?.[k] !== false}" aria-label="${esc(t(`notify.${k}`))}" data-set="${key}"></button></div>`;
330
+ const pv = (k) => `<button role="radio" aria-checked="${previewTheme === k}" data-preview="${k}">${esc(t(`notify.${k}`))}</button>`;
331
+ return `<main class="notifications">${bannerHtml()}
332
+ <div class="head"><h1>${esc(t('notify.title'))}</h1><div class="sub">${esc(t('notify.sub'))}</div></div>
333
+ <div class="styles" role="radiogroup" aria-label="${esc(t('aria.style'))}">${style('toast')}${style('popup')}</div>
334
+ <div class="nsplit"><section class="card" aria-label="${esc(t('aria.options'))}">${sw('sound', 'notify.sound')}${sw('offline', 'notify.offline')}${sw('queueDone', 'notify.queueDone')}${sw('limit', 'notify.limit')}</section>
335
+ <section class="preview" aria-label="${esc(t('notify.preview'))}"><div class="bar-top"><span class="b">${esc(t('notify.preview'))}</span><div class="seg" role="radiogroup" aria-label="${esc(t('aria.previewTheme'))}">${pv('light')}${pv('dark')}</div></div>
336
+ <div class="toast ${previewTheme}"><div class="app">${LOGO16}<span class="grow">Bridgerator</span><span>⋯</span></div><div class="h">${esc(t('notify.pHead'))}</div><div class="c">${esc(t('notify.pCmd'))}</div><div class="m">${esc(t('notify.pMeta', { name: state.name || 'PC' }))}</div>
337
+ <div class="btns"><span>${esc(t('notify.allow'))}</span><span>${esc(t('notify.allow10'))}</span><span>${esc(t('notify.deny'))}</span></div></div>
338
+ <div class="grow"></div><button class="btn btn-primary" data-act="testNotification">${esc(t('notify.test'))}</button></section></div></main>`;
339
+ };
340
+
341
+ // Settings of the extra tools (screen capture…): labels come with the state (bridge extra-tools.mjs), EN + RU.
342
+ const toolsCard = () => {
343
+ // Settings that belong to a page of their own (page: 'agents') are shown there, not here.
344
+ const list = Array.isArray(state.extraSettings) ? state.extraSettings.filter((x) => !x.page && typeof x.default === 'boolean') : [];
345
+ if (!list.length) return '';
346
+ const tr = (o) => (o && typeof o === 'object' ? String((state.settings.language === 'ru' ? o.ru : o.en) || o.en || '') : '');
347
+ const rows = list.map((x) => `<div class="row tall"><div class="stack"><span>${esc(tr(x.label))}</span>${x.hint ? `<span class="hint">${esc(tr(x.hint))}</span>` : ''}</div><button class="switch" role="switch" aria-checked="${x.value === true}" aria-label="${esc(tr(x.label))}" data-set="${esc(x.key)}"></button></div>`).join('');
348
+ return `<section class="card" aria-labelledby="tl-h"><h2 id="tl-h" class="sec-title">${esc(t('settings.tools'))}</h2>${rows}</section>`;
349
+ };
350
+ P.settings = () => {
351
+ const s = state.settings;
352
+ const th = s.theme || 'system';
353
+ const seg = ['system', 'light', 'dark'].map((k) => `<button role="radio" aria-checked="${th === k}" data-set="theme" data-value="${k}">${esc(t(`settings.${k}`))}</button>`).join('');
354
+ const p = state.plan;
355
+ const usage = p && p.plan === 'free' && p.limit ? ` · ${t('status.calls', { calls: nf().format(p.calls || 0), limit: nf().format(p.limit) })}` : '';
356
+ const planBtn = !p ? '' : p.plan === 'pro' ? `<button class="btn btn-secondary" data-act="plan">${esc(t('settings.manage'))}</button>` : `<button class="btn btn-secondary" data-act="plan">${esc(t('settings.upgrade'))}</button>`;
357
+ const u = updateInfo;
358
+ const uline = !u ? `<span class="hint ok-text">${esc(t('settings.upToDate'))}</span>` : u.checking ? `<span class="hint">${esc(t('settings.checking'))}</span>` : u.failed ? `<span class="hint warn-text">${esc(t('settings.checkFailed'))}</span>` : u.updating ? `<span class="hint">${esc(t('settings.updating'))}</span>` : u.upToDate ? `<span class="hint ok-text">${esc(t('settings.upToDate'))}</span>` : `<span class="hint warn-text">${esc(t('settings.updateAvail', { v: u.latest }))}</span>`;
359
+ // Owner 21.10: left and right cards side by side in rows, each pair the same height.
360
+ const left = [`<section class="card" aria-labelledby="ap-h"><h2 id="ap-h" class="sec-title">${esc(t('settings.appearance'))}</h2>
361
+ <div class="row"><span id="th-l" class="grow">${esc(t('settings.theme'))}</span><div class="seg w168" role="radiogroup" aria-labelledby="th-l">${seg}</div></div>
362
+ <div class="row"><label for="st-lang" class="grow">${esc(t('settings.language'))}</label><select id="st-lang" class="select w168"><option value="en" ${s.language !== 'ru' ? 'selected' : ''}>English</option><option value="ru" ${s.language === 'ru' ? 'selected' : ''}>Русский</option></select></div></section>`, `<section class="card" aria-labelledby="g-h"><h2 id="g-h" class="sec-title">${esc(t('settings.thisPc'))}</h2>
363
+ <div class="row"><label for="st-name" class="grow">${esc(t('settings.pcName'))}</label><input id="st-name" class="input w168" maxlength="40" value="${esc(s.deviceName || state.name || '')}"></div>
364
+ <div class="row"><span class="grow">${esc(t('settings.autostart'))}</span><button class="switch" role="switch" aria-checked="${s.autostart !== false}" aria-label="${esc(t('settings.autostart'))}" data-set="autostart"></button></div>
365
+ <div class="row"><span class="grow">${esc(t('settings.keepAwake'))}</span><button class="switch" role="switch" aria-checked="${s.keepAwake !== false}" aria-label="${esc(t('settings.keepAwake'))}" data-set="keepAwake"></button></div>
366
+ <div class="row tall"><div class="stack"><span>${esc(t('settings.askSettings'))}</span><span class="hint">${esc(t(s.askSettings === true ? 'settings.askSettingsHintOn' : 'settings.askSettingsHint'))}</span></div><button class="switch" role="switch" aria-checked="${s.askSettings === true}" aria-label="${esc(t('settings.askSettings'))}" data-set="askSettings"></button></div></section>`, `<section class="card" aria-labelledby="a-h"><h2 id="a-h" class="sec-title">${esc(t('settings.account'))}</h2>
367
+ <div class="acct"><span>${esc(state.email || state.name || '')}</span><span class="hint">${esc((p ? (p.plan === 'pro' ? t('status.proPlan') : t('status.freePlan')) : '') + usage)}</span></div>
368
+ <div class="${planBtn ? 'grid2' : 'grid1'}"><button class="btn btn-primary" data-act="openAccount">${esc(t('settings.openAccount'))}</button>${planBtn}</div>
369
+ <div class="grid1"><button class="btn btn-secondary" data-act="unlink">${esc(t('settings.unlink'))}</button></div></section>`];
370
+ const right = [`<section class="card" aria-labelledby="p-h"><h2 id="p-h" class="sec-title">${esc(t('settings.privacy'))}</h2>
371
+ <div class="row tall"><div class="stack"><span>${esc(t('settings.stats'))}</span><span class="hint">${esc(t('settings.statsHint'))}</span></div><button class="switch" role="switch" aria-checked="${s.telemetry !== false}" aria-label="${esc(t('settings.stats'))}" data-set="telemetryEnabled"></button></div>
372
+ <div class="row tall"><div class="stack"><span>${esc(t('settings.history'))}</span><span class="hint">${esc(t('settings.historyHint'))}</span></div><button class="switch" role="switch" aria-checked="${!!s.localHistory}" aria-label="${esc(t('settings.history'))}" data-set="localHistory"></button></div></section>`, toolsCard(), `<section class="card" aria-labelledby="u-h"><h2 id="u-h" class="sec-title">${esc(t('settings.updates'))}</h2>
373
+ <div class="row tall"><div class="stack"><span>${esc(t('settings.version', { v: state.version }))}</span>${uline}</div>${updateInfo?.latest && !updateInfo.upToDate && !updateInfo.updating ? `<button class="btn btn-primary w120" data-act="update">${esc(t('settings.updateNow'))}</button>` : `<button class="btn btn-secondary w120" data-act="checkUpdate"${updateInfo?.updating ? ' disabled' : ''}>${esc(t('settings.check'))}</button>`}</div></section>`].filter(Boolean);
374
+ return `<main class="settings">${bannerHtml()}<h1>${esc(t('settings.title'))}</h1>
375
+ <div class="pairs">${left.map((c, i) => c + (right[i] || '<div></div>')).join('')}</div>
376
+ <section class="card danger-card" aria-labelledby="x-h"><h2 id="x-h" class="sec-title">${esc(t('settings.remove'))}</h2>
377
+ <div class="row tall note-sm"><span class="grow wrap">${esc(t('settings.removeText'))}</span><button class="btn btn-danger w120" data-act="uninstall">${esc(t('settings.uninstall'))}</button></div></section></main>`;
378
+ };
379
+
380
+ const HELP = ['talk', 'start', 'queue', 'agents', 'confirm', 'folders', 'undo', 'privacy', 'trouble'];
381
+ const PHRASES = ['setup', 'add', 'work', 'continue', 'delegate', 'show', 'confirmOn'];
382
+ const phraseList = () => `<div class="phrases">${PHRASES.map((k) => `<div class="phrase"><div class="stack"><span class="mono">${esc(t(`talk.${k}`))}</span><span class="hint">${esc(t(`talk.${k}Hint`))}</span></div><button class="btn btn-secondary w96" data-copy="${esc(t(`talk.${k}`))}">${esc(t('talk.copy'))}</button></div>`).join('')}</div>`;
383
+ P.help = () => {
384
+ if (helpTopic === 'talk') return `<main class="helpp">${bannerHtml()}<h1>${esc(t('help.title'))}</h1><div class="help">
385
+ <nav class="topics" aria-label="${esc(t('aria.helpTopics'))}">${HELP.map((k) => `<button data-topic="${k}" aria-current="${k === helpTopic ? 'page' : 'false'}">${esc(k === 'talk' ? t('talk.label') : S.help.topics[k].label)}</button>`).join('')}<div class="grow"></div>
386
+ <button class="btn btn-secondary" data-act="support">${esc(t('help.support'))}</button></nav>
387
+ <article class="card article"><h2>${esc(t('talk.title'))}</h2><p>${esc(t('talk.intro'))}</p>${phraseList()}</article></div></main>`;
388
+ const d = S.help.topics[helpTopic];
389
+ return `<main class="helpp">${bannerHtml()}<h1>${esc(t('help.title'))}</h1><div class="help">
390
+ <nav class="topics" aria-label="${esc(t('aria.helpTopics'))}">${HELP.map((k) => `<button data-topic="${k}" aria-current="${k === helpTopic ? 'page' : 'false'}">${esc(k === 'talk' ? t('talk.label') : S.help.topics[k].label)}</button>`).join('')}<div class="grow"></div>
391
+ <button class="btn btn-secondary" data-act="support">${esc(t('help.support'))}</button></nav>
392
+ <article class="card article"><h2>${esc(d.title)}</h2><p>${esc(d.intro)}</p><ol>${d.steps.map((s, i) => `<li><span class="num">${i + 1}</span><span>${esc(s)}</span></li>`).join('')}</ol>
393
+ <div class="tip"><span class="k">${esc(t(`help.${d.tipLabel}`))}</span><span class="mono">${esc(d.tip)}</span></div></article></div></main>`;
394
+ };
395
+
396
+ function render() {
397
+ renderNav();
398
+ $('#main').outerHTML = P[page]().replace('<main class="', '<main id="main" class="');
399
+ document.querySelectorAll('[data-w]').forEach((el) => { el.style.width = `${el.dataset.w}%`; }); // CSSOM: allowed by the CSP
400
+ }
401
+
402
+ // ---- in-window toast (Undo / Copied) ----------------------------------------------------------------
403
+ let snackTimer;
404
+ function snack(text, undo) {
405
+ const l = $('#layer');
406
+ l.querySelector('.snack')?.remove();
407
+ const el = document.createElement('div');
408
+ el.className = `snack ${undo ? '' : 'plain'}`; el.setAttribute('role', 'status');
409
+ el.innerHTML = `<span>${esc(text)}</span>${undo ? `<button class="btn btn-secondary">${esc(t('queue.undo'))}</button>` : ''}`;
410
+ if (undo) el.querySelector('button').onclick = () => { el.remove(); undo(); };
411
+ l.append(el);
412
+ clearTimeout(snackTimer); snackTimer = setTimeout(() => el.remove(), undo ? 8000 : 2000);
413
+ }
414
+ const fail = (e) => snack(/CONFIRM_LOCKED/.test(String((e && e.message) || e)) ? t('settings.confirmLocked') : t('error', { msg: e.message || e }));
415
+
416
+ // ---- Undo AI changes ------------------------------------------------------------------------------------
417
+ async function runUndo(ids) {
418
+ try {
419
+ const r = await rpc('undo.run', { ids });
420
+ await refresh(true);
421
+ const n = (r && r.restored ? r.restored.length : 0), sk = (r && r.skipped) || [];
422
+ if (!sk.length) return snack(t('recent.undoneN', { n }));
423
+ const o = document.createElement('div');
424
+ o.className = 'overlay';
425
+ o.innerHTML = `<section class="dialog" role="dialog" aria-modal="true" aria-labelledby="us-t"><h2 id="us-t">${esc(t('recent.skippedTitle', { n }))}</h2><p>${esc(t('recent.skippedText'))}</p>
426
+ <pre class="diff mono">${esc(sk.map((x) => `${x.path || '—'}: ${x.why}`).join('\n'))}</pre><div class="dlg-foot"><div class="grow"></div><button class="btn btn-primary w96" data-x>${esc(t('recent.ok'))}</button></div></section>`;
427
+ o.querySelector('[data-x]').onclick = closeLayer;
428
+ $('#layer').append(o); o.querySelector('[data-x]').focus();
429
+ } catch (e) { fail(e); }
430
+ }
431
+ function undoDialog() {
432
+ const groups = (undoInfo && undoInfo.groups) || [];
433
+ const last = undoInfo && undoInfo.actions.find((a) => !a.undone);
434
+ const tm = (x) => { const d = new Date(x); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
435
+ const rows = [
436
+ ...(last ? [`<div class="phrase"><div class="stack"><span>${esc(t('recent.lastChange'))}</span><span class="hint mono">${esc(`${tm(last.t)} · ${toolName(last.tool)} · ${last.label || last.paths[0] || ''}`)}</span></div><button class="btn btn-secondary w96" data-ids="${esc(last.id)}">${esc(t('recent.undo'))}</button></div>`] : []),
437
+ ...groups.map((g) => `<div class="phrase"><div class="stack"><span>${esc(g.kind === 'task' ? t('recent.taskGroup', { title: g.title }) : t('recent.batchGroup', { from: tm(g.t0), to: tm(g.t1) }))}</span><span class="hint">${esc(t('recent.changes', { n: g.ids.length }))}</span></div><button class="btn btn-secondary w96" data-ids="${esc(g.ids.join(','))}">${esc(t('recent.undo'))}</button></div>`),
438
+ ].join('');
439
+ const o = document.createElement('div');
440
+ o.className = 'overlay';
441
+ o.innerHTML = `<section class="dialog" role="dialog" aria-modal="true" aria-labelledby="ud-t"><h2 id="ud-t">${esc(t('recent.undoTitle'))}</h2><p>${esc(t('recent.undoText'))}</p>
442
+ <div class="phrases">${rows || `<div class="empty">${esc(t('recent.nothingToUndo'))}</div>`}</div><div class="dlg-foot"><div class="grow"></div><button class="btn btn-secondary w96" data-x>${esc(t('settings.cancel'))}</button></div></section>`;
443
+ o.querySelector('[data-x]').onclick = closeLayer;
444
+ o.querySelectorAll('[data-ids]').forEach((b) => { b.onclick = () => { closeLayer(); runUndo(b.dataset.ids.split(',')); }; });
445
+ $('#layer').append(o); o.querySelector('[data-x]').focus();
446
+ }
447
+
448
+ // ---- dialogs ------------------------------------------------------------------------------------------
449
+ function closeLayer() { $('#layer').querySelectorAll('.overlay, .menu').forEach((e) => e.remove()); render(); }
450
+ function confirmDialog(title, text, goLabel, onGo) {
451
+ const o = document.createElement('div');
452
+ o.className = 'overlay';
453
+ o.innerHTML = `<section class="dialog" role="dialog" aria-modal="true" aria-labelledby="cd-t"><h2 id="cd-t">${esc(title)}</h2><p>${esc(text)}</p><div class="dlg-foot"><div class="grow"></div><button class="btn btn-secondary w96" data-x>${esc(t('settings.cancel'))}</button><button class="btn btn-danger w96" data-go>${esc(goLabel)}</button></div></section>`;
454
+ o.querySelector('[data-x]').onclick = closeLayer;
455
+ o.querySelector('[data-go]').onclick = () => { closeLayer(); onGo(); };
456
+ $('#layer').append(o); o.querySelector('[data-x]').focus();
457
+ }
458
+
459
+ // ---- usage forecast (Status) --------------------------------------------------------------------------
460
+ // The month's pace from the relay's numbers (plan.mjs forecast); shown only here, never in ChatGPT.
461
+ function forecastLine() {
462
+ const u = state.usage;
463
+ if (!u) return '';
464
+ const loc = state.settings.language === 'ru' ? 'ru-RU' : 'en-US';
465
+ const k = u.state === 'unlimited' ? t('status.callsMonth', { calls: nf().format(u.calls) })
466
+ : u.state === 'enough' ? t('status.forecastEnough')
467
+ : u.state === 'runs_out' ? t('status.forecastOut', { date: new Date(`${u.date}T00:00:00`).toLocaleDateString(loc, { day: 'numeric', month: 'short' }) })
468
+ : '';
469
+ return k ? `<div class="hint forecast ${u.state === 'runs_out' ? 'warn-text' : ''}">${esc(k)}</div>` : '';
470
+ }
471
+
472
+ // ---- Automations (scheduled scripts) -------------------------------------------------------------------
473
+ const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
474
+ const when = (ms) => { const loc = state.settings.language === 'ru' ? 'ru-RU' : 'en-US'; return new Date(ms).toLocaleString(loc, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); };
475
+ function schedText(s) {
476
+ if (!s) return t('auto.manual');
477
+ if (s.type === 'daily') return t('auto.daily', { time: s.time });
478
+ if (s.type === 'weekly') return t('auto.weekly', { days: s.days.map((d) => t(`auto.d_${d}`)).join(', '), time: s.time });
479
+ if (s.type === 'hourly') return s.every === 1 ? t('auto.hourly1') : t('auto.hourly', { n: s.every });
480
+ if (s.type === 'logon') return t('auto.logon');
481
+ const [d, hm] = s.at.split('T'); const [y, mo, dd] = d.split('-').map(Number); const [h, mi] = hm.split(':').map(Number);
482
+ return t('auto.once', { when: when(new Date(y, mo - 1, dd, h, mi).getTime()) });
483
+ }
484
+ function lastText(a) {
485
+ if (a.running) return t('auto.running');
486
+ if (!a.last) return t('auto.neverRun');
487
+ return a.last.code === 0 ? t('auto.lastOk', { ago: ago(Date.parse(a.last.t)) }) : t('auto.lastFail', { ago: ago(Date.parse(a.last.t)), code: a.last.code });
488
+ }
489
+ P.automations = () => {
490
+ const list = state.automations || [];
491
+ const notifyOn = state.settings.automations ? state.settings.automations.notifySchedule !== false : true;
492
+ const rows = list.map((a) => {
493
+ const dot = a.running ? 'doing' : a.last && a.last.code !== 0 ? 'failed' : !a.enabled ? 'skipped' : a.last ? 'done' : 'todo';
494
+ const parts = [schedText(a.schedule)];
495
+ if (a.schedule && !a.scheduled) parts.push(t('auto.notScheduled'));
496
+ else if (a.schedule && !a.enabled) parts.push(t('auto.off'));
497
+ else if (a.next) parts.push(t('auto.next', { when: when(a.next) }));
498
+ parts.push(lastText(a));
499
+ return `<div class="qrow arow" role="listitem"><span class="sdot ${dot}" aria-hidden="true"></span>
500
+ <div class="qtext"><span class="t">${esc(a.name)}</span><span class="n" title="${esc(a.taskError || '')}">${esc(parts.join(' · '))}</span></div>
501
+ ${a.schedule ? `<button class="switch" role="switch" aria-checked="${a.enabled && a.scheduled}" aria-label="${esc(t('auto.toggle', { name: a.name }))}" data-auto-toggle="${esc(a.id)}" ${a.scheduled ? '' : 'disabled'}></button>` : '<span></span>'}
502
+ <button class="btn btn-secondary w112" data-auto-run="${esc(a.id)}" ${a.running ? 'disabled' : ''}>${esc(t('auto.runNow'))}</button>
503
+ <button class="icon-btn" data-act="amenu" data-id="${esc(a.id)}" aria-label="${esc(t('queue.more'))}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"></circle><circle cx="12" cy="12" r="1.8"></circle><circle cx="19" cy="12" r="1.8"></circle></svg></button></div>`;
504
+ }).join('');
505
+ const phrases = ['autoBackup', 'autoClean', 'autoReport'].map((k) => `<div class="phrase"><span class="mono grow">${esc(t(`auto.${k}`))}</span><button class="btn btn-secondary w96" data-copy="${esc(t(`auto.${k}`))}">${esc(t('talk.copy'))}</button></div>`).join('');
506
+ return `<main class="automations">${bannerHtml()}
507
+ <div class="head"><h1>${esc(t('auto.title'))}</h1><div class="sub">${esc(t('auto.sub'))}</div></div>
508
+ <div role="list" class="card list" id="alist">${rows || `<div class="empty">${esc(t('auto.empty'))}</div>`}</div>
509
+ ${list.length ? '' : `<section class="card phrases">${phrases}</section>`}
510
+ <div class="toggle-line"><button class="switch" role="switch" aria-checked="${notifyOn}" data-set="automations.notifySchedule" aria-label="${esc(t('auto.notify'))}"></button><span>${esc(t('auto.notify'))}</span></div></main>`;
511
+ };
512
+ function autoMenu(btn) {
513
+ const a = (state.automations || []).find((x) => x.id === btn.dataset.id);
514
+ if (!a) return;
515
+ $('#layer').querySelector('.menu')?.remove();
516
+ const m = document.createElement('div');
517
+ m.className = 'menu'; m.setAttribute('role', 'menu');
518
+ m.innerHTML = [['log', 'auto.showLog'], ['delete', 'auto.delete']].map(([k, l]) => `<button role="menuitem" data-m="${k}" class="${k === 'delete' ? 'danger' : ''}">${esc(t(l))}</button>`).join('');
519
+ const r = btn.getBoundingClientRect();
520
+ m.style.top = `${Math.min(r.bottom + 4, window.innerHeight - 100)}px`; m.style.left = `${Math.max(8, r.right - 180)}px`;
521
+ m.onclick = async (e) => {
522
+ const k = e.target.closest('[data-m]')?.dataset.m; if (!k) return;
523
+ m.remove();
524
+ if (k === 'log') return autoLog(a.id);
525
+ try {
526
+ const removed = await rpc('automation.remove', { id: a.id });
527
+ await refresh();
528
+ snack(t('auto.deleted', { name: a.name }), async () => { try { await rpc('automation.restore', { removed }); refresh(); } catch (err) { fail(err); } });
529
+ } catch (err) { fail(err); }
530
+ };
531
+ $('#layer').append(m); m.querySelector('button').focus();
532
+ setTimeout(() => document.addEventListener('click', function off(ev) { if (!m.contains(ev.target)) m.remove(); document.removeEventListener('click', off); }), 0);
533
+ }
534
+ async function autoLog(id) {
535
+ let a;
536
+ try { a = await rpc('automation.get', { id }); } catch (e) { return fail(e); }
537
+ const o = document.createElement('div');
538
+ o.className = 'overlay';
539
+ const runs = (a.runs || []).map((r) => `<details class="run"><summary><span class="pill ${r.code === 0 ? 't-done' : 't-failed'}">${esc(r.code === 0 ? t('auto.ok') : t('auto.code', { code: r.code }))}</span><span>${esc(when(Date.parse(r.t)))}</span><span class="hint">${esc(t(`auto.by_${['schedule', 'manual', 'ai'].includes(r.trigger) ? r.trigger : 'schedule'}`))} · ${esc(t('auto.secs', { n: Math.max(1, Math.round(r.ms / 1000)) }))}</span></summary><pre class="diff mono">${esc(r.tail || t('auto.noOutput'))}</pre></details>`).join('');
540
+ o.innerHTML = `<section class="dialog wide" role="dialog" aria-modal="true" aria-labelledby="al-t"><h2 id="al-t">${esc(a.name)}</h2><p class="hint">${esc(schedText(a.schedule))}</p>
541
+ <div class="runs">${runs || `<div class="empty">${esc(t('auto.noRuns'))}</div>`}</div>
542
+ <details class="run"><summary>${esc(t('auto.script'))}</summary><pre class="diff mono">${esc(a.script)}</pre></details>
543
+ <div class="dlg-foot"><div class="grow"></div><button class="btn btn-secondary w96" data-x>${esc(t('auto.close'))}</button></div></section>`;
544
+ o.querySelector('[data-x]').onclick = closeLayer;
545
+ const first = o.querySelector('.runs details'); if (first) first.open = true;
546
+ $('#layer').append(o); o.querySelector('[data-x]').focus();
547
+ }
548
+
549
+ // ---- Agents (AI ↔ AI delegation, bridge delegate.mjs) ----------------------------------------------------------
550
+ // Detected agent CLIs, per-agent allow (on once found), limits, the run log with Undo. Everything here is also
551
+ // readable / settable by the AI (bridgerator_settings `delegate`); changes from either side show on the next refresh.
552
+ const RUN_PILL = { running: 'doing', done: 'done', failed: 'failed', timeout: 'failed', interrupted: 'failed', cancelled: 'skipped' };
553
+ const AGENT_LIMITS = [['parallel', [1, 2, 3, 4], 'agents.n'], ['timeoutMin', [5, 10, 15, 30, 60, 120, 240], 'agents.minutes'], ['maxOutputKb', [8, 16, 32, 64, 128, 256], 'agents.kb'], ['undoMb', [0, 50, 100, 200, 500, 1000, 2000], 'agents.mb']];
554
+ P.agents = () => {
555
+ const g = state.agents || { checking: true, agents: [], limits: {}, runs: [] };
556
+ const lim = g.limits || {};
557
+ const rows = !g.agents.length ? `<div class="empty">${esc(t(g.checking ? 'agents.checking' : 'agents.none'))}</div>` : g.agents.map((a) => {
558
+ const st = !a.found ? t('agents.notFound') : [a.version ? `v${a.version}` : '', t(`agents.signed_${['yes', 'no', 'unknown'].includes(a.signedIn) ? a.signedIn : 'unknown'}`), a.startable ? '' : t('agents.cantStart')].filter(Boolean).join(' · ');
559
+ const dot = !a.found ? 'skipped' : !a.startable || a.signedIn === 'no' ? 'failed' : 'done';
560
+ return `<div class="qrow arow agrow" role="listitem"><span class="sdot ${dot}" aria-hidden="true"></span>
561
+ <div class="qtext"><span class="t">${esc(a.name)}</span><span class="n">${esc(st)}</span>${a.path ? `<span class="n mono" title="${esc(a.path)}">${esc(a.path)}</span>` : ''}</div>
562
+ <span class="hint">${esc(t('agents.allow'))}</span><button class="switch" role="switch" aria-checked="${!!(a.found && a.allowed)}" aria-label="${esc(t('agents.allowFor', { name: a.name }))}" data-set="delegate.${esc(a.id)}" ${a.found ? '' : 'disabled'}></button></div>`;
563
+ }).join('');
564
+ const mode = lim.mode || 'full';
565
+ const seg = ['read', 'edit', 'full'].map((k) => `<button role="radio" aria-checked="${mode === k}" data-set="delegate.mode" data-value="${k}">${esc(t(`agents.mode_${k}`))}</button>`).join('');
566
+ const nums = AGENT_LIMITS.map(([k, list, label]) => `<div class="cat"><span class="grow">${esc(t(`agents.${k}`))}</span>${numSel(`delegate.${k}`, Number(lim[k] ?? list[0]), list, label)}</div>`).join('');
567
+ const phrase = (k) => `<div class="phrase"><span class="mono grow">${esc(t(`agents.${k}`))}</span><button class="btn btn-secondary w96" data-copy="${esc(t(`agents.${k}`))}">${esc(t('talk.copy'))}</button></div>`;
568
+ return `<main class="agents">${bannerHtml()}
569
+ <div class="head-row"><div class="head grow"><h1>${esc(t('agents.title'))}</h1><div class="sub">${esc(t('agents.sub'))}</div></div><button class="btn btn-secondary" data-act="detectAgents">${esc(t('agents.refresh'))}</button></div>
570
+ <div role="list" class="card list" id="aglist">${rows}</div>
571
+ <div class="two">
572
+ <section class="card confirm-card" aria-labelledby="al-h"><div class="top"><div class="stack"><h2 id="al-h">${esc(t('agents.limits'))}</h2><span class="hint">${esc(t('agents.modeHint'))}</span></div></div>
573
+ <div class="cat"><span class="grow" id="am-l">${esc(t('agents.mode'))}</span><div class="seg w168" role="radiogroup" aria-labelledby="am-l">${seg}</div></div>${nums}
574
+ <div class="note"><span>${esc(t('agents.undoHint'))}</span></div></section>
575
+ <section class="card confirm-card phrases" aria-labelledby="ap-h"><div class="top"><div class="stack"><h2 id="ap-h">${esc(t('agents.sayChatgpt'))}</h2></div></div>${phrase('p1')}${phrase('p2')}
576
+ <div class="top"><div class="stack"><h2>${esc(t('agents.sayOther'))}</h2><span class="hint">${esc(t('agents.p3hint'))}</span></div></div>${phrase('p3')}</section></div>
577
+ </main>`;
578
+ };
579
+ // Agent tasks (owner 21.10: own page, newest on top; "Keep finished agent tasks" in Settings, on by default).
580
+ P.delegated = () => {
581
+ const g = state.agents || { runs: [] };
582
+ const keep = (state.extraSettings || []).find((x) => x.key === 'delegate.keepFinished')?.value !== false;
583
+ const list = (g.runs || []).filter((r) => keep || r.state === 'running');
584
+ const runs = list.map((r) => {
585
+ const files = r.changed ? r.changed.modified + r.changed.created + r.changed.deleted : null;
586
+ const meta = [when(Date.parse(r.startedAt)), r.name, t('agents.from', { from: r.from || '—' }), files === null ? '' : files ? t('agents.changed', { n: files }) : t('agents.noChanges')].filter(Boolean).join(' · ');
587
+ const act = r.state === 'running' ? `<button class="btn btn-secondary w96" data-dcancel="${esc(r.id)}">${esc(t('agents.stop'))}</button>`
588
+ : r.undoId && !r.undone ? `<button class="btn btn-secondary w96" data-undo="${esc(r.undoId)}">${esc(t('agents.undo'))}</button>`
589
+ : r.undoId ? `<span class="hint w96">${esc(t('agents.undone'))}</span>` : '<span class="w96"></span>';
590
+ return `<div class="qrow arow" role="listitem"><span class="sdot ${RUN_PILL[r.state] || 'skipped'}" aria-hidden="true"></span>
591
+ <div class="qtext"><span class="t" title="${esc(r.task || '')}">${esc(r.summary || r.task || '')}</span><span class="n">${esc(meta)}</span><span class="n mono" title="${esc(r.folder)}">${esc(r.folder)}</span></div>
592
+ <span class="qpill"><span class="pill t-${RUN_PILL[r.state] || 'skipped'}">${esc(t(`agents.st_${r.state}`))}</span></span>${act}
593
+ <button class="icon-btn" data-drun="${esc(r.id)}" aria-label="${esc(t('agents.details'))}" title="${esc(t('agents.details'))}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"></circle><path d="M12 11v5M12 8h.01"></path></svg></button></div>`;
594
+ }).join('');
595
+ return `<main class="delegated">${bannerHtml()}
596
+ <div class="head-row"><div class="head grow"><h1>${esc(t('agents.log'))}</h1><div class="sub">${esc(t(keep ? 'agents.logSub' : 'agents.logSubRunning'))}</div></div></div>
597
+ <div role="list" class="card list" id="drlist">${runs || `<div class="empty">${esc(t('agents.empty'))}</div>`}</div></main>`;
598
+ };
599
+ async function runDialog(id) {
600
+ let r;
601
+ try { r = await rpc('delegate.get', { id }); } catch (e) { return fail(e); }
602
+ const o = document.createElement('div');
603
+ o.className = 'overlay';
604
+ const list = (k, l) => (l && l.length ? `<p class="hint">${esc(t(`agents.dlg_${k}`))}</p><pre class="diff mono">${esc(l.join('\n'))}</pre>` : '');
605
+ const f = r.files || {};
606
+ o.innerHTML = `<section class="dialog wide" role="dialog" aria-modal="true" aria-labelledby="dr-t"><h2 id="dr-t">${esc(`${(state.agents?.agents || []).find((a) => a.id === r.agent)?.name || r.agent} · ${t(`agents.st_${r.state}`)}`)}</h2>
607
+ <p class="hint">${esc([t('agents.from', { from: r.from || '—' }), r.folder, r.mode].join(' · '))}</p>
608
+ <details class="run"><summary>${esc(t('agents.dlgTask'))}</summary><pre class="diff mono">${esc(r.task || '')}</pre></details>
609
+ <p class="hint">${esc(t('agents.dlgAnswer'))}</p><pre class="diff mono">${esc(r.text || r.error || '—')}</pre>
610
+ ${list('modified', f.modified)}${list('created', f.created)}${list('deleted', f.deleted)}
611
+ <div class="dlg-foot"><div class="grow"></div><button class="btn btn-secondary w96" data-x>${esc(t('agents.close'))}</button></div></section>`;
612
+ o.querySelector('[data-x]').onclick = closeLayer;
613
+ $('#layer').append(o); o.querySelector('[data-x]').focus();
614
+ }
615
+
616
+ // ---- plan card (bridgerator_plan): many similar actions, one answer -----------------------------------------
617
+ function batchDialog(b) {
618
+ const o = document.createElement('div');
619
+ o.className = 'overlay';
620
+ const rows = b.rows.map((r) => `<label class="prow"><input type="checkbox" checked data-i="${r.i}"><span class="mono">${esc(r.text)}</span></label>`).join('');
621
+ o.innerHTML = `<section class="dialog wide" role="dialog" aria-modal="true" aria-labelledby="bd-t"><h2 id="bd-t">${esc(b.head)}</h2><p>${esc(b.title ? `${b.title} — ${t('batch.text')}` : t('batch.text'))}</p>
622
+ <div class="head-row"><button class="linkbtn" data-all>${esc(t('batch.selectAll'))}</button><button class="linkbtn" data-none>${esc(t('batch.selectNone'))}</button><div class="grow"></div><span class="hint" id="bd-n"></span></div>
623
+ <div class="plist">${rows}</div>
624
+ <div class="dlg-foot"><button class="btn btn-secondary w96" data-x>${esc(t('batch.deny'))}</button><div class="grow"></div><button class="btn btn-secondary" data-sel>${esc(t('batch.allowSel'))}</button><button class="btn btn-primary" data-go>${esc(t('batch.allowAll'))}</button></div></section>`;
625
+ const boxes = () => [...o.querySelectorAll('input[data-i]')];
626
+ const count = () => { const n = boxes().filter((x) => x.checked).length; $('#bd-n', o).textContent = t('batch.selected', { n, total: b.rows.length }); o.querySelector('[data-sel]').disabled = !n; };
627
+ // Buttons off while the answer is on its way: a double click never sends two decisions.
628
+ const decide = async (allow) => {
629
+ if (o.dataset.busy) return;
630
+ o.dataset.busy = '1'; o.querySelectorAll('button, input').forEach((x) => { x.disabled = true; });
631
+ try { await rpc('batch.decide', { id: b.id, allow }); closeLayer(); } catch (e) { delete o.dataset.busy; o.querySelectorAll('button, input').forEach((x) => { x.disabled = false; }); count(); shownBatch = null; fail(e); }
632
+ };
633
+ o.querySelector('[data-x]').onclick = () => decide([]);
634
+ o.querySelector('[data-go]').onclick = () => decide(b.rows.map((r) => r.i));
635
+ o.querySelector('[data-sel]').onclick = () => decide(boxes().filter((x) => x.checked).map((x) => Number(x.dataset.i)));
636
+ o.querySelector('[data-all]').onclick = () => { boxes().forEach((x) => { x.checked = true; }); count(); };
637
+ o.querySelector('[data-none]').onclick = () => { boxes().forEach((x) => { x.checked = false; }); count(); };
638
+ o.addEventListener('change', count);
639
+ $('#layer').append(o); count(); o.querySelector('[data-x]').focus();
640
+ }
641
+
642
+ // A settings proposal from ChatGPT too long for a notification: the full diff, Apply here.
643
+ function setupDialog(ps) {
644
+ const o = document.createElement('div');
645
+ o.className = 'overlay';
646
+ o.innerHTML = `<section class="dialog" role="dialog" aria-modal="true" aria-labelledby="sd-t"><h2 id="sd-t">${esc(t('setup.title'))}</h2><p>${esc(t('setup.text'))}</p>
647
+ <pre class="diff mono">${esc(ps.lines.join('\n'))}</pre><div class="dlg-foot"><div class="grow"></div><button class="btn btn-secondary w96" data-x>${esc(t('settings.cancel'))}</button><button class="btn btn-primary w96" data-go>${esc(t('setup.apply'))}</button></div></section>`;
648
+ const decide = async (apply) => {
649
+ if (o.dataset.busy) return;
650
+ o.dataset.busy = '1'; o.querySelectorAll('button').forEach((x) => { x.disabled = true; });
651
+ try { await rpc('setup.decide', { id: ps.id, apply }); closeLayer(); } catch (e) { delete o.dataset.busy; o.querySelectorAll('button').forEach((x) => { x.disabled = false; }); shownSetup = null; fail(e); }
652
+ };
653
+ o.querySelector('[data-x]').onclick = () => decide(false);
654
+ o.querySelector('[data-go]').onclick = () => decide(true);
655
+ $('#layer').append(o); o.querySelector('[data-x]').focus();
656
+ }
657
+
658
+ function taskDialog(item) {
659
+ const it = item ? { ...item, paths: [...(item.paths || [])] } : { title: '', details: '', paths: [], status: 'todo' };
660
+ const o = document.createElement('div');
661
+ o.className = 'overlay';
662
+ const draw = () => {
663
+ o.innerHTML = `<section class="dialog" role="dialog" aria-modal="true" aria-labelledby="dlg-t"><h2 id="dlg-t">${esc(t(item ? 'edit.titleEdit' : 'edit.titleNew'))}</h2>
664
+ <div class="field"><label for="t-title">${esc(t('edit.task'))}</label><input id="t-title" class="input" maxlength="200" value="${esc(it.title)}"></div>
665
+ <div class="field"><label for="t-details">${esc(t('edit.details'))} <span class="opt">${esc(t('edit.optional'))}</span></label><textarea id="t-details" class="textarea" rows="3" maxlength="4000">${esc(it.details || '')}</textarea></div>
666
+ <div class="field"><span class="flabel">${esc(t('edit.files'))}</span><div class="chips">${it.paths.map((p, i) => `<span class="chip inline"><span title="${esc(p)}">${esc(p)}</span><button aria-label="${esc(t('edit.remove', { path: p }))}" data-rm="${i}">✕</button></span>`).join('')}
667
+ <button class="btn btn-dashed" data-pick="file">${esc(t('edit.addFile'))}</button><button class="btn btn-dashed" data-pick="folder">${esc(t('edit.addFolder'))}</button></div><span class="note-sm">${esc(t('edit.dropHint'))}</span></div>
668
+ ${item ? `<div class="head-row"><label for="t-status" class="flabel">${esc(t('edit.status'))}</label><select id="t-status" class="select">${STATUSES.map((s) => `<option value="${s}" ${it.status === s ? 'selected' : ''}>${esc(t(`queue.${s}`))}</option>`).join('')}</select></div>` : ''}
669
+ <span class="err" id="t-err" hidden>${esc(t('edit.needTitle'))}</span>
670
+ <div class="dlg-foot">${item ? `<button class="btn btn-danger w96" data-del>${esc(t('edit.delete'))}</button>` : ''}<div class="grow"></div><button class="btn btn-secondary w96" data-x>${esc(t('edit.cancel'))}</button><button class="btn btn-primary w96" data-save>${esc(t('edit.save'))}</button></div></section>`;
671
+ const keep = () => { it.title = $('#t-title', o).value; it.details = $('#t-details', o).value; const s = $('#t-status', o); if (s) it.status = s.value; };
672
+ o.querySelectorAll('[data-rm]').forEach((b) => { b.onclick = () => { keep(); it.paths.splice(Number(b.dataset.rm), 1); draw(); }; });
673
+ o.querySelectorAll('[data-pick]').forEach((b) => { b.onclick = async () => { keep(); try { const r = await rpc(b.dataset.pick === 'file' ? 'pickFiles' : 'pickFolder', {}, undefined, 0); for (const p of [].concat(r || [])) if (p && !it.paths.includes(p)) it.paths.push(p); draw(); } catch (e) { fail(e); } }; });
674
+ o.querySelector('[data-x]').onclick = closeLayer;
675
+ const del = o.querySelector('[data-del]'); if (del) del.onclick = () => { closeLayer(); deleteTask(item); };
676
+ o.querySelector('[data-save]').onclick = async () => {
677
+ keep();
678
+ if (!it.title.trim()) { $('#t-err', o).hidden = false; $('#t-title', o).focus(); return; }
679
+ try {
680
+ if (item) { await rpc('queue.edit', { id: item.id, title: it.title, details: it.details, paths: it.paths }); if (it.status !== item.status) await rpc('queue.update', { id: item.id, status: it.status }); }
681
+ else await rpc('queue.add', { title: it.title, details: it.details || undefined, paths: it.paths });
682
+ closeLayer(); refresh();
683
+ } catch (e) { fail(e); }
684
+ };
685
+ // Files dragged in from Explorer: their paths come through the host (WebView2 file objects).
686
+ const dlg = o.querySelector('.dialog');
687
+ dlg.ondragover = (e) => { e.preventDefault(); dlg.classList.add('drop'); };
688
+ dlg.ondragleave = () => dlg.classList.remove('drop');
689
+ dlg.ondrop = async (e) => {
690
+ e.preventDefault(); dlg.classList.remove('drop'); keep();
691
+ try { const r = await rpc('droppedPaths', {}, [...e.dataTransfer.files]); for (const p of [].concat(r || [])) if (p && !it.paths.includes(p)) it.paths.push(p); draw(); } catch (err) { fail(err); }
692
+ };
693
+ };
694
+ draw();
695
+ $('#layer').append(o); $('#t-title', o).focus();
696
+ }
697
+
698
+ async function deleteTask(item) {
699
+ try {
700
+ const removed = await rpc('queue.remove', { id: item.id });
701
+ await refresh();
702
+ snack(t('queue.deleted'), async () => { try { await rpc('queue.restore', { item: removed }); refresh(); } catch (e) { fail(e); } });
703
+ } catch (e) { fail(e); }
704
+ }
705
+
706
+ function queueMenu(btn) {
707
+ const item = queueItems.find((i) => i.id === btn.dataset.id);
708
+ if (!item) return;
709
+ $('#layer').querySelector('.menu')?.remove();
710
+ const m = document.createElement('div');
711
+ m.className = 'menu'; m.setAttribute('role', 'menu');
712
+ const acts = [['edit', 'queue.edit'], ['top', 'queue.toTop'], item.status === 'done' ? ['todo', 'queue.markTodo'] : ['done', 'queue.markDone'], ['delete', 'queue.delete']];
713
+ m.innerHTML = acts.map(([a, k]) => `<button role="menuitem" data-m="${a}" class="${a === 'delete' ? 'danger' : ''}">${esc(t(k))}</button>`).join('');
714
+ const r = btn.getBoundingClientRect();
715
+ m.style.top = `${Math.min(r.bottom + 4, window.innerHeight - 160)}px`; m.style.left = `${Math.max(8, r.right - 180)}px`;
716
+ m.onclick = async (e) => {
717
+ const a = e.target.closest('[data-m]')?.dataset.m; if (!a) return;
718
+ m.remove();
719
+ try {
720
+ if (a === 'edit') return taskDialog(item);
721
+ if (a === 'delete') return deleteTask(item);
722
+ if (a === 'top') await rpc('queue.reorder', { ids: [item.id] });
723
+ if (a === 'done' || a === 'todo') await rpc('queue.update', { id: item.id, status: a });
724
+ refresh();
725
+ } catch (err) { fail(err); }
726
+ };
727
+ $('#layer').append(m); m.querySelector('button').focus();
728
+ setTimeout(() => document.addEventListener('click', function off(ev) { if (!m.contains(ev.target)) { m.remove(); } document.removeEventListener('click', off); }), 0);
729
+ }
730
+
731
+ // "Clear…": done / failed + skipped / all — no dialog, Undo in the window.
732
+ function clearMenu(btn) {
733
+ $('#layer').querySelector('.menu')?.remove();
734
+ const m = document.createElement('div');
735
+ m.className = 'menu'; m.setAttribute('role', 'menu');
736
+ m.innerHTML = [['done', 'queue.clearDone'], ['failed+skipped', 'queue.clearFailed'], ['all', 'queue.clearAll']].map(([v, k]) => `<button role="menuitem" data-c="${v}" class="${v === 'all' ? 'danger' : ''}">${esc(t(k))}</button>`).join('');
737
+ const r = btn.getBoundingClientRect();
738
+ m.style.top = `${r.bottom + 4}px`; m.style.left = `${Math.max(8, r.right - 200)}px`;
739
+ m.onclick = async (e) => {
740
+ const v = e.target.closest('[data-c]')?.dataset.c; if (!v) return;
741
+ m.remove();
742
+ try {
743
+ const gone = await rpc('queue.clear', { status: v });
744
+ await refresh();
745
+ if (gone.length) snack(t('queue.cleared', { n: gone.length }), async () => { try { await rpc('queue.restoreMany', { items: gone }); refresh(); } catch (err) { fail(err); } });
746
+ } catch (err) { fail(err); }
747
+ };
748
+ $('#layer').append(m); m.querySelector('button').focus();
749
+ setTimeout(() => document.addEventListener('click', function off(ev) { if (!m.contains(ev.target)) m.remove(); document.removeEventListener('click', off); }), 0);
750
+ }
751
+
752
+ // ---- actions ---------------------------------------------------------------------------------------------
753
+ async function setSetting(key, value) {
754
+ try { await rpc('setSetting', { key, value }); await refresh(); } catch (e) { fail(e); refresh(); }
755
+ }
756
+ // The title bar moves the window: the host starts the native move (no CSS app-region — it swallowed the buttons' clicks).
757
+ document.addEventListener('mousedown', (e) => { if (e.button === 0 && e.target.closest('.titlebar') && !e.target.closest('button')) rpc('drag').catch(() => {}); });
758
+ document.addEventListener('click', async (e) => {
759
+ const el = e.target.closest('button, a');
760
+ if (!el) return;
761
+ if (el.closest('#layer .dialog, #layer .menu, #layer .snack')) return;
762
+ const d = el.dataset;
763
+ if (el.id === 'w-min') return rpc('hide').catch(() => {}); // owner 21.10: only "minimize", and it goes to the tray
764
+ if (d.copy) { try { await navigator.clipboard.writeText(d.copy); snack(t('copied')); } catch (err) { fail(err); } return; }
765
+ if (d.filter) { qFilter = d.filter; return render(); }
766
+ if (d.topic) { helpTopic = d.topic; return render(); }
767
+ if (d.preview) { previewTheme = d.preview; return render(); }
768
+ if (d.tab) { safetyTab = d.tab; return refresh(); }
769
+ if (d.undo) return runUndo([d.undo]);
770
+ if (d.dcancel) return rpc('delegate.cancel', { id: d.dcancel }).then(() => refresh(), (err) => { fail(err); refresh(); });
771
+ if (d.drun) return runDialog(d.drun);
772
+ if (d.autoRun) { snack(t('auto.started')); return rpc('automation.run', { id: d.autoRun }).then(() => refresh(), fail); }
773
+ if (d.autoToggle) return rpc('automation.enable', { id: d.autoToggle, on: el.getAttribute('aria-checked') !== 'true' }).then(() => refresh(), (err) => { fail(err); refresh(); });
774
+ if (d.set) {
775
+ const v = d.value !== undefined ? d.value : el.getAttribute('aria-checked') !== 'true';
776
+ return setSetting(d.set, v);
777
+ }
778
+ if (d.rmdir) {
779
+ const list = (state.settings[d.rmdir] || []).filter((p) => p !== d.path);
780
+ return setSetting(d.rmdir, list);
781
+ }
782
+ if (d.adddir) {
783
+ try { const p = await rpc('pickFolder', {}, undefined, 0); if (p) setSetting(d.adddir, [...(state.settings[d.adddir] || []), p]); } catch (err) { fail(err); }
784
+ return;
785
+ }
786
+ switch (d.act) {
787
+ case 'openAccount': return rpc('openUrl', { url: 'https://bridgerator.recondeck.com/app' }).catch(fail);
788
+ case 'support': return rpc('openUrl', { url: 'https://bridgerator.recondeck.com/support' }).catch(fail);
789
+ case 'plan': return rpc('openUrl', { url: state.banner?.url || state.plan?.renewUrl || 'https://bridgerator.recondeck.com/app' }).catch(fail);
790
+ case 'dismissBanner': return rpc('dismissBanner').then(() => refresh(), fail);
791
+ case 'pause': case 'resume': return rpc(d.act).then(() => refresh(), fail);
792
+ case 'addTask': return taskDialog(null);
793
+ case 'allowAll': return setSetting('allowedDirectories', []);
794
+ case 'qmenu': e.stopPropagation(); return queueMenu(el);
795
+ case 'clearMenu': e.stopPropagation(); return clearMenu(el);
796
+ case 'amenu': e.stopPropagation(); return autoMenu(el);
797
+ case 'clearRecent': return rpc('recent.clear').then(() => { snack(t('recent.cleared')); refresh(); }, fail);
798
+ case 'undoDialog': return undoDialog();
799
+ case 'detectAgents': snack(t('agents.checking')); return rpc('delegate.detect').then(() => refresh(), fail);
800
+ case 'clearSnapshots': return confirmDialog(t('safety.clearSnapsTitle'), t('safety.clearSnapsText'), t('safety.clearSnapsGo'), () => rpc('undo.clear').then(() => refresh(true), fail));
801
+ case 'testNotification': snack(t('notify.testSent')); return rpc('testNotification').catch(fail);
802
+ case 'update':
803
+ try { await rpc('update', { version: updateInfo?.latest }); updateInfo = { ...updateInfo, updating: true }; } catch (err) { fail(err); }
804
+ return render();
805
+ case 'checkUpdate':
806
+ updateInfo = { checking: true }; render();
807
+ try { updateInfo = await rpc('checkUpdate'); } catch { updateInfo = { failed: true }; }
808
+ return render();
809
+ case 'unlink': return confirmDialog(t('settings.unlinkTitle'), t('settings.unlinkText'), t('settings.unlinkGo'), () => rpc('unlink').catch(fail));
810
+ case 'uninstall': return confirmDialog(t('settings.uninstallTitle'), t('settings.uninstallText'), t('settings.uninstallGo'), () => rpc('uninstall').catch(fail));
811
+ default:
812
+ }
813
+ });
814
+ document.addEventListener('change', (e) => {
815
+ const el = e.target;
816
+ if (el.id === 'st-lang') return setSetting('language', el.value);
817
+ if (el.id === 's-timeout') return setSetting('confirm.timeout', Number(el.value));
818
+ if (el.dataset && el.dataset.num) return setSetting(el.dataset.num, Number(el.value));
819
+ if (el.id === 'r-result') { rResult = el.value; return render(); }
820
+ if (el.id === 'st-name') return setSetting('deviceName', el.value.trim());
821
+ });
822
+ document.addEventListener('input', (e) => {
823
+ if (e.target.id === 'r-search') {
824
+ rSearch = e.target.value;
825
+ const pos = e.target.selectionStart; render();
826
+ const i = $('#r-search'); i.focus(); i.setSelectionRange(pos, pos);
827
+ }
828
+ });
829
+ document.addEventListener('keydown', (e) => {
830
+ // Esc = the dialog's own Cancel (a settings proposal must get its answer, not just disappear).
831
+ if (e.key === 'Escape') { const l = $('#layer'); if (l.querySelector('.overlay, .menu')) { e.preventDefault(); const x = l.querySelector('.overlay [data-x]'); if (x) x.click(); else closeLayer(); } }
832
+ if (e.key === 'Enter' && e.target.id === 'st-name') e.target.blur();
833
+ });
834
+
835
+ // drag to reorder queue rows
836
+ let dragId = null;
837
+ document.addEventListener('dragstart', (e) => { const r = e.target.closest?.('.qrow'); if (r) { dragId = r.dataset.id; r.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; } });
838
+ document.addEventListener('dragover', (e) => { const r = e.target.closest?.('.qrow'); if (dragId && r) { e.preventDefault(); document.querySelectorAll('.drop-above').forEach((x) => x.classList.remove('drop-above')); r.classList.add('drop-above'); } });
839
+ document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('.dragging, .drop-above').forEach((x) => x.classList.remove('dragging', 'drop-above')); });
840
+ document.addEventListener('drop', async (e) => {
841
+ const r = e.target.closest?.('.qrow');
842
+ if (!dragId || !r) return;
843
+ e.preventDefault();
844
+ if (r.dataset.id === dragId) { dragId = null; return; } // dropped on itself: nothing moves
845
+ const ids = queueItems.map((i) => i.id).filter((id) => id !== dragId);
846
+ const at = ids.indexOf(r.dataset.id);
847
+ if (at < 0) { dragId = null; return; }
848
+ ids.splice(at, 0, dragId);
849
+ dragId = null;
850
+ try { await rpc('queue.reorder', { ids }); refresh(); } catch (err) { fail(err); }
851
+ });
852
+
853
+ // ---- routing / start -------------------------------------------------------------------------------------------
854
+ function go(p) {
855
+ // #queue-edit:<id> opens that task (deep link from the tray / notifications).
856
+ const m = /^queue-edit:([\w-]+)$/.exec(p);
857
+ if (m) { page = 'queue'; location.hash = 'queue'; refresh(true).then(() => { const it = queueItems.find((i) => i.id === m[1]); if (it) taskDialog(it); }); return; }
858
+ if (PAGES.includes(p)) { page = p; if (location.hash !== `#${p}`) location.hash = p; refresh(); }
859
+ }
860
+ window.addEventListener('hashchange', () => go(location.hash.slice(1) || 'home'));
861
+ (async () => {
862
+ const h = location.hash.slice(1);
863
+ page = PAGES.includes(h) ? h : 'home';
864
+ await loadLang('en');
865
+ await refresh(true);
866
+ if (h && !PAGES.includes(h)) go(h);
867
+ setInterval(() => { if (!document.hidden) refresh(); }, 2000);
868
+ })();