PageSourceSearch

https://droprompt.com/_static/cd603bf35fdbcf40f9f52119/assets/dro-agent/app.js?v=20260913204000

js droprompt.com collected 2026-09-24 14:42:36 UTC 72,873 bytes, 1,349 lines download raw bytes

1/*
2 * dro-agent/app.js — orchestrates the dro Agent page: composer, attachments,
3 * voice messages, streaming turns over SSE (JSON fallback), suggestion cards
4 * with prefilled deep links, the conversation sidebar and the sign-in gate.
5 * Glue only; rendering lives in ui.js, transport in api.js, files in
6 * attachments.js. Exposes window.DP_DroAgentApp.init().
7 */
8(function () {
9  'use strict';
10  if (window.DP_DroAgentApp) return;
11
12  var Api, Ui, Files;
13  var $ = function (id) { return document.getElementById(id); };
14  function toast(m, o) { if (window.dpToast) window.dpToast(m, o); }
15
16  var EFFORT_KEY = 'dp:da:effort';
17  var RAIL_KEY = 'dp:da:rail';
18  var DEFAULT_EFFORT = 'balanced';
19  // Mirrors services/dro-agent/effort.js; the server re-sends the real list
20  // (labels + prices) from GET /dro-agent/features and we re-render then.
21  var EFFORT_FALLBACK = [
22    { id: 'fast', label: 'Fast', hint: 'Straight answer, lowest latency', units: 1 },
23    { id: 'balanced', label: 'Balanced', hint: 'Thinks before it answers', units: 1, default: true },
24    { id: 'deep', label: 'Deep', hint: 'Plans, answers, then checks its own work', units: 3 },
25  ];
26  var state = { conversationId: null, refinement: null, sending: false, loading: false, draftReady: false, conversations: [], history: [], signedIn: false, account: '', effort: DEFAULT_EFFORT, levels: EFFORT_FALLBACK, connectors: [], connectionFilter: 'ready', connectionPopup: null, pending: null, unresolved: false, controller: null, selection: 0, historyRequest: 0, connectionRequest: 0, memoryRequest: 0 };
27  var DRAFT_PREFIX = 'dp:da:user:';
28  var SESSION_OWNER = 'dp:da:session-owner';
29  // How long a turn whose outcome never arrived keeps the composer locked.
30  var STALE_PENDING_MS = 10 * 60 * 1000;
31  // …and how long the notice itself is worth anything. A marker this old has
32  // no live question behind it, so it is dropped on sight rather than checked:
33  // whatever it warned about was decided long ago.
34  var PENDING_EXPIRY_MS = 60 * 60 * 1000;
35  var failedViews = null;
36  var els = {};
37
38
39  // i18n.js is deferred, so at init <html lang> is still the shipped 'en';
40  // the stored preference is what the visitor actually reads.
41  function lang() {
42    var l = (document.documentElement.getAttribute('lang') || '').slice(0, 2);
43    if (!l || l === 'en') { try { l = (localStorage.getItem('DROPPROMPT_LANG') || l || 'en').slice(0, 2); } catch (_) { l = l || 'en'; } }
44    return l || 'en';
45  }
46  function isAr() { return lang() === 'ar'; }
47  function t(key, fb) { return Ui ? Ui.t(key, fb) : fb; }
48
49  // ── init ──────────────────────────────────────────────────────────────
50  function init() {
51    if (els.input) return;
52    Api = window.DP_DroAgentApi;
53    els = {
54      main: $('da-main'), sidebar: $('da-sidebar'), scrim: $('da-scrim'), menu: $('da-menu'), sideClose: $('da-side-close'),
55      newChat: $('da-new'), list: $('da-conversations'), search: $('da-search'),
56      newTop: $('da-new-top'), exportChat: $('da-export'), chatTitle: $('da-chat-title'), jumpLatest: $('da-jump-latest'),
57      scroll: $('da-scroll'), messages: $('da-messages'), empty: $('da-empty'), gate: $('da-gate'),
58      composer: $('da-composer'), input: $('dro-agent-input'), send: $('da-send'), stop: $('da-stop'), sendNote: $('da-send-note'), attach: $('da-attach'), file: $('da-file'),
59      record: $('da-record'), recChip: $('da-rec-chip'), recTime: $('da-rec-time'), tray: $('da-tray'), cost: $('da-cost'), costNum: $('da-cost-num'),
60      costHelp: $('da-cost-help'), costBase: $('da-cost-base'), costMedia: $('da-cost-media'), costLevel: $('da-cost-level'), characterCount: $('da-character-count'),
61      drop: $('da-drop'), chat: $('da-chat'),
62      effort: $('da-effort'), effortBtn: $('da-effort-btn'), effortLabel: $('da-effort-label'), effortMenu: $('da-effort-menu'),
63      sideHide: $('da-side-hide'),
64      connect: $('da-connect'), connCount: $('da-conn-count'), connSheet: $('da-conn-sheet'), connList: $('da-conn-list'), connClose: $('da-conn-close'), connSearch: $('da-conn-search'), connResult: $('da-conn-result'), connApprovals: $('da-conn-approvals'),
65      memory: $('da-memory'), memSheet: $('da-mem-sheet'), memList: $('da-mem-list'), memClose: $('da-mem-close'), memClear: $('da-mem-clear'),
66    };
67    Ui = window.DP_DroAgentUi.create({
68      onToolDecision: decideToolAndContinue,
69      onToolStatus: function (id) { return Api.toolStatus(id); },
70      messagesEl: els.messages, scrollEl: els.scroll, emptyEl: els.empty, listEl: els.list, mainEl
70: els.main,
71      onOpenFeature: openFeature,
72      onRefineTask: function (task) {
73        var lead = lang() === 'ar' ? 'عدّل المهمة «' + task.title + '»: ' : 'Refine "' + task.title + '": ';
74        state.refinement = { id: task.id, lead: lead.trim() };
75        els.input.value = (els.input.value ? els.input.value + '\n' : '') + lead;
76        saveDraft(); syncComposer(); focusComposer();
77      },
78      onReuse: reuseMessage, jumpEl: els.jumpLatest,
79    });
80    Files = window.DP_DroAgentAttachments.create({
81      trayEl: els.tray, fileInput: els.file, onChange: syncComposer, onRecording: onRecording,
82    });
83
84    state.signedIn = Api.isSignedIn();
85    state.account = Api.sessionKey();
86    if (window.DP_OutputTry) window.DP_OutputTry.setHandler(function (output) {
87      var account = state.account;
88      var conversation = state.conversationId;
89      var old = els.messages.querySelector('.dpt-review'); if (old) old.remove();
90      els.empty.hidden = true;
91      els.main.classList.remove('da-idle');
92      window.DP_OutputTry.review(els.messages, output, {
93        valid: function () { return account === state.account && conversation === state.conversationId; },
94        onRun: function (source, sample, trial) { return send(source, sample, trial); },
95        onCancel: function () { syncComposer(); focusComposer(); },
96      });
97      els.scroll.scrollTop = els.scroll.scrollHeight;
98    });
99    claimDraftStorage();
100    try { state.effort = localStorage.getItem(EFFORT_KEY) || DEFAULT_EFFORT; } catch (_) { /* private mode */ }
101    try { if (localStorage.getItem(RAIL_KEY) === 'hidden') setRail(false); } catch (_) { /* private mode */ }
102    if (window.DP_HomeChatEmbed) els.main.classList.add('is-rail-hidden');
103    renderEffort();
104    loadEffortLevels();
105    if (state.signedIn) loadConnections();
106    wire();
107    syncSidebar();
108    syncShellHeight();
109    if (window.ResizeObserver) {
110      var chromeObserver = new window.ResizeObserver(syncShellHeight);
111      document.querySelectorAll('[data-dp-header], .dp-promo').forEach(function (node) { chromeObserver.observe(node); });
112    }
113    if (window.visualViewport) window.visualViewport.addEventListener('resize', syncShellHeight);
114    restoreEntry();
115    syncComposer();
116    if (state.signedIn) {
117      loadConversations();
118      els.gate.hidden = true;
119    } else {
120      els.gate.hidden = false;
121    }
122    // Sign-in from the gate (or the header) refreshes the page state.
123    window.addEventListener('storage', function (e) { if (e.key === 'dro_token' || e.key === 'dro_user') onAuthChange(); });
124    document.addEventListener('dp:auth-change', onAuthChange);
125    if (els.connect) els.connect.addEventListener('click', openConnections);
126    if (els.connClose) els.connClose.addEventListener('click', closeConnections);
127    if (els.connSearch) els.connSearch.addEventListener('input', renderConnections);
128    var customOpen = $('da-conn-custom-open'), customBack = $('da-conn-back');
129    if (customOpen) customOpen.addEventListener('click', function () { showCustomConnection(); });
130    if (customBack) customBack.addEventListener('click', browseConnections);
131    document.querySelectorAll('[data-conn-filter]').forEach(function (button) {
132      button.addEventListener('click', function () { state.connectionFilter = button.getAttribute('data-conn-filter'); renderConnections(); });
133    });
134    var customForm = document.getElementById('da-conn-custom');
135    if (customForm) customForm.addEventListener('submit', addCustomServer);
136    if (els.connSheet) els.connSheet.addEventListener('click', function (e) { if (e.target === els.connSheet) closeConnections(); });
137    if (els.memory) els.memory.addEventListener('click', openMemory);
138    if (els.memClose) els.memClose.addEventListener('click', function () { els.memSheet.hidden = true; });
139    if (els.memClear) els.memClear.addEventListener('click', function () { forget(''); });
140    if (els.memSheet) els.memSheet.addEventListener('click', function (e) { if (e.target === els.memSheet) els.memSheet.hidden = true; });
141    document.addEventListener('keydown', function (e) {
142      if (els.connSheet && !els.connSheet.hidden && e.key === 'Tab') {
143        var stops = Array.prototype.filter.call(els.connSheet.querySelectorAll('button, input, a[href]'), function (el) { return !el.disabled && el.getClientRects().length; });
144        var first = stops[0], last = stops[stops.length - 1];
145        if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
146        else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
147      }
148      if (e.key !== 'Escape') return;
149      if (els.connSheet && !els.connSheet.hidden) closeConnections();
150      if (els.memSheet && !els.memSheet.hidden) els.memSheet.hidden = true;
151    });
152    window.addEventListener('message', function (e) {
153      if (e.origin !== location.origin) return;                 // only our own callback page
154      var d = e.data;
155      if (!d || d.source !== 'dro-agent-connect' || !state.connectionPopup || e.source !== state.connectionPopup) return;
156      state.connectionPopup = null;
157      if (d.ok) { toast(t('droagent.connected', 'Connected.'), { type: 'success' }); loadConnections(); }
158      else toast(t('droagent.connect_failed', 'Could not start that connection.'), { type: 'error' });
159    });
160    window.addEventListener('popstate', function () {
161      if (state.sending) { persistConversation(state.conversationId); return; }
162      var id = new URLSearchParams(location.search).get('conversation');
163      if (id) openConversation({ id: id }); else newChat();
164    });
165    window.addEventListener('pagehide', saveDraft);
166    document.addEventListener('droprompt:languagechange', function () { renderEffort(); loadEffortLevels(); renderConversationList(); if (state.signedIn) loadConnections(); });
167    if (new URLSearchParams(location.search).get('connections') === '1' && state.signedIn) openConnections();
168  }
169
170  function accountPrefix(account) { return DRAFT_PREFIX + (account || state.account) + ':'; }
171  function draftKey(id) { return accountPrefix() + 'draft:' + (id || 'new'); }
172  function clearAccountStorage(account) {
173    if (!account) return;
174    try {
175      var prefix = accountPrefix(account);
176      for (var i = sessionStorage.length - 1; i >= 0; i--) {
177        var key = sessionStorage.key(i);
178        if (key && key.indexOf(prefix) === 0) sessionStorage.removeItem(key);
179      }
180    } catch (_) {}
181  }
182  function claimDraftStorage() {
183    try {
184      var previous = sessionStorage.getItem(SESSION_OWNER);
185      if (previous && previous !== state.account) clearAccountStorage(previous);
186      if (state.account) sessionStorage.setItem(SESSION_OWNER, state.account);
187      else sessionStorage.removeItem(SESSION_OWNER);
188    } catch (_) {}
189  }
190  function saveDraft() {
191    if (!state.account || !els.input || !state.draftReady) return;
192    if (state.refinement && !els.input.value.includes(state.refinement.lead)) state.refinement = null;
193    try {
194      if (els.input.value || state.pending) sessionStorage.setItem(draftKey(state.conversationId), JSON.stringify({ text: els.input.value, pending: state.pending, refinement: state.refinement }));
195      else sessionStorage.removeItem(draftKey(state.conversationId));
196    } catch (_) {}
197  }
198  function restoreDraft() {
199    state.draftReady = true;
200    state.refinement = null;
201    els.input.value = '';
202    state.pending = null;
203    state.unresolved = false;
204    try {
205      var draft = JSON.parse(sessionStorage.getItem(draftKey(state.conversationId)) || '{}');
206      els.input.value = String(draft.text || '').slice(0, 8000);
207      state.pending = draft.pending || null;
208      if (state.pending && Date.now() - (state.pending.at || 0) >= PENDING_EXPIRY_MS) state.pending = null;
209      state.refinement = draft.refinement && els.input.value.includes(draft.refinement.lead) ? draft.refinement : null;
210      // The lock exists so a turn whose outcome is still open is not sent
211      // twice. One that has settled needs no lock: the server is done with it.
212      state.unresolved = !!state.pending && !settledPending(state.pending);
213    } catch (_) {}
214    autosize();
215    syncComposer();
216  }
217  function persistConversation(id) {
218    var oldKey = draftKey(state.conversationId);
219    state.conversationId = id || null;
220    try {
221      var url = new URL(location.href);
222      if (id) url.searchParams.set('conversation', id); else url.searchParams.delete('conversation');
223      url.searchParams.delete('q'); url.searchParams.delete('idea');
224      window.history.replaceState(null, '', url.pathname + url.search + url.hash);
225    } catch (_) {}
226    // A new conversation takes ownership of its in-flight draft.
227    if (state.sending && oldKey !== draftKey(state.conversationId)) {
228      try { sessionStorage.removeItem(oldKey); } catch (_) {}
229      saveDraft();
230    }
231  }
232  function restoreEntry() {
233    var params = new URLSearchParams(location.search);
234    // Only a link that names a chat reopens one. Arriving at /dro-agent starts
235    // a new one, rather than dropping the user back into yesterday's thread.
236    var id = params.get('conversation');
237    var q = params.get('q') || params.get('idea');
238    if (id && state.signedIn) openConversation({ id: id }, q);
239    else {
240      restoreDraft();
241      if (q) { els.input.value = q.slice(0, 8000); saveDraft(); autosize(); }
242      // A settled turn gets no card until the check below has looked for it.
243      if (state.pending) {
244        if (!settledPending(state.pending)) showPendingRecovery();
245        checkSavedReply({ quiet: true }).catch(function () {});
246      }
247    }
248  }
249  function onAuthChange() {
250    var now = Api.isSignedIn();
251    var account = Api.sessionKey();
252    if (now === state.signedIn && account === state.account) return;
253    if (state.controller) state.controller.abort();
254    clearAccountStorage(state.account);
255    state.selection++; state.historyRequest++; state.connectionRequest++; state.memoryRequest++;
256    state.signedIn = now; state.account = account;
257    state.sending = false; state.loading = false; state.controller = null;
258    state.pending = null; state.unresolved = false; failedViews = null;
259    state.history = []; state.conversations = []; state.connectors = [];
260    claimDraftStorage();
261    persistConversation(null);
262    Ui.clear(); Files.clear();
263    if (Files.setLocked) Files.setLocked(false);
264    els.input.value = ''; state.draftReady = true;
265    if (els.memList) els.memList.innerHTML = '';
266    if (els.memSheet) els.memSheet.hidden = true;
267    if (els.connSheet) closeConnections();
268    if (state.connectionPopup) { try { state.connectionPopup.close(); } catch (_) {} state.connectionPopup = null; }
269    if (els.connApprovals) els.connApprovals.innerHTML = '';
270    renderConnections(); renderConversationList();
271    els.gate.hidden = now;
272    autosize(); syncComposer();
273    if (now) { loadConversations(); loadConnections(); }
274  }
275
276  // ── wiring ────────────────────────────────────────────────────────────
277  function wire() {
278    els.composer.addEventListener('submit', function (e) { e.preventDefault(); send(); });
279    els.input.addEventListener('input', function () { autosize(); saveDraft(); syncComposer(); });
280    els.input.addEventListener('keydown', function (e) {
281      if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
282    });
283    els.attach.addEventListener('click', function () { els.file.click(); });
284    if (window.dpVoice) window.dpVoice.attach(els.input, { button: els.record, mode: 'inline', languageHost: document.querySelector('.da-tools'), contextKey: function () { return state.account + ':' + state.selection; } });
285    else els.record.addEventListener('click', toggleRecording);
286    if (els.stop) els.stop.addEventListener('click', function () { if (state.controller) state.controller.abort(); });
287    if (els.search) els.search.addEventListener('input', renderConversationList);
288    document.querySelectorAll('[data-da-starter]').forEach(function (button) {
289      button.addEventListener('click', function () {
290        if (state.sending || state.loading) return;
291        els.input.value = t(button.getAttribute('data-da-starter'), button.getAttribute('data-da-prompt') || '');
292        saveDraft(); autosize(); syncComposer(); focusComposer();
293      });
294    });
295    window.addEventListener('resize', function () { syncSidebar(); syncShellHeight(); autosize(); });
296    els.newChat.addEventListener('click', function () { newChat(); closeDrawer(); });
297    if (els.newTop) els.newTop.addEventListener('click', function () { newChat(); closeDrawer(); });
298    if (els.exportChat) els.exportChat.addEventListener('click', downloadChat);
299    document.addEventListener('click', function (e) {
300      if (els.costHelp && els.costHelp.open && !els.costHelp.contains(e.target)) els.costHelp.open = false;
301    });
302    if (els.menu) els.menu.addEventListener('click', function () { if (isMobile()) openDrawer(); else setRail(true); });
303    if (els.sideHide) els.sideHide.addEventListener('click', function () { setRail(false); });
304    if (els.sideClose) els.sideClose.addEventListener('click', closeDrawer);
305    if (els.scrim) els.scrim.addEventListener('click', closeDrawer);
306    document.addEventListener('keydown', function (e) {
307      if (e.key === 'Escape') closeDrawer();
308      if (e.key === 'Escape' && els.costHelp && els.costHelp.open) { els.costHelp.open = false; els.cost.focus(); }
309      if (e.key !== 'Tab' || !isMobile() || !els.main.classList.contains('is-drawer-open')) return;
310      var focusable = Array.prototype.filter.call(els.sidebar.querySelectorAll('button, input, a[href], [tabindex="0"]'), function (node) { return !node.disabled && !node.hidden && node.offsetParent !== null; });
311      var first = focusable[0]; var last = focusable[focusable.length - 1];
312      if (!first) return;
313      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
314      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
315    });
316
317    if (els.effortBtn) {
318      els.effortBtn.addEventListener('click', function () { if (els.effortMenu.hidden) openEffort(); else closeEffort(true); });
319      els.effort.addEventListener('keydown', function (e) {
320        if (e.key === 'Escape') { closeEffort(true); return; }
321        if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
322        e.preventDefault();
323        if (els.effortMenu.hidden) { openEffort(); return; }
324        var opts = Array.prototype.slice.call(els.effortMenu.children);
325        var at = opts.indexOf(document.activeElement);
326        var next = opts[(at + (e.key === 'ArrowDown' ? 1 : opts.length - 1) + opts.length) % opts.length];
327        if (next) next.focus();
328      });
329    }
330
331    // Drag & drop anywhere on the chat column.
332    var depth = 0;
333    els.chat.addEventListener('dragenter', function (e) { if (hasFiles(e)) { e.preventDefault(); depth++; els.drop.hidden = false; } });
334    els.chat.addEventListener('dragover', function (e) { if (hasFiles(e)) e.preventDefault(); });
335    els.chat.addEventListener('dragleave', function (e) { if (hasFiles(e)) { depth = Math.max(0, depth - 1); if (!depth) els.drop.hidden = true; } });
336    els.chat.addEventListener('drop', function (e) {
337      if (!hasFiles(e)) return;
338      e.preventDefault(); depth = 0; els.drop.hidden = true;
339      if (state.sending || state.loading) return;
340      Files.addFiles(e.dataTransfer.files);
341      focusComposer();
342    });
343    // Paste an image (screenshot) straight into the composer.
344    els.input.addEventListener('paste', function (e) {
345      if (state.sending || state.loading) return;
346      var items = e.clipboardData && e.clipboardData.items;
347      if (!items) return;
348      var files = [];
349      for (var i = 0; i < items.length; i++) if (items[i].kind === 'file') { var f = items[i].getAsFile(); if (f) files.push(f); }
350      if (files.length) { e.preventDefault(); Files.addFiles(files); }
351    });
352    // Gate buttons
353    els.gate.querySelectorAll('[data-da-auth]').forEach(function (b) {
354      b.addEventListener('click', function () { if (window.dpOpenAuth) window.dpOpenAuth(b.getAttribute('data-da-auth')); });
355    });
356  }
357
358  function hasFiles(e) {
359    var types = e.dataTransfer && e.dataTransfer.types;
360    return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1;
361  }
362
363  function autosize() {
364    els.input.style.height = 'auto';
365    els.input.style.height = Math.min(200, els.input.scrollHeight) + 'px';
366  }
367
368  function syncComposer() {
369    var hasText = !!els.input.value.trim();
370    var hasFilesReady = Files && Files.count() > 0;
371    els.send.disabled = state.sending || state.loading || state.unresolved || Files.busy() || !(hasText || hasFilesReady);
372    els.input.readOnly = state.sending || state.loading;
373    els.composer.setAttribute('aria-busy', String(state.sending || state.loading));
374    els.send.hidden = state.sending;
375    if (els.stop) els.stop.hidden = !state.sending;
376    if (els.newChat) els.newChat.disabled = state.sending;
377    if (els.newTop) els.newTop.disabled = state.sending;
378    syncConversationTools();
379    if (els.record) els.record.disabled = state.sending || state.loading;
380    if (els.effortBtn) els.effortBtn.disabled = state.sending || state.loading;
381    var level = currentLevel();
382    var units = (level && level.units) || 1;
383    // 5 credits per unit of thinking + 5 per media attachment (mirrors
384    // config/credits.js). Files.quote() already counts the attachments.
385    var total = (Files ? Files.quote() : 5) + (units - 1) * 5;
386    if (els.costNum) els.costNum.textContent = String(total);
387    if (els.costBase) els.costBase.textContent = String(units * 5);
388    if (els.costMedia) els.costMedia.textContent = String(Math.max(0, total - units * 5));
389    if (els.costLevel) els.costLevel.textContent = level ? t('droagent.effort_' + level.id, level.label || level.id) : '';
390    var note = t('droagent.send_cost', '{credits} credits per message').replace('{credits}', String(total));
391    if (els.cost) els.cost.title = note;
392    if (els.sendNote) els.sendNote.textContent = note;
393    if (els.characterCount) {
394      els.characterCount.hidden = els.input.value.length < 6000;
395      els.characterCount.textContent = els.input.value.length.toLocaleString() + ' / 8,000';
396    }
397    els.send.setAttribute('aria-label', t('droagent.send', 'Send') + ' · ' + note);
398    els.attach.disabled = state.sending || state.loading || Files.count() >= 6;
399  }
400
401  function syncConversationTools() {
402    var current = state.conversations.find(function (c) { return String(c.id) === String(state.conversationId); });
403    if (els.chatTitle) { els.chatTitle.textContent = current ? current.title || '' : ''; els.chatTitle.hidden = !els.chatTitle.textContent; }
404    if (els.exportChat) els.exportChat.disabled = state.sending || state.loading || !(Ui && Ui.hasMessages && Ui.hasMessages());
405  }
406
407  function downloadChat() {
408    if (state.sending || state.loading || !Ui || !Ui.exportMarkdown) return;
409    var text = Ui.exportMarkdown(els.chatTitle && els.chatTitle.textContent);
410    if (!text) return;
411    var url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' }));
412    var link = document.createElement('a');
413    link.href = url; link.download = 'dro-agent-chat.md';
414    document.body.appendChild(link); link.click(); link.remove();
415    setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
416  }
417
418  async function reuseMessage(message) {
419    if (state.sending || state.loading || state.unresolved) {
420      toast(t('droagent.reuse_wait', 'Finish the current request before reusing a message.'));
421      return;
422    }
423    var text = String(message.content || '').slice(0, 8000);
424    if (!text) return;
425    var account = state.account; var selection = state.selection;
426    var original = els.input.value;
427    if (original.trim() && original !== text) {
428      var approved = window.dpConfirm
429        ? await window.dpConfirm({ title: t('droagent.replace_draft', 'Replace your current draft?'), message: t('droagent.replace_draft_note', 'The message will be placed in the composer for you to edit. Nothing is sent yet.'), confirmText: t('droagent.replace', 'Replace draft') })
430        : window.confirm(t('droagent.replace_draft', 'Replace your current draft?'));
431      if (!approved) return;
432    }
433    if (account !== state.account || selection !== state.selection || state.sending || state.loading || state.unresolved || els.input.value !== original) return;
434    els.input.value = text; saveDraft(); autosize(); syncComposer(); focusComposer();
435    if (message.attachments && message.attachments.length) toast(t('droagent.reattach_files', 'Text added. Reattach any files you want to use.'));
436  }
437
438  // ── effort picker ─────────────────────────────────────────────────────
439  function currentLevel() {
440    for (var i = 0; i < state.levels.length; i++) if (state.levels[i].id === state.effort) return state.levels[i];
441    return state.levels[1] || state.levels[0];
442  }
443
444  function renderEffort() {
445    if (!els.effortMenu) return;
446    var active = currentLevel();
447    if (els.effortLabel) els.effortLabel.textContent = active ? t('droagent.effort_' + active.id, active.label || active.id) : '';
448    els.effortMenu.innerHTML = '';
449    state.levels.forEach(function (l) {
450      var on = l.id === state.effort;
451      var b = document.createElement('button');
452      b.type = 'button';
453      b.className = 'da-menu-opt' + (on ? ' is-on' : '');
454      b.setAttribute('role', 'menuitemradio');
455      b.setAttribute('aria-checked', String(on));
456      b.innerHTML = '<span class="da-menu-check" aria-hidden="true">'
457        + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12.5 4.5 4.5L19 7"/></svg>'
458        + '</span><span class="da-menu-text"><b></b><i></i></span><span class="da-menu-cost"></span>';
459      b.querySelector('b').textContent = t('droagent.effort_' + l.id, l.label || l.id);
460      b.querySelector('i').textContent = t('droagent.effort_' + l.id + '_hint', l.hint || '');
461      b.querySelector('.da-menu-cost').textContent = String(((l.units || 1) * 5)) + ' ' + t('droagent.credits', 'credits');
462      b.addEventListener('click', function () { setEffort(l.id); closeEffort(true); });
463      els.effortMenu.appendChild(b);
464    });
465    syncComposer();
466  }
467
468  function openEffort() {
469    if (!els.effortMenu || !els.effortMenu.hidden) return;
470    els.effortMenu.hidden = false;
471    els.effortBtn.setAttribute('aria-expanded', 'true');
472    var first = els.effortMenu.querySelector('.da-menu-opt.is-on') || els.effortMenu.firstElementChild;
473    if (first) first.focus();
474    document.addEventListener('click', onEffortOutside, true);
475  }
476
477  function closeEffort(refocus) {
478    if (!els.effortMenu || els.effortMenu.hidden) return;
479    els.effortMenu.hidden = true;
480    els.effortBtn.setAttribute('aria-expanded', 'false');
481    document.removeEventListener('click', onEffortOutside, true);
482    if (refocus) els.effortBtn.focus();
483  }
484
485  function onEffortOutside(e) {
486    if (!els.effort.contains(e.target)) closeEffort(false);
487  }
488
489  function setEffort(id) {
490    if (state.sending || id === state.effort) return;
491    state.effort = id;
492    try { localStorage.setItem(EFFORT_KEY, id); } catch (_) { /* private mode */ }
493    renderEffort();
494  }
495
496  async function loadEffortLevels() {
497    try {
498      var data = await Api.features(lang());
499      var levels = (data && (data.effortLevels || (data.data && data.data.effortLevels))) || null;
500      if (Array.isArray(levels) && levels.length) {
501        state.levels = levels;
502        if (!levels.some(function (l) { return l.id === state.effort; })) state.effort = DEFAULT_EFFORT;
503        renderEffort();
504      }
505    } catch (_) { /* the fallback list is already on screen */ }
506  }
507
508  // ── connected services ────────────────────────────────────────────────
509  async function decideToolAndContinue(id, approve, alwaysApprove, onProgress) {
510    var account = state.account;
511    var result;
512    try { result = await Api.decideTool(id, approve, alwaysApprove); }
513    catch (err) {
514      // The write may have reached the server. Recover with a GET, never a
515      // second approval POST that could create another paid generation.
516      result = await Api.toolStatus(id);
517      if (result.status === 'pending') throw err;
518    }
519    var deadline = Date.now() + 300000;
520    while (account === state.account && (result.status === 'executing' || result.continuationStatus === 'pending' || result.continuationStatus === 'running')) {
521      if (onProgress) onProgress(result.status === 'executing' ? 'running' : 'fetching');
522      if (Date.now() >= deadline) throw new Error((lang() === 'ar' ? 'لسه بنتابع نتيجة الطلب. افتح الشات تاني لاسترجاعها؛ ما تبعتش نفس الطلب من جديد.' : 'The action is still being checked. Reopen this chat to retrieve its result; do not submit it again.'));
523      await new Promise(function (resolve) { setTimeout(resolve, 1800); });
524      result = await Api.toolStatus(id);
525    }
526    if (account !== state.account) return { status: 'unknown', text: '' };
527    if (alwaysApprove) loadConnections();
528    var continuation = result.continuation;
529    if (continuation && String(continuation.conversationId) === String(state.conversationId) && !state.sending) {
530      var data = await Api.conversation(state.conversationId);
531      if (account === state.account && String(data.conversation.id) === String(state.conversationId)) {
532        state.history = data.messages || []; Ui.renderHistory(state.history);
533      }
534    }
535    return { ...result, text: continuation && continuation.reply || result.text || result.status };
536  }
537
538  async function loadConnections() {
539    var request = ++state.connectionRequest;
540    var account = state.account;
541    try {
542      var data = await Api.connections(lang());
543      if (request !== state.connectionRequest || account !== state.account) return;
544      state.connectors = (data && (data.connectors || (data.data && data.data.connectors))) || [];
545      renderConnections();
546      var approvals = await Api.toolApprovals().catch(function () { return { data: [] }; });
547      if (request !== state.connectionRequest || account !== state.account) return;
548      if (els.connApprovals) {
549        els.connApprovals.innerHTML = '';
550        (approvals.data || (Array.isArray(approvals) ? approvals : [])).forEach(function (approval) { els.connApprovals.appendChild(Ui.approvalCard(approval)); });
551      }
552    } catch (_) {
553      if (request !== state.connectionRequest || account !== state.account) return;
554      showLoadError(els.connList, t('droagent.connections_failed', 'Could not load connected services.'), loadConnections);
555    }
556  }
557
558  function showLoadError(container, message, retry) {
559    if (!container) return;
560    container.innerHTML = '';
561    var notice = document.createElement('p');
562    notice.className = 'da-error'; notice.setAttribute('role', 'status'); notice.textContent = message;
563    var button = document.createElement('button');
564    button.type = 'button'; button.className = 'da-btn';
565    button.textContent = t('droagent.retry', 'Retry');
566    button.addEventListener('click', retry);
567    container.appendChild(notice); container.appendChild(button);
568  }
569
570  function renderConnections() {
571    var connected = state.connectors.filter(function (c) { return c.connected; });
572    if (els.connCount) { els.connCount.hidden = !connected.length; els.connCount.textContent = String(connected.length); }
573    if (!els.connList) return;
574    var query = (els.connSearch && els.connSearch.value || '').trim().toLocaleLowerCase();
575    var filter = state.connectionFilter;
576    document.querySelectorAll('[data-conn-filter]').forEach(function (button) {
577      button.setAttribute('aria-pressed', String(button.getAttribute('data-conn-filter') === filter));
578    });
579    var connectors = state.connectors.filter(function (c) {
580      if (filter === 'ready' && !c.configured && !c.connected) return false;
581      if (filter === 'connected' && !c.connected) return false;
582      if (filter === 'design' && c.category !== 'design') return false;
583      return !query || [c.name, c.vendor, c.blurb, c.label].concat(c.aliases || []).join(' ').toLocaleLowerCase().indexOf(query) !== -1;
584    }).sort(function (a, b) { return Number(b.connected) - Number(a.connected) || Number(b.configured) - Number(a.configured); });
585    if (els.connResult) els.connResult.textContent = connectors.length + ' ' + t('droagent.services_shown', 'services shown');
586    els.connList.innerHTML = '';
587    if (!connectors.length) {
588      var empty = document.createElement('p'); empty.className = 'da-conn-empty';
589      empty.textContent = query ? t('droagent.connections_no_match', 'No matching connections. Try another search or All services.')
590        : filter === 'connected' ? t('droagent.connections_no_connected', 'Your connected tools will appear here. Choose a service to get started.')
591        : t('droagent.connections_none', 'No services are ready yet. You can add your own MCP server.');
592      els.connList.appendChild(empty); return;
593    }
594    connectors.forEach(function (c) {
595      var row = document.createElement('div');
596      row.className = 'da-conn' + (c.connected ? ' is-on' : '');
597      row.innerHTML = '<span class="da-conn-ic" aria-hidden="true"></span>'
598        + '<span class="da-conn-body"><b class="da-conn-name"></b><span class="da-conn-blurb"></span><span class="da-conn-state"></span></span>'
599        + '<span class="da-conn-actions"><button type="button" class="da-btn da-conn-btn"></button></span>';
600      row.querySelector('.da-conn-ic').innerHTML = window.DP_DroAgentLogos.svg(c.id);
601      row.querySelector('.da-conn-name').textContent = c.name;
602      row.querySelector('.da-conn-blurb').textContent = c.blurb || '';
603      row.querySelector('.da-conn-state').textContent = c.connected
604        ? t('droagent.connections_connected', 'Connected') + (c.label ? ' · ' + c.label : '')
605        : !c.configured ? c.setupNote || t('droagent.connection_needs_setup', 'Requires setup')
606        : c.auth === 'token' ? t('droagent.connection_api_key', 'API key') : t('droagent.connection_signin', 'Sign in to connect');
607      var btn = row.querySelector('.da-conn-btn');
608      btn.textContent = c.connected ? t('droagent.disconnect', 'Disconnect')
609        : !c.configured || c.custom ? t('droagent.set_up', 'Set up')
610        : c.auth === 'token' ? t('droagent.add_key', 'Add key') : t('droagent.connect', 'Connect');
611      btn.setAttribute('aria-label', btn.textContent + ' · ' + c.name);
612      btn.addEventListener('click', function () {
613        if (c.connected) { toggleConnection(c, btn); return; }
614        if (!c.configured && c.setupAction === 'guide' && c.docsUrl) { window.open(c.docsUrl, '_blank', 'noopener,noreferrer'); return; }
615        if (!c.configured || c.custom) { showCustomConnection(c); return; }
616        if (c.auth === 'token') { askForToken(c, row, btn); return; }
617        toggleConnection(c, btn);
618      });
619      if (c.connected) {
620        var preference = document.createElement('button'); preference.type = 'button'; preference.className = 'da-btn da-approval-pref';
621        var always = c.approvalMode === 'always';
622        preference.textContent = always ? 'Always approve ✓' : 'Ask before actions';
623        preference.setAttribute('aria-pressed', String(always));
624        preference.setAttribute('aria-label', 'Always approve · ' + c.name);
625        preference.addEventListener('click', async function () {
626          preference.disabled = true;
627          try { await Api.approvalMode(c.id, always ? 'ask' : 'always'); await loadConnections(); }
628          catch (err) { toast(err.message, { type: 'error' }); preference.disabled = false; }
629        });
630        row.querySelector('.da-conn-actions').appendChild(preference);
631      }
632      els.connList.appendChild(row);
633    });
634  }
635
636  function closeConnections() {
637    els.connSheet.hidden = true;
638    var form = $('da-conn-custom');
639    if (form) form.reset();
640    els.connSheet.querySelectorAll('input[type="password"]').forEach(function (input) { input.value = ''; });
641    if (els.connect) els.connect.focus();
642  }
643
644  function browseConnections() {
645    $('da-conn-custom').hidden = true;
646    $('da-conn-toolbar').hidden = false;
647    $('da-conn-title').textContent = t('droagent.connections_title', 'Connect your tools');
648    $('da-conn-browse').hidden = false;
649    $('da-conn-custom-open').setAttribute('aria-expanded', 'false');
650    els.connSearch.disabled = false;
651    $('da-conn-token').value = '';
652    els.connSearch.focus();
653  }
654
655  function showCustomConnection(connector) {
656    var form = $('da-conn-custom');
657    form.reset(); form.hidden = false;
658    $('da-conn-toolbar').hidden = true;
659    $('da-conn-title').textContent = t('droagent.custom_title', 'Add an MCP server');
660    $('da-conn-browse').hidden = true;
661    $('da-conn-custom-open').setAttribute('aria-expanded', 'true');
662    els.connSearch.disabled = true;
663    $('da-conn-name').value = connector ? connector.name : '';
664    $('da-conn-custom-hint').textContent = connector && !connector.custom
665      ? t('droagent.custom_provider_hint', 'Direct sign-in for this service needs setup. Connect a hosted MCP server for it using the URL from your provider.')
666      : t('droagent.custom_sub', 'Use the hosted MCP URL from your provider. Add a token only if the server requires one.');
667    var guide = $('da-conn-custom-guide');
668    guide.hidden = !connector || !connector.docsUrl;
669    if (!guide.hidden) guide.href = connector.docsUrl;
670    $('da-conn-custom-error').hidden = true;
671    (connector ? $('da-conn-url') : $('da-conn-name')).focus();
672  }
673
674  function askForToken(c, row, opener) {
675    var existing = row.querySelector('.da-conn-token');
676    if (existing) { existing.querySelector('input').focus(); return; }
677    var form = document.createElement('form'); form.className = 'da-conn-token';
678    form.innerHTML = '<label></label><input type="password" autocomplete="off" spellcheck="false" maxlength="500" required>'
679      + '<button type="submit" class="da-btn da-btn-primary"></button><button type="button" class="da-btn"></button>'
680      + '<a class="da-conn-where" target="_blank" rel="noopener noreferrer"></a><p class="da-conn-feedback" role="status" hidden></p>';
681    var input = form.querySelector('input'), submit = form.querySelector('button[type="submit"]');
682    input.id = 'da-provider-key-' + c.id;
683    input.placeholder = t('droagent.token_ph', 'Paste your API token');
684    var label = form.querySelector('label'); label.htmlFor = input.id;
685    label.textContent = c.name + ' · ' + t('droagent.connection_api_key', 'API key');
686    submit.textContent = t('droagent.connect', 'Connect');
687    var cancel = form.querySelector('button[type="button"]');
688    cancel.textContent = t('droagent.cancel', 'Cancel');
689    cancel.addEventListener('click', function () { input.value = ''; form.remove(); opener.focus(); });
690    var where = form.querySelector('.da-conn-where');
691    if (c.tokenDocsUrl) { where.href = c.tokenDocsUrl; where.textContent = t('droagent.token_where', 'Where do I get one?'); }
692    else where.remove();
693    form.addEventListener('submit', async function (e) {
694      e.preventDefault(); if (submit.disabled || !input.value.trim()) return;
695      var account = state.account, feedback = form.querySelector('.da-conn-feedback');
696      submit.disabled = true; feedback.hidden = true;
697      try {
698        await Api.connectToken(c.id, input.value.trim());
699        input.value = '';
700        if (account !== state.account) return;
701        toast(t('droagent.connected', 'Connected.'), { type: 'success' });
702        await loadConnections();
703      } catch (err) {
704        if (account !== state.account) return;
705        feedback.textContent = (err && err.message) || t('droagent.connect_failed', 'Could not start that connection.');
706        feedback.hidden = false;
707      } finally { submit.disabled = false; }
708    });
709    row.appendChild(form); input.focus();
710  }
711
712  async function toggleConnection(connector, btn) {
713    if (btn.disabled) return;
714    var account = state.account, previousLabel = btn.textContent, popup = null;
715    btn.disabled = true;
716    btn.textContent = t('droagent.connection_working', 'Connecting…');
717    try {
718      if (connector.connected) {
719        await Api.disconnect(connector.id);
720        if (account !== state.account) return;
721        toast(t('droagent.disconnected', 'Disconnected.'), { type: 'info' });
722        await loadConnections(); return;
723      }
724      saveDraft();
725      // Reserve the popup inside the actual click, before awaiting the API.
726      // Safari otherwise blocks it and unexpectedly navigates away from chat.
727      popup = window.open('about:blank', 'dro-agent-connect', 'width=520,height=680');
728      state.connectionPopup = popup;
729      try { if (popup) { popup.document.title = 'droPrompt'; popup.document.body.textContent = t('droagent.connection_working', 'Connecting…'); } } catch (_) { /* a reused popup may still be navigating */ }
730      var data = await Api.connectStart(connector.id);
731      if (account !== state.account) { if (popup) popup.close(); return; }
732      var url = new URL((data && (data.url || (data.data && data.data.url))) || '');
733      if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Invalid connection URL.');
734      if (!popup) { location.href = url.href; return; }
735      if (popup.closed) return;
736      popup.location.replace(url.href);
737    } catch (err) {
738      if (popup) popup.close();
739      if (state.connectionPopup === popup) state.connectionPopup = null;
740      if (account === state.account) toast((err && err.message) || t('droagent.connect_failed', 'Could not start that connection.'), { type: 'error' });
741    } finally { btn.disabled = false; btn.textContent = previousLabel; }
742  }
743
744  async function addCustomServer(e) {
745    e.preventDefault();
746    var btn = $('da-conn-add'), feedback = $('da-conn-custom-error');
747    var label = ($('da-conn-name').value || '').trim();
748    var url = ($('da-conn-url').value || '').trim();
749    var tok = ($('da-conn-token').value || '').trim();
750    if (!label || !url || btn.disabled) return;
751    var account = state.account;
752    btn.disabled = true; feedback.hidden = true;
753    try {
754      await Api.connectCustom({ label: label, serverUrl: url, token: tok });
755      if (account !== state.account) return;
756      $('da-conn-custom').reset();
757      state.connectionFilter = 'connected'; els.connSearch.value = ''; browseConnections();
758      toast(t('droagent.connected', 'Connected.'), { type: 'success' });
759      await loadConnections();
760    } catch (err) {
761      if (account !== state.account) return;
762      feedback.textContent = (err && err.message) || t('droagent.connect_failed', 'Could not start that connection.'); feedback.hidden = false;
763    } finally { btn.disabled = false; }
764  }
765
766  // ── memory ────────────────────────────────────────────────────────────
767  var MEM_KIND = { profile: 'droagent.mem_profile', preference: 'droagent.mem_pref', project: 'droagent.mem_project', style: 'droagent.mem_style' };
768  var MEM_KIND_FB = { profile: 'About you', preference: 'How you like answers', project: 'What you are working on', style: 'How you write' };
769
770  async function openMemory() {
771    if (!Api.isSignedIn()) { if (window.dpOpenAuth) window.dpOpenAuth('login'); return; }
772    var request = ++state.memoryRequest;
773    var account = state.account;
774    els.memSheet.hidden = false;
775    renderMemory(null);
776    try {
777      var data = await Api.memory();
778      if (request !== state.memoryRequest || account !== state.account) return;
779      renderMemory((data && (data.memories || (data.data && data.data.memories))) || []);
780    } catch (_) {
781      if (request !== state.memoryRequest || account !== state.account) return;
782      showLoadError(els.memList, t('droagent.memory_failed', 'Could not load memory. Your saved memory has not changed.'), openMemory);
783    }
784  }
785
786  /** @param {Array|null} rows null renders the loading line. */
787  function renderMemory(rows) {
788    if (!els.memList) return;
789    els.memList.innerHTML = '';
790    if (els.memClear) els.memClear.hidden = !rows || !rows.length;
791    if (rows === null) {
792      els.memList.appendChild(Object.assign(document.createElement('p'), {
793        className: 'da-mem-empty', textContent: t('droagent.memory_loading', 'Reading…'),
794      }));
795      return;
796    }
797    if (els.memClear) els.memClear.hidden = !rows.length;
798    if (!rows.length) {
799      els.memList.appendChild(Object.assign(document.createElement('p'), {
800        className: 'da-mem-empty',
801        textContent: t('droagent.memory_none', 'Nothing yet — it starts remembering once you have talked for a bit.'),
802      }));
803      return;
804    }
805    rows.forEach(function (m) {
806      var row = document.createElement('div');
807      row.className = 'da-mem';
808      row.innerHTML = '<span class="da-mem-body"><span class="da-mem-kind"></span><span class="da-mem-value" data-no-translate translate="no" dir="auto"></span></span>'
809        + '<button type="button" class="da-act da-mem-drop"></button>';
810      row.querySelector('.da-mem-kind').textContent = t(MEM_KIND[m.kind] || MEM_KIND.profile, MEM_KIND_FB[m.kind] || MEM_KIND_FB.profile);
811      row.querySelector('.da-mem-value').textContent = m.value;
812      var drop = row.querySelector('.da-mem-drop');
813      drop.textContent = t('droagent.forget', 'Forget');
814      drop.addEventListener('click', function () { forget(m.key, row); });
815      els.memList.appendChild(row);
816    });
817  }
818
819  async function forget(key, row) {
820    var account = state.account;
821    if (!key) {
822      var ok = window.dpConfirm
823        ? await window.dpConfirm({ title: t('droagent.memory_clear', 'Forget everything'), message: t('droagent.memory_clear_sub', 'droAgent will start from nothing next time you write.'), confirmText: t('droagent.forget', 'Forget'), danger: true })
824        : window.confirm('Forget everything?');
825      if (!ok || account !== state.account) return;
826    }
827    if (account !== state.account) return;
828    try {
829      await Api.forget(key);
830      if (account !== state.account) return;
831      if (key && row) await openMemory();
832      else renderMemory([]);
833      toast(t('droagent.forgotten', 'Forgotten.'), { type: 'info' });
834    } catch (err) {
835      if (account !== state.account) return;
836      toast((err && err.message) || t('error.generic', 'Something went wrong. Try again.'), { type: 'error' });
837    }
838  }
839
840  function focusComposer() {
841    if ((els.connSheet && !els.connSheet.hidden) || (els.memSheet && !els.memSheet.hidden)) return;
842    els.input.focus({ preventScroll: true });
843  }
844
845  function openConnections() {
846    if (!Api.isSignedIn()) { if (window.dpOpenAuth) window.dpOpenAuth('login'); return; }
847    loadConnections();
848    els.connSheet.hidden = false;
849    browseConnections();
850  }
851
852
853  // ── conversations rail ────────────────────────────────────────────────
854  function isMobile() { return window.matchMedia('(max-width: 820px)').matches; }
855
856  function syncShellHeight() {
857    if (!els.main || !els.main.getBoundingClientRect) return;
858    var viewport = window.visualViewport;
859    var top = Math.max(0, els.main.getBoundingClientRect().top - (viewport ? viewport.offsetTop : 0));
860    els.main.style.setProperty('--da-chrome-height', Math.ceil(top) + 'px');
861    if (viewport) els.main.style.setProperty('--da-viewport-height', Math.floor(viewport.height) + 'px');
862  }
863
864  function syncSidebar() {
865    var open = isMobile() ? els.main.classList.contains('is-drawer-open') : !els.main.classList.contains('is-rail-hidden');
866    if (!open && els.sidebar.contains(document.activeElement) && els.menu) els.menu.focus();
867    els.sidebar.inert = !open;
868    if (els.chat) els.chat.inert = isMobile() && open;
869    els.sidebar.setAttribute('aria-hidden', String(!open));
870    if (els.menu) els.menu.setAttribute('aria-expanded', String(open));
871    if (els.scrim) els.scrim.hidden = !isMobile() || !open;
872  }
873
874  /** Show or hide the rail on desktop; the drawer covers the mobile case. */
875  function setRail(show) {
876    els.main.classList.toggle('is-rail-hidden', !show);
877    if (els.sideHide) els.sideHide.setAttribute('aria-expanded', String(!!show));
878    syncSidebar();
879    try { localStorage.setItem(RAIL_KEY, show ? 'shown' : 'hidden'); } catch (_) {}
880  }
881
882  function openDrawer() {
883    els.main.classList.add('is-drawer-open');
884    syncSidebar();
885    if (els.sideClose) els.sideClose.focus();
886  }
887  function closeDrawer() {
888    if (!els.main.classList.contains('is-drawer-open')) return;
889    els.main.classList.remove('is-drawer-open');
890    syncSidebar();
891    if (els.menu) els.menu.focus();
892  }
893
894  // ── recording ─────────────────────────────────────────────────────────
895  function toggleRecording() {
896    if (state.sending || state.loading) return;
897    if (Files.isRecording()) Files.stopRecording();
898    else Files.startRecording();
899  }
900  function onRecording(info) {
901    els.record.classList.toggle('is-rec', !!info.recording);
902    els.record.setAttribute('aria-pressed', String(!!info.recording));
903    els.record.setAttribute('aria-label', info.recording ? t('droagent.stop', 'Stop recording') : t('droagent.record', 'Record a voice message'));
904    if (els.recChip) {
905      els.recChip.hidden = !info.recording;
906      if (els.recTime) {
907        var s = info.seconds || 0;
908        els.recTime.textContent = Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
909      }
910    }
911  }
912
913  // ── conversations ─────────────────────────────────────────────────────
914  function renderConversationList() {
915    syncConversationTools();
916    var query = els.search ? els.search.value.trim().toLocaleLowerCase() : '';
917    var list = state.conversations.filter(function (c) { return !query || String(c.title || '').toLocaleLowerCase().indexOf(query) !== -1; });
918    Ui.renderConversations(list, state.conversationId, {
919      onOpen: openConversation, onDelete: deleteConversation, onRename: renameConversation,
920      emptyText: query ? t('droagent.no_matches', 'No matching chats.') : null,
921    });
922  }
923  async function loadConversations() {
924    var request = ++state.historyRequest;
925    var account = state.account;
926    if (!state.conversations.length) Ui.renderConversations([], state.conversationId, { emptyText: t('droagent.history_loading', 'Loading chats…') });
927    try {
928      var data = await Api.conversations();
929      if (request !== state.historyRequest || account !== state.account) return;
930      state.conversations = (data && data.conversations) || [];
931      renderConversationList();
932    } catch (err) {
933      if (request !== state.historyRequest || account !== state.account) return;
934      if (err.needsAuth) { onAuthChange(); els.gate.hidden = false; }
935      showLoadError(els.list, t('droagent.history_failed', 'Could not load your chats. Your saved chats have not changed.'), loadConversations);
936    }
937  }
938
939  async function openConversation(c, prefill) {
940    if (state.sending) return;
941    saveDraft();
942    var importCompanion = !state.draftReady && new URLSearchParams(location.search).get('conversation') === String(c.id
942);
943    var request = ++state.selection;
944    var account = state.account;
945    state.loading = true;
946    if (Files.setLocked) Files.setLocked(true);
947    syncComposer(); closeDrawer();
948    try {
949      var data = await Api.conversation(c.id);
950      if (request !== state.selection || account !== state.account) return;
951      persistConversation(c.id);
952      state.history = data.messages || [];
953      Ui.renderHistory(state.history);
954      Files.clear(); failedViews = null;
955      restoreDraft();
956      // The floating companion uses the same account identity. Import only
957      // when the expanded chat has no draft of its own.
958      if (!els.input.value && !state.pending && importCompanion) {
959        try {
960          var companionKey = 'dp_companion_draft:' + state.account;
961          var companionDraft = sessionStorage.getItem(companionKey);
962          if (companionDraft) {
963            els.input.value = companionDraft.slice(0, 8000); saveDraft();
964            if (sessionStorage.getItem(draftKey(state.conversationId))) sessionStorage.removeItem(companionKey);
965            autosize();
966          }
967        } catch (_) {}
968      }
969      if (prefill) { els.input.value = prefill.slice(0, 8000); saveDraft(); autosize(); }
970      if (state.pending) {
971        if (savedReply(state.history, state.pending)) clearPendingDraft();
972        else if (settledPending(state.pending)) dropSettledPending();
973        else showPendingRecovery();
974      }
975      renderConversationList();
976    } catch (err) {
977      if (request !== state.selection || account !== state.account) return;
978      if (!state.draftReady) {
979        var typed = els.input.value;
980        persistConversation(null); restoreDraft();
981        if (typed) { els.input.value = typed; saveDraft(); autosize(); }
982      }
983      toast(t('droagent.chat_open_failed', 'Could not open that chat. Please try again.'), { type: 'error' });
984    } finally {
985      if (request === state.selection && account === state.account) {
986        state.loading = false;
987        if (Files.setLocked) Files.setLocked(false);
988        syncComposer(); focusComposer();
989      }
990    }
991  }
992
993  async function deleteConversation(c) {
994    if (state.sending) return;
995    var account = state.account;
996    var ok = window.dpConfirm
997      ? await window.dpConfirm({ title: t('droagent.delete_title', 'Delete this chat?'), message: c.title || '', confirmText: t('droagent.delete', 'Delete'), danger: true })
998      : window.confirm('Delete this chat?');
999    if (!ok || account !== state.account) return;
1000    try {
1001      await Api.remove(c.id);
1002      if (account !== state.account) return;
1003      state.selection++; state.loading = false;
1004      if (Files.setLocked) Files.setLocked(false);
1005      syncComposer();
1006      if (String(state.conversationId) === String(c.id)) newChat();
1007      await loadConversations();
1008    } catch (err) { if (account !== state.account) return; toast(err.message || 'Could not delete.', { type: 'error' }); }
1009  }
1010
1011  async function renameConversation(c) {
1012    if (state.sending) return;
1013    var account = state.account;
1014    var title = window.prompt(t('droagent.rename', 'Rename chat'), c.title || '');
1015    if (title == null || account !== state.account) return;
1016    title = title.trim();
1017    if (!title) return;
1018    try {
1019      await Api.rename(c.id, title);
1020      if (account !== state.account) return;
1021      await loadConversations();
1022    } catch (err) { if (account !== state.account) return; toast(err.message || 'Could not rename.', { type: 'error' }); }
1023  }
1024
1025  function newChat() {
1026    if (state.sending) return;
1027    saveDraft();
1028    state.selection++; state.loading = false;
1029    persistConversation(null);
1030    state.history = []; failedViews = null;
1031    Ui.clear(); Files.clear();
1032    if (Files.setLocked) Files.setLocked(false);
1033    restoreDraft();
1034    if (state.pending) showPendingRecovery();
1035    renderConversationList(); focusComposer();
1036  }
1037
1038  /** True once the server has certainly finished with a turn, answered or not. */
1039  function settledPending(pending) {
1040    return Date.now() - ((pending && pending.at) || 0) >= STALE_PENDING_MS;
1041  }
1042
1043  /**
1044   * The turn is old, its chat has been read, and no reply was ever saved: it
1045   * produced nothing, so the notice has nothing left to warn about. The
1046   * request goes back to the composer instead of a red card that returns on
1047   * every visit.
1048   */
1049  function dropSettledPending() {
1050    if (state.pending && state.pending.text && !els.input.value.trim()) els.input.value = state.pending.text;
1051    state.pending = null; state.unresolved = false;
1052    if (failedViews) {
1053      if (failedViews.user) failedViews.user.remove();
1054      failedViews.bot.remove(); failedViews = null;
1055    }
1056    saveDraft(); autosize(); syncComposer();
1057  }
1058
1059  function clearPendingDraft() {
1060    var pending = state.pending;
1061    var names = Files.list().map(function (a) { return a.name; }).join('|');
1062    // The composer emptied when the turn went out, so anything in it now was
1063    // typed or attached since: that is a new draft, and it stays.
1064    var edited = !!els.input.value.trim() || !!(names && names !== ((pending && pending.fileNames) || []).join('|'));
1065    state.pending = null; state.unresolved = false;
1066    if (!edited) { els.input.value = ''; Files.clear(); }
1067    saveDraft(); autosize(); syncComposer();
1068  }
1069
1070  function savedReply(messages, pending) {
1071    var before = (pending && pending.beforeIds) || [];
1072    for (var i = 0; i < messages.length - 1; i++) {
1073      var user = messages[i];
1074      var assistant = messages[i + 1];
1075      if (user.role !== 'user' || assistant.role !== 'assistant' || !assistant.content) continue;
1076      if (before.indexOf(String(user.id)) !== -1 || before.indexOf(String(assistant.id)) !== -1) continue;
1077      if (pending.text && user.content === pending.text) return true;
1078      if (!pending.text && pending.fileNames && pending.fileNames.length && Array.isArray(user.attachments)
1079          && pending.fileNames.join('|') === user.attachments.map(function (a) { return a.name; }).join('|')) return true;
1080    }
1081    return false;
1082  }
1083
1084  /**
1085   * The chat a dropped turn landed in when the page never learned its id: the
1086   * server creates the conversation before it answers, so the newest one
1087   * touched since the request went out is that turn's.
1088   */
1089  function conversationSince(pending) {
1090    var sent = (pending && pending.at) || 0;
1091    for (var i = 0; i < state.conversations.length; i++) {   // newest first
1092      var touched = Date.parse(state.conversations[i].last_message_at || state.conversations[i].updated_at || '');
1093      if (touched && touched >= sent - 60000) return state.conversations[i];
1094    }
1095    return null;
1096  }
1097
1098  async function checkSavedReply(options) {
1099    var quiet = !!(options && options.quiet);
1100    var missing = function () {
1101      if (settledPending(state.pending)) return dropSettledPending();
1102      if (!quiet) toast(t('droagent.no_saved_reply', 'No completed reply is saved yet. Check again shortly, or edit your request to send a new message.'), { type: 'info', duration: 7000 });
1103    };
1104    var account = state.account;
1105    var pending = state.pending;
1106    if (!pending) return;
1107    if (!state.conversationId) {
1108      await loadConversations();
1109      if (account !== state.account || pending !== state.pending) return;
1110      var landed = conversationSince(pending);
1111      if (!landed) { missing(); return; }
1112      try {
1113        var found = await Api.conversation(landed.id);
1114        if (account !== state.account || pending !== state.pending || state.conversationId) return;
1115        if (!savedReply(found.messages || [], pending)) { missing(); return; }
1116        // Clear before adopting the chat: the notice is stored under the key
1117        // this turn was sent with, and that key is about to change.
1118        clearPendingDraft();
1119        persistConversation(landed.id);
1120        state.history = found.messages || [];
1121        Ui.renderHistory(state.history); failedViews = null;
1122        syncCredits({}); loadConversations();
1123      } catch (_) {
1124        if (!quiet) toast(t('droagent.chat_open_failed', 'Could not open that chat. Please try again.'), { type: 'error' });
1125      }
1126      return;
1127    }
1128    var id = state.conversationId;
1129    try {
1130      var data = await Api.conversation(id);
1131      if (account !== state.account || id !== state.conversationId || pending !== state.pending) return;
1132      if (savedReply(data.messages || [], pending)) {
1133        state.history = data.messages || [];
1134        Ui.renderHistory(state.history); failedViews = null;
1135        clearPendingDraft(); syncCredits({}); loadConversations();
1136      } else {
1137        missing();
1138      }
1139    } catch (_) { toast(t('droagent.chat_open_failed', 'Could not open that chat. Please try again.'), { type: 'error' }); }
1140  }
1141
1142  function editPendingRequest() {
1143    if (state.sending) return;
1144    if (state.pending && state.pending.text && !els.input.value.trim()) els.input.value = state.pending.text;
1145    state.unresolved = false; state.pending = null;
1146    autosize();
1147    if (failedViews) {
1148      if (failedViews.user) failedViews.user.remove();
1149      failedViews.bot.remove(); failedViews = null;
1150    }
1151    saveDraft(); syncComposer(); focusComposer();
1152  }
1153
1154  function recoveryActions(ambiguous) {
1155    var actions = [];
1156    if (ambiguous) actions.push({ label: t('droagent.check_saved', 'Check saved chat'), onClick: checkSavedReply });
1157    else actions.push({ label: t('droagent.retry', 'Retry'), onClick: function () { editPendingRequest(); send(); } });
1158    actions.push({ label: t('droagent.edit_request', 'Edit request'), onClick: editPendingRequest });
1159    return actions;
1160  }
1161
1162  function showPendingRecovery() {
1163    var bot = Ui.beginAssistant();
1164    bot.fail(t('droagent.dropped', 'The connection ended before completion. The reply may still be saved; check the chat before sending again.'), { actions: recoveryActions(true) });
1165    failedViews = { bot: bot, user: null };
1166  }
1167
1168  // ── one turn ──────────────────────────────────────────────────────────
1169  async function send(trialSource, sample, trial) {
1170    // DOM event listeners pass an Event; only an explicit output starts Try.
1171    if (!trialSource || typeof trialSource.content !== 'string') trialSource = null;
1172    if (state.sending || state.loading || state.unresolved) return;
1173    if (!Api.isSignedIn()) {
1174      toast(t('droagent.signin_toast', 'Sign in to chat with droAgent.'), { type: 'info' });
1175      if (window.dpOpenAuth) window.dpOpenAuth('login');
1176      return;
1177    }
1178    if (Files.isRecording()) { Files.stopRecording(); return; }
1179    if (Files.busy()) { toast(t('droagent.wait_files', 'Your files are still being prepared…'), { type: 'info' }); return; }
1180    var text = trialSource ? (lang() === 'ar' ? 'جرّب: ' : 'Try: ') + trialSource.title + (sample ? '\n\n' + sample : '') : els.input.value.trim();
1181    var attachments = trialSource ? [] : Files.list();
1182    if (!text && !attachments.length) return;
1183    if (failedViews) { if (failedViews.user) failedViews.user.remove(); failedViews.bot.remove(); failedViews = null; }
1184
1185    var account = state.account;
1186    var controller = new AbortController();
1187    state.controller = controller;
1188    state.sending = true;
1189    state.pending = { text: text, trial: !!trialSource, at: Date.now(), fileNames: attachments.map(function (a) { return a.name; }), beforeIds: state.history.map(function (m) { return String(m.id); }) };
1190    saveDraft();
1191    if (Files.setLocked) Files.setLocked(true);
1192    closeEffort(false); syncComposer();
1193    var previews = attachments.map(function (a) {
1194      return { name: a.name, kind: a.kind, previewUrl: a.kind === 'image' ? ('data:' + a.mime + ';base64,' + a.data) : '' };
1195    });
1196    var userView = Ui.addUser({ content: text, attachments: previews });
1197    var body = {
1198      conversationId: state.conversationId,
1199      message: text,
1200      taskId: !trialSource && state.refinement && text.includes(state.refinement.lead) ? state.refinement.id : undefined,
1201      intent: trialSource ? 'try' : 'chat',
1202      trial: trialSource ? trial : undefined,
1203      context: trialSource ? { builder: trialSource.kind, output: trialSource.content, idea: '' } : undefined,
1204      attachments: attachments.map(function (a) { return { name: a.name, mime: a.mime, data: a.data }; }),
1205      locale: lang(), page: pageFromReferrer(), effort: trialSource ? 'balanced' : state.effort,
1206    };
1207    // The composer empties as the message leaves, the way every chat composer
1208    // does. The text is kept on the pending record, so a turn that never lands
1209    // can still be put back for editing or a retry.
1210    if (!trialSource) { els.input.value = ''; autosize(); saveDraft(); }
1211    var bot = Ui.beginAssistant();
1212    var finished = false;
1213    var suggestions = null;
1214    function current() { return account === state.account && state.controller === controller; }
1215
1216    function onEvent(type, data) {
1217      if (!current() || finished) return;
1218      data = data || {};
1219      switch (type) {
1220        case 'status': bot.setStatus(data.stage); break;
1221        case 'conversation':
1222          if (data.id) persistConversation(data.id);
1223          if (data.isNew) loadConversations();
1224          break;
1225        case 'perception': userView.setPerception(data.attachments); break;
1226        case 'delta': bot.append(data.text); break;
1227        case 'replace': bot.replace(data.text); break;
1228        case 'artifact': bot.addArtifact(data); break;
1229        case 'tool_start': bot.setToolProgress(data); break;
1230        case 'tool': bot.addToolCall(data); break;
1231        case 'suggestions': suggestions = data.features || []; bot.setSuggestions(suggestions); break;
1232        case 'done': finished = true; complete(data.data || {}, data); break;
1233        case 'error': finished = true; failTurn(data, false); break;
1234        default: break;
1235      }
1236    }
1237    function complete(result, envelope) {
1238      bot.finish({ id: result.assistantMessageId, content: result.reply, toolCalls: result.toolCalls || [], suggestions: result.suggestions || suggestions, tasks: result.tasks || [] });
1239      if (result.conversationId) persistConversation(result.conversationId);
1240      if (result.messageId) state.history.push({ id: result.messageId, role: 'user', content: text });
1241      if (result.assistantMessageId) state.history.push({ id: result.assistantMessageId, role: 'assistant', content: result.reply, toolCalls: result.toolCalls || [], tasks: result.tasks || [] });
1242      clearPendingDraft();
1243      syncCredits(envelope || result); loadConversations();
1244    }
1245    function failTurn(err, ambiguous) {
1246      var code = err && err.code;
1247      var message = (err && (err.error || err.message)) || t('error.generic', 'Something went wrong. Try again.');
1248      if (ambiguous) {
1249        if (code === 'STREAM_ABORTED') message = t('droagent.stopped', 'Stopped receiving. The reply may still finish and use credits. Check saved chat before sending again.');
1250        else if (code === 'STREAM_TIMEOUT') message = t('droagent.timeout', 'The reply timed out. It may still finish and use credits. Check saved chat before sending again.');
1251        else message = t('droagent.dropped', 'The connection ended before completion. The reply may still be saved; check the chat before sending again.');
1252      }
1253      state.unresolved = ambiguous;
1254      if (!ambiguous) state.pending = null;
1255      saveDraft();
1256      failedViews = { user: userView, bot: bot };
1257      var actions = recoveryActions(ambiguous);
1258      if (trialSource && !ambiguous) actions = [{ label: lang() === 'ar' ? 'مراجعة التجربة' : 'Review trial', onClick: function () { editPendingRequest(); window.DP_OutputTry.open(trialSource).catch(window.DP_OutputTry.report); } }];
1259      bot.fail(message, { actions: actions, cancelled: code === 'STREAM_ABORTED' });
1260      if (code === 'PLAN_FEATURE_NOT_INCLUDED' || code === 'FREE_DAILY_CAP_REACHED' || code === 'DAILY_LIMIT') {
1261        toast(message, { type: 'warning', duration: 6000, action: { label: t('nav.upgrade', 'Upgrade'), href: '/pricing' } });
1262      } else if (err && (err.needsAuth || err.status === 401 || code === 'AUTH_REQUIRED')) {
1263        els.gate.hidden = false;
1264        if (window.dpOpenAuth) window.dpOpenAuth('login');
1265      }
1266      syncCredits({}); loadConversations();
1267    }
1268    try {
1269      await Api.chatStream(body, onEvent, { signal: controller.signal });
1270      if (!finished && current()) failTurn({ code: 'STREAM_INCOMPLETE' }, true);
1271    } catch (err) {
1272      if (!finished && current()) failTurn(err, !err.status || err.status >= 500);
1273    } finally {
1274      if (current()) {
1275        state.sending = false; state.controller = null;
1276        if (Files.setLocked) Files.setLocked(false);
1277        syncComposer(); focusComposer();
1278      }
1279    }
1280    return true;
1281  }
1282
1283  function syncCredits(payload) {
1284    try { if (window.dpSyncCreditStateFromResponse) window.dpSyncCreditStateFromResponse(payload); } catch (_) {}
1285    try {
1286      var user = window.dpCreditSystem && window.dpCreditSystem.readCachedCreditUser ? window.dpCreditSystem.readCachedCreditUser() : null;
1287      if (user && window.dpRenderCreditMeter) window.dpRenderCreditMeter(user);
1288    } catch (_) {}
1289    if (window.DP_HomeChatEmbed) document.dispatchEvent(new CustomEvent('dp:chat-usage'));
1290  }
1291
1292  function pageFromReferrer() {
1293    try {
1294      var ref = document.referrer ? new URL(document.referrer) : null;
1295      if (ref && ref.origin === location.origin && ref.pathname && ref.pathname !== location.pathname) return ref.pathname.slice(0, 120);
1296    } catch (_) {}
1297    return location.pathname;
1298  }
1299
1300
1301  function openFeature(f) {
1302    if (!f || !f.href) return;
1303    var url;
1304    try { url = new URL(f.href, location.origin); } catch (_) { return; }
1305    if (url.protocol !== 'https:' && url.protocol !== 'http:') return;
1306    if (url.origin !== location.origin) {
1307      if (f.external) window.open(url.href, '_blank', 'noopener,noreferrer');
1308      return;
1309    }
1310    var allowed = ['dro_prefill_prompt', 'dp:wf:stash', 'dp:da:handoff:agent', 'dp:da:handoff:skill', 'dp:da:handoff:automation'];
1311    var prefill = f.prefill;
1312    if (prefill) {
1313      var writes = [];
1314      var previous = [];
1315      try {
1316        if (prefill.storage !== 'session' || allowed.indexOf(prefill.key) === -1 || typeof prefill.value !== 'string') throw new Error('Invalid handoff');
1317        writes.push([prefill.key, prefill.value]);
1318        if (prefill.key === 'dro_prefill_prompt') {
1319          writes.push(['dro_prefill_mode', prefill.mode === 'refine' ? 'refine' : 'new']);
1320          writes.push(['dro_prefill_category', ['general', 'image', 'video'].indexOf(prefill.category) !== -1 ? prefill.category : 'general']);
1321          writes.push(['dro_prefill_source', 'dro-agent']);
1322        }
1323        previous = writes.map(function (entry) { return [entry[0], sessionStorage.getItem(entry[0])]; });
1324        writes.forEach(function (entry) {
1325          sessionStorage.setItem(entry[0], entry[1]);
1326          if (sessionStorage.getItem(entry[0]) !== entry[1]) throw new Error('Handoff was not saved');
1327        });
1328      } catch (_) {
1329        previous.forEach(function (entry) { try { if (entry[1] === null) sessionStorage.removeItem(entry[0]); else sessionStorage.setItem(entry[0], entry[1]); } catch (_) {} });
1330        toast(t('droagent.handoff_failed', 'Could not transfer the prompt. Copy it from this reply and paste it into the builder.'), { type: 'error', duration: 7000 });
1331        return;
1332      }
1333    }
1334
1335    saveDraft();
1336    var homeBuilder = url.pathname === '/' || url.pathname === '/index.html';
1337    if (homeBuilder && !url.hash) url.hash = 'builders';
1338    if (window.DP_HomeChatEmbed) {
1339      // This mode is enabled only for the same-origin homepage parent.
1340      if (homeBuilder && typeof window.parent.dpOpenHomePromptBuilder === 'function' &&
1341          window.parent.dpOpenHomePromptBuilder({ prefill: !!(prefill && prefill.key === 'dro_prefill_prompt'), category: url.searchParams.get('cat') })) return;
1342      window.parent.location.href = url.href;
1343      return;
1344    }
1345    location.href = url.href;
1346  }
1347
1348  window.DP_DroAgentApp = { init: init };
1349})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.