main
js 861 lines 26.8 KB
Raw
1 import * as msgs from "/js/messages.js";
2 import * as api from "/js/api.js";
3 import { callJsExtensions } from "/js/extensions.js";
4 import * as css from "/js/css.js";
5 import { sleep } from "/js/sleep.js";
6 import { ttsService } from "/js/tts-service.js";
7 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
8 import { store as notificationStore } from "/components/notifications/notification-store.js";
9 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
10 import { store as inputStore } from "/components/chat/input/input-store.js";
11 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
12 import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
13 import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
14 import { store as _tooltipsStore } from "/components/tooltips/tooltip-store.js";
15 import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
16 import { store as syncStore } from "/components/sync/sync-store.js"
17 import { store as welcomeStore } from "/components/welcome/welcome-store.js";
18 import { store as modelGateStore } from "/components/chat/model-gate-store.js";
19 import { getUserHour12, getUserTimezone } from "/js/time-utils.js";
20 import { createThreeBubbleLoader } from "/js/loading-indicators.js";
21
22 globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
23
24 // Declare variables for DOM elements, they will be assigned on DOMContentLoaded
25 let leftPanel,
26 rightPanel,
27 container,
28 chatInput,
29 sendButton,
30 inputSection,
31 statusSection,
32 progressBar,
33 autoScrollSwitch,
34 timeDate;
35
36 let autoScroll = true;
37 let context = null;
38 let loadingContext = null;
39 let chatLoadingSplashVisible = false;
40 let chatLoadingSplashTimer = null;
41 globalThis.resetCounter = 0; // Used by stores and getChatBasedId
42 let skipOneSpeech = false;
43 const CHAT_LOADING_SPLASH_DELAY_MS = 300;
44
45 function syncChatLoadingSplash() {
46 const splash = document.getElementById("chat-loading-splash");
47 if (!splash) return;
48 if (!splash.querySelector(":scope > .three-bubble-loader")) {
49 splash.prepend(createThreeBubbleLoader({ active: true }));
50 }
51 splash.hidden = !chatLoadingSplashVisible;
52 }
53
54 function beginChatLoading(id) {
55 if (chatLoadingSplashTimer) {
56 clearTimeout(chatLoadingSplashTimer);
57 chatLoadingSplashTimer = null;
58 }
59 loadingContext = id || null;
60 chatLoadingSplashVisible = false;
61 syncChatLoadingSplash();
62
63 if (loadingContext !== null) {
64 const expectedContext = loadingContext;
65 chatLoadingSplashTimer = setTimeout(() => {
66 chatLoadingSplashTimer = null;
67 if (loadingContext !== expectedContext) return;
68 chatLoadingSplashVisible = true;
69 syncChatLoadingSplash();
70 }, CHAT_LOADING_SPLASH_DELAY_MS);
71 }
72 }
73
74 function finishChatLoading(id) {
75 if (loadingContext === null || id !== loadingContext) return;
76 if (chatLoadingSplashTimer) {
77 clearTimeout(chatLoadingSplashTimer);
78 chatLoadingSplashTimer = null;
79 }
80 loadingContext = null;
81 chatLoadingSplashVisible = false;
82 syncChatLoadingSplash();
83 }
84
85 // Sidebar toggle logic is now handled by sidebar-store.js
86
87 export async function sendMessage(options = {}) {
88 try {
89 const hasProvidedMessage = Object.prototype.hasOwnProperty.call(options, "message");
90 let message = String(hasProvidedMessage ? options.message : inputStore.message).trim();
91 let attachmentsWithUrls = options.attachments || attachmentsStore.getAttachmentsForSending();
92 let hasAttachments = attachmentsWithUrls.length > 0;
93
94 const sendCtx = { message, attachments: attachmentsWithUrls, context: options.context || context, cancel: false };
95 if (!options.skipExtensions) await callJsExtensions("send_message_before", sendCtx);
96 if (sendCtx.cancel) return;
97 message = sendCtx.message;
98 attachmentsWithUrls = sendCtx.attachments;
99 hasAttachments = attachmentsWithUrls.length > 0;
100 const sendContext = options.context || context;
101 const messageId = options.messageId || generateGUID();
102 const shouldResetInput = !hasProvidedMessage && !options.preserveInput;
103
104 // If empty input but has queued messages, send all queued
105 if (!message && !hasAttachments && messageQueueStore.hasQueue) {
106 await messageQueueStore.sendAll();
107 return;
108 }
109
110 if (message || hasAttachments) {
111 if (!options.bypassModelGate && !(await modelGateStore.canSendToModel())) {
112 modelGateStore.start({
113 message,
114 attachments: attachmentsWithUrls,
115 messageId,
116 context: sendContext,
117 });
118
119 if (shouldResetInput) {
120 inputStore.reset();
121 adjustTextareaHeight();
122 }
123
124 await setMessages(modelGateStore.syntheticMessages(sendContext));
125 forceScrollChatToBottom();
126 return;
127 }
128
129 // Check if agent is busy - queue instead of sending
130 if (chatsStore.selectedContext?.running || messageQueueStore.hasQueue) {
131 const success = messageQueueStore.addToQueue(message, attachmentsWithUrls);
132 // no await for the queue
133 // if (success) {
134 if (shouldResetInput) inputStore.reset();
135 adjustTextareaHeight();
136 // }
137 return;
138 }
139
140 // Sending a message is an explicit user intent to go to the bottom
141 msgs.scrollOnNextProcessGroup();
142 forceScrollChatToBottom();
143
144 let response;
145
146 // Clear input and attachments
147 if (shouldResetInput) {
148 inputStore.reset();
149 adjustTextareaHeight();
150 }
151
152 // Render immediately; the backend log reuses messageId and merges into this row.
153 const heading = hasAttachments ? "Uploading attachments..." : "";
154 await setMessages([{ id: messageId, type: "user", heading, content: message, kvps: {} }]);
155
156 // Include attachments in the user message
157 if (hasAttachments) {
158 // sleep one frame to render the message before upload starts - better UX
159 sleep(0);
160
161 const formData = new FormData();
162 formData.append("text", message);
163 formData.append("context", sendContext);
164 formData.append("message_id", messageId);
165
166 for (let i = 0; i < attachmentsWithUrls.length; i++) {
167 formData.append("attachments", attachmentsWithUrls[i].file);
168 }
169
170 response = await api.fetchApi("/message_async", {
171 method: "POST",
172 body: formData,
173 });
174 } else {
175 // For text-only messages
176 const data = {
177 text: message,
178 context: sendContext,
179 message_id: messageId,
180 };
181 response = await api.fetchApi("/message_async", {
182 method: "POST",
183 headers: {
184 "Content-Type": "application/json",
185 },
186 body: JSON.stringify(data),
187 });
188 }
189
190 // Handle response
191 const jsonResponse = await response.json();
192 if (!jsonResponse) {
193 toast("No response returned.", "error");
194 } else {
195 setContext(jsonResponse.context);
196 }
197 }
198 } catch (e) {
199 toastFetchError("Error sending message", e); // Will use new notification system
200 }
201 }
202 globalThis.sendMessage = sendMessage;
203
204 function forceScrollChatToBottom() {
205 return msgs.scrollMessageWindowToEdge("end");
206 }
207 globalThis.forceScrollChatToBottom = forceScrollChatToBottom;
208
209 export function toastFetchError(text, error) {
210 console.error(text, error);
211 // Use new frontend error notification system (async, but we don't need to wait)
212 const errorMessage = error?.message || error?.toString() || "Unknown error";
213
214 if (getConnectionStatus()) {
215 // Backend is connected, just show the error
216 toastFrontendError(`${text}: ${errorMessage}`).catch((e) =>
217 console.error("Failed to show error toast:", e)
218 );
219 } else {
220 // Backend is disconnected, show connection error
221 toastFrontendError(
222 `${text} (backend appears to be disconnected): ${errorMessage}`,
223 "Connection Error"
224 ).catch((e) => console.error("Failed to show connection error toast:", e));
225 }
226 }
227 globalThis.toastFetchError = toastFetchError;
228
229 // Event listeners will be set up in DOMContentLoaded
230
231 export function updateChatInput(text) {
232 if (!inputStore) {
233 console.warn("`chatInput` store not found, cannot update.");
234 return;
235 }
236 console.log("updateChatInput called with:", text);
237
238 // Append text with proper spacing in Alpine store first.
239 const currentValue = inputStore.message || "";
240 const needsSpace = currentValue.length > 0 && !currentValue.endsWith(" ");
241 inputStore.message = currentValue + (needsSpace ? " " : "") + text + " ";
242
243 // Adjust height after Alpine applies store value.
244 setTimeout(() => {
245 adjustTextareaHeight();
246 }, 0);
247
248 console.log("Updated chat input value:", inputStore.message);
249 }
250
251 async function updateUserTime() {
252 let userTimeElement = document.getElementById("time-date");
253
254 while (!userTimeElement) {
255 await sleep(100);
256 userTimeElement = document.getElementById("time-date");
257 }
258
259 const now = new Date();
260 const timezone = getUserTimezone();
261 const hour12 = getUserHour12();
262 const timeString = new Intl.DateTimeFormat(undefined, {
263 hour: "numeric",
264 minute: "2-digit",
265 second: "2-digit",
266 hour12,
267 timeZone: timezone,
268 }).format(now).toLowerCase();
269 const dateString = new Intl.DateTimeFormat(undefined, {
270 year: "numeric",
271 month: "short",
272 day: "numeric",
273 timeZone: timezone,
274 }).format(now);
275
276 // Update the HTML
277 userTimeElement.innerHTML = `${timeString}<br><span id="user-date">${dateString}</span>`;
278 }
279
280 updateUserTime();
281 setInterval(updateUserTime, 1000);
282
283 async function setMessages(...params) {
284 return await msgs.setMessages(...params);
285 }
286
287 globalThis.loadKnowledge = async function () {
288 await inputStore.loadKnowledge();
289 };
290
291 function adjustTextareaHeight() {
292 inputStore.adjustTextareaHeight();
293 }
294
295 export const sendJsonData = async function (url, data) {
296 return await api.callJsonApi(url, data);
297 // const response = await api.fetchApi(url, {
298 // method: 'POST',
299 // headers: {
300 // 'Content-Type': 'application/json'
301 // },
302 // body: JSON.stringify(data)
303 // });
304
305 // if (!response.ok) {
306 // const error = await response.text();
307 // throw new Error(error);
308 // }
309 // const jsonResponse = await response.json();
310 // return jsonResponse;
311 };
312 globalThis.sendJsonData = sendJsonData;
313
314 function generateGUID() {
315 return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
316 var r = (Math.random() * 16) | 0;
317 var v = c === "x" ? r : (r & 0x3) | 0x8;
318 return v.toString(16);
319 });
320 }
321
322 export function getConnectionStatus() {
323 return chatTopStore.connected;
324 }
325 globalThis.getConnectionStatus = getConnectionStatus;
326
327 function setConnectionStatus(connected) {
328 chatTopStore.connected = connected;
329 // connectionStatus = connected;
330 // // Broadcast connection status without touching Alpine directly
331 // try {
332 // window.dispatchEvent(
333 // new CustomEvent("connection-status", { detail: { connected } })
334 // );
335 // } catch (_e) {
336 // // no-op
337 // }
338 }
339
340 let lastLogVersion = 0;
341 let lastLogGuid = "";
342 let lastSpokenNo = 0;
343
344 export function buildStateRequestPayload(options = {}) {
345 const { forceFull = false } = options || {};
346 const timezone = getUserTimezone();
347 return {
348 context: context || null,
349 log_from: forceFull ? 0 : lastLogVersion,
350 notifications_from: forceFull ? 0 : notificationStore.lastNotificationVersion || 0,
351 timezone,
352 collections_delta: true,
353 };
354 }
355
356 export async function applySnapshot(snapshot, options = {}) {
357 const { touchConnectionStatus = false, onLogGuidReset = null } = options || {};
358
359 let updated = false;
360
361 // Check if the snapshot is valid
362 if (!snapshot || typeof snapshot !== "object") {
363 console.error("Invalid snapshot payload");
364 return { updated: false };
365 }
366
367 // deselect chat if it is requested by the backend
368 if (snapshot.deselect_chat) {
369 chatsStore.deselectChat();
370 return { updated: false };
371 }
372
373 if (
374 snapshot.context != context &&
375 context !== null
376 ) {
377 return { updated: false };
378 }
379
380 const hasCollections =
381 Array.isArray(snapshot.contexts) && Array.isArray(snapshot.tasks);
382 const extensionSnapshot = hasCollections
383 ? snapshot
384 : {
385 ...snapshot,
386 contexts: chatsStore.contexts,
387 tasks: tasksStore.tasks,
388 };
389 const snapCtx = {
390 snapshot: extensionSnapshot,
391 willUpdateMessages: lastLogVersion != snapshot.log_version,
392 skip: false,
393 };
394 await callJsExtensions("apply_snapshot_before", snapCtx);
395 if (snapCtx.skip) return { updated: false };
396
397 // If the chat has been reset, reset cursors and request a resync from the caller.
398 // Note: on first snapshot after a context switch, lastLogGuid is intentionally empty,
399 // so the mismatch is expected and should not trigger a second state_request/poll.
400 if (lastLogGuid != snapshot.log_guid) {
401 if (lastLogGuid) {
402 msgs.resetMessageRenderState();
403 lastLogVersion = 0;
404 lastLogGuid = snapshot.log_guid;
405 if (typeof onLogGuidReset === "function") {
406 await onLogGuidReset();
407 }
408 return { updated: false, resynced: true };
409 }
410 // First guid observed for this context: accept it and continue applying snapshot.
411 lastLogVersion = 0;
412 lastLogGuid = snapshot.log_guid;
413 }
414
415 if (lastLogVersion != snapshot.log_version) {
416 updated = true;
417 if (snapshot.logs?.[0]?.no === 0) {
418 msgs.resetMessageRenderState();
419 }
420 await setMessages(modelGateStore.mergeSyntheticMessages(snapshot.logs, context));
421 afterMessagesUpdate(snapshot.logs);
422 }
423
424 lastLogVersion = snapshot.log_version;
425 lastLogGuid = snapshot.log_guid;
426
427 updateProgress(snapshot.log_progress, snapshot.log_progress_active);
428
429 // Update notifications from snapshot
430 notificationStore.updateFromPoll(snapshot);
431
432 // set ui model vars from backend
433 inputStore.paused = snapshot.paused;
434
435 // Optional: treat snapshot application as proof of connectivity (poll path)
436 if (touchConnectionStatus) {
437 setConnectionStatus(true);
438 }
439
440 if (hasCollections) {
441 // Update chats list using store
442 chatsStore.applyContexts(snapshot.contexts);
443
444 // Update tasks list using store
445 tasksStore.applyTasks(snapshot.tasks);
446
447 // Make sure the active context is properly selected in both lists
448 // Leave an empty selection unchanged so the welcome screen stays visible.
449 if (context) {
450 // Update selection in both stores
451 chatsStore.setSelected(context);
452
453 const contextInChats = chatsStore.contains(context);
454 const contextInTasks = tasksStore.contains(context);
455
456 if (contextInTasks) {
457 tasksStore.setSelected(context);
458 }
459
460 if (!contextInChats && !contextInTasks) {
461 if (chatsStore.contexts.length > 0) {
462 // If it doesn't exist in the list but other contexts do, fall back to the first
463 const firstChatId = chatsStore.firstId();
464 if (firstChatId) {
465 setContext(firstChatId);
466 chatsStore.setSelected(firstChatId);
467 }
468 } else if (typeof deselectChat === "function") {
469 // No contexts remain – clear state so the welcome screen can surface
470 deselectChat();
471 }
472 }
473 }
474 }
475
476 // update message queue
477 messageQueueStore.updateFromPoll();
478
479 // A context switch is visually complete only after its matching snapshot
480 // has rendered and the surrounding chat state has been synchronized.
481 finishChatLoading(snapshot.context);
482
483 return { updated };
484 }
485
486 export async function poll() {
487 try {
488 const timezone = getUserTimezone();
489
490 const log_from = lastLogVersion;
491 const response = await sendJsonData("/poll", {
492 log_from: log_from,
493 notifications_from: notificationStore.lastNotificationVersion || 0,
494 context: context || null,
495 timezone: timezone,
496 });
497
498 const result = await applySnapshot(response, {
499 touchConnectionStatus: true,
500 onLogGuidReset: poll,
501 });
502 return { ok: true, updated: Boolean(result && result.updated) };
503 } catch (error) {
504 console.error("Error:", error);
505 setConnectionStatus(false);
506 return { ok: false, updated: false };
507 }
508 }
509 globalThis.poll = poll;
510
511 function afterMessagesUpdate(logs) {
512 if (preferencesStore.speech) speakMessages(logs);
513 }
514
515 function speakMessages(logs) {
516 if (skipOneSpeech) {
517 skipOneSpeech = false;
518 return;
519 }
520 // log.no, log.type, log.heading, log.content
521 for (let i = logs.length - 1; i >= 0; i--) {
522 const log = logs[i];
523
524 // if already spoken, end
525 // if(log.no < lastSpokenNo) break;
526
527 // finished response
528 if (log.type == "response") {
529 // lastSpokenNo = log.no;
530 ttsService.speakStream(
531 getChatBasedId(log.no),
532 log.content,
533 log.kvps?.finished
534 );
535 return;
536
537 // finished LLM headline, not response
538 } else if (
539 log.type == "agent" &&
540 log.kvps &&
541 log.kvps.headline &&
542 log.kvps.tool_args &&
543 log.kvps.tool_name != "response"
544 ) {
545 // lastSpokenNo = log.no;
546 ttsService.speakStream(getChatBasedId(log.no), log.kvps.headline, true);
547 return;
548 }
549 }
550 }
551
552 function updateProgress(progress, active) {
553 if (!progress) progress = "";
554
555 // Strip HTML tags for plain-text placeholder use
556 const plainText = progress.replace(/<[^>]*>/g, "").trim();
557
558 // Update the input store so the placeholder reflects progress
559 inputStore.progressText = plainText;
560 inputStore.progressActive = !!active;
561
562 // Apply shimmer class to the textarea when active
563 const chatInputEl = document.getElementById("chat-input");
564 if (chatInputEl) {
565 if (active && plainText) {
566 addClassToElement(chatInputEl, "progress-active");
567 } else {
568 removeClassFromElement(chatInputEl, "progress-active");
569 }
570 }
571
572 // Also update legacy progress bar element if it still exists
573 const progressBarEl = document.getElementById("progress-bar");
574 if (progressBarEl) {
575 setProgressBarShine(progressBarEl, active);
576 const html = msgs.convertIcons(progress);
577 if (progressBarEl.innerHTML != html) {
578 progressBarEl.innerHTML = html;
579 }
580 }
581 }
582
583 function setProgressBarShine(progressBarEl, active) {
584 if (!progressBarEl) return;
585 if (!active) {
586 removeClassFromElement(progressBarEl, "shiny-text");
587 } else {
588 addClassToElement(progressBarEl, "shiny-text");
589 }
590 }
591
592 globalThis.pauseAgent = async function (paused) {
593 await inputStore.pauseAgent(paused);
594 };
595
596 function generateShortId() {
597 const chars =
598 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
599 let result = "";
600 for (let i = 0; i < 8; i++) {
601 result += chars.charAt(Math.floor(Math.random() * chars.length));
602 }
603 return result;
604 }
605
606 export const newContext = function () {
607 context = generateShortId();
608 setContext(context);
609 };
610 globalThis.newContext = newContext;
611
612 export const setContext = function (id) {
613 if (id == context) return;
614 inputStore.setDraftContext(id);
615 context = id;
616 if (id) beginChatLoading(id);
617 else beginChatLoading(null);
618 // Always reset the log tracking variables when switching contexts
619 // This ensures we get fresh data from the backend
620 lastLogGuid = "";
621 lastLogVersion = 0;
622 lastSpokenNo = 0;
623
624 // Stop speech when switching chats
625 ttsService.stop();
626
627 // Clear the chat history immediately to avoid showing stale content
628 msgs.resetMessageRenderState();
629
630 // Update both selected states using stores
631 chatsStore.setSelected(id);
632 tasksStore.setSelected(id);
633
634 // Trigger a new WS handshake for the newly selected context (push-based sync).
635 // This keeps the UI current without needing /poll during healthy operation.
636 try {
637 if (typeof syncStore.sendStateRequest === "function") {
638 syncStore.sendStateRequest({ forceFull: true }).catch((error) => {
639 console.error("[index] syncStore.sendStateRequest failed:", error);
640 });
641 }
642 } catch (_error) {
643 // no-op: sync store may not be initialized yet
644 }
645
646 //skip one speech if enabled when switching context
647 if (preferencesStore.speech) skipOneSpeech = true;
648
649 // Focus the chat input
650 if (id) {
651 setTimeout(() => {
652 inputStore.focus();
653 }, 50);
654 }
655 };
656
657 export const deselectChat = function () {
658 // Clear current context to show welcome screen
659 setContext(null);
660
661 // Clear selections so we don't auto-restore
662 sessionStorage.removeItem("lastSelectedChat");
663 sessionStorage.removeItem("lastSelectedTask");
664
665 // Clear the chat history
666 msgs.resetMessageRenderState();
667 };
668 globalThis.deselectChat = deselectChat;
669
670 export const getContext = function () {
671 return context;
672 };
673 globalThis.getContext = getContext;
674 globalThis.setContext = setContext;
675
676 export const getChatBasedId = function (id) {
677 return context + "-" + globalThis.resetCounter + "-" + id;
678 };
679
680 function addClassToElement(element, className) {
681 element.classList.add(className);
682 }
683
684 function removeClassFromElement(element, className) {
685 element.classList.remove(className);
686 }
687
688 export function justToast(text, type = "info", timeout = 5000, group = "") {
689 notificationStore.addFrontendToastOnly(type, text, "", timeout / 1000, group);
690 }
691 globalThis.justToast = justToast;
692
693 export function toast(text, type = "info", timeout = 5000) {
694 // Convert timeout from milliseconds to seconds for new notification system
695 const display_time = Math.max(timeout / 1000, 1); // Minimum 1 second
696
697 // Use new frontend notification system based on type
698 switch (type.toLowerCase()) {
699 case "error":
700 return notificationStore.frontendError(text, "Error", display_time);
701 case "success":
702 return notificationStore.frontendInfo(text, "Success", display_time);
703 case "warning":
704 return notificationStore.frontendWarning(text, "Warning", display_time);
705 case "info":
706 default:
707 return notificationStore.frontendInfo(text, "Info", display_time);
708 }
709 }
710 globalThis.toast = toast;
711
712
713 import { store as _chatNavigationStore } from "/components/chat/navigation/chat-navigation-store.js";
714
715
716 // Navigation logic in chat-navigation-store.js
717 // forceScrollChatToBottom is kept here as it is used by system events
718
719
720 // setInterval(poll, 250);
721
722 async function startPolling() {
723 // Fallback polling cadence:
724 // - DISCONNECTED: do not poll (transport down, avoid request spam)
725 // - HANDSHAKE_PENDING/DEGRADED: steady fallback cadence to keep UI responsive
726 const degradedIntervalMs = 250;
727 let missingSyncSinceMs = null;
728 let consecutivePollFailures = 0;
729 let lastHandshakeKickMs = 0;
730 const startedAtMs = Date.now();
731 const initialNoPollGraceMs = 2000;
732 let pollInFlight = false;
733
734 async function _doPoll() {
735 const tickStartedAt = Date.now();
736 let nextInterval = degradedIntervalMs;
737
738 try {
739 const syncMode = typeof syncStore.mode === "string" ? syncStore.mode : null;
740 // Polling is a fallback. In V1:
741 // - DEGRADED: poll at fallback cadence to keep the UI usable while WS sync is unavailable.
742 // - DISCONNECTED: do not poll; rely on Socket.IO reconnect and avoid console/network spam.
743 // Safety net: if the sync store never loads, start polling after a short grace period.
744 if (!syncStore || !syncMode) {
745 if (missingSyncSinceMs == null) {
746 missingSyncSinceMs = Date.now();
747 }
748 } else {
749 missingSyncSinceMs = null;
750 }
751
752 const shouldPoll =
753 syncMode === "DEGRADED" ||
754 (missingSyncSinceMs != null && Date.now() - missingSyncSinceMs > 2000);
755 if (!shouldPoll) {
756 setTimeout(_doPoll.bind(this), nextInterval);
757 return;
758 }
759
760 if (pollInFlight) {
761 setTimeout(_doPoll.bind(this), nextInterval);
762 return;
763 }
764
765 // Avoid a “single poll on boot” while the websocket handshake is racing to take over.
766 if (Date.now() - startedAtMs < initialNoPollGraceMs && (!syncStore || !syncMode)) {
767 setTimeout(_doPoll.bind(this), nextInterval);
768 return;
769 }
770
771 // Call through `globalThis.poll` so test harnesses (and future instrumentation)
772 // can wrap/spy on polling behaviour. Fall back to the module-local function
773 // if the global is unavailable.
774 const pollFn = typeof globalThis.poll === "function" ? globalThis.poll : poll;
775 pollInFlight = true;
776 let result;
777 try {
778 result = await pollFn();
779 } finally {
780 pollInFlight = false;
781 }
782 const pollOk = Boolean(result && result.ok);
783
784 if (!pollOk) {
785 consecutivePollFailures += 1;
786 } else {
787 consecutivePollFailures = 0;
788 }
789
790 // If we are degraded but polling repeatedly fails, upgrade to DISCONNECTED.
791 if (
792 syncStore &&
793 syncMode === "DEGRADED" &&
794 !pollOk &&
795 consecutivePollFailures >= 3
796 ) {
797 syncStore.mode = "DISCONNECTED";
798 }
799
800 // If we're polling and the backend responds, try to re-establish push sync immediately.
801 if (syncStore && pollOk) {
802 const now = Date.now();
803 const modeNow = typeof syncStore.mode === "string" ? syncStore.mode : null;
804 const kickCooldownMs = modeNow === "DISCONNECTED" ? 0 : 3000;
805 const eligible =
806 (modeNow === "DISCONNECTED" || modeNow === "DEGRADED") &&
807 typeof syncStore.sendStateRequest === "function" &&
808 now - lastHandshakeKickMs >= kickCooldownMs;
809 if (eligible) {
810 lastHandshakeKickMs = now;
811 syncStore.sendStateRequest({ forceFull: true }).catch(() => {});
812 }
813 }
814
815 const effectiveMode =
816 syncStore && typeof syncStore.mode === "string" ? syncStore.mode : syncMode;
817 nextInterval =
818 effectiveMode === "DEGRADED" || effectiveMode === "HANDSHAKE_PENDING"
819 ? degradedIntervalMs
820 : degradedIntervalMs;
821 } catch (error) {
822 console.error("Error:", error);
823 }
824
825 // Call the function again after the selected interval
826 const elapsedMs = Date.now() - tickStartedAt;
827 const delayMs = Math.max(0, nextInterval - elapsedMs);
828 setTimeout(_doPoll.bind(this), delayMs);
829 }
830
831 _doPoll();
832 }
833
834 // All initializations and event listeners are now consolidated here
835 document.addEventListener("DOMContentLoaded", function () {
836 // Assign DOM elements to variables now that the DOM is ready
837 leftPanel = document.getElementById("left-panel");
838 rightPanel = document.getElementById("right-panel");
839 container = document.querySelector(".container");
840 chatInput = document.getElementById("chat-input");
841 sendButton = document.getElementById("send-button");
842 inputSection = document.getElementById("input-section");
843 statusSection = document.getElementById("status-section");
844 progressBar = document.getElementById("progress-bar");
845 autoScrollSwitch = document.getElementById("auto-scroll-switch");
846 timeDate = document.getElementById("time-date-container");
847 syncChatLoadingSplash();
848
849
850 // Start polling for updates
851 startPolling();
852 });
853
854 /*
855 * A0 Chat UI
856 *
857 * Unified sidebar layout:
858 * - Both Chats and Tasks lists are always visible in a vertical layout
859 * - Both lists are sorted by creation time (newest first)
860 * - Tasks use the same context system as chats for communication with the backend
861 */