@symerian/symi 3.5.0 → 3.5.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.
- package/dist/build-info.json +3 -3
- package/dist/bundled/boot-md/handler.js +4 -4
- package/dist/bundled/session-memory/handler.js +4 -4
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/dist/{chrome-C_I81hbq.js → chrome-B7-rO4i9.js} +4 -4
- package/dist/{chrome-BKUACyeO.js → chrome-DPjznJQ-.js} +4 -4
- package/dist/control-ui/css/revert-red-theme.md +141 -0
- package/dist/control-ui/css/style.css +5843 -0
- package/dist/control-ui/css/style.css.backup-2026-03-03-162525 +3546 -0
- package/dist/control-ui/css/style.css.backup-before-red-2026-03-03-162525 +3546 -0
- package/dist/control-ui/css/style.css.backup-before-red-theme-2026-03-03-162530 +3546 -0
- package/dist/control-ui/css/style.css.pre-2row +2165 -0
- package/dist/control-ui/css/style.css.pre-brand +1776 -0
- package/dist/control-ui/css/style.css.pre-history +1974 -0
- package/dist/control-ui/css/style.css.pre-nav +2264 -0
- package/dist/control-ui/css/style.css.pre-newsession +1898 -0
- package/dist/control-ui/css/style.css.pre-queue +2195 -0
- package/dist/control-ui/css/style.css.pre-red-prompt +2524 -0
- package/dist/control-ui/css/style.css.pre-stop +2239 -0
- package/dist/control-ui/css/style.css.pre-textarea +2184 -0
- package/dist/control-ui/css/style.css.pre-watchdog +1848 -0
- package/dist/control-ui/css/style.css.red-theme +2999 -0
- package/dist/control-ui/index.html +1049 -0
- package/dist/control-ui/js/app.js +1304 -0
- package/dist/control-ui/js/app.js.pre-2row +463 -0
- package/dist/control-ui/js/app.js.pre-heartbeat-filter +595 -0
- package/dist/control-ui/js/app.js.pre-newsession +408 -0
- package/dist/control-ui/js/app.js.pre-queue +476 -0
- package/dist/control-ui/js/app.js.pre-stop +564 -0
- package/dist/control-ui/js/app.js.pre-textarea +467 -0
- package/dist/control-ui/js/app.js.pre-watchdog +293 -0
- package/dist/control-ui/js/connections.js +438 -0
- package/dist/control-ui/js/gateway.js +233 -0
- package/dist/control-ui/js/gateway.js.pre-stop +110 -0
- package/dist/control-ui/js/history.js +732 -0
- package/dist/control-ui/js/logs.js +238 -0
- package/dist/control-ui/js/menu.js +232 -0
- package/dist/control-ui/js/menu.js.pre-nav +66 -0
- package/dist/control-ui/js/metrics.js +53 -0
- package/dist/control-ui/js/models.js +138 -0
- package/dist/control-ui/js/render.js +882 -0
- package/dist/control-ui/js/render.test.js +112 -0
- package/dist/control-ui/js/scheduling.js +461 -0
- package/dist/control-ui/js/settings.js +910 -0
- package/dist/control-ui/js/slash-autocomplete.js +168 -0
- package/dist/control-ui/js/subagents.js +560 -0
- package/dist/control-ui/js/utils.js +29 -0
- package/dist/control-ui/vendor/highlight.min.js +2518 -0
- package/dist/control-ui/vendor/marked.min.js +69 -0
- package/dist/{deliver-DyO3QD8O.js → deliver-DTRkeYm3.js} +4 -4
- package/dist/{deliver-Cjyb6h4g.js → deliver-oWGJwzFf.js} +4 -4
- package/dist/extensionAPI.js +4 -4
- package/dist/llm-slug-generator.js +4 -4
- package/dist/{manager-rvtFoeFT.js → manager-CFenq_aO.js} +1 -1
- package/dist/{manager-PTSjHNVq.js → manager-CsxTf96V.js} +1 -1
- package/dist/{pi-embedded-BPuUM-gD.js → pi-embedded-Cdub5Vs9.js} +10 -10
- package/dist/{pw-ai-BFS9ezWe.js → pw-ai-BOOB8qoi.js} +1 -1
- package/dist/{pw-ai-Cx-Ko_FL.js → pw-ai-D2pEVS5n.js} +1 -1
- package/dist/{synthesis-7UL3pCpj.js → synthesis-Be9nYyDd.js} +4 -4
- package/dist/{synthesis-fD8J2vag.js → synthesis-CBIT6Vnk.js} +4 -4
- package/dist/{unified-runner-BIiKFnNF.js → unified-runner-BVvvnjXW.js} +10 -10
- package/package.json +1 -1
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
// ── Symi UI — Live Gateway App ────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
const responseArea = document.getElementById("response-area");
|
|
4
|
+
const promptInput = document.getElementById("prompt-input");
|
|
5
|
+
const sendBtn = document.getElementById("send-btn");
|
|
6
|
+
const newSessionBtn = document.getElementById("new-session-btn");
|
|
7
|
+
const stopBtn = document.getElementById("stop-btn");
|
|
8
|
+
|
|
9
|
+
let isStreaming = false;
|
|
10
|
+
let workingActive = false;
|
|
11
|
+
let activeSubagentCount = 0;
|
|
12
|
+
let currentRunId = null;
|
|
13
|
+
let streamBubble = null;
|
|
14
|
+
let streamContent = null;
|
|
15
|
+
let streamStart = 0;
|
|
16
|
+
|
|
17
|
+
// ── Feed cache (localStorage fallback) ──────────────────────────
|
|
18
|
+
const FEED_CACHE_KEY = "symi:feed-cache";
|
|
19
|
+
const FEED_CACHE_MAX = 200; // max messages to cache
|
|
20
|
+
|
|
21
|
+
const feedCache = {
|
|
22
|
+
save(messages) {
|
|
23
|
+
try {
|
|
24
|
+
const trimmed = Array.isArray(messages) ? messages.slice(-FEED_CACHE_MAX) : [];
|
|
25
|
+
localStorage.setItem(FEED_CACHE_KEY, JSON.stringify(trimmed));
|
|
26
|
+
} catch {
|
|
27
|
+
// localStorage full or unavailable — ignore
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
load() {
|
|
31
|
+
try {
|
|
32
|
+
const raw = localStorage.getItem(FEED_CACHE_KEY);
|
|
33
|
+
return raw ? JSON.parse(raw) : [];
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
append(message) {
|
|
39
|
+
try {
|
|
40
|
+
const cached = this.load();
|
|
41
|
+
cached.push(message);
|
|
42
|
+
this.save(cached);
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
clear() {
|
|
48
|
+
try {
|
|
49
|
+
localStorage.removeItem(FEED_CACHE_KEY);
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── Lazy-loading pagination state ────────────────────────────────
|
|
57
|
+
let historyStartIndex = 0; // index of first loaded message in full transcript
|
|
58
|
+
let historyHasMore = false; // server says there are older messages
|
|
59
|
+
let isLoadingMore = false; // guard against concurrent fetches
|
|
60
|
+
|
|
61
|
+
// ── Prompt queue ──────────────────────────────────────────────────
|
|
62
|
+
let chatQueue = []; // [{id, text}]
|
|
63
|
+
|
|
64
|
+
// ── Watchdog + elapsed timer state ────────────────────────────────
|
|
65
|
+
let watchdogTimer = null; // fires if no activity for WATCHDOG_MS
|
|
66
|
+
let elapsedTimer = null; // ticks every second to show run duration
|
|
67
|
+
let activityStart = 0; // timestamp of when current run began
|
|
68
|
+
let elapsedBaseText = ""; // base sub-text for the elapsed display
|
|
69
|
+
let WATCHDOG_MS = 300000; // 300 seconds of silence → failure (profile-overridable)
|
|
70
|
+
// Track time of last upstream agent/chat event so the elapsed-timer tick can
|
|
71
|
+
// surface a "still reasoning" hint when reasoning runs silently for >5s
|
|
72
|
+
// (some models batch reasoning without streaming deltas; the orb timer alone
|
|
73
|
+
// looks frozen even though work is happening).
|
|
74
|
+
let lastEventTs = 0;
|
|
75
|
+
const EXTENDED_SILENCE_MS = 5000;
|
|
76
|
+
|
|
77
|
+
// ── Model profile state (updated via "profile" gateway event) ────
|
|
78
|
+
let activeProfile = null;
|
|
79
|
+
// NOTE: Client-side dedup removed — server-side per-client seq tracking in
|
|
80
|
+
// glass-ui-ws.ts prevents duplicate broadcasts. See Phase 5 of v3 plan.
|
|
81
|
+
|
|
82
|
+
// ── Utility ───────────────────────────────────────────────────────
|
|
83
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
84
|
+
function escapeHtml(t) {
|
|
85
|
+
return t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
86
|
+
}
|
|
87
|
+
// Alias used by panel scripts (subagents.js, connections.js, scheduling.js)
|
|
88
|
+
window.escHtml = escapeHtml;
|
|
89
|
+
|
|
90
|
+
// ── Status indicator (top-right of response area) ─────────────────
|
|
91
|
+
const statusEl = document.createElement("div");
|
|
92
|
+
statusEl.className = "conn-status";
|
|
93
|
+
statusEl.innerHTML =
|
|
94
|
+
'<span class="conn-dot dot-yellow pulse"></span><span class="conn-label">connecting…</span>';
|
|
95
|
+
responseArea.before(statusEl);
|
|
96
|
+
|
|
97
|
+
function setStatus(state) {
|
|
98
|
+
// 'connecting' | 'online' | 'offline'
|
|
99
|
+
const dot = statusEl.querySelector(".conn-dot");
|
|
100
|
+
const label = statusEl.querySelector(".conn-label");
|
|
101
|
+
dot.className = `conn-dot ${state === "online" ? "dot-green pulse" : state === "offline" ? "dot-red" : "dot-yellow pulse"}`;
|
|
102
|
+
label.textContent =
|
|
103
|
+
state === "online" ? "live" : state === "offline" ? "disconnected" : "connecting…";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Render helpers ─────────────────────────────────────────────────
|
|
107
|
+
function addUserMessage(text) {
|
|
108
|
+
const msg = document.createElement("div");
|
|
109
|
+
msg.className = "message user-msg";
|
|
110
|
+
msg.innerHTML = `<div class="bubble">${escapeHtml(text)}</div><div class="message-clearfix"></div>`;
|
|
111
|
+
responseArea.appendChild(msg);
|
|
112
|
+
msg.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Thinking bubble ────────────────────────────────────────────────
|
|
116
|
+
function createThinkingBubble() {
|
|
117
|
+
const msg = document.createElement("div");
|
|
118
|
+
msg.className = "message symi-msg thinking-msg";
|
|
119
|
+
msg.innerHTML = `
|
|
120
|
+
<div class="bubble thinking-bubble">
|
|
121
|
+
<div class="think-header">
|
|
122
|
+
<span class="think-badge">◈ PROCESSING</span>
|
|
123
|
+
<span class="think-dots"><span>.</span><span>.</span><span>.</span></span>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="think-bar-wrap"><div class="think-bar"></div></div>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="message-clearfix"></div>
|
|
128
|
+
`;
|
|
129
|
+
responseArea.appendChild(msg);
|
|
130
|
+
msg.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
131
|
+
return msg;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Live thinking → Reasoning panel ───────────────────────────────
|
|
135
|
+
// Buffers thinking deltas per run and flushes to the Reasoning panel as
|
|
136
|
+
// a single entry on thinking_end. Keeps the main response bubble clean
|
|
137
|
+
// (it only receives real text deltas) while surfacing the reasoning in
|
|
138
|
+
// the dedicated panel.
|
|
139
|
+
const thinkingBuffers = new Map();
|
|
140
|
+
|
|
141
|
+
function handleThinkingAgentEvent(evt) {
|
|
142
|
+
if (!evt || !evt.data) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const runId = evt.runId ?? "live";
|
|
146
|
+
const phase = evt.data.phase;
|
|
147
|
+
const delta = typeof evt.data.delta === "string" ? evt.data.delta : "";
|
|
148
|
+
const content = typeof evt.data.content === "string" ? evt.data.content : "";
|
|
149
|
+
|
|
150
|
+
if (phase === "thinking_start") {
|
|
151
|
+
thinkingBuffers.set(runId, "");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (phase === "thinking_delta") {
|
|
155
|
+
// Prefer appending deltas; fall back to the full content field if the
|
|
156
|
+
// provider only sends cumulative content.
|
|
157
|
+
const prev = thinkingBuffers.get(runId) ?? "";
|
|
158
|
+
thinkingBuffers.set(runId, delta ? prev + delta : content || prev);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (phase === "thinking_end") {
|
|
162
|
+
const buffered = (thinkingBuffers.get(runId) ?? "").trim();
|
|
163
|
+
thinkingBuffers.delete(runId);
|
|
164
|
+
if (buffered && typeof window.appendToReasoningPanel === "function") {
|
|
165
|
+
window.appendToReasoningPanel({ type: "thinking", thinking: buffered });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Stream bubble lifecycle ────────────────────────────────────────
|
|
171
|
+
function openStreamBubble() {
|
|
172
|
+
streamStart = Date.now();
|
|
173
|
+
const msg = document.createElement("div");
|
|
174
|
+
msg.className = "message symi-msg stream-msg";
|
|
175
|
+
msg.innerHTML = `<div class="bubble stream-bubble streaming"><div class="bubble-content"></div></div><div class="message-clearfix"></div>`;
|
|
176
|
+
responseArea.appendChild(msg);
|
|
177
|
+
streamBubble = msg.querySelector(".stream-bubble");
|
|
178
|
+
streamContent = msg.querySelector(".bubble-content");
|
|
179
|
+
msg.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
180
|
+
return msg;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function updateStream(text) {
|
|
184
|
+
if (!streamContent) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
streamContent.textContent += text;
|
|
188
|
+
responseArea.scrollTop = responseArea.scrollHeight;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function closeStreamBubble(model) {
|
|
192
|
+
if (!streamBubble) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
streamBubble.classList.remove("streaming");
|
|
196
|
+
streamBubble.classList.add("done");
|
|
197
|
+
|
|
198
|
+
// Apply full markdown rendering to the final text. Build the replacement
|
|
199
|
+
// BEFORE wiping the existing content so a failing/empty render can't leave
|
|
200
|
+
// the bubble visibly blank ("response appeared then disappeared").
|
|
201
|
+
const rawText = streamContent?.textContent ?? "";
|
|
202
|
+
if (streamContent && rawText) {
|
|
203
|
+
let textBlock = null;
|
|
204
|
+
try {
|
|
205
|
+
textBlock = renderBlock({ type: "text", text: rawText });
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.warn("[app] renderBlock failed during closeStreamBubble:", err);
|
|
208
|
+
}
|
|
209
|
+
if (textBlock && textBlock.textContent.trim()) {
|
|
210
|
+
streamContent.innerHTML = "";
|
|
211
|
+
streamContent.appendChild(textBlock);
|
|
212
|
+
window.applyHighlighting(streamContent);
|
|
213
|
+
}
|
|
214
|
+
// else: preserve original textContent — the plain stream is better than
|
|
215
|
+
// an empty bubble.
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const elapsed = Date.now() - streamStart;
|
|
219
|
+
const chars = Math.round(rawText.length / 4);
|
|
220
|
+
|
|
221
|
+
// Add copy button
|
|
222
|
+
const copyBtn = document.createElement("button");
|
|
223
|
+
copyBtn.className = "msg-copy-btn msg-copy-visible";
|
|
224
|
+
copyBtn.title = "Copy";
|
|
225
|
+
copyBtn.addEventListener("click", function () {
|
|
226
|
+
window.copyMessage(this);
|
|
227
|
+
});
|
|
228
|
+
copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
|
229
|
+
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" stroke-width="2"/>
|
|
230
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
|
|
231
|
+
</svg>`;
|
|
232
|
+
streamBubble.appendChild(copyBtn);
|
|
233
|
+
|
|
234
|
+
const footer = document.createElement("div");
|
|
235
|
+
footer.className = "telemetry";
|
|
236
|
+
footer.innerHTML = `
|
|
237
|
+
<span class="tele-item">✦ ${model.includes("/") ? model.split("/").slice(1).join("/") : model}</span>
|
|
238
|
+
<span class="tele-sep">·</span>
|
|
239
|
+
<span class="tele-item">${chars} tokens</span>
|
|
240
|
+
<span class="tele-sep">·</span>
|
|
241
|
+
<span class="tele-item">${elapsed}ms</span>
|
|
242
|
+
`;
|
|
243
|
+
streamBubble.appendChild(footer);
|
|
244
|
+
requestAnimationFrame(() => {
|
|
245
|
+
footer.classList.add("tele-visible");
|
|
246
|
+
// Scroll to bottom immediately after markdown + footer are in the DOM
|
|
247
|
+
responseArea.scrollTop = responseArea.scrollHeight;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Second scroll after the fit-content width transition finishes (0.3s)
|
|
251
|
+
setTimeout(() => {
|
|
252
|
+
responseArea.scrollTop = responseArea.scrollHeight;
|
|
253
|
+
}, 350);
|
|
254
|
+
|
|
255
|
+
streamBubble = null;
|
|
256
|
+
streamContent = null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Elapsed timer ─────────────────────────────────────────────────
|
|
260
|
+
function startElapsedTimer(baseText) {
|
|
261
|
+
stopElapsedTimer();
|
|
262
|
+
activityStart = Date.now();
|
|
263
|
+
elapsedBaseText = baseText;
|
|
264
|
+
lastEventTs = activityStart;
|
|
265
|
+
elapsedTimer = setInterval(() => {
|
|
266
|
+
const secs = Math.floor((Date.now() - activityStart) / 1000);
|
|
267
|
+
const m = Math.floor(secs / 60);
|
|
268
|
+
const s = String(secs % 60).padStart(2, "0");
|
|
269
|
+
const silentMs = Date.now() - lastEventTs;
|
|
270
|
+
const showHint = elapsedBaseText.startsWith("Reasoning") && silentMs > EXTENDED_SILENCE_MS;
|
|
271
|
+
const hint = showHint ? " (still reasoning)" : "";
|
|
272
|
+
if (asoSub) {
|
|
273
|
+
asoSub.textContent = `${elapsedBaseText}${hint} ${m}:${s}`;
|
|
274
|
+
}
|
|
275
|
+
}, 1000);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stopElapsedTimer() {
|
|
279
|
+
if (elapsedTimer) {
|
|
280
|
+
clearInterval(elapsedTimer);
|
|
281
|
+
elapsedTimer = null;
|
|
282
|
+
}
|
|
283
|
+
lastEventTs = 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Watchdog ───────────────────────────────────────────────────────
|
|
287
|
+
function armWatchdog() {
|
|
288
|
+
clearWatchdog();
|
|
289
|
+
watchdogTimer = setTimeout(() => handleRunFailure("timeout"), WATCHDOG_MS);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clearWatchdog() {
|
|
293
|
+
if (watchdogTimer) {
|
|
294
|
+
clearTimeout(watchdogTimer);
|
|
295
|
+
watchdogTimer = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Unified run failure handler ────────────────────────────────────
|
|
300
|
+
// Called when: watchdog fires, gateway disconnects mid-run, or server
|
|
301
|
+
// sends an explicit error event. Cleans up all state and shows the user
|
|
302
|
+
// a clear error message + ERROR orb state.
|
|
303
|
+
function handleRunFailure(reason) {
|
|
304
|
+
// Stop timers immediately
|
|
305
|
+
stopElapsedTimer();
|
|
306
|
+
clearWatchdog();
|
|
307
|
+
if (typeof window.closeLiveThinking === "function") {
|
|
308
|
+
window.closeLiveThinking();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Clean up any in-flight UI elements
|
|
312
|
+
if (thinkingEl) {
|
|
313
|
+
thinkingEl.remove();
|
|
314
|
+
thinkingEl = null;
|
|
315
|
+
}
|
|
316
|
+
if (streamBubble) {
|
|
317
|
+
// Don't render markdown on a partial/failed response — just mark it failed
|
|
318
|
+
streamBubble.classList.remove("streaming");
|
|
319
|
+
streamBubble.classList.add("done", "stream-failed");
|
|
320
|
+
streamBubble = null;
|
|
321
|
+
streamContent = null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Human-readable reason labels
|
|
325
|
+
const labels = {
|
|
326
|
+
timeout: `No response received after ${WATCHDOG_MS / 1000}s — the agent may still be running in the background`,
|
|
327
|
+
disconnected: "Connection lost mid-run — gateway disconnected",
|
|
328
|
+
};
|
|
329
|
+
const label = labels[reason] || reason;
|
|
330
|
+
|
|
331
|
+
// Inject error bubble into the feed
|
|
332
|
+
const errEl = document.createElement("div");
|
|
333
|
+
errEl.className = "message symi-msg";
|
|
334
|
+
errEl.innerHTML = `
|
|
335
|
+
<div class="bubble stream-bubble done run-error-bubble">
|
|
336
|
+
<div class="run-error-icon">⚠</div>
|
|
337
|
+
<div class="bubble-content run-error-text">${escapeHtml(label)}</div>
|
|
338
|
+
</div>
|
|
339
|
+
<div class="message-clearfix"></div>
|
|
340
|
+
`;
|
|
341
|
+
responseArea.appendChild(errEl);
|
|
342
|
+
requestAnimationFrame(() => {
|
|
343
|
+
responseArea.scrollTop = responseArea.scrollHeight;
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Show ERROR state on orb, then reset input
|
|
347
|
+
setAgentStatus("error");
|
|
348
|
+
hideWorkingIndicator();
|
|
349
|
+
isStreaming = false;
|
|
350
|
+
currentRunId = null;
|
|
351
|
+
stopBtn.style.opacity = "";
|
|
352
|
+
stopBtn.disabled = false;
|
|
353
|
+
enableInput();
|
|
354
|
+
updateQueueBtn(); // keep queue button visible if items remain
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Render history (on first connect) ─────────────────────────────
|
|
358
|
+
function renderHistory(messages) {
|
|
359
|
+
const isEmpty = !messages || messages.length === 0;
|
|
360
|
+
const hasExistingContent = responseArea.querySelectorAll(".message").length > 0;
|
|
361
|
+
|
|
362
|
+
// Guard: if server sends empty history but the DOM already has messages,
|
|
363
|
+
// keep the existing feed — prevents blank screen on transient failures.
|
|
364
|
+
if (isEmpty && hasExistingContent) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Fallback: server is empty and DOM is empty — try localStorage cache
|
|
369
|
+
if (isEmpty) {
|
|
370
|
+
const cached = feedCache.load();
|
|
371
|
+
if (cached.length > 0) {
|
|
372
|
+
messages = cached;
|
|
373
|
+
} else {
|
|
374
|
+
return; // nothing to render anywhere
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
// Server sent real history — cache it for future fallback
|
|
378
|
+
feedCache.save(messages);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Clear reasoning panel in sync with the feed — prevents double-entries on reconnect
|
|
382
|
+
if (typeof window.clearReasoningPanel === "function") {
|
|
383
|
+
window.clearReasoningPanel();
|
|
384
|
+
}
|
|
385
|
+
responseArea.innerHTML = "";
|
|
386
|
+
responseArea.prepend(loadMoreSentinel);
|
|
387
|
+
for (const m of messages) {
|
|
388
|
+
if (!m || !m.role) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Route toolResult messages to the Reasoning Panel — they're tool output, not user-facing
|
|
393
|
+
if (m.role === "toolResult" || m.role === "tool") {
|
|
394
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
395
|
+
const text = content
|
|
396
|
+
.map((b) => b.text ?? "")
|
|
397
|
+
.join("\n")
|
|
398
|
+
.trim();
|
|
399
|
+
if (text && typeof window.appendToReasoningPanel === "function") {
|
|
400
|
+
window.appendToReasoningPanel({
|
|
401
|
+
type: "tool_result",
|
|
402
|
+
content: m.content,
|
|
403
|
+
is_error: m.isError === true,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Skip system/compaction messages entirely
|
|
410
|
+
if (m.role === "system") {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Route plugin context user messages to Reasoning Panel
|
|
415
|
+
if (m.role === "user") {
|
|
416
|
+
const txt = Array.isArray(m.content)
|
|
417
|
+
? m.content
|
|
418
|
+
.filter((b) => b.type === "text")
|
|
419
|
+
.map((b) => b.text ?? "")
|
|
420
|
+
.join("")
|
|
421
|
+
.trim()
|
|
422
|
+
: extractText(m.content).trim();
|
|
423
|
+
if (/^\[.+\]\s+(Not connected|Connected as)\b/i.test(txt)) {
|
|
424
|
+
if (typeof window.appendToReasoningPanel === "function") {
|
|
425
|
+
window.appendToReasoningPanel({ type: "text", text: "[Plugin context]" });
|
|
426
|
+
}
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (/^Review your response against the original user request/i.test(txt)) {
|
|
430
|
+
if (typeof window.appendToReasoningPanel === "function") {
|
|
431
|
+
window.appendToReasoningPanel({ type: "text", text: "[Post-generation verification]" });
|
|
432
|
+
}
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const hasContent = Array.isArray(m.content)
|
|
438
|
+
? m.content.some((b) => b.type === "text" && (b.text ?? "").trim())
|
|
439
|
+
: extractText(m.content).trim();
|
|
440
|
+
if (!hasContent) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const el = window.renderMessage(m);
|
|
445
|
+
if (!el) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
responseArea.appendChild(el);
|
|
449
|
+
window.applyHighlighting(el);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Update sentinel visibility
|
|
453
|
+
updateLoadMoreSentinel();
|
|
454
|
+
|
|
455
|
+
// Scroll to very bottom after history renders
|
|
456
|
+
requestAnimationFrame(() => {
|
|
457
|
+
responseArea.scrollTop = responseArea.scrollHeight;
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Lazy-loading: sentinel + IntersectionObserver ─────────────────
|
|
462
|
+
const loadMoreSentinel = document.createElement("div");
|
|
463
|
+
loadMoreSentinel.className = "load-more-sentinel";
|
|
464
|
+
loadMoreSentinel.id = "load-more-sentinel";
|
|
465
|
+
loadMoreSentinel.textContent = "Loading older messages…";
|
|
466
|
+
loadMoreSentinel.style.display = "none";
|
|
467
|
+
responseArea.prepend(loadMoreSentinel);
|
|
468
|
+
|
|
469
|
+
function updateLoadMoreSentinel() {
|
|
470
|
+
loadMoreSentinel.style.display = historyHasMore ? "" : "none";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const loadMoreObserver = new IntersectionObserver(
|
|
474
|
+
(entries) => {
|
|
475
|
+
if (entries[0]?.isIntersecting && historyHasMore && !isLoadingMore) {
|
|
476
|
+
void loadMoreHistory();
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
{ root: responseArea, threshold: 0.1 },
|
|
480
|
+
);
|
|
481
|
+
loadMoreObserver.observe(loadMoreSentinel);
|
|
482
|
+
|
|
483
|
+
async function loadMoreHistory() {
|
|
484
|
+
if (isLoadingMore || !historyHasMore || historyStartIndex <= 0) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
isLoadingMore = true;
|
|
488
|
+
loadMoreSentinel.textContent = "Loading older messages…";
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const result = await gateway.rpc("chat.history", {
|
|
492
|
+
sessionKey: window.SESSION_KEY,
|
|
493
|
+
limit: 100,
|
|
494
|
+
before: historyStartIndex,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const msgs = result.messages ?? [];
|
|
498
|
+
historyStartIndex = result.startIndex ?? 0;
|
|
499
|
+
historyHasMore = result.hasMore ?? false;
|
|
500
|
+
|
|
501
|
+
if (msgs.length === 0) {
|
|
502
|
+
historyHasMore = false;
|
|
503
|
+
updateLoadMoreSentinel();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Remember scroll position so we can restore it after prepending
|
|
508
|
+
const prevScrollHeight = responseArea.scrollHeight;
|
|
509
|
+
const prevScrollTop = responseArea.scrollTop;
|
|
510
|
+
|
|
511
|
+
// Build fragment of older messages (reuse same filtering as renderHistory)
|
|
512
|
+
const frag = document.createDocumentFragment();
|
|
513
|
+
for (const m of msgs) {
|
|
514
|
+
if (!m || !m.role) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (m.role === "toolResult" || m.role === "tool") {
|
|
518
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
519
|
+
const text = content
|
|
520
|
+
.map((b) => b.text ?? "")
|
|
521
|
+
.join("\n")
|
|
522
|
+
.trim();
|
|
523
|
+
if (text && typeof window.appendToReasoningPanel === "function") {
|
|
524
|
+
window.appendToReasoningPanel({
|
|
525
|
+
type: "tool_result",
|
|
526
|
+
content: m.content,
|
|
527
|
+
is_error: m.isError === true,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (m.role === "system") {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Route plugin context & verification user messages to Reasoning Panel
|
|
537
|
+
if (m.role === "user") {
|
|
538
|
+
const txt = Array.isArray(m.content)
|
|
539
|
+
? m.content
|
|
540
|
+
.filter((b) => b.type === "text")
|
|
541
|
+
.map((b) => b.text ?? "")
|
|
542
|
+
.join("")
|
|
543
|
+
.trim()
|
|
544
|
+
: extractText(m.content).trim();
|
|
545
|
+
if (/^Review your response against the original user request/i.test(txt)) {
|
|
546
|
+
if (typeof window.appendToReasoningPanel === "function") {
|
|
547
|
+
window.appendToReasoningPanel({ type: "text", text: "[Post-generation verification]" });
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const hasContent = Array.isArray(m.content)
|
|
554
|
+
? m.content.some((b) => b.type === "text" && (b.text ?? "").trim())
|
|
555
|
+
: extractText(m.content).trim();
|
|
556
|
+
if (!hasContent) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const el = window.renderMessage(m);
|
|
560
|
+
if (!el) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
frag.appendChild(el);
|
|
564
|
+
window.applyHighlighting(el);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Insert after sentinel (which stays at top)
|
|
568
|
+
loadMoreSentinel.after(frag);
|
|
569
|
+
|
|
570
|
+
// Restore scroll position so the view doesn't jump
|
|
571
|
+
requestAnimationFrame(() => {
|
|
572
|
+
const newScrollHeight = responseArea.scrollHeight;
|
|
573
|
+
responseArea.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
updateLoadMoreSentinel();
|
|
577
|
+
} catch (err) {
|
|
578
|
+
console.warn("[lazy-load] Failed to load older history:", err);
|
|
579
|
+
loadMoreSentinel.textContent = "Failed to load — scroll up to retry";
|
|
580
|
+
} finally {
|
|
581
|
+
isLoadingMore = false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Gateway event handler ──────────────────────────────────────────
|
|
586
|
+
let thinkingEl = null;
|
|
587
|
+
|
|
588
|
+
function handleGatewayEvent(event) {
|
|
589
|
+
// Handle sub-agent lifecycle events (independent of isStreaming)
|
|
590
|
+
if (event.event === "subagent") {
|
|
591
|
+
const s = event.payload;
|
|
592
|
+
if (s && s.phase === "started") {
|
|
593
|
+
activeSubagentCount++;
|
|
594
|
+
showWorkingIndicator();
|
|
595
|
+
} else if (s && s.phase === "completed") {
|
|
596
|
+
activeSubagentCount = Math.max(0, activeSubagentCount - 1);
|
|
597
|
+
if (activeSubagentCount === 0 && !isStreaming) {
|
|
598
|
+
hideWorkingIndicator();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Handle agent lifecycle/tool events — these prove the agent is alive.
|
|
605
|
+
// No isStreaming guard: agent events must be processed even before the
|
|
606
|
+
// first text delta arrives (critical for Gemma/vLLM tool-use phases).
|
|
607
|
+
if (event.event === "agent") {
|
|
608
|
+
const a = event.payload;
|
|
609
|
+
if (a) {
|
|
610
|
+
// Cross-session filter: agent events for OTHER sessions (cron-fired
|
|
611
|
+
// channel runs, slack/msteams inbound, scheduled jobs) must not arm the
|
|
612
|
+
// user's watchdog. Without this guard, every background run resets the
|
|
613
|
+
// watchdog and produces phantom "Run timed out" warnings when no
|
|
614
|
+
// follow-up event arrives within WATCHDOG_MS.
|
|
615
|
+
if (a.sessionKey && a.sessionKey !== window.SESSION_KEY) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
// Cross-run filter: late agent events from a finished run (e.g.
|
|
619
|
+
// lifecycle:end fires after chat:final cleared currentRunId) must not
|
|
620
|
+
// re-arm a fresh watchdog timer.
|
|
621
|
+
if (a.runId && currentRunId && a.runId !== currentRunId) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// Any agent event resets the watchdog — the agent is working
|
|
625
|
+
armWatchdog();
|
|
626
|
+
showWorkingIndicator();
|
|
627
|
+
lastEventTs = Date.now();
|
|
628
|
+
|
|
629
|
+
// Update the sub-label with what the agent is actually doing
|
|
630
|
+
if (a.stream === "tool" && a.data) {
|
|
631
|
+
if (a.data.phase === "start" && a.data.name) {
|
|
632
|
+
elapsedBaseText = `Running tool: ${a.data.name}`;
|
|
633
|
+
} else if (a.data.phase === "result") {
|
|
634
|
+
elapsedBaseText = "Reasoning through request";
|
|
635
|
+
}
|
|
636
|
+
} else if (a.stream === "compaction" && a.data) {
|
|
637
|
+
if (a.data.phase === "start") {
|
|
638
|
+
elapsedBaseText = "Compacting context";
|
|
639
|
+
} else if (a.data.phase === "end") {
|
|
640
|
+
elapsedBaseText = "Reasoning through request";
|
|
641
|
+
}
|
|
642
|
+
} else if (a.stream === "thinking") {
|
|
643
|
+
elapsedBaseText = "Reasoning";
|
|
644
|
+
// Stream the thinking content into the Reasoning panel (bottom-left).
|
|
645
|
+
// Deltas are buffered per run; flushed as a single entry on
|
|
646
|
+
// thinking_end so the panel gets one entry per thinking block, not
|
|
647
|
+
// one per token.
|
|
648
|
+
handleThinkingAgentEvent(a);
|
|
649
|
+
} else if (a.stream === "lifecycle" && a.data) {
|
|
650
|
+
if (a.data.phase === "start") {
|
|
651
|
+
elapsedBaseText = "Reasoning through request";
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (event.event !== "chat") {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const p = event.payload;
|
|
662
|
+
if (!p || p.sessionKey !== window.SESSION_KEY) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (p.runId && currentRunId && p.runId !== currentRunId) {
|
|
667
|
+
// Cross-run chat event (e.g. cron-fired channel run, chat.injectMessage,
|
|
668
|
+
// late event from a finished run). Not relevant to the user's active turn.
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (isStreaming) {
|
|
672
|
+
// Any chat event for our session and our run proves the agent is alive —
|
|
673
|
+
// reset watchdog.
|
|
674
|
+
armWatchdog();
|
|
675
|
+
lastEventTs = Date.now();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Handle "thinking" state broadcast from gateway (agent started processing)
|
|
679
|
+
if (p.state === "thinking") {
|
|
680
|
+
if (p.runId && currentRunId && p.runId !== currentRunId) {
|
|
681
|
+
return; // Cross-run thinking event
|
|
682
|
+
}
|
|
683
|
+
if (!isStreaming) {
|
|
684
|
+
return; // Ignore stale thinking events
|
|
685
|
+
}
|
|
686
|
+
setAgentStatus("thinking");
|
|
687
|
+
showWorkingIndicator();
|
|
688
|
+
armWatchdog();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (p.state === "delta") {
|
|
693
|
+
// Capture runId on first delta so abort can target the exact run
|
|
694
|
+
if (p.runId && !currentRunId) {
|
|
695
|
+
currentRunId = p.runId;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// First delta — remove thinking bubble, open stream bubble
|
|
699
|
+
if (thinkingEl) {
|
|
700
|
+
thinkingEl.remove();
|
|
701
|
+
thinkingEl = null;
|
|
702
|
+
}
|
|
703
|
+
if (!streamBubble) {
|
|
704
|
+
openStreamBubble();
|
|
705
|
+
setAgentStatus("streaming");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const text = extractText(p.message);
|
|
709
|
+
if (text) {
|
|
710
|
+
updateStream(text);
|
|
711
|
+
}
|
|
712
|
+
// Stream thinking deltas live to the Reasoning Panel
|
|
713
|
+
const thinkingText =
|
|
714
|
+
typeof window.extractThinkingText === "function" ? window.extractThinkingText(p.message) : "";
|
|
715
|
+
if (thinkingText && typeof window.updateLiveThinking === "function") {
|
|
716
|
+
window.updateLiveThinking(thinkingText);
|
|
717
|
+
}
|
|
718
|
+
} else if (p.state === "final") {
|
|
719
|
+
stopElapsedTimer();
|
|
720
|
+
clearWatchdog();
|
|
721
|
+
if (typeof window.closeLiveThinking === "function") {
|
|
722
|
+
window.closeLiveThinking();
|
|
723
|
+
}
|
|
724
|
+
// If final arrives with message content but no prior deltas (burst response),
|
|
725
|
+
// render the full message directly instead of closing an empty stream bubble.
|
|
726
|
+
if (!streamBubble && p.message) {
|
|
727
|
+
const finalText = extractText(p.message?.content ?? p.message);
|
|
728
|
+
if (finalText && finalText.trim()) {
|
|
729
|
+
if (thinkingEl) {
|
|
730
|
+
thinkingEl.remove();
|
|
731
|
+
thinkingEl = null;
|
|
732
|
+
}
|
|
733
|
+
const rendered = window.renderMessage({
|
|
734
|
+
role: "assistant",
|
|
735
|
+
content: p.message.content ?? [{ type: "text", text: finalText }],
|
|
736
|
+
timestamp: p.message.timestamp ?? Date.now(),
|
|
737
|
+
});
|
|
738
|
+
if (rendered) {
|
|
739
|
+
responseArea.appendChild(rendered);
|
|
740
|
+
window.applyHighlighting(rendered);
|
|
741
|
+
responseArea.scrollTop = responseArea.scrollHeight;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
closeStreamBubble(window.activeModelId || "unknown model");
|
|
746
|
+
}
|
|
747
|
+
// Cache the completed assistant message for feed persistence
|
|
748
|
+
if (p.message) {
|
|
749
|
+
const finalText = extractText(p.message?.content ?? p.message);
|
|
750
|
+
if (finalText && finalText.trim()) {
|
|
751
|
+
feedCache.append({
|
|
752
|
+
role: "assistant",
|
|
753
|
+
content: p.message.content ?? [{ type: "text", text: finalText }],
|
|
754
|
+
timestamp: p.message.timestamp ?? Date.now(),
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
setAgentStatus("done");
|
|
759
|
+
// Sync sub-agent count from server and conditionally hide working indicator
|
|
760
|
+
if (typeof p.activeSubagentCount === "number") {
|
|
761
|
+
activeSubagentCount = p.activeSubagentCount;
|
|
762
|
+
}
|
|
763
|
+
if (activeSubagentCount <= 0) {
|
|
764
|
+
hideWorkingIndicator();
|
|
765
|
+
}
|
|
766
|
+
isStreaming = false;
|
|
767
|
+
currentRunId = null;
|
|
768
|
+
stopBtn.style.opacity = ""; // reset stop button visual state
|
|
769
|
+
stopBtn.disabled = false;
|
|
770
|
+
enableInput();
|
|
771
|
+
drainQueue();
|
|
772
|
+
} else if (p.state === "aborted") {
|
|
773
|
+
// Clean abort (user-initiated or model decided to stop) — not an error
|
|
774
|
+
stopElapsedTimer();
|
|
775
|
+
clearWatchdog();
|
|
776
|
+
if (typeof window.closeLiveThinking === "function") {
|
|
777
|
+
window.closeLiveThinking();
|
|
778
|
+
}
|
|
779
|
+
if (streamBubble) {
|
|
780
|
+
closeStreamBubble(window.activeModelId || "unknown model");
|
|
781
|
+
}
|
|
782
|
+
if (thinkingEl) {
|
|
783
|
+
thinkingEl.remove();
|
|
784
|
+
thinkingEl = null;
|
|
785
|
+
}
|
|
786
|
+
setAgentStatus("idle");
|
|
787
|
+
hideWorkingIndicator();
|
|
788
|
+
isStreaming = false;
|
|
789
|
+
currentRunId = null;
|
|
790
|
+
stopBtn.style.opacity = ""; // reset stop button visual state
|
|
791
|
+
stopBtn.disabled = false;
|
|
792
|
+
enableInput();
|
|
793
|
+
drainQueue();
|
|
794
|
+
} else if (p.state === "error") {
|
|
795
|
+
// Server-reported error — route through unified failure handler.
|
|
796
|
+
// The gateway emits the diagnostic at top-level `errorMessage`
|
|
797
|
+
// (camelCase); fall back to nested `error.message` for forward-compat
|
|
798
|
+
// and the legacy default if neither shape is present.
|
|
799
|
+
const message = p.errorMessage || p.error?.message || "Server reported an error";
|
|
800
|
+
handleRunFailure(message);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── Agent Status Orb ───────────────────────────────────────────────
|
|
805
|
+
const asoPanel = document.getElementById("agent-status-panel");
|
|
806
|
+
const asoLabel = document.getElementById("aso-label");
|
|
807
|
+
const asoSub = document.getElementById("aso-sub");
|
|
808
|
+
const asoWorking = document.getElementById("aso-working");
|
|
809
|
+
let asoDoneTimer = null;
|
|
810
|
+
|
|
811
|
+
const ASO_STATES = {
|
|
812
|
+
idle: { state: "idle", label: "STANDBY", sub: "Awaiting your prompt" },
|
|
813
|
+
waiting: { state: "waiting", label: "WAITING", sub: "Sending to Landman\u2026" },
|
|
814
|
+
thinking: { state: "thinking", label: "PROCESSING", sub: "Reasoning through request" },
|
|
815
|
+
streaming: { state: "streaming", label: "RESPONDING", sub: "Generating response" },
|
|
816
|
+
done: { state: "done", label: "COMPLETE", sub: "Response ready" },
|
|
817
|
+
error: { state: "error", label: "FAILED", sub: "Run ended unexpectedly" },
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
function setAgentStatus(key) {
|
|
821
|
+
if (!asoPanel) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Clear any pending auto-transition timers
|
|
826
|
+
if (asoDoneTimer) {
|
|
827
|
+
clearTimeout(asoDoneTimer);
|
|
828
|
+
asoDoneTimer = null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const s = ASO_STATES[key] || ASO_STATES.idle;
|
|
832
|
+
asoPanel.dataset.state = s.state;
|
|
833
|
+
if (asoLabel) {
|
|
834
|
+
asoLabel.textContent = s.label;
|
|
835
|
+
}
|
|
836
|
+
if (asoSub) {
|
|
837
|
+
asoSub.textContent = s.sub;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Pulse the reasoning live-dot while agent is actively thinking/streaming
|
|
841
|
+
const reasoningDot = document.getElementById("reasoning-live-dot");
|
|
842
|
+
if (reasoningDot) {
|
|
843
|
+
const active = key === "thinking" || key === "streaming" || key === "waiting";
|
|
844
|
+
reasoningDot.classList.toggle("active", active);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ── Elapsed timer management ──────────────────────────────────
|
|
848
|
+
if (key === "waiting") {
|
|
849
|
+
// For burst-style models (Gemma/vLLM), arm watchdog immediately on send
|
|
850
|
+
// so the user gets feedback if the model never responds.
|
|
851
|
+
if (activeProfile?.ui?.armWatchdogOnSend !== false) {
|
|
852
|
+
armWatchdog();
|
|
853
|
+
}
|
|
854
|
+
startElapsedTimer("Sending to agent");
|
|
855
|
+
} else if (key === "thinking") {
|
|
856
|
+
// Start elapsed timer and watchdog when processing begins
|
|
857
|
+
armWatchdog();
|
|
858
|
+
elapsedBaseText = "Reasoning through request";
|
|
859
|
+
} else if (key === "streaming") {
|
|
860
|
+
// Transition from thinking → streaming: keep watchdog running (will be
|
|
861
|
+
// reset on each delta), switch elapsed timer base text
|
|
862
|
+
elapsedBaseText = "Generating response";
|
|
863
|
+
} else {
|
|
864
|
+
// Any terminal/non-active state: stop elapsed timer and watchdog
|
|
865
|
+
stopElapsedTimer();
|
|
866
|
+
clearWatchdog();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ── Auto-revert timers ────────────────────────────────────────
|
|
870
|
+
if (key === "done") {
|
|
871
|
+
asoDoneTimer = setTimeout(() => setAgentStatus("idle"), 3000);
|
|
872
|
+
}
|
|
873
|
+
if (key === "error") {
|
|
874
|
+
asoDoneTimer = setTimeout(() => setAgentStatus("idle"), 4000);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ── Working indicator helpers ─────────────────────────────────────
|
|
879
|
+
function showWorkingIndicator() {
|
|
880
|
+
if (!workingActive) {
|
|
881
|
+
workingActive = true;
|
|
882
|
+
if (asoWorking) {
|
|
883
|
+
asoWorking.classList.add("active");
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function hideWorkingIndicator() {
|
|
888
|
+
if (workingActive) {
|
|
889
|
+
workingActive = false;
|
|
890
|
+
if (asoWorking) {
|
|
891
|
+
asoWorking.classList.remove("active");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ── Input state ────────────────────────────────────────────────────
|
|
897
|
+
function disableInput() {
|
|
898
|
+
// Keep textarea enabled so user can type their next queued message while AI responds
|
|
899
|
+
sendBtn.disabled = true;
|
|
900
|
+
newSessionBtn.disabled = true;
|
|
901
|
+
sendBtn.classList.add("btn-loading");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function enableInput() {
|
|
905
|
+
sendBtn.disabled = false;
|
|
906
|
+
newSessionBtn.disabled = false;
|
|
907
|
+
sendBtn.classList.remove("btn-loading");
|
|
908
|
+
promptInput.focus();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ── Queue helpers ──────────────────────────────────────────────────
|
|
912
|
+
const queueBtn = document.getElementById("queue-btn");
|
|
913
|
+
const queueCancelX = document.getElementById("queue-cancel-x");
|
|
914
|
+
|
|
915
|
+
function updateQueueBtn() {
|
|
916
|
+
const streaming = isStreaming;
|
|
917
|
+
const count = chatQueue.length;
|
|
918
|
+
|
|
919
|
+
if (streaming || count > 0) {
|
|
920
|
+
// Show stop + queue buttons, hide New Session
|
|
921
|
+
stopBtn.classList.add("visible");
|
|
922
|
+
queueBtn.classList.add("visible");
|
|
923
|
+
newSessionBtn.style.display = "none";
|
|
924
|
+
|
|
925
|
+
if (count > 0) {
|
|
926
|
+
queueBtn.classList.add("has-queue");
|
|
927
|
+
queueBtn.querySelector(".queue-btn-label").textContent = `Queued (${count})`;
|
|
928
|
+
} else {
|
|
929
|
+
queueBtn.classList.remove("has-queue");
|
|
930
|
+
queueBtn.querySelector(".queue-btn-label").textContent = "Queue ↵";
|
|
931
|
+
}
|
|
932
|
+
} else {
|
|
933
|
+
// Hide stop + queue, restore New Session
|
|
934
|
+
stopBtn.classList.remove("visible");
|
|
935
|
+
queueBtn.classList.remove("visible", "has-queue");
|
|
936
|
+
newSessionBtn.style.display = "";
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function queueMessage(text) {
|
|
941
|
+
if (!text.trim()) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
chatQueue = [...chatQueue, { id: Date.now(), text: text.trim() }];
|
|
945
|
+
updateQueueBtn();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function clearQueue() {
|
|
949
|
+
chatQueue = [];
|
|
950
|
+
updateQueueBtn();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function drainQueue() {
|
|
954
|
+
if (chatQueue.length === 0) {
|
|
955
|
+
updateQueueBtn();
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const [next, ...rest] = chatQueue;
|
|
959
|
+
chatQueue = rest;
|
|
960
|
+
updateQueueBtn();
|
|
961
|
+
// Small delay so the UI settles before starting the next send
|
|
962
|
+
setTimeout(() => sendText(next.text), 120);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ── Core send (no input reading — accepts text directly) ───────────
|
|
966
|
+
async function sendText(text) {
|
|
967
|
+
if (!gateway.connected) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
isStreaming = true;
|
|
971
|
+
showWorkingIndicator();
|
|
972
|
+
disableInput();
|
|
973
|
+
updateQueueBtn();
|
|
974
|
+
|
|
975
|
+
addUserMessage(text);
|
|
976
|
+
feedCache.append({
|
|
977
|
+
role: "user",
|
|
978
|
+
content: [{ type: "text", text }],
|
|
979
|
+
timestamp: Date.now(),
|
|
980
|
+
});
|
|
981
|
+
await sleep(200);
|
|
982
|
+
|
|
983
|
+
// Mark the start of this run in the Reasoning Panel
|
|
984
|
+
if (typeof window.injectReasoningRunSeparator === "function") {
|
|
985
|
+
window.injectReasoningRunSeparator();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
setAgentStatus("waiting");
|
|
989
|
+
thinkingEl = createThinkingBubble();
|
|
990
|
+
setAgentStatus("thinking");
|
|
991
|
+
|
|
992
|
+
try {
|
|
993
|
+
await gateway.send(text);
|
|
994
|
+
} catch (err) {
|
|
995
|
+
handleRunFailure(err.message || "Failed to send message");
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ── Send handler (reads input, queues if busy) ─────────────────────
|
|
1000
|
+
async function handleSend() {
|
|
1001
|
+
const text = promptInput.value.trim();
|
|
1002
|
+
if (!text) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Clear the input immediately regardless of streaming state
|
|
1007
|
+
promptInput.value = "";
|
|
1008
|
+
promptInput.style.height = "auto";
|
|
1009
|
+
|
|
1010
|
+
if (isStreaming) {
|
|
1011
|
+
// AI is busy — queue it
|
|
1012
|
+
queueMessage(text);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (!gateway.connected) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
await sendText(text);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ── New Session handler ────────────────────────────────────────────
|
|
1023
|
+
async function handleNewSession() {
|
|
1024
|
+
if (isStreaming || !gateway.connected) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
promptInput.disabled = true; // lock textarea during reset
|
|
1029
|
+
disableInput();
|
|
1030
|
+
setAgentStatus("waiting");
|
|
1031
|
+
|
|
1032
|
+
try {
|
|
1033
|
+
// Send the /new slash command — gateway resets the session
|
|
1034
|
+
await gateway.send("/new");
|
|
1035
|
+
|
|
1036
|
+
// Fade out existing messages, then show cleared state
|
|
1037
|
+
responseArea.style.transition = "opacity 0.25s ease";
|
|
1038
|
+
responseArea.style.opacity = "0";
|
|
1039
|
+
|
|
1040
|
+
await sleep(260);
|
|
1041
|
+
|
|
1042
|
+
responseArea.innerHTML = "";
|
|
1043
|
+
responseArea.prepend(loadMoreSentinel);
|
|
1044
|
+
responseArea.style.opacity = "1";
|
|
1045
|
+
historyStartIndex = 0;
|
|
1046
|
+
historyHasMore = false;
|
|
1047
|
+
feedCache.clear(); // clear cached feed — new session starts fresh
|
|
1048
|
+
updateLoadMoreSentinel();
|
|
1049
|
+
if (typeof window.clearReasoningPanel === "function") {
|
|
1050
|
+
window.clearReasoningPanel();
|
|
1051
|
+
}
|
|
1052
|
+
if (typeof window.clearSymipulsePanel === "function") {
|
|
1053
|
+
window.clearSymipulsePanel();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Show empty-state indicator
|
|
1057
|
+
const cleared = document.createElement("div");
|
|
1058
|
+
cleared.className = "session-cleared";
|
|
1059
|
+
cleared.innerHTML = `
|
|
1060
|
+
<div class="session-cleared-icon">◈</div>
|
|
1061
|
+
<div class="session-cleared-text">NEW SESSION STARTED</div>
|
|
1062
|
+
`;
|
|
1063
|
+
responseArea.appendChild(cleared);
|
|
1064
|
+
|
|
1065
|
+
setAgentStatus("idle");
|
|
1066
|
+
activeSubagentCount = 0;
|
|
1067
|
+
hideWorkingIndicator();
|
|
1068
|
+
clearQueue(); // discard any queued prompts — new session = clean slate
|
|
1069
|
+
|
|
1070
|
+
// Show archive toast — lets user jump directly to history if they regret it
|
|
1071
|
+
if (typeof window.showArchiveToast === "function") {
|
|
1072
|
+
window.showArchiveToast();
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// After a short pause, remove the cleared indicator and let history load
|
|
1076
|
+
await sleep(1800);
|
|
1077
|
+
cleared.style.transition = "opacity 0.4s ease";
|
|
1078
|
+
cleared.style.opacity = "0";
|
|
1079
|
+
await sleep(420);
|
|
1080
|
+
cleared.remove();
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
handleRunFailure(err.message || "Failed to start new session");
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
promptInput.disabled = false; // re-enable textarea after reset
|
|
1087
|
+
enableInput();
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ── Stop handler ───────────────────────────────────────────────────
|
|
1091
|
+
async function handleStop() {
|
|
1092
|
+
if (!isStreaming || !gateway.connected) {
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
await gateway.abort(currentRunId);
|
|
1097
|
+
// The gateway will send an 'aborted' event which cleans up state.
|
|
1098
|
+
// Visually dim the stop button while we wait for confirmation.
|
|
1099
|
+
stopBtn.style.opacity = "0.45";
|
|
1100
|
+
stopBtn.disabled = true;
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
console.warn("[stop] abort failed:", err.message);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
sendBtn.addEventListener("click", handleSend);
|
|
1107
|
+
newSessionBtn.addEventListener("click", handleNewSession);
|
|
1108
|
+
stopBtn.addEventListener("click", handleStop);
|
|
1109
|
+
|
|
1110
|
+
// Queue button — queue current input (if idle text present) or show queued count
|
|
1111
|
+
queueBtn.addEventListener("click", (e) => {
|
|
1112
|
+
// Cancel X — clears the queue
|
|
1113
|
+
if (e.target === queueCancelX || queueCancelX.contains(e.target)) {
|
|
1114
|
+
clearQueue();
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
// Main button click — queue the current input if no item queued yet
|
|
1118
|
+
if (chatQueue.length === 0) {
|
|
1119
|
+
const text = promptInput.value.trim();
|
|
1120
|
+
if (text) {
|
|
1121
|
+
promptInput.value = "";
|
|
1122
|
+
promptInput.style.height = "auto";
|
|
1123
|
+
queueMessage(text);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
promptInput.addEventListener("keydown", (e) => {
|
|
1128
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1129
|
+
e.preventDefault();
|
|
1130
|
+
void handleSend();
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// ── Textarea auto-resize (up to 3 lines) ──────────────────────────
|
|
1135
|
+
function autoResizePrompt() {
|
|
1136
|
+
promptInput.style.height = "auto";
|
|
1137
|
+
promptInput.style.height = promptInput.scrollHeight + "px";
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
promptInput.addEventListener("input", autoResizePrompt);
|
|
1141
|
+
|
|
1142
|
+
// Clicking anywhere on the prompt bar (padding, icon, gaps) focuses the input
|
|
1143
|
+
// Click anywhere on the main input row (not buttons) focuses the input
|
|
1144
|
+
document.getElementById("prompt-bar").addEventListener("click", (e) => {
|
|
1145
|
+
const inSend = sendBtn.contains(e.target) || e.target === sendBtn;
|
|
1146
|
+
const inNewSession = newSessionBtn.contains(e.target) || e.target === newSessionBtn;
|
|
1147
|
+
const inHistory = document.getElementById("history-btn").contains(e.target);
|
|
1148
|
+
const inQueue = queueBtn.contains(e.target);
|
|
1149
|
+
const inStop = stopBtn.contains(e.target);
|
|
1150
|
+
// Only focus if clicking in the main row area (not the actions row)
|
|
1151
|
+
const inActionsRow = e.target.closest(".prompt-row-actions");
|
|
1152
|
+
if (!inSend && !inNewSession && !inHistory && !inQueue && !inStop && !inActionsRow) {
|
|
1153
|
+
promptInput.focus();
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
// ── Model profile listener ────────────────────────────────────────
|
|
1158
|
+
window.addEventListener("symi:profile-changed", (e) => {
|
|
1159
|
+
activeProfile = e.detail;
|
|
1160
|
+
WATCHDOG_MS = activeProfile?.ui?.watchdogMs ?? 300000;
|
|
1161
|
+
|
|
1162
|
+
// Update model label in header if present
|
|
1163
|
+
const modelLabel = document.getElementById("active-model-label");
|
|
1164
|
+
if (modelLabel) {
|
|
1165
|
+
modelLabel.textContent = activeProfile?.label ?? "";
|
|
1166
|
+
}
|
|
1167
|
+
const modelBadge = document.getElementById("model-badge");
|
|
1168
|
+
if (modelBadge) {
|
|
1169
|
+
modelBadge.textContent = activeProfile?.ui?.badge ?? "";
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
console.log(
|
|
1173
|
+
"[app] profile updated:",
|
|
1174
|
+
activeProfile?.label,
|
|
1175
|
+
"watchdog:",
|
|
1176
|
+
WATCHDOG_MS,
|
|
1177
|
+
"armOnSend:",
|
|
1178
|
+
activeProfile?.ui?.armWatchdogOnSend,
|
|
1179
|
+
);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
// ── Gateway setup ──────────────────────────────────────────────────
|
|
1183
|
+
// ── Session reload (called after session restore from history drawer) ──
|
|
1184
|
+
window.reloadSession = async function () {
|
|
1185
|
+
feedCache.clear();
|
|
1186
|
+
responseArea.innerHTML = "";
|
|
1187
|
+
responseArea.prepend(loadMoreSentinel);
|
|
1188
|
+
historyStartIndex = 0;
|
|
1189
|
+
historyHasMore = false;
|
|
1190
|
+
updateLoadMoreSentinel();
|
|
1191
|
+
if (typeof window.clearReasoningPanel === "function") {
|
|
1192
|
+
window.clearReasoningPanel();
|
|
1193
|
+
}
|
|
1194
|
+
if (typeof window.clearSymipulsePanel === "function") {
|
|
1195
|
+
window.clearSymipulsePanel();
|
|
1196
|
+
}
|
|
1197
|
+
setAgentStatus("idle");
|
|
1198
|
+
activeSubagentCount = 0;
|
|
1199
|
+
hideWorkingIndicator();
|
|
1200
|
+
|
|
1201
|
+
// Show restored indicator
|
|
1202
|
+
const restored = document.createElement("div");
|
|
1203
|
+
restored.className = "session-cleared";
|
|
1204
|
+
restored.innerHTML = `
|
|
1205
|
+
<div class="session-cleared-icon">◈</div>
|
|
1206
|
+
<div class="session-cleared-text">SESSION RESTORED</div>
|
|
1207
|
+
`;
|
|
1208
|
+
responseArea.appendChild(restored);
|
|
1209
|
+
|
|
1210
|
+
// Load history from the restored session
|
|
1211
|
+
try {
|
|
1212
|
+
const result = await gateway.rpc("chat.history", { sessionKey: window.SESSION_KEY });
|
|
1213
|
+
if (result?.messages) {
|
|
1214
|
+
renderHistory(result.messages);
|
|
1215
|
+
}
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
console.error("[app] Failed to load restored session history:", err);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Remove indicator after a pause
|
|
1221
|
+
setTimeout(() => {
|
|
1222
|
+
restored.style.transition = "opacity 0.4s ease";
|
|
1223
|
+
restored.style.opacity = "0";
|
|
1224
|
+
setTimeout(() => restored.remove(), 420);
|
|
1225
|
+
}, 1500);
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
const gateway = new SymiGateway();
|
|
1229
|
+
window.gateway = gateway;
|
|
1230
|
+
|
|
1231
|
+
gateway.addEventListener("connect", () => {
|
|
1232
|
+
setStatus("online");
|
|
1233
|
+
enableInput();
|
|
1234
|
+
// History arrives via separate 'history' event pushed by the server
|
|
1235
|
+
// Bridge to window events for panel scripts
|
|
1236
|
+
window.dispatchEvent(new Event("gateway:connected"));
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
gateway.addEventListener("history", (e) => {
|
|
1240
|
+
const msgs = Array.isArray(e.detail?.messages) ? e.detail.messages : [];
|
|
1241
|
+
historyStartIndex = e.detail?.startIndex ?? 0;
|
|
1242
|
+
historyHasMore = e.detail?.hasMore ?? false;
|
|
1243
|
+
renderHistory(msgs);
|
|
1244
|
+
// Server-side retries were exhausted — keep the cached feed visible but
|
|
1245
|
+
// show a small banner so the user knows they're on stale data and can
|
|
1246
|
+
// take action (reload, check gateway logs, /new, etc.).
|
|
1247
|
+
if (e.detail?.error === "history.unavailable") {
|
|
1248
|
+
showHistoryUnavailableBanner();
|
|
1249
|
+
} else {
|
|
1250
|
+
clearHistoryUnavailableBanner();
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
function showHistoryUnavailableBanner() {
|
|
1255
|
+
let banner = document.getElementById("history-unavailable-banner");
|
|
1256
|
+
if (!banner) {
|
|
1257
|
+
banner = document.createElement("div");
|
|
1258
|
+
banner.id = "history-unavailable-banner";
|
|
1259
|
+
banner.className = "history-unavailable-banner";
|
|
1260
|
+
banner.textContent =
|
|
1261
|
+
"History unavailable — showing cached view. Gateway retry failed; try reloading.";
|
|
1262
|
+
const dismiss = document.createElement("button");
|
|
1263
|
+
dismiss.type = "button";
|
|
1264
|
+
dismiss.className = "history-unavailable-dismiss";
|
|
1265
|
+
dismiss.textContent = "×";
|
|
1266
|
+
dismiss.addEventListener("click", clearHistoryUnavailableBanner);
|
|
1267
|
+
banner.appendChild(dismiss);
|
|
1268
|
+
// Insert at the top of the response area so it's the first thing seen.
|
|
1269
|
+
if (responseArea?.parentElement) {
|
|
1270
|
+
responseArea.parentElement.insertBefore(banner, responseArea);
|
|
1271
|
+
} else {
|
|
1272
|
+
document.body.prepend(banner);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
banner.style.display = "";
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function clearHistoryUnavailableBanner() {
|
|
1279
|
+
const banner = document.getElementById("history-unavailable-banner");
|
|
1280
|
+
if (banner) {
|
|
1281
|
+
banner.remove();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
gateway.addEventListener("disconnect", () => {
|
|
1286
|
+
setStatus("offline");
|
|
1287
|
+
// If the gateway drops while a run is in progress, surface the failure
|
|
1288
|
+
// immediately rather than leaving the UI in a permanently locked state.
|
|
1289
|
+
if (isStreaming) {
|
|
1290
|
+
handleRunFailure("disconnected");
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
gateway.addEventListener("event", (e) => {
|
|
1295
|
+
handleGatewayEvent(e.detail);
|
|
1296
|
+
// Forward to native debug panel if open
|
|
1297
|
+
if (window.__debugPanelCapture) {
|
|
1298
|
+
window.__debugPanelCapture(e.detail?.event ?? "unknown", e.detail?.payload);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// Start connecting
|
|
1303
|
+
disableInput();
|
|
1304
|
+
gateway.start();
|