@sksoftofficial/ocduet 0.2.1

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.
@@ -0,0 +1,1716 @@
1
+ (() => {
2
+ "use strict";
3
+
4
+ const $ = (id) => document.getElementById(id);
5
+ const els = {
6
+ project: $("project-name"), list: $("session-list"),
7
+ timeline: $("timeline"), empty: $("empty"), input: $("input"),
8
+ send: $("send-btn"), stop: $("stop-btn"), sessionsBtn: $("sessions-btn"),
9
+ toast: $("toast"), main: $("main"), search: $("session-search"), newSession: $("new-session"),
10
+ activity: $("activity"), activityLabel: $("activity-label"), activityTime: $("activity-time"),
11
+ permissions: $("permissions"), questions: $("questions"), modalRoot: $("modal-root"),
12
+ menuBtn: $("menu-btn"), sessionMenu: $("session-menu"), menuDelete: $("menu-delete"), menuLevel: $("menu-level"),
13
+ cmdBtn: $("cmd-btn"), menuUsage: $("menu-usage"),
14
+ modelPill: $("model-pill"), modelPillLabel: $("model-pill-label"),
15
+ variantPill: $("variant-pill"), variantPillLabel: $("variant-pill-label"),
16
+ contextPill: $("context-pill"), contextRing: $("context-ring"), contextLabel: $("context-pill-label"),
17
+ };
18
+
19
+ // ---------- token ----------
20
+ const TOKEN_KEY = "ocduet-token";
21
+ const RELAY_ROUTE_KEY = "ocduet-relay-route"; // { origin, d } — the always-on route
22
+ // write text only when it actually changed — identical textContent/style writes
23
+ // still dirty layout and are the #1 source of streaming jank
24
+ function setText(el, v) { if (el && el.textContent !== v) el.textContent = v; }
25
+ const hash = new URLSearchParams(location.hash.replace(/^#/, ""));
26
+ if (hash.get("t")) {
27
+ try { localStorage.setItem(TOKEN_KEY, hash.get("t")); } catch {}
28
+ history.replaceState(null, "", location.pathname);
29
+ }
30
+ let token = null;
31
+ try { token = localStorage.getItem(TOKEN_KEY); } catch {}
32
+
33
+ // ---------- state ----------
34
+ const state = {
35
+ instances: new Map(), // instanceId -> { info, sessions[] }
36
+ selected: null, // sessionID
37
+ selectedInstance: null, // instanceId
38
+ connected: false,
39
+ rpcSeq: 0,
40
+ rpcPending: new Map(),
41
+ refetchTimer: null,
42
+ listTimer: null,
43
+ busy: new Map(),
44
+ statuses: new Map(),
45
+ permissions: new Map(),
46
+ questions: new Map(),
47
+ modelChoices: new Map(),
48
+ levels: new Map(),
49
+ providers: new Map(),
50
+ providersLoading: new Set(),
51
+ lastActivity: new Map(),
52
+ filter: "",
53
+ loading: false,
54
+ loadVersion: 0,
55
+ everSelected: false,
56
+ };
57
+
58
+ // ---------- helpers ----------
59
+ function toast(text, ms = 2500) {
60
+ // swallow only the generic connection-state texts — actionable messages
61
+ // ("That opencode instance is offline — start X…", delete errors, …) must
62
+ // reach the user, otherwise taps look completely dead
63
+ const t = String(text);
64
+ if (t === "Your desktop is offline." || t.startsWith("Connection lost")) return;
65
+ els.toast.textContent = text;
66
+ els.toast.hidden = false;
67
+ clearTimeout(toast._t);
68
+ toast._t = setTimeout(() => { els.toast.hidden = true; }, ms);
69
+ }
70
+
71
+ function timeAgo(ts) {
72
+ if (!ts) return "";
73
+ const s = Math.max(0, (Date.now() - ts) / 1000);
74
+ if (s < 60) return "now";
75
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
76
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
77
+ return `${Math.floor(s / 86400)}d`;
78
+ }
79
+
80
+ function esc(s) {
81
+ return String(s ?? "").replace(/[&<>"']/g, (c) =>
82
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
83
+ }
84
+
85
+ function renderText(text) {
86
+ const parts = String(text ?? "").split(/```/);
87
+ let html = "";
88
+ for (let i = 0; i < parts.length; i++) {
89
+ if (i % 2 === 1) {
90
+ const nl = parts[i].indexOf("\n");
91
+ const lang = nl > 0 ? parts[i].slice(0, nl).trim() : "";
92
+ const body = nl >= 0 ? parts[i].slice(nl + 1) : parts[i];
93
+ html += `<pre data-lang="${esc(lang)}">${esc(body)}</pre>`;
94
+ } else {
95
+ html += esc(parts[i]).replace(/`([^`\n]+)`/g, "<code>$1</code>")
96
+ .replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
97
+ }
98
+ }
99
+ return html;
100
+ }
101
+
102
+ // ---------- websocket ----------
103
+ let ws = null;
104
+ let retryMs = 800;
105
+ let retryTimer = null;
106
+ let failingSince = null; // set while the LAN origin can't connect
107
+
108
+ // off-LAN fallback: if this page came from the desktop's LAN origin and it
109
+ // has been unreachable for a while, hop to the relay route (carrying the
110
+ // token in the hash — the relay origin then runs the normal pairing flow,
111
+ // words check included). sessionStorage guards against redirect loops.
112
+ function tryRelayFallback() {
113
+ try {
114
+ if (sessionStorage.getItem("ocduet-relay-hop")) return false;
115
+ const raw = localStorage.getItem(RELAY_ROUTE_KEY);
116
+ if (!raw) return false;
117
+ const route = JSON.parse(raw);
118
+ if (!route?.origin || route.origin === location.origin) return false;
119
+ sessionStorage.setItem("ocduet-relay-hop", "1");
120
+ location.replace(`${route.origin}/?d=${encodeURIComponent(route.d)}${token ? `#t=${encodeURIComponent(token)}` : ""}`);
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function setConn(mode, label) {
128
+ state.connected = mode === "on";
129
+ $("sync-dot").className = `status-dot ${mode}`;
130
+ $("sync-note").textContent =
131
+ mode === "on" ? "Connected" :
132
+ mode === "wait" ? "Connecting…" :
133
+ mode === "recon" ? "Reconnecting" :
134
+ (label || "Disconnected");
135
+ updateChrome();
136
+ renderActivity();
137
+ }
138
+
139
+ function killSocket() {
140
+ if (!ws) return;
141
+ const old = ws;
142
+ ws = null;
143
+ old.onclose = null;
144
+ try { old.close(); } catch {}
145
+ }
146
+
147
+ let lastMsgAt = Date.now();
148
+ let sec = null; // {pd, dp, sendN, recvN, pendingEcho} — the encrypted channel
149
+
150
+ // ---------- e2ee (WebCrypto; mirrors src/e2ee.js on the daemon) ----------
151
+ const te = new TextEncoder();
152
+ const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
153
+ const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
154
+ const PD_TAG = Uint8Array.from([0x50, 0x32, 0x44, 0]); // "P2D\0" phone->daemon
155
+ const DP_TAG = Uint8Array.from([0x44, 0x32, 0x50, 0]); // "D2P\0" daemon->phone
156
+ const hs1Msg = (phonePub, eph, tok, ts) => `ocduet-hs1|${phonePub}|${eph}|${tok}|${ts}`;
157
+ const hs2Msg = (phonePub, phoneEph, daemonEph) => `ocduet-hs2|${phonePub}|${phoneEph}|${daemonEph}`;
158
+ const hsInfo = (phonePub, phoneEph, daemonEph) => `ocduet-e2ee-v1|${phonePub}|${phoneEph}|${daemonEph}`;
159
+ async function sha256hex(str) {
160
+ return [...new Uint8Array(await crypto.subtle.digest("SHA-256", te.encode(str)))]
161
+ .map((b) => b.toString(16).padStart(2, "0")).join("");
162
+ }
163
+ function ivFor(tag, n) {
164
+ const iv = new Uint8Array(12);
165
+ iv.set(tag, 0);
166
+ new DataView(iv.buffer).setBigUint64(4, BigInt(n));
167
+ return iv;
168
+ }
169
+ async function encFrame(key, tag, n, obj) {
170
+ return b64(await crypto.subtle.encrypt({ name: "AES-GCM", iv: ivFor(tag, n) }, key, te.encode(JSON.stringify(obj))));
171
+ }
172
+ async function decFrame(key, tag, n, ctB64) {
173
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivFor(tag, n) }, key, unb64(ctB64));
174
+ return JSON.parse(new TextDecoder().decode(pt));
175
+ }
176
+ function openIdb() {
177
+ return new Promise((resolve, reject) => {
178
+ const r = indexedDB.open("ocduet-keys", 1);
179
+ r.onupgradeneeded = () => r.result.createObjectStore("keys");
180
+ r.onsuccess = () => resolve(r.result);
181
+ r.onerror = () => reject(r.error);
182
+ });
183
+ }
184
+ async function idbGet(key) {
185
+ const db = await openIdb();
186
+ return new Promise((resolve, reject) => {
187
+ const rq = db.transaction("keys", "readonly").objectStore("keys").get(key);
188
+ rq.onsuccess = () => resolve(rq.result || null);
189
+ rq.onerror = () => reject(rq.error);
190
+ });
191
+ }
192
+ let identityPromise = null;
193
+ function ensureIdentity() {
194
+ identityPromise ??= (async () => {
195
+ try {
196
+ const rec = await idbGet(location.origin);
197
+ if (!rec?.priv || !rec?.daemonPub || !rec?.phoneId || !rec?.phonePubB64) return null;
198
+ return rec;
199
+ } catch {
200
+ return null;
201
+ }
202
+ })();
203
+ return identityPromise;
204
+ }
205
+ async function startHandshake() {
206
+ const rec = await ensureIdentity();
207
+ if (!rec) {
208
+ if (token) location.href = `/pair#t=${encodeURIComponent(token)}`;
209
+ else setConn("off", "Not paired");
210
+ return false;
211
+ }
212
+ const eph = await crypto.subtle.generateKey({ name: "X25519" }, false, ["deriveBits"]);
213
+ const ephB64 = b64(await crypto.subtle.exportKey("raw", eph.publicKey));
214
+ const ts = Date.now();
215
+ const sig = await crypto.subtle.sign({ name: "Ed25519" }, rec.priv, te.encode(hs1Msg(rec.phonePubB64, ephB64, token, ts)));
216
+ sec = { eph, rec, ephB64, pendingEcho: null, established: false };
217
+ ws.send(JSON.stringify({ t: "hs1", phonePub: rec.phonePubB64, eph: ephB64, token, ts, sig: b64(sig) }));
218
+ return true;
219
+ }
220
+ async function finishHandshake(hs2) {
221
+ const { rec, eph, ephB64 } = sec;
222
+ const daemonPub = await crypto.subtle.importKey("raw", unb64(rec.daemonPub), { name: "Ed25519" }, false, ["verify"]);
223
+ const okSig = await crypto.subtle.verify({ name: "Ed25519" }, daemonPub, unb64(hs2.sig), te.encode(hs2Msg(rec.phonePubB64, ephB64, hs2.eph)));
224
+ if (!okSig || unb64(hs2.eph).length !== 32) { try { ws.close(4003, "daemon signature failed"); } catch {} return; }
225
+ const shared = await crypto.subtle.deriveBits({ name: "X25519", public: await crypto.subtle.importKey("raw", unb64(hs2.eph), { name: "X25519" }, false, []) }, eph.privateKey, 256);
226
+ const hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]);
227
+ const okm = new Uint8Array(await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: te.encode("ocduet-e2ee-v1"), info: te.encode(hsInfo(rec.phonePubB64, ephB64, hs2.eph)) }, hkdf, 512));
228
+ const imp = (raw) => crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
229
+ sec = {
230
+ ...sec,
231
+ pd: await imp(okm.subarray(0, 32)),
232
+ dp: await imp(okm.subarray(32, 64)),
233
+ sendN: 1, recvN: 0,
234
+ pendingEcho: await sha256hex(hs2.eph),
235
+ };
236
+ const ct = await encFrame(sec.pd, PD_TAG, 0, { t: "hs3", ts: Date.now(), echo: sec.pendingEcho });
237
+ ws.send(JSON.stringify({ t: "sec", n: 0, ct }));
238
+ }
239
+
240
+ function connect() {
241
+ clearTimeout(retryTimer);
242
+ killSocket();
243
+ sec = null;
244
+ if (!token) {
245
+ setConn("off", "Not paired");
246
+ return;
247
+ }
248
+ const proto = location.protocol === "https:" ? "wss" : "ws";
249
+ setConn("wait", "Connecting");
250
+ ws = new WebSocket(`${proto}://${location.host}/ws?e2ee=1`);
251
+
252
+ ws.onopen = () => { retryMs = 800; failingSince = null; startHandshake(); };
253
+ ws.onmessage = (ev) => {
254
+ lastMsgAt = Date.now();
255
+ let msg;
256
+ try { msg = JSON.parse(ev.data); } catch { return; }
257
+ if (msg.t === "hs2") return void finishHandshake(msg);
258
+ if (msg.t !== "sec" || !sec) return;
259
+ (async () => {
260
+ try {
261
+ if (!sec.established) {
262
+ if (msg.n !== 0) throw new Error("bad hs3 counter");
263
+ const m = await decFrame(sec.dp, DP_TAG, 0, msg.ct);
264
+ if (m?.t !== "hs4" || !m.ok) throw new Error("bad hs4");
265
+ sec.established = true;
266
+ sec.recvN = 1;
267
+ retryMs = 800;
268
+ failingSince = null;
269
+ setConn("on", "Connected");
270
+ return;
271
+ }
272
+ if (msg.n !== sec.recvN) throw new Error("replay/out-of-order");
273
+ sec.recvN++;
274
+ dispatch(await decFrame(sec.dp, DP_TAG, msg.n, msg.ct));
275
+ } catch (err) {
276
+ try { ws && ws.close(4002, "bad frame"); } catch {}
277
+ }
278
+ })();
279
+ };
280
+ ws.onclose = (ev) => {
281
+ if (ws !== null) {
282
+ ws = null;
283
+ sec = null;
284
+ state.statuses.clear();
285
+ if (ev?.code === 4003) { setConn("off", "Not paired on this daemon — re-scan the QR"); return; }
286
+ if (ev?.code === 4001) { setConn("off", "Handshake failed — reload or re-pair"); return; }
287
+ setConn("recon", "Reconnecting");
288
+ for (const [id, pending] of state.rpcPending) {
289
+ if (pending.survive) continue;
290
+ clearTimeout(pending.timer);
291
+ state.rpcPending.delete(id);
292
+ pending.reject(new Error("Connection lost. Reconnecting to your desktop…"));
293
+ }
294
+ clearTimeout(retryTimer);
295
+ if (!failingSince) failingSince = Date.now();
296
+ // ~15s of continuous failure on the LAN origin → try the relay
297
+ if (Date.now() - failingSince > 15_000 && tryRelayFallback()) return;
298
+ retryTimer = setTimeout(connect, retryMs);
299
+ retryMs = Math.min(retryMs * 1.7, 10000);
300
+ }
301
+ };
302
+ ws.onerror = () => { try { ws && ws.close(); } catch {} };
303
+ }
304
+
305
+ function dispatch(msg) {
306
+ if (msg.t === "hello") return onHello(msg);
307
+ if (msg.t === "instances") return onInstances(msg.instances);
308
+ if (msg.t === "rpcResult") return onRpcResult(msg);
309
+ if (msg.t === "event") return onEvent(msg.event, msg.instanceId);
310
+ if (msg.t === "clients") return;
311
+ }
312
+
313
+ let hiddenAt = 0;
314
+ document.addEventListener("visibilitychange", () => {
315
+ if (document.hidden) { hiddenAt = Date.now(); return; }
316
+ const away = hiddenAt ? Date.now() - hiddenAt : 0;
317
+ if (!ws || ws.readyState > 1 || Date.now() - lastMsgAt > 90_000) connect();
318
+ // the page may have been frozen with the socket alive — missed events never replay
319
+ if (away > 10_000 && state.connected && state.selected && state.selectedInstance) {
320
+ loadMessages(state.selectedInstance, state.selected, false);
321
+ }
322
+ });
323
+
324
+ let sendChain = Promise.resolve();
325
+ function send(obj) {
326
+ if (!ws || ws.readyState !== 1 || !sec?.established) return;
327
+ const n = sec.sendN++;
328
+ const s = sec;
329
+ sendChain = sendChain.then(async () => {
330
+ if (sec !== s || !ws || ws.readyState !== 1) return; // stale (reconnected) frame
331
+ const ct = await encFrame(s.pd, PD_TAG, n, obj);
332
+ if (sec === s && ws && ws.readyState === 1) ws.send(JSON.stringify({ t: "sec", n, ct }));
333
+ }).catch(() => {});
334
+ }
335
+
336
+ function rpc(method, args = {}, instanceId = null) {
337
+ return new Promise((resolve, reject) => {
338
+ if (!state.connected) return reject(new Error("Your desktop is offline."));
339
+ const id = String(++state.rpcSeq);
340
+ const timer = setTimeout(() => {
341
+ if (state.rpcPending.has(id)) {
342
+ state.rpcPending.delete(id);
343
+ reject(new Error("rpc timeout"));
344
+ }
345
+ }, method === "session.prompt" || method === "session.compact" ? 30 * 60 * 1000 : 15000);
346
+ state.rpcPending.set(id, { resolve, reject, timer, survive: method === "session.prompt" || method === "session.compact" });
347
+ send({ t: "rpc", id, instanceId, method, args });
348
+ });
349
+ }
350
+
351
+ function onRpcResult(msg) {
352
+ const p = state.rpcPending.get(String(msg.id));
353
+ if (!p) return;
354
+ clearTimeout(p.timer);
355
+ state.rpcPending.delete(String(msg.id));
356
+ if (msg.ok) p.resolve(msg.result);
357
+ else p.reject(new Error(msg.error || "rpc failed"));
358
+ }
359
+
360
+ // ---------- data flow ----------
361
+ function flattenSessions() {
362
+ const out = [];
363
+ for (const [instId, inst] of state.instances) {
364
+ for (const s of inst.sessions || []) {
365
+ out.push({ instId, inst, session: s });
366
+ }
367
+ }
368
+ return out;
369
+ }
370
+
371
+ function sessionDirOf(instId, sid) {
372
+ const inst = state.instances.get(instId);
373
+ const s = inst?.sessions?.find((x) => sessionID(x) === sid);
374
+ return s?.directory || inst?.directory;
375
+ }
376
+
377
+ function sessionLabel(inst, s) {
378
+ const base = s?.directory?.split("/").filter(Boolean).pop();
379
+ return base || projectName(inst);
380
+ }
381
+
382
+ function dirArg(instId, sid) {
383
+ const d = sessionDirOf(instId, sid);
384
+ return d ? { directory: d } : {};
385
+ }
386
+
387
+ function onHello(msg) {
388
+ // remember the relay route — enables off-LAN fallback without a new QR
389
+ if (msg.relay?.url && msg.relay.desktopId) {
390
+ try {
391
+ const origin = new URL(msg.relay.url).origin;
392
+ const route = { origin, d: String(msg.relay.desktopId) };
393
+ if (localStorage.getItem(RELAY_ROUTE_KEY) !== JSON.stringify(route)) {
394
+ localStorage.setItem(RELAY_ROUTE_KEY, JSON.stringify(route));
395
+ }
396
+ } catch {}
397
+ }
398
+ onInstances(msg.instances);
399
+ scheduleListRefetch();
400
+ refreshPermissions();
401
+ refreshQuestions();
402
+ for (const instId of state.instances.keys()) loadLevel(instId);
403
+ // events missed while disconnected are gone for good — re-pull the open timeline
404
+ if (state.selected && state.selectedInstance) loadMessages(state.selectedInstance, state.selected, false);
405
+ // prompts/compacts very likely reached the server before the socket dropped —
406
+ // resolve survivors so their follow-up refresh shows the truth instead of refilling the input
407
+ for (const [id, pending] of state.rpcPending) {
408
+ if (!pending.survive) continue;
409
+ state.rpcPending.delete(id);
410
+ clearTimeout(pending.timer);
411
+ pending.resolve(undefined);
412
+ }
413
+ }
414
+
415
+ function onInstances(list) {
416
+ const next = new Map();
417
+ for (const inst of list || []) next.set(inst.id, inst);
418
+ state.instances = next;
419
+ if (!state.selected && !state.everSelected) {
420
+ const latest = flattenSessions().sort(
421
+ (a, b) => sessionTime(b.instId, b.session) - sessionTime(a.instId, a.session))[0];
422
+ if (latest) selectSession(latest.instId, latest.session.id ?? latest.session.sessionID);
423
+ }
424
+ renderSessions();
425
+ updateChrome();
426
+ renderActivity();
427
+ }
428
+
429
+ function onEvent(event, instanceId) {
430
+ if (!event || !event.type) return;
431
+ const p = event.properties ?? {};
432
+ const sid = p.sessionID ?? p.session_id ?? p.info?.sessionID ?? p.message?.sessionID ?? p.part?.sessionID;
433
+ const key = activityKey(instanceId, sid);
434
+ if (sid && instanceId && (event.type.startsWith("message.") || event.type === "session.idle" || event.type === "session.status")) {
435
+ state.lastActivity.set(key, Date.now());
436
+ }
437
+ if (event.type.startsWith("session.")) scheduleListRefetch();
438
+ if (event.type === "session.deleted") {
439
+ const goneID = p.info?.id ?? p.sessionID ?? p.id;
440
+ if (goneID) dropSession(instanceId, goneID);
441
+ }
442
+ if (event.type === "session.status" && sid) {
443
+ const status = p.status?.type ?? p.status;
444
+ state.statuses.set(key, status);
445
+ setBusy(instanceId, sid, status === "busy" || status === "retry", status === "retry" ? "Retrying" : "Working");
446
+ }
447
+ if ((event.type === "session.idle" || event.type === "session.error") && sid) {
448
+ state.statuses.set(key, "idle");
449
+ setBusy(instanceId, sid, false);
450
+ if (event.type === "session.error") toast(p.error?.data?.message || p.error?.message || "The agent encountered an error.");
451
+ if (sid === state.selected && instanceId === state.selectedInstance) scheduleRefetch();
452
+ }
453
+ if (event.type.startsWith("message.")) {
454
+ if (sid === state.selected && instanceId === state.selectedInstance) scheduleRefetch();
455
+ if (sid && instanceId) {
456
+ const info = p.info ?? p.message;
457
+ if (!state.statuses.has(key)) {
458
+ if (info?.role === "user" || (info?.role === "assistant" && !info.time?.completed)) setBusy(instanceId, sid, true);
459
+ if (info?.role === "assistant" && (info.error || (info.time?.completed && info.finish !== "tool-calls"))) setBusy(instanceId, sid, false);
460
+ }
461
+ const part = p.part;
462
+ if (state.busy.has(key) && part) {
463
+ const busy = state.busy.get(key);
464
+ busy.label = part.type === "tool" && ["pending", "running"].includes(part.state?.status)
465
+ ? `Running ${part.tool || "tool"}` : part.type === "reasoning" ? "Thinking" : "Working";
466
+ }
467
+ }
468
+ }
469
+ if (event.type === "permission.updated" || event.type === "permission.asked" || event.type === "permission.v2.asked") {
470
+ const pid = p.id ?? p.requestID;
471
+ if (sid && pid && state.levels.get(instanceId) !== "auto") {
472
+ let m = state.permissions.get(key);
473
+ if (!m) { m = new Map(); state.permissions.set(key, m); }
474
+ m.set(pid, { id: pid, sessionID: sid, type: p.type ?? p.permission ?? p.action ?? "tool", pattern: p.pattern ?? p.patterns ?? p.resources, title: p.title });
475
+ if (key !== activityKey(state.selectedInstance, state.selected)) toast("Another session is waiting for permission");
476
+ }
477
+ }
478
+ if (event.type === "permission.replied" || event.type === "permission.v2.replied") {
479
+ removePermission(instanceId, p.sessionID, p.permissionID ?? p.requestID);
480
+ }
481
+ if (event.type === "question.asked" || event.type === "question.v2.asked") {
482
+ const qid = p.id ?? p.requestID;
483
+ if (sid && qid) {
484
+ const key = activityKey(instanceId, sid);
485
+ if (!state.questions.has(key)) state.questions.set(key, []);
486
+ const list = state.questions.get(key);
487
+ if (!list.some((q) => q.id === String(qid))) {
488
+ list.push({ id: String(qid), sessionID: sid, questions: Array.isArray(p.questions) ? p.questions : [], time: Date.now() });
489
+ if (key !== activityKey(state.selectedInstance, state.selected)) toast("Another session is asking a question");
490
+ }
491
+ }
492
+ }
493
+ if (event.type === "question.replied" || event.type === "question.rejected"
494
+ || event.type === "question.v2.replied" || event.type === "question.v2.rejected") {
495
+ removeQuestion(instanceId, p.sessionID, p.requestID ?? p.id);
496
+ }
497
+ // targeted rendering only — a full sidebar/card pass per streaming event
498
+ // is what froze typing and restarted the CSS dot animation mid-pulse
499
+ if (sid && (event.type.startsWith("message.") || event.type === "session.status"
500
+ || event.type === "session.idle" || event.type === "session.error")) {
501
+ patchSidebarRow(instanceId, sid);
502
+ if (key === activityKey(state.selectedInstance, state.selected)) renderActivity();
503
+ }
504
+ if (event.type === "permission.updated" || event.type === "permission.asked" || event.type === "permission.v2.asked"
505
+ || event.type === "permission.replied" || event.type === "permission.v2.replied") {
506
+ if (sid) patchSidebarRow(instanceId, sid);
507
+ renderPermissions();
508
+ }
509
+ if (event.type.startsWith("question.")) renderQuestions();
510
+ }
511
+
512
+ // remove a session locally (deleted here or elsewhere) and, if it was open,
513
+ // hand selection back to the auto-picker so the top session loads next
514
+ function dropSession(instanceId, sid) {
515
+ const inst = state.instances.get(instanceId);
516
+ if (inst?.sessions) inst.sessions = inst.sessions.filter((s) => sessionID(s) !== sid);
517
+ const key = activityKey(instanceId, sid);
518
+ state.busy.delete(key);
519
+ state.permissions.delete(key);
520
+ state.lastActivity.delete(key);
521
+ try { localStorage.removeItem(`ocduet-draft:${instanceId}:${sid}`); } catch {}
522
+ if (state.selected === sid && state.selectedInstance === instanceId) {
523
+ state.selected = null;
524
+ state.selectedInstance = null;
525
+ state.everSelected = false;
526
+ state.messages = new Map();
527
+ state.loading = false;
528
+ renderTimeline();
529
+ renderPermissions();
530
+ renderQuestions();
531
+ }
532
+ renderSessions();
533
+ updateChrome();
534
+ }
535
+
536
+ function scheduleListRefetch() {
537
+ clearTimeout(state.listTimer);
538
+ state.listTimer = setTimeout(async () => {
539
+ try {
540
+ const list = await rpc("instances.list");
541
+ onInstances(list);
542
+ } catch {}
543
+ }, 250);
544
+ }
545
+
546
+ function scheduleRefetch() {
547
+ clearTimeout(state.refetchTimer);
548
+ state.refetchTimer = setTimeout(() => {
549
+ if (state.selected && state.selectedInstance) loadMessages(state.selectedInstance, state.selected, false);
550
+ }, 180);
551
+ }
552
+
553
+ async function loadMessages(instanceId, sessionID, showSpinner) {
554
+ const version = ++state.loadVersion;
555
+ try {
556
+ const msgs = await rpc("session.messages", { id: sessionID, ...dirArg(instanceId, sessionID) }, instanceId);
557
+ if (state.selected !== sessionID || state.selectedInstance !== instanceId || version !== state.loadVersion) return;
558
+ const map = new Map();
559
+ for (const m of msgs || []) map.set(m.id ?? m.info?.id, m);
560
+ state.messages = map;
561
+ state.loading = false;
562
+ const key = activityKey(instanceId, sessionID);
563
+ if (!state.statuses.has(key)) {
564
+ const last = msgs?.[msgs.length - 1];
565
+ const info = last?.info ?? last;
566
+ const running = info && !info.error && (info.role === "user" ||
567
+ (info.role === "assistant" && (!info.time?.completed || info.finish === "tool-calls")));
568
+ setBusy(instanceId, sessionID, !!running);
569
+ }
570
+ renderTimeline();
571
+ renderSessions();
572
+ updateChrome();
573
+ renderActivity();
574
+ } catch (err) {
575
+ if (state.selected === sessionID && state.selectedInstance === instanceId && version === state.loadVersion) {
576
+ state.loading = false;
577
+ if (showSpinner) toast(String(err.message || err));
578
+ updateChrome();
579
+ }
580
+ }
581
+ }
582
+
583
+ // ---------- rendering ----------
584
+ function sessionID(s) { return s.sessionID ?? s.id; }
585
+ function sessionTime(instId, s) {
586
+ return Math.max(s.time?.updated ?? s.time?.created ?? 0, state.lastActivity.get(activityKey(instId, sessionID(s))) ?? 0);
587
+ }
588
+ function sessionTitle(s) { return s.title || `${String(sessionID(s) || "Session").slice(0, 8)}…`; }
589
+ function projectName(inst) { return inst?.project?.name || inst?.directory?.split("/").pop() || "opencode"; }
590
+ function activityKey(instanceId, sid) { return `${instanceId}:${sid}`; }
591
+ function setBusy(instanceId, sid, busy, label = null) {
592
+ const key = activityKey(instanceId, sid);
593
+ if (busy) {
594
+ const previous = state.busy.get(key);
595
+ state.busy.set(key, { since: previous?.since ?? Date.now(), label: label ?? previous?.label ?? "Working" });
596
+ } else state.busy.delete(key);
597
+ }
598
+
599
+ function removePermission(instanceId, sessionID, permissionID) {
600
+ if (!sessionID || !permissionID) return;
601
+ const key = activityKey(instanceId, sessionID);
602
+ const m = state.permissions.get(key);
603
+ if (!m) return;
604
+ m.delete(permissionID);
605
+ if (!m.size) state.permissions.delete(key);
606
+ }
607
+
608
+ function renderPermissions() {
609
+ const box = els.permissions;
610
+ if (!box) return;
611
+ const before = box.querySelectorAll(".permission").length;
612
+ const key = activityKey(state.selectedInstance, state.selected);
613
+ const list = [...(state.permissions.get(key)?.values() ?? [])];
614
+ box.innerHTML = "";
615
+ box.hidden = list.length === 0;
616
+ for (const perm of list) {
617
+ const div = document.createElement("div");
618
+ div.className = "permission";
619
+ const pattern = Array.isArray(perm.pattern) ? perm.pattern.join(" ") : perm.pattern;
620
+ div.innerHTML = `
621
+ <div class="permission-head"><svg><use href="#i-shield"/></svg>Permission needed</div>
622
+ <div class="permission-title">${esc(perm.title || perm.type)}</div>
623
+ ${pattern ? `<span class="permission-pattern" title="${esc(String(pattern))}">${esc(String(pattern))}</span>` : ""}
624
+ <div class="permission-actions">
625
+ <button class="allow" data-response="once">Allow</button>
626
+ <button data-response="always">Always</button>
627
+ <button class="deny" data-response="reject">Deny</button>
628
+ </div>`;
629
+ for (const btn of div.querySelectorAll(".permission-actions button")) {
630
+ btn.onclick = () => respondPermission(state.selectedInstance, state.selected, perm.id, btn.dataset.response, btn);
631
+ }
632
+ box.appendChild(div);
633
+ }
634
+ if (list.length > before) {
635
+ const last = box.lastElementChild;
636
+ if (last) last.scrollIntoView({ behavior: "smooth", block: "end" });
637
+ }
638
+ }
639
+
640
+ async function respondPermission(instanceId, sessionID, permissionID, response, btn) {
641
+ if (!instanceId || !sessionID || !state.connected) return;
642
+ const actions = btn.closest(".permission-actions");
643
+ for (const b of actions.querySelectorAll("button")) b.disabled = true;
644
+ try {
645
+ await rpc("permission.respond", { sessionID, permissionID, response, ...dirArg(instanceId, sessionID) }, instanceId);
646
+ removePermission(instanceId, sessionID, permissionID);
647
+ } catch (err) {
648
+ const msg = String(err?.message || err);
649
+ toast(msg.includes("not found") ? "Permission already resolved" : msg);
650
+ if (msg.includes("not found")) removePermission(instanceId, sessionID, permissionID);
651
+ else for (const b of actions.querySelectorAll("button")) b.disabled = false;
652
+ }
653
+ renderPermissions();
654
+ renderSessions();
655
+ }
656
+
657
+ async function refreshPermissions() {
658
+ if (!state.connected) return;
659
+ const live = new Map();
660
+ const dir = state.selected && state.instances.get(state.selectedInstance)
661
+ ? dirArg(state.selectedInstance, state.selected) : {};
662
+ await Promise.all([...state.instances.keys()].map(async (instId) => {
663
+ try {
664
+ const list = await rpc("permission.list", dir, instId);
665
+ for (const req of list || []) {
666
+ const sid = req?.sessionID;
667
+ const pid = req?.id ?? req?.permissionID;
668
+ if (!sid || !pid) continue;
669
+ const key = activityKey(instId, sid);
670
+ if (!live.has(key)) live.set(key, new Map());
671
+ live.get(key).set(pid, { id: pid, sessionID: sid, type: req.type ?? req.permission ?? req.action ?? "tool", pattern: req.pattern ?? req.patterns ?? req.resources, title: req.title });
672
+ }
673
+ } catch {}
674
+ }));
675
+ state.permissions = live;
676
+ renderPermissions();
677
+ renderSessions();
678
+ }
679
+
680
+ // ---------- questions ----------
681
+ function removeQuestion(instanceId, sessionID, questionID) {
682
+ if (!instanceId || !sessionID || !questionID) return;
683
+ const key = activityKey(instanceId, sessionID);
684
+ const list = state.questions.get(key);
685
+ if (!list) return;
686
+ const next = list.filter((q) => q.id !== String(questionID));
687
+ if (next.length) state.questions.set(key, next);
688
+ else state.questions.delete(key);
689
+ renderQuestions();
690
+ }
691
+
692
+ function renderQuestions() {
693
+ const box = els.questions;
694
+ if (!box) return;
695
+ const before = box.querySelectorAll(".question").length;
696
+ const key = activityKey(state.selectedInstance, state.selected);
697
+ const list = state.questions.get(key) ?? [];
698
+ box.innerHTML = "";
699
+ box.hidden = list.length === 0;
700
+ for (const req of list) {
701
+ const card = document.createElement("div");
702
+ card.className = "permission question";
703
+ const qs = req.questions?.length ? req.questions : [{ question: "The question details didn't load — type your answer below.", header: "Question", options: [] }];
704
+ const immediate = qs.length === 1 && !qs[0].multiple;
705
+ let html = `<div class="permission-head"><svg><use href="#i-bolt"/></svg>${esc(qs[0]?.header || "Question")}</div>`;
706
+ for (const q of qs) {
707
+ if (q.question) html += `<div class="permission-title">${esc(q.question)}</div>`;
708
+ html += `<div class="question-options">`;
709
+ for (const [i, opt] of (q.options ?? []).entries()) {
710
+ html += `<button class="question-option" data-q="${qs.indexOf(q)}" data-i="${i}"><span>${esc(opt.label)}</span>${opt.description ? `<small>${esc(opt.description)}</small>` : ""}</button>`;
711
+ }
712
+ html += `</div>`;
713
+ if (q.custom !== false) {
714
+ html += `<div class="question-custom"><button class="question-custom-btn">Type your own answer</button><div class="question-custom-row" hidden><input type="text" placeholder="Your answer" maxlength="500"><button class="allow">Send</button></div></div>`;
715
+ }
716
+ }
717
+ if (!immediate) html += `<div class="permission-actions"><button class="allow question-submit" disabled>Answer</button></div>`;
718
+ card.innerHTML = html;
719
+
720
+ const selections = new Map();
721
+ for (const btn of card.querySelectorAll(".question-option")) {
722
+ const qi = Number(btn.dataset.q);
723
+ const oi = Number(btn.dataset.i);
724
+ const multi = !!qs[qi]?.multiple;
725
+ btn.onclick = () => {
726
+ if (immediate) { respondQuestion(req, [[qs[0].options[oi].label]], card); return; }
727
+ const cur = selections.get(qi) ?? new Set();
728
+ if (multi) { cur.has(oi) ? cur.delete(oi) : cur.add(oi); }
729
+ else { cur.clear(); cur.add(oi); }
730
+ selections.set(qi, cur);
731
+ for (const b of card.querySelectorAll(`.question-option[data-q="${qi}"]`)) {
732
+ b.classList.toggle("selected", (selections.get(qi) ?? new Set()).has(Number(b.dataset.i)));
733
+ }
734
+ updateSubmit();
735
+ };
736
+ }
737
+ for (const wrap of card.querySelectorAll(".question-custom")) {
738
+ const qi = [...card.querySelectorAll(".question-custom")].indexOf(wrap);
739
+ const toggle = wrap.querySelector(".question-custom-btn");
740
+ const row = wrap.querySelector(".question-custom-row");
741
+ const input = wrap.querySelector("input");
742
+ const send = wrap.querySelector(".question-custom-row .allow");
743
+ if (toggle) toggle.onclick = () => {
744
+ if (toggle.hidden) return;
745
+ toggle.hidden = true;
746
+ row.hidden = false;
747
+ input.focus();
748
+ };
749
+ if (send) send.onclick = () => {
750
+ const v = input.value.trim();
751
+ if (!v) return;
752
+ const answers = qs.map((q, idx) => {
753
+ if (idx === qi) return [v];
754
+ const cur = selections.get(idx);
755
+ return cur?.size ? [...cur].map((i) => q.options[i]?.label).filter(Boolean) : [];
756
+ });
757
+ if (answers.some((a, idx) => !a.length && qs[idx].custom === false && !(idx === qi))) {
758
+ toast("Answer every question first");
759
+ return;
760
+ }
761
+ respondQuestion(req, answers, card);
762
+ };
763
+ if (input) input.onkeydown = (ev) => { if (ev.key === "Enter") { ev.preventDefault(); send?.click(); } };
764
+ }
765
+ const submitBtn = card.querySelector(".question-submit");
766
+ const updateSubmit = () => {
767
+ if (!submitBtn) return;
768
+ const ok = qs.every((q, idx) => (selections.get(idx))?.size || q.custom !== false);
769
+ submitBtn.disabled = !ok;
770
+ };
771
+ if (submitBtn) submitBtn.onclick = () => {
772
+ const answers = qs.map((q, idx) => {
773
+ const typed = card.querySelectorAll(".question-custom")[idx]?.querySelector("input")?.value?.trim();
774
+ if (typed) return [typed];
775
+ const cur = selections.get(idx);
776
+ return cur?.size ? [...cur].map((i) => q.options[i]?.label).filter(Boolean) : [];
777
+ });
778
+ if (answers.some((a) => !a.length)) { toast("Answer every question first"); return; }
779
+ respondQuestion(req, answers, card);
780
+ };
781
+ box.appendChild(card);
782
+ }
783
+ if (list.length > before) {
784
+ const last = box.lastElementChild;
785
+ if (last) last.scrollIntoView({ behavior: "smooth", block: "end" });
786
+ }
787
+ }
788
+
789
+ async function respondQuestion(req, answers, card) {
790
+ const instanceId = state.selectedInstance;
791
+ if (!instanceId || !state.connected) return;
792
+ card.querySelectorAll("button").forEach((b) => (b.disabled = true));
793
+ try {
794
+ await rpc("question.respond", { requestID: req.id, answers, ...dirArg(instanceId, req.sessionID) }, instanceId);
795
+ removeQuestion(instanceId, req.sessionID, req.id);
796
+ } catch (err) {
797
+ const msg = String(err?.message || err);
798
+ toast(msg.includes("not found") ? "Question already answered" : msg);
799
+ card.querySelectorAll("button").forEach((b) => (b.disabled = false));
800
+ }
801
+ }
802
+
803
+ async function refreshQuestions() {
804
+ if (!state.connected) return;
805
+ const live = new Map();
806
+ const dir = state.selected && state.instances.get(state.selectedInstance)
807
+ ? dirArg(state.selectedInstance, state.selected) : {};
808
+ await Promise.all([...state.instances.keys()].map(async (instId) => {
809
+ try {
810
+ const list = await rpc("question.list", dir, instId);
811
+ for (const req of list || []) {
812
+ const sid = req?.sessionID;
813
+ const qid = req?.id;
814
+ if (!sid || !qid || !Array.isArray(req.questions)) continue;
815
+ const key = activityKey(instId, sid);
816
+ if (!live.has(key)) live.set(key, []);
817
+ if (!live.get(key).some((q) => q.id === String(qid))) live.get(key).push({ id: String(qid), sessionID: sid, questions: req.questions, time: req.time });
818
+ }
819
+ } catch {}
820
+ }));
821
+ state.questions = live;
822
+ renderQuestions();
823
+ }
824
+
825
+ // ---------- controls (model / variant / permission level) ----------
826
+ function currentModel() {
827
+ const key = state.selectedInstance && state.selected ? activityKey(state.selectedInstance, state.selected) : null;
828
+ const choice = key ? state.modelChoices.get(key) : null;
829
+ if (choice) return choice;
830
+ const cur = flattenSessions().find(({ instId, session }) => instId === state.selectedInstance && sessionID(session) === state.selected);
831
+ const m = cur?.session?.model;
832
+ if (m?.providerID && (m.id ?? m.modelID)) return { providerID: m.providerID, modelID: m.id ?? m.modelID, variant: m.variant };
833
+ return null;
834
+ }
835
+
836
+ function fmtTokens(n) {
837
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
838
+ }
839
+
840
+ function contextUsage() {
841
+ const msgs = [...(state.messages?.values?.() || [])];
842
+ for (let i = msgs.length - 1; i >= 0; i--) {
843
+ const info = msgs[i].info ?? msgs[i];
844
+ if (info.role === "assistant" && info.time?.completed) {
845
+ const t = info.tokens || {};
846
+ return { used: (t.input || 0) + (t.cache?.read || 0) + (t.cache?.write || 0) + (t.reasoning || 0) + (t.output || 0) };
847
+ }
848
+ }
849
+ return null;
850
+ }
851
+
852
+ function modelLimit() {
853
+ const m = currentModel();
854
+ if (!m || !state.selectedInstance) return null;
855
+ const provider = (state.providers.get(providersKey(state.selectedInstance)) || []).find((p) => p.id === m.providerID);
856
+ return provider?.models?.find((x) => x.id === m.modelID)?.context || null;
857
+ }
858
+
859
+ const RING_C = 2 * Math.PI * 8;
860
+
861
+ function renderControls() {
862
+ const m = currentModel();
863
+ const enabled = state.connected && !!state.selectedInstance;
864
+ setText(els.modelPillLabel, m ? (m.modelID || "").split("/").pop() : "Model");
865
+ if (els.modelPill.disabled !== !enabled) els.modelPill.disabled = !enabled;
866
+ if (els.variantPill.hidden !== !m) els.variantPill.hidden = !m;
867
+ if (els.variantPill.disabled !== !enabled) els.variantPill.disabled = !enabled;
868
+ setText(els.variantPillLabel, m ? (m.variant && m.variant !== "default" ? m.variant.charAt(0).toUpperCase() + m.variant.slice(1) : "Default") : "Variant");
869
+ const usage = contextUsage();
870
+ const limit = modelLimit();
871
+ if (els.contextPill.hidden !== !m) els.contextPill.hidden = !m;
872
+ if (usage && usage.used > 0) {
873
+ const pct = limit ? Math.min(1, usage.used / limit) : null;
874
+ setText(els.contextLabel, fmtTokens(usage.used));
875
+ els.contextPill.title = limit ? `Context: ${usage.used.toLocaleString()} of ${limit.toLocaleString()} tokens (${Math.round(pct * 100)}%)` : `Context: ${usage.used.toLocaleString()} tokens (model limit unknown)`;
876
+ els.contextPill.classList.toggle("warn", pct !== null && pct > 0.75);
877
+ els.contextPill.classList.toggle("full", pct !== null && pct > 0.9);
878
+ const display = pct === null ? "none" : "";
879
+ if (els.contextRing.style.display !== display) els.contextRing.style.display = display;
880
+ const dash = pct === null ? String(RING_C) : String(RING_C * (1 - pct));
881
+ if (els.contextRing.style.strokeDashoffset !== dash) els.contextRing.style.strokeDashoffset = dash;
882
+ } else {
883
+ setText(els.contextLabel, "—");
884
+ els.contextPill.title = "";
885
+ els.contextPill.classList.remove("warn", "full");
886
+ els.contextRing.style.display = "none";
887
+ els.contextRing.style.strokeDashoffset = String(RING_C);
888
+ }
889
+ }
890
+
891
+ function providersKey(instanceId) {
892
+ return instanceId + ":" + (sessionDirOf(instanceId, state.selected) || "");
893
+ }
894
+
895
+ async function ensureProviders(instanceId) {
896
+ if (!instanceId) return;
897
+ const key = providersKey(instanceId);
898
+ if (state.providers.has(key) || state.providersLoading.has(key)) return;
899
+ state.providersLoading.add(key);
900
+ try {
901
+ const dir = sessionDirOf(instanceId, state.selected);
902
+ state.providers.set(key, await rpc("provider.list", dir ? { directory: dir } : {}, instanceId) || []);
903
+ } catch {
904
+ state.providers.delete(key);
905
+ } finally {
906
+ state.providersLoading.delete(key);
907
+ }
908
+ }
909
+
910
+ async function loadLevel(instanceId) {
911
+ if (!instanceId) return;
912
+ try {
913
+ state.levels.set(instanceId, await rpc("permission.level", {}, instanceId));
914
+ } catch {}
915
+ renderControls();
916
+ }
917
+
918
+ function closeModal() {
919
+ els.modalRoot.hidden = true;
920
+ els.modalRoot.innerHTML = "";
921
+ }
922
+
923
+ function openModal(build) {
924
+ els.modalRoot.innerHTML = "";
925
+ const backdrop = document.createElement("div");
926
+ backdrop.className = "modal-backdrop";
927
+ const modal = document.createElement("div");
928
+ modal.className = "modal";
929
+ modal.setAttribute("role", "dialog");
930
+ backdrop.appendChild(modal);
931
+ els.modalRoot.appendChild(backdrop);
932
+ els.modalRoot.hidden = false;
933
+ backdrop.onmousedown = (e) => { if (e.target === backdrop) closeModal(); };
934
+ build(modal, closeModal);
935
+ return modal;
936
+ }
937
+
938
+ function modalHead(modal, title, close) {
939
+ const head = document.createElement("div");
940
+ head.className = "modal-head";
941
+ head.innerHTML = `<div class="modal-title">${esc(title)}</div>`;
942
+ const btn = document.createElement("button");
943
+ btn.className = "icon-button";
944
+ btn.setAttribute("aria-label", "Close");
945
+ btn.innerHTML = `<svg><use href="#i-close"/></svg>`;
946
+ btn.onclick = close;
947
+ head.appendChild(btn);
948
+ modal.appendChild(head);
949
+ }
950
+
951
+ function optionRow({ title, sub, active, icon, onclick }) {
952
+ const row = document.createElement("button");
953
+ row.className = "modal-option" + (active ? " active" : "");
954
+ row.innerHTML = `${icon ? `<svg><use href="${icon}"/></svg>` : ""}
955
+ <span class="option-body"><span class="option-title">${esc(title)}</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ""}</span>
956
+ ${active ? `<svg class="check"><use href="#i-check"/></svg>` : ""}`;
957
+ row.onclick = onclick;
958
+ return row;
959
+ }
960
+
961
+ function openModelModal() {
962
+ const instanceId = state.selectedInstance;
963
+ if (!instanceId) return;
964
+ let renderList = () => {};
965
+ ensureProviders(instanceId).then(() => { if (!els.modalRoot.hidden) renderList(); });
966
+ let filter = "";
967
+ openModal((modal, close) => {
968
+ modalHead(modal, "Model", close);
969
+ const search = document.createElement("input");
970
+ search.type = "search";
971
+ search.placeholder = "Search models…";
972
+ search.className = "modal-search";
973
+ search.autocomplete = "off";
974
+ search.addEventListener("input", () => { filter = search.value.toLowerCase().trim(); renderList(); });
975
+ modal.appendChild(search);
976
+ const body = document.createElement("div");
977
+ body.className = "modal-body";
978
+ modal.appendChild(body);
979
+ renderList = () => {
980
+ const current = currentModel();
981
+ body.innerHTML = "";
982
+ const providers = state.providers.get(providersKey(instanceId));
983
+ if (!providers) {
984
+ body.innerHTML = `<div class="modal-empty">Loading models…</div>`;
985
+ return;
986
+ }
987
+ let shown = 0;
988
+ for (const provider of providers) {
989
+ const models = (provider.models || []).filter((m) =>
990
+ `${m.name} ${m.id} ${provider.name}`.toLowerCase().includes(filter));
991
+ if (!models.length) continue;
992
+ shown += models.length;
993
+ const label = document.createElement("div");
994
+ label.className = "modal-group-label";
995
+ label.textContent = provider.name;
996
+ body.appendChild(label);
997
+ for (const m of models) {
998
+ const active = current?.providerID === provider.id && current?.modelID === m.id;
999
+ body.appendChild(optionRow({
1000
+ title: m.name,
1001
+ sub: `${m.id}${m.context ? ` · ${Math.round(m.context / 1000)}k ctx` : ""}`,
1002
+ active,
1003
+ onclick: () => {
1004
+ const key = activityKey(instanceId, state.selected);
1005
+ state.modelChoices.set(key, { providerID: provider.id, modelID: m.id, variant: active ? (current?.variant ?? null) : null });
1006
+ renderControls();
1007
+ close();
1008
+ },
1009
+ }));
1010
+ }
1011
+ }
1012
+ if (!shown) body.innerHTML = `<div class="modal-empty">No matching models</div>`;
1013
+ };
1014
+ renderList();
1015
+ if (!matchMedia("(pointer: coarse)").matches) setTimeout(() => search.focus(), 30);
1016
+ });
1017
+ }
1018
+
1019
+ function currentVariants() {
1020
+ const m = currentModel();
1021
+ if (!m || !state.selectedInstance) return [];
1022
+ const provider = (state.providers.get(providersKey(state.selectedInstance)) || []).find((p) => p.id === m.providerID);
1023
+ const model = provider?.models?.find((x) => x.id === m.modelID);
1024
+ const vs = model?.variants;
1025
+ if (Array.isArray(vs)) return vs.filter((v) => typeof v === "string");
1026
+ return Object.keys(vs || {});
1027
+ }
1028
+
1029
+ function openVariantModal() {
1030
+ const instanceId = state.selectedInstance;
1031
+ if (!instanceId) return;
1032
+ const variants = currentVariants();
1033
+ openModal((modal, close) => {
1034
+ modalHead(modal, "Variant", close);
1035
+ const body = document.createElement("div");
1036
+ body.className = "modal-body";
1037
+ modal.appendChild(body);
1038
+ const pick = (v) => {
1039
+ const base = currentModel() || {};
1040
+ state.modelChoices.set(activityKey(instanceId, state.selected), {
1041
+ providerID: base.providerID, modelID: base.modelID, variant: v || null,
1042
+ });
1043
+ renderControls();
1044
+ close();
1045
+ };
1046
+ const cur = currentModel()?.variant;
1047
+ body.appendChild(optionRow({ title: "Default", sub: "Model default", active: !cur || cur === "default", onclick: () => pick(null) }));
1048
+ for (const v of variants) {
1049
+ body.appendChild(optionRow({ title: v.charAt(0).toUpperCase() + v.slice(1), active: cur === v, onclick: () => pick(v) }));
1050
+ }
1051
+ if (!variants.length) {
1052
+ const note = document.createElement("div");
1053
+ note.className = "modal-empty";
1054
+ note.textContent = "This model has no variants";
1055
+ body.appendChild(note);
1056
+ }
1057
+ });
1058
+ }
1059
+
1060
+ function quotaResetLabel(ms) {
1061
+ if (!ms || !Number.isFinite(ms) || ms <= Date.now()) return "";
1062
+ let secs = Math.round((ms - Date.now()) / 1000);
1063
+ const days = Math.floor(secs / 86400);
1064
+ secs -= days * 86400;
1065
+ const hours = Math.floor(secs / 3600);
1066
+ const mins = Math.floor((secs % 3600) / 60);
1067
+ if (days > 0) return `${days}d ${hours}h`;
1068
+ if (hours > 0) return `${hours}h ${mins}m`;
1069
+ if (mins > 0) return `${mins}m`;
1070
+ return "<1m";
1071
+ }
1072
+
1073
+ function openUsageModal() {
1074
+ const instanceIds = [...state.instances.keys()];
1075
+ if (!instanceIds.length) return;
1076
+ const multi = instanceIds.length > 1;
1077
+ openModal((modal, close) => {
1078
+ modalHead(modal, "Usage", close);
1079
+ const body = document.createElement("div");
1080
+ body.className = "modal-body quota-body";
1081
+ modal.appendChild(body);
1082
+ const clampPctLocal = (v) => Math.max(0, Math.min(100, v));
1083
+ const renderProviders = (holder, data) => {
1084
+ if (!data?.ok || !Array.isArray(data.providers) || !data.providers.length) {
1085
+ holder.innerHTML = `<div class="modal-empty">${esc(data?.error || "No usage providers found")}</div>`;
1086
+ return;
1087
+ }
1088
+ for (const p of data.providers) {
1089
+ const section = document.createElement("div");
1090
+ section.className = "quota-provider";
1091
+ if (!p.ok) {
1092
+ section.innerHTML = `<div class="quota-name">${esc(p.name)}</div><div class="quota-error">${esc(p.error)}</div>`;
1093
+ holder.appendChild(section);
1094
+ continue;
1095
+ }
1096
+ let html = `<div class="quota-name">${esc(p.name)}</div>`;
1097
+ if (typeof p.resetsAvailable === "number" && p.resetsAvailable > 0) {
1098
+ html += `<div class="quota-resets">${p.resetsAvailable} reset${p.resetsAvailable === 1 ? "" : "s"} available</div>`;
1099
+ }
1100
+ for (const w of p.windows) {
1101
+ const used = 100 - w.remainingPct;
1102
+ const tone = w.remainingPct >= 50 ? "ok" : w.remainingPct >= 20 ? "warn" : "low";
1103
+ const reset = quotaResetLabel(w.resetMs);
1104
+ html += `<div class="quota-window"><div class="quota-row"><span class="quota-label">${esc(w.label)}</span><span class="quota-pct ${tone}">${used}% used${reset ? ` · ${reset}` : ""}</span></div><div class="quota-track"><i class="${tone}" style="width:${clampPctLocal(used)}%"></i></div></div>`;
1105
+ }
1106
+ section.innerHTML = html;
1107
+ holder.appendChild(section);
1108
+ }
1109
+ if (data.updatedAt) {
1110
+ const t = document.createElement("div");
1111
+ t.className = "quota-updated";
1112
+ t.textContent = `Updated ${new Date(data.updatedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
1113
+ holder.appendChild(t);
1114
+ }
1115
+ };
1116
+ for (const instId of instanceIds) {
1117
+ const inst = state.instances.get(instId);
1118
+ const wrap = document.createElement("div");
1119
+ wrap.className = "quota-instance";
1120
+ const label = () => (multi ? `<div class="quota-inst-name">${esc(projectName(inst))}</div>` : "");
1121
+ wrap.innerHTML = `${label()}<div class="modal-empty">Loading usage…</div>`;
1122
+ body.appendChild(wrap);
1123
+ rpc("quota.get", {}, instId)
1124
+ .then((data) => {
1125
+ const holder = document.createElement("div");
1126
+ wrap.innerHTML = label();
1127
+ wrap.appendChild(holder);
1128
+ renderProviders(holder, data);
1129
+ })
1130
+ .catch((err) => { wrap.innerHTML = `${label()}<div class="modal-empty">${esc(String(err?.message || err))}</div>`; });
1131
+ }
1132
+ });
1133
+ }
1134
+
1135
+ function openLevelModal() {
1136
+ const instanceId = state.selectedInstance;
1137
+ if (!instanceId) return;
1138
+ const current = state.levels.get(instanceId) || "ask";
1139
+ openModal((modal, close) => {
1140
+ modalHead(modal, "Permission level", close);
1141
+ const body = document.createElement("div");
1142
+ body.className = "modal-body";
1143
+ modal.appendChild(body);
1144
+ const pick = async (level) => {
1145
+ state.levels.set(instanceId, level);
1146
+ renderControls();
1147
+ close();
1148
+ try { await rpc("permission.level", { level }, instanceId); }
1149
+ catch (err) { toast(String(err?.message || err)); state.levels.set(instanceId, current); renderControls(); }
1150
+ };
1151
+ body.appendChild(optionRow({
1152
+ title: "Ask", sub: "Show a card on this phone and wait for you", active: current !== "auto", icon: "#i-shield",
1153
+ onclick: () => pick("ask"),
1154
+ }));
1155
+ body.appendChild(optionRow({
1156
+ title: "Auto-approve", sub: "Approve tool permissions automatically", active: current === "auto", icon: "#i-bolt",
1157
+ onclick: () => pick("auto"),
1158
+ }));
1159
+ });
1160
+ }
1161
+
1162
+ els.modelPill.onclick = openModelModal;
1163
+ els.variantPill.onclick = openVariantModal;
1164
+
1165
+ function openCommandsModal() {
1166
+ openModal((modal, close) => {
1167
+ modalHead(modal, "Commands", close);
1168
+ const body = document.createElement("div");
1169
+ body.className = "modal-body";
1170
+ modal.appendChild(body);
1171
+ for (const c of [
1172
+ { cmd: "/compact", sub: "Summarize and compact this session's context" },
1173
+ { cmd: "/share", sub: "Create a share link for this session" },
1174
+ ]) {
1175
+ body.appendChild(optionRow({
1176
+ title: c.cmd,
1177
+ sub: c.sub,
1178
+ onclick: () => {
1179
+ close();
1180
+ els.input.value = `${c.cmd} `;
1181
+ flushDraft();
1182
+ autosize();
1183
+ els.input.focus();
1184
+ els.input.setSelectionRange(els.input.value.length, els.input.value.length);
1185
+ },
1186
+ }));
1187
+ }
1188
+ const hint = document.createElement("div");
1189
+ hint.className = "confirm-text";
1190
+ hint.textContent = "Picking a command fills the input — press send to run it.";
1191
+ body.appendChild(hint);
1192
+ });
1193
+ }
1194
+ els.cmdBtn.onclick = () => { toggleMenu(false); openCommandsModal(); };
1195
+
1196
+ // ---------- session menu ----------
1197
+ function toggleMenu(open) {
1198
+ if (open && (!state.selected || !state.selectedInstance)) return;
1199
+ els.sessionMenu.hidden = !open;
1200
+ els.menuBtn.setAttribute("aria-expanded", String(open));
1201
+ }
1202
+
1203
+ function openDeleteConfirm() {
1204
+ if (!state.selected || !state.selectedInstance || !state.connected) return;
1205
+ const cur = flattenSessions().find(({ instId, session }) => instId === state.selectedInstance && sessionID(session) === state.selected);
1206
+ openModal((modal, close) => {
1207
+ modalHead(modal, "Delete session", close);
1208
+ const body = document.createElement("div");
1209
+ body.className = "modal-body";
1210
+ const text = document.createElement("div");
1211
+ text.className = "confirm-text";
1212
+ text.textContent = `Permanently delete “${cur ? sessionTitle(cur.session) : "this session"}” and all of its messages? This cannot be undone.`;
1213
+ body.appendChild(text);
1214
+ modal.appendChild(body);
1215
+ const actions = document.createElement("div");
1216
+ actions.className = "modal-actions";
1217
+ const cancel = document.createElement("button");
1218
+ cancel.textContent = "Cancel";
1219
+ cancel.onclick = close;
1220
+ const del = document.createElement("button");
1221
+ del.className = "primary-danger";
1222
+ del.textContent = "Delete";
1223
+ del.onclick = async () => {
1224
+ cancel.disabled = true;
1225
+ del.disabled = true;
1226
+ del.textContent = "Deleting…";
1227
+ const instanceId = state.selectedInstance;
1228
+ const sid = state.selected;
1229
+ try {
1230
+ await rpc("session.delete", { id: sid, ...dirArg(instanceId, sid) }, instanceId);
1231
+ dropSession(instanceId, sid);
1232
+ close();
1233
+ scheduleListRefetch();
1234
+ setTimeout(scheduleListRefetch, 1500);
1235
+ toast("Session deleted");
1236
+ } catch (err) {
1237
+ toast(String(err?.message || err));
1238
+ cancel.disabled = false;
1239
+ del.disabled = false;
1240
+ del.textContent = "Delete";
1241
+ }
1242
+ };
1243
+ actions.append(cancel, del);
1244
+ modal.appendChild(actions);
1245
+ });
1246
+ }
1247
+
1248
+ els.menuBtn.onclick = (e) => { e.stopPropagation(); toggleMenu(els.sessionMenu.hidden); };
1249
+ els.menuUsage.onclick = () => { toggleMenu(false); openUsageModal(); };
1250
+ els.menuLevel.onclick = () => { toggleMenu(false); openLevelModal(); };
1251
+ els.menuDelete.onclick = () => { toggleMenu(false); openDeleteConfirm(); };
1252
+ document.addEventListener("click", (e) => {
1253
+ if (!els.sessionMenu.hidden && !els.sessionMenu.contains(e.target) && !els.menuBtn.contains(e.target)) toggleMenu(false);
1254
+ });
1255
+
1256
+ function updateChrome() {
1257
+ const current = flattenSessions().find(({ instId, session }) => instId === state.selectedInstance && sessionID(session) === state.selected);
1258
+ setText(els.project, current ? sessionTitle(current.session) : "Your workspace, anywhere");
1259
+ setText($("session-subtitle"), current ? sessionLabel(current.inst, current.session) : "The mobile companion for opencode");
1260
+ const enabled = state.connected && !!current;
1261
+ if (els.input.disabled !== !enabled) els.input.disabled = !enabled;
1262
+ const sendDisabled = !enabled || !els.input.value.trim();
1263
+ if (els.send.disabled !== sendDisabled) els.send.disabled = sendDisabled;
1264
+ if (els.menuBtn.disabled !== !enabled) els.menuBtn.disabled = !enabled;
1265
+ if (els.cmdBtn.disabled !== !state.connected) els.cmdBtn.disabled = !state.connected;
1266
+ if (els.sessionMenu.hidden === false && !current) toggleMenu(false);
1267
+ renderControls();
1268
+ $("pairing-steps").hidden = !!token;
1269
+ let title = "Good work travels with you.";
1270
+ let description = "Your desktop session, right here. Choose a session to keep the conversation going.";
1271
+ if (!token) description = "Pair your phone with opencode and take your workspace with you. All you need is the same Wi-Fi.";
1272
+ else if (!state.connected) { title = "Finding your desktop…"; description = "Keep opencode running and stay on the same network. We'll reconnect automatically."; }
1273
+ else if (!state.instances.size) { title = "Ready when you are."; description = "Open opencode on your desktop. Your projects and sessions will appear here automatically."; }
1274
+ else if (state.loading || (current && !els.timeline.children.length && state.busy.has(activityKey(state.selectedInstance, state.selected)))) { title = "Opening your session…"; description = "Picking up the conversation from your desktop."; }
1275
+ else if (current) { title = "A little space for your next idea."; description = "Send a message below. The conversation stays in sync with your desktop."; }
1276
+ else { title = "Your workspace is connected."; description = "Start a session in opencode on your desktop to see it here."; }
1277
+ $("empty-title").textContent = title;
1278
+ $("empty-description").textContent = description;
1279
+ }
1280
+
1281
+ let activityTimer = null;
1282
+ // some opencode builds complete cross-directory runs without emitting any
1283
+ // events (and the prompt rpc then hangs) — session.messages still works, so
1284
+ // poll the open session while it is busy to surface replies anyway
1285
+ let busyPollTimer = null;
1286
+ function syncBusyPolling() {
1287
+ const need = state.connected && state.busy.has(activityKey(state.selectedInstance, state.selected));
1288
+ if (need && !busyPollTimer) {
1289
+ busyPollTimer = setInterval(() => {
1290
+ if (!state.connected || !state.busy.has(activityKey(state.selectedInstance, state.selected))) {
1291
+ clearInterval(busyPollTimer);
1292
+ busyPollTimer = null;
1293
+ return;
1294
+ }
1295
+ loadMessages(state.selectedInstance, state.selected, false);
1296
+ // cross-directory runs never emit question/permission events, so the
1297
+ // cards only appear through periodic live queries while busy
1298
+ refreshQuestions();
1299
+ refreshPermissions();
1300
+ }, 2500);
1301
+ } else if (!need && busyPollTimer) {
1302
+ clearInterval(busyPollTimer);
1303
+ busyPollTimer = null;
1304
+ }
1305
+ }
1306
+ function renderActivity() {
1307
+ const busy = state.busy.get(activityKey(state.selectedInstance, state.selected));
1308
+ const active = state.connected && !!busy;
1309
+ if (els.activity.hidden !== !active) els.activity.hidden = !active;
1310
+ if (active) {
1311
+ setText(els.activityLabel, busy.label);
1312
+ const seconds = Math.floor((Date.now() - busy.since) / 1000);
1313
+ setText(els.activityTime, seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`);
1314
+ if (!activityTimer) activityTimer = setInterval(renderActivity, 1000);
1315
+ // never blank the page behind the activity bar — keep the empty-state
1316
+ // hero (the "getting data" component) visible until messages render
1317
+ els.empty.hidden = !!els.timeline.children.length;
1318
+ } else {
1319
+ clearInterval(activityTimer);
1320
+ activityTimer = null;
1321
+ els.empty.hidden = !!els.timeline.children.length;
1322
+ }
1323
+ syncBusyPolling();
1324
+ const sendDisabled = !state.connected || !state.selected || !els.input.value.trim();
1325
+ if (els.send.disabled !== sendDisabled) els.send.disabled = sendDisabled;
1326
+ }
1327
+
1328
+ // ---------- sidebar (keyed diffing: never rebuild rows we can patch) ----------
1329
+ const rowNodes = new Map(); // "instId:sid" -> button element
1330
+ let sidebarListedEmpty = false;
1331
+
1332
+ function rowStateClasses(div, instId, inst, s) {
1333
+ const offline = inst.attached === false;
1334
+ const selected = sessionID(s) === state.selected && instId === state.selectedInstance;
1335
+ const key = activityKey(instId, sessionID(s));
1336
+ const cls = "session" + (selected ? " active" : "") + (offline ? " offline" : "")
1337
+ + (state.busy.has(key) ? " busy" : "") + (state.permissions.has(key) ? " pending" : "");
1338
+ // identical className writes can restart CSS animations — never assign what you already have
1339
+ if (div.className !== cls) div.className = cls;
1340
+ const aria = selected ? "true" : "false";
1341
+ if (div.getAttribute("aria-current") !== aria) div.setAttribute("aria-current", aria);
1342
+ if (div.title !== sessionTitle(s)) div.title = sessionTitle(s);
1343
+ }
1344
+
1345
+ function patchRow(div, instId, inst, s) {
1346
+ const sig = `${div.className}\x00${div.title}`;
1347
+ rowStateClasses(div, instId, inst, s);
1348
+ if (`${div.className}\x00${div.title}` === sig) return; // nothing structural changed
1349
+ const meta = div.querySelector(".meta");
1350
+ const project = meta.querySelector(".project");
1351
+ const label = sessionLabel(inst, s);
1352
+ if (project.textContent !== label) project.textContent = label;
1353
+ let tag = meta.querySelector(".offline-tag");
1354
+ if (inst.attached === false && !tag) {
1355
+ tag = document.createElement("span");
1356
+ tag.className = "offline-tag";
1357
+ tag.textContent = "offline";
1358
+ meta.insertBefore(tag, meta.querySelector("time"));
1359
+ } else if (inst.attached !== false && tag) tag.remove();
1360
+ }
1361
+
1362
+ function buildRow(instId, inst, s) {
1363
+ const div = document.createElement("button");
1364
+ div._instId = instId;
1365
+ div._sid = sessionID(s);
1366
+ div.innerHTML = `<svg class="session-icon"><use href="#i-chat"/></svg><div class="session-content"><div class="name"></div>
1367
+ <div class="meta"><span class="dot"></span><span class="project"></span><time></time></div></div>`;
1368
+ div.querySelector(".name").textContent = sessionTitle(s);
1369
+ div.querySelector(".project").textContent = sessionLabel(inst, s);
1370
+ div.querySelector("time").textContent = timeAgo(sessionTime(instId, s));
1371
+ rowStateClasses(div, instId, inst, s);
1372
+ div.onclick = () => { selectSession(instId, sessionID(s)); toggleSessions(false); };
1373
+ return div;
1374
+ }
1375
+
1376
+ // hot path: update ONE row's classes + age (busy toggles, permission dots)
1377
+ function patchSidebarRow(instId, sid) {
1378
+ const div = rowNodes.get(instId + ":" + sid);
1379
+ if (!div) return;
1380
+ const inst = state.instances.get(instId);
1381
+ const s = inst?.sessions?.find((x) => sessionID(x) === sid);
1382
+ if (s) patchRow(div, instId, inst, s);
1383
+ }
1384
+
1385
+ function renderSessions() {
1386
+ const all = flattenSessions();
1387
+ const sorted = all.filter(({ inst, session }) => `${sessionTitle(session)} ${sessionLabel(inst, session)}`.toLowerCase().includes(state.filter)).sort(
1388
+ (a, b) => sessionTime(b.instId, b.session) - sessionTime(a.instId, a.session));
1389
+ setText($("session-count"), String(all.length));
1390
+ if (sorted.length === 0) {
1391
+ rowNodes.clear();
1392
+ els.list.innerHTML = state.filter ? `<div class="list-empty"><strong>No matching sessions</strong>Try another title or project name.</div>` : `<div class="list-empty"><strong>Your sessions will live here</strong>Open opencode on your desktop to get started.</div>`;
1393
+ sidebarListedEmpty = true;
1394
+ return;
1395
+ }
1396
+ if (sidebarListedEmpty) { els.list.innerHTML = ""; sidebarListedEmpty = false; }
1397
+ const seen = new Set();
1398
+ let anchor = null;
1399
+ for (const { instId, inst, session: s } of sorted) {
1400
+ const key = instId + ":" + sessionID(s);
1401
+ seen.add(key);
1402
+ let div = rowNodes.get(key);
1403
+ if (!div) { div = buildRow(instId, inst, s); rowNodes.set(key, div); }
1404
+ else patchRow(div, instId, inst, s);
1405
+ div._s = s;
1406
+ setText(div.querySelector("time"), timeAgo(sessionTime(instId, s)));
1407
+ // append in sorted order — moves existing nodes, only worth skipping when
1408
+ // the row is already in the right place
1409
+ if (anchor ? anchor.nextSibling !== div : els.list.firstChild !== div) els.list.insertBefore(div, anchor ? anchor.nextSibling : els.list.firstChild);
1410
+ anchor = div;
1411
+ }
1412
+ for (const [key, div] of rowNodes) {
1413
+ if (!seen.has(key)) { div.remove(); rowNodes.delete(key); }
1414
+ }
1415
+ }
1416
+
1417
+ // refresh age labels without touching anything else
1418
+ setInterval(() => {
1419
+ if (document.hidden || !rowNodes.size) return;
1420
+ for (const div of rowNodes.values()) {
1421
+ if (div._s) setText(div.querySelector("time"), timeAgo(sessionTime(div._instId, div._s)));
1422
+ }
1423
+ }, 30000);
1424
+
1425
+ function partToolSummary(part) {
1426
+ const st = part.state ?? {};
1427
+ const input = st.input ?? part.input ?? {};
1428
+ let detail = input.command || input.filePath || input.pattern || input.query || input.url
1429
+ || input.path || input.name || input.content || "";
1430
+ if (Array.isArray(detail)) detail = detail.join(" ");
1431
+ if (typeof detail === "object") detail = JSON.stringify(detail);
1432
+ const tool = part.tool ?? "tool";
1433
+ const title = st.title || tool;
1434
+ return { tool, title, detail: String(detail).slice(0, 80), status: st.status ?? "" };
1435
+ }
1436
+
1437
+ function renderPart(part) {
1438
+ if (!part) return "";
1439
+ switch (part.type) {
1440
+ case "text":
1441
+ return part.text?.trim() ? `<div class="bubble">${renderText(part.text)}</div>` : "";
1442
+ case "reasoning":
1443
+ return "";
1444
+ case "tool": {
1445
+ const t = partToolSummary(part);
1446
+ const st = part.state ?? {};
1447
+ const out = st.output ?? st.error ?? "";
1448
+ const outText = typeof out === "string" ? out : out ? JSON.stringify(out, null, 2) : "";
1449
+ return `<details class="tool" data-part-id="${esc(part.id || "")}"><summary><span class="tool-status">${esc(t.status)}</span><span class="tool-title">${esc(t.title)}</span><span class="tool-detail">${esc(t.detail)}</span></summary>${outText ? `<pre>${esc(String(outText).slice(0, 4000))}</pre>` : ""}</details>`;
1450
+ }
1451
+ case "step-start":
1452
+ return "";
1453
+ case "step-finish":
1454
+ return "";
1455
+ case "agent":
1456
+ return `<div class="part-step">↳ ${esc(part.name ?? "agent")}</div>`;
1457
+ default:
1458
+ return "";
1459
+ }
1460
+ }
1461
+
1462
+ function renderTimeline() {
1463
+ const msgs = [...(state.messages?.values?.() || [])];
1464
+ const nearBottom = els.main.scrollHeight - els.main.scrollTop - els.main.clientHeight < 120;
1465
+ const expanded = new Set([...els.timeline.querySelectorAll("details[open]")].map(el => el.dataset.partId));
1466
+ // keyed diff: only rebuild messages whose html changed, keep the rest in place
1467
+ const existing = new Map();
1468
+ for (const el of [...els.timeline.children]) if (el._id) existing.set(el._id, el);
1469
+ const seen = new Set();
1470
+ let anchor = null;
1471
+ for (const [msgIdx, m] of msgs.entries()) {
1472
+ const info = m.info ?? m;
1473
+ const parts = m.parts ?? info.parts ?? [];
1474
+ const role = info.role ?? m.role;
1475
+ const body = (Array.isArray(parts) ? parts : []).map(renderPart).join("");
1476
+ // Reasoning and step-only messages are activity, not empty chat bubbles.
1477
+ if (!body) continue;
1478
+ const id = m.id ?? info.id ?? "idx" + msgIdx;
1479
+ const created = info.time?.created;
1480
+ const time = created ? new Date(created).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
1481
+ const sig = `${role}\x00${time}\x00${body}`;
1482
+ let el = existing.get(id);
1483
+ if (el) {
1484
+ if (el._sig === sig) { seen.add(id); }
1485
+ else {
1486
+ el.className = `msg ${role === "user" ? "user" : "assistant"}`;
1487
+ el.innerHTML = `<div class="role">${role === "user" ? "You" : "opencode"}<time>${esc(time)}</time></div>${body}`;
1488
+ for (const detail of el.querySelectorAll("details")) {
1489
+ if (detail.dataset.partId && expanded.has(detail.dataset.partId)) detail.open = true;
1490
+ }
1491
+ el._sig = sig;
1492
+ seen.add(id);
1493
+ }
1494
+ } else {
1495
+ el = document.createElement("div");
1496
+ el._id = id;
1497
+ el._sig = sig;
1498
+ el.className = `msg ${role === "user" ? "user" : "assistant"}`;
1499
+ el.innerHTML = `<div class="role">${role === "user" ? "You" : "opencode"}<time>${esc(time)}</time></div>${body}`;
1500
+ seen.add(id);
1501
+ }
1502
+ if (anchor ? anchor.nextSibling !== el : els.timeline.firstChild !== el) els.timeline.insertBefore(el, anchor ? anchor.nextSibling : els.timeline.firstChild);
1503
+ anchor = el;
1504
+ }
1505
+ for (const el of existing.values()) if (!seen.has(el._id)) el.remove();
1506
+ els.empty.hidden = !!els.timeline.children.length;
1507
+ renderActivity();
1508
+ if (nearBottom) els.main.scrollTop = els.main.scrollHeight;
1509
+ }
1510
+
1511
+ async function selectSession(instanceId, sessionID) {
1512
+ const inst = state.instances.get(instanceId);
1513
+ if (inst && inst.attached === false) {
1514
+ const name = (inst.directory || "").split("/").filter(Boolean).pop();
1515
+ toast(`That opencode instance is offline${name ? ` — start ${name} to open this session` : ""}`);
1516
+ return;
1517
+ }
1518
+ state.selected = sessionID;
1519
+ state.selectedInstance = instanceId;
1520
+ state.everSelected = true;
1521
+ state.messages = new Map();
1522
+ state.loading = true;
1523
+ renderSessions();
1524
+ updateChrome();
1525
+ renderTimeline();
1526
+ renderPermissions();
1527
+ renderQuestions();
1528
+ els.main.scrollTop = 0;
1529
+ await loadMessages(instanceId, sessionID, true);
1530
+ restoreDraft();
1531
+ refreshPermissions();
1532
+ refreshQuestions();
1533
+ loadLevel(instanceId);
1534
+ ensureProviders(instanceId).then(() => renderControls()).catch(() => {});
1535
+ }
1536
+
1537
+ // ---------- composer ----------
1538
+ const draftKey = () => `ocduet-draft:${state.selectedInstance ?? "-"}:${state.selected ?? "-"}`;
1539
+ function saveDraft() {
1540
+ try {
1541
+ const v = els.input.value;
1542
+ if (v) localStorage.setItem(draftKey(), v);
1543
+ else localStorage.removeItem(draftKey());
1544
+ } catch {}
1545
+ }
1546
+ // localStorage is synchronous disk I/O — never per keystroke
1547
+ let draftTimer = null;
1548
+ function queueDraftSave() {
1549
+ clearTimeout(draftTimer);
1550
+ draftTimer = setTimeout(saveDraft, 400);
1551
+ }
1552
+ function flushDraft() { clearTimeout(draftTimer); saveDraft(); }
1553
+ addEventListener("pagehide", flushDraft);
1554
+ document.addEventListener("visibilitychange", () => { if (document.hidden) flushDraft(); });
1555
+ function restoreDraft() {
1556
+ let v = "";
1557
+ try { v = localStorage.getItem(draftKey()) ?? ""; } catch {}
1558
+ els.input.value = v;
1559
+ autosize();
1560
+ }
1561
+ function autosize() {
1562
+ els.input.style.height = "auto";
1563
+ els.input.style.height = Math.min(els.input.scrollHeight, window.innerHeight * 0.25) + "px";
1564
+ }
1565
+
1566
+ async function sendPrompt() {
1567
+ const text = els.input.value.trim();
1568
+ if (!text || !state.selected || !state.selectedInstance || !state.connected) return;
1569
+ const instanceId = state.selectedInstance;
1570
+ const sid = state.selected;
1571
+ if (text === "/compact") {
1572
+ els.input.value = "";
1573
+ flushDraft();
1574
+ autosize();
1575
+ toast("Compacting…");
1576
+ try {
1577
+ await rpc("session.compact", { id: sid, ...dirArg(instanceId, sid) }, instanceId);
1578
+ toast("Compacted");
1579
+ loadMessages(instanceId, sid, false);
1580
+ } catch (err) {
1581
+ toast(String(err?.message || err));
1582
+ }
1583
+ return;
1584
+ }
1585
+ if (text === "/share") {
1586
+ els.input.value = "";
1587
+ flushDraft();
1588
+ autosize();
1589
+ toast("Creating share link…");
1590
+ try {
1591
+ const res = await rpc("session.share", { id: sid, ...dirArg(instanceId, sid) }, instanceId);
1592
+ const url = res?.share?.url || res?.url || "";
1593
+ if (url) {
1594
+ try { await navigator.clipboard.writeText(url); toast("Link copied to clipboard"); } catch { toast(url); }
1595
+ } else toast("Shared (no url returned)");
1596
+ } catch (err) {
1597
+ toast(String(err?.message || err));
1598
+ }
1599
+ return;
1600
+ }
1601
+ els.input.value = "";
1602
+ flushDraft();
1603
+ autosize();
1604
+ state.statuses.delete(activityKey(instanceId, sid));
1605
+ setBusy(instanceId, sid, true);
1606
+ renderSessions();
1607
+ renderActivity();
1608
+ els.main.scrollTop = els.main.scrollHeight;
1609
+ try {
1610
+ const choice = state.modelChoices.get(activityKey(instanceId, sid));
1611
+ await rpc("session.prompt", {
1612
+ id: sid,
1613
+ text,
1614
+ ...dirArg(instanceId, sid),
1615
+ ...(choice ? { model: { providerID: choice.providerID, modelID: choice.modelID }, variant: choice.variant } : {}),
1616
+ }, instanceId);
1617
+ setBusy(instanceId, sid, false);
1618
+ if (state.selected === sid && state.selectedInstance === instanceId) loadMessages(instanceId, sid, false);
1619
+ } catch (err) {
1620
+ setBusy(instanceId, sid, false);
1621
+ if (!els.input.value && state.selected === sid && state.selectedInstance === instanceId) { els.input.value = text; flushDraft(); }
1622
+ toast(String(err.message || err));
1623
+ }
1624
+ renderSessions();
1625
+ renderActivity();
1626
+ autosize();
1627
+ }
1628
+
1629
+ els.send.onclick = sendPrompt;
1630
+ els.input.addEventListener("keydown", (e) => {
1631
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendPrompt(); }
1632
+ });
1633
+ els.input.addEventListener("input", () => {
1634
+ queueDraftSave();
1635
+ autosize();
1636
+ // only the send button's availability depends on typing — nothing else re-renders
1637
+ const sendDisabled = !state.connected || !state.selected || !els.input.value.trim();
1638
+ if (els.send.disabled !== sendDisabled) els.send.disabled = sendDisabled;
1639
+ });
1640
+ els.stop.onclick = async () => {
1641
+ if (state.selected && state.selectedInstance) {
1642
+ const instanceId = state.selectedInstance;
1643
+ const sid = state.selected;
1644
+ try {
1645
+ await rpc("session.abort", { id: sid, ...dirArg(instanceId, sid) }, instanceId);
1646
+ state.statuses.set(activityKey(instanceId, sid), "idle");
1647
+ setBusy(instanceId, sid, false);
1648
+ renderSessions();
1649
+ renderActivity();
1650
+ } catch (err) { toast(String(err.message || err)); }
1651
+ }
1652
+ };
1653
+ function toggleSessions(open) {
1654
+ document.body.classList.toggle("sessions-open", open);
1655
+ $("workspace").inert = open && matchMedia("(max-width: 719px)").matches;
1656
+ els.sessionsBtn.setAttribute("aria-expanded", String(open));
1657
+ $("sidebar-backdrop").hidden = !open;
1658
+ if (open) requestAnimationFrame(() => {
1659
+ if (document.body.classList.contains("sessions-open")) {
1660
+ (matchMedia("(max-width: 719px)").matches ? $("close-sessions") : els.search).focus();
1661
+ }
1662
+ });
1663
+ else if (matchMedia("(max-width: 719px)").matches) els.sessionsBtn.focus();
1664
+ }
1665
+ els.sessionsBtn.onclick = () => toggleSessions(!document.body.classList.contains("sessions-open"));
1666
+ $("close-sessions").onclick = $("sidebar-backdrop").onclick = () => toggleSessions(false);
1667
+
1668
+ // new session: create in the active instance (first attached one as fallback),
1669
+ // then open it ready to type
1670
+ els.newSession.onclick = async () => {
1671
+ let instId = state.selectedInstance;
1672
+ if (!instId || state.instances.get(instId)?.attached === false) {
1673
+ instId = [...state.instances.entries()].find(([, i]) => i.attached !== false)?.[0] ?? null;
1674
+ }
1675
+ if (!instId) { toast("No opencode instance online — start opencode on your desktop"); return; }
1676
+ els.newSession.disabled = true;
1677
+ try {
1678
+ const created = await rpc("session.create", {}, instId);
1679
+ const sid = created?.id ?? created?.info?.id;
1680
+ if (!sid) throw new Error("no session id in reply");
1681
+ try { onInstances(await rpc("instances.list")); } catch {}
1682
+ await selectSession(instId, sid);
1683
+ toggleSessions(false);
1684
+ els.input.focus();
1685
+ } catch (err) {
1686
+ toast(String(err?.message || err));
1687
+ } finally {
1688
+ els.newSession.disabled = false;
1689
+ }
1690
+ };
1691
+ els.search.addEventListener("input", () => { state.filter = els.search.value.toLowerCase().trim(); renderSessions(); });
1692
+ document.addEventListener("keydown", (event) => {
1693
+ if (event.key === "Escape") {
1694
+ if (!els.modalRoot.hidden) { closeModal(); return; }
1695
+ if (!els.sessionMenu.hidden) { toggleMenu(false); return; }
1696
+ toggleSessions(false);
1697
+ }
1698
+ if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) {
1699
+ event.preventDefault();
1700
+ toggleSessions(true);
1701
+ }
1702
+ if (event.key === "Tab" && document.body.classList.contains("sessions-open") && matchMedia("(max-width: 719px)").matches) {
1703
+ const focusable = [...$("sessions").querySelectorAll("button, input")];
1704
+ const first = focusable[0], last = focusable[focusable.length - 1];
1705
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
1706
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
1707
+ }
1708
+ });
1709
+ matchMedia("(min-width: 720px)").addEventListener("change", () => toggleSessions(false));
1710
+
1711
+ state.messages = new Map();
1712
+ renderSessions();
1713
+ els.input.value = ""; // kill browser session-restore phantoms; real drafts restore on select
1714
+ flushDraft();
1715
+ connect();
1716
+ })();