@oar-sh/oar 0.9.21 โ†’ 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  6. package/server/claude-worker/claude-session-process.mjs +9 -4
  7. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  9. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  10. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  11. package/server/db-schema.mjs +19 -0
  12. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  13. package/server/grok-worker/grok-session-worker.mjs +10 -1
  14. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  15. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  16. package/server/migrations/0038-turn-seq.mjs +92 -0
  17. package/server/public/app/activity-replay-state.mjs +160 -42
  18. package/server/public/app/api-client.js +7 -0
  19. package/server/public/app/ask-user-view.js +4 -3
  20. package/server/public/app/bootstrap.js +62 -4
  21. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  22. package/server/public/app/context-usage-view.mjs +68 -0
  23. package/server/public/app/conversation-view.js +306 -49
  24. package/server/public/app/header-priority-actions.mjs +5 -1
  25. package/server/public/app/held-details.mjs +202 -0
  26. package/server/public/app/inbox-ui.js +70 -79
  27. package/server/public/app/inbox-view.mjs +35 -19
  28. package/server/public/app/journal-view.js +17 -0
  29. package/server/public/app/live-activity-cap.mjs +103 -0
  30. package/server/public/app/questions-panel-ui.js +634 -0
  31. package/server/public/app/questions-stub-view.mjs +40 -0
  32. package/server/public/app/questions-view.mjs +89 -0
  33. package/server/public/app/relay-board-view.js +12 -2
  34. package/server/public/app/relay-thoughts.mjs +15 -1
  35. package/server/public/app/session-history-ui.js +1 -0
  36. package/server/public/app/settings-modal.js +6 -0
  37. package/server/public/app/socket-handlers.js +42 -11
  38. package/server/public/app/stream-view-preference.mjs +60 -0
  39. package/server/public/app/subagent-fold.mjs +6 -3
  40. package/server/public/app/subagent-timeline-view.mjs +12 -5
  41. package/server/public/app/subagent-viewer.mjs +8 -6
  42. package/server/public/app/thoughts-preference.mjs +79 -0
  43. package/server/public/app/turn-stream-view.mjs +124 -0
  44. package/server/public/index.html +391 -214
  45. package/server/repositories/message-repository.mjs +10 -0
  46. package/server/repositories/question-repository.mjs +51 -7
  47. package/server/repositories/relay-turn-rows.mjs +56 -0
  48. package/server/routes/ask-user-routes.mjs +34 -5
  49. package/server/routes/messages-routes.mjs +35 -14
  50. package/server/routes/relay-ask-routes.mjs +15 -5
  51. package/server/routes/sessions-routes.mjs +76 -5
  52. package/server/server-runtime.mjs +178 -131
  53. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  54. package/server/services/idle-timers.mjs +184 -0
  55. package/server/services/queue-wake-hook.mjs +145 -0
  56. package/server/services/queue-waker.mjs +127 -0
  57. package/server/services/relay-ask-service.mjs +64 -5
  58. package/server/services/relay-event-service.mjs +4 -1
  59. package/server/services/remote-relay-dispatcher.mjs +23 -4
  60. package/server/services/session-history-refresh-service.mjs +4 -0
  61. package/server/services/session-worker-supervisor-service.mjs +36 -0
  62. package/server/services/session-worker-websocket-service.mjs +143 -15
  63. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  64. package/shared/ask-user-bridge.mjs +9 -6
  65. package/shared/relay-asks.mjs +10 -0
  66. package/shared/relay-events.mjs +1 -0
  67. package/shared/worker-runtime/heartbeat.mjs +26 -0
  68. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  69. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
@@ -0,0 +1,634 @@
1
+ // ๐Ÿ““ Questions: the header icon with its count and the list that drops down
2
+ // under it, for the conversation on screen. questions-view.mjs orders and
3
+ // counts; inbox-view.mjs builds the cards; inbox-ui.js answers them.
4
+ //
5
+ // The list comes from GET /api/relay-asks?conversationId= when a
6
+ // conversation opens (again after a reconnect), and the socket's
7
+ // relay_ask_updated keeps it current: an ask that moved to another
8
+ // conversation (a compaction) leaves the old list and joins the new one.
9
+ // Asks on paired relays (mirrors) are never listed here.
10
+ //
11
+ // The list is a fixed panel on <body> (the recipe of the "watching" chip's
12
+ // list, relay-event-subscriptions-ui.js): under ๐Ÿ““, or under ๏ธ™ when ๐Ÿ““ went
13
+ // into the menu. On a phone it spans the width under the header over a dim
14
+ // backdrop.
15
+
16
+ import { apiFetch } from './api-client.js';
17
+ import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
18
+ import { HEADER_ACTIONS_CHANGE_EVENT, notifyHeaderActionsChanged } from './header-priority-actions.mjs';
19
+ import { buildAskCard } from './inbox-view.mjs';
20
+ import { bindInboxActionHost, captureDrafts, restoreDrafts, setInboxAskHooks } from './inbox-ui.js';
21
+ import { isOpeningGestureClick } from './tap-actions.js';
22
+ import { currentConvId, IS_SHARED_VIEW } from './store.js';
23
+ import { findTurnBubble } from './relay-board-view.js';
24
+ import { isStatusViewActive } from './status-view.mjs';
25
+ import { askStubHostKey, askStubKey, askStubText } from './questions-stub-view.mjs';
26
+ import {
27
+ questionsAria,
28
+ questionsBadge,
29
+ questionsOfConversation,
30
+ questionsPanelTitle,
31
+ } from './questions-view.mjs';
32
+
33
+ const PHONE_QUERY = '(max-width: 680px)';
34
+
35
+ // conversation id โ†’ Map(ask id โ†’ ask), for the conversation on screen.
36
+ const asksByConversation = new Map();
37
+ // The conversations whose list has come in at least once.
38
+ const loadedConversations = new Set();
39
+ let currentConversationId = '';
40
+ // One load per conversation at a time; `generation` drops a stale answer.
41
+ const loads = new Map();
42
+ // What the socket said while a load ran (conversation id โ†’ Map(ask id โ†’
43
+ // ask, or null: it left that conversation)): laid over the list that comes
44
+ // back, which the relay read before them.
45
+ const updatesDuringLoad = new Map();
46
+ let generation = 0;
47
+ let panelOpen = false;
48
+ let panelAnchor = null;
49
+ let panelOpenedAt = 0;
50
+ // The ask the panel was opened at: marked while it is open, scrolled into
51
+ // view once.
52
+ let focusAskId = '';
53
+ let scrollToFocus = false;
54
+ // Where the list last hung from: kept while it is open, so ๐Ÿ““ hiding (its
55
+ // last pending ask answered) does not throw the list over to ๏ธ™.
56
+ let lastAnchorRect = null;
57
+ let renderedKey = '';
58
+ // The Inbox's "open" on an ask: the panel opens at it once its conversation
59
+ // is on screen and its list is in.
60
+ let pendingOpen = null;
61
+ // Details closed by hand in the panel (they open by default there).
62
+ const foldedDetails = new Set();
63
+ let started = false;
64
+
65
+ function toText(value) {
66
+ return String(value ?? '').trim();
67
+ }
68
+
69
+ // A phone, upright or turned sideways: the list is a sheet under the header.
70
+ function isPhone() {
71
+ if (document.documentElement.hasAttribute('data-phone-landscape')) return true;
72
+ return typeof window.matchMedia === 'function' && window.matchMedia(PHONE_QUERY).matches;
73
+ }
74
+
75
+ function asksOf(conversationId) {
76
+ return [...(asksByConversation.get(toText(conversationId))?.values() || [])];
77
+ }
78
+
79
+ /** The open conversation's questions as the panel orders them. */
80
+ export function currentQuestions(now = Date.now()) {
81
+ return questionsOfConversation(asksOf(currentConversationId), currentConversationId, now);
82
+ }
83
+
84
+ // โ”€โ”€โ”€ Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
85
+
86
+ function timeOf(value) {
87
+ const ms = Date.parse(toText(value));
88
+ return Number.isFinite(ms) ? ms : 0;
89
+ }
90
+
91
+ /**
92
+ * The list as the relay sent it, with what the socket said meanwhile laid
93
+ * over it: an ask it changed is kept unless the list's copy is newer (both
94
+ * times are the relay's), one it moved away stays away.
95
+ */
96
+ function storeList(conversationId, asks, updates = null) {
97
+ const map = new Map();
98
+ for (const ask of Array.isArray(asks) ? asks : []) {
99
+ if (!ask?.id || ask.relayId || toText(ask.conversationId) !== conversationId) continue;
100
+ map.set(ask.id, ask);
101
+ }
102
+ for (const [id, update] of updates || []) {
103
+ const listed = map.get(id);
104
+ if (listed && update && timeOf(listed.updatedAt) > timeOf(update.updatedAt)) continue;
105
+ if (update) map.set(id, update);
106
+ else map.delete(id);
107
+ }
108
+ asksByConversation.set(conversationId, map);
109
+ loadedConversations.add(conversationId);
110
+ }
111
+
112
+ async function loadQuestions(conversationId) {
113
+ const id = toText(conversationId);
114
+ if (!id || IS_SHARED_VIEW) return null;
115
+ const mine = ++generation;
116
+ loads.set(id, mine);
117
+ updatesDuringLoad.set(id, new Map());
118
+ const response = await apiFetch(`/api/relay-asks?conversationId=${encodeURIComponent(id)}`);
119
+ if (loads.get(id) !== mine) return response;
120
+ loads.delete(id);
121
+ const updates = updatesDuringLoad.get(id);
122
+ updatesDuringLoad.delete(id);
123
+ if (response && Array.isArray(response.asks)) storeList(id, response.asks, updates);
124
+ else if (!loadedConversations.has(id)) storeList(id, [], updates);
125
+ if (id === currentConversationId) {
126
+ syncQuestionsHeaderButton();
127
+ renderPanel();
128
+ renderQuestionStubs();
129
+ consumePendingOpen();
130
+ }
131
+ return response;
132
+ }
133
+
134
+ /** Reloads the open conversation's list (after a reconnect: whatever changed while away). */
135
+ export function refreshQuestions() {
136
+ if (IS_SHARED_VIEW || !currentConversationId) return Promise.resolve(null);
137
+ return loadQuestions(currentConversationId);
138
+ }
139
+
140
+ /**
141
+ * The conversation on screen (null: none). Called on every render of a
142
+ * conversation, the live poll's too: only a new one loads its list.
143
+ */
144
+ export function setQuestionsConversation(conversationId) {
145
+ const id = IS_SHARED_VIEW ? '' : toText(conversationId);
146
+ // The Inbox asked to open another conversation at an ask: that is off.
147
+ if (id && pendingOpen && pendingOpen.conversationId !== id) pendingOpen = null;
148
+ if (id !== currentConversationId) {
149
+ currentConversationId = id;
150
+ if (panelOpen) closeQuestionsPanel({ focus: false });
151
+ renderedKey = '';
152
+ // Lists of conversations left are dropped: a return loads anew.
153
+ for (const key of [...asksByConversation.keys()]) {
154
+ if (key === id) continue;
155
+ asksByConversation.delete(key);
156
+ loadedConversations.delete(key);
157
+ }
158
+ if (id) {
159
+ // Updates that come before the first list land here (and on it).
160
+ if (!asksByConversation.has(id)) asksByConversation.set(id, new Map());
161
+ void loadQuestions(id);
162
+ }
163
+ syncQuestionsHeaderButton();
164
+ renderQuestionStubs();
165
+ }
166
+ consumePendingOpen();
167
+ }
168
+
169
+ /**
170
+ * Socket (relay_ask_updated) or an answer route: one ask as it is now. An
171
+ * ask of a paired relay (a mirror) is not listed here.
172
+ */
173
+ export function noteQuestionAskUpdated(ask) {
174
+ if (!ask?.id || ask.relayId) return;
175
+ const target = toText(ask.conversationId || ask.session?.id);
176
+ // A load running now read the relay before this: it is laid over its list.
177
+ for (const [conversationId, updates] of updatesDuringLoad) {
178
+ updates.set(ask.id, conversationId === target ? ask : null);
179
+ }
180
+ let touched = false;
181
+ for (const [conversationId, map] of asksByConversation) {
182
+ if (conversationId !== target && map.delete(ask.id)) touched = true;
183
+ }
184
+ const map = asksByConversation.get(target);
185
+ if (map) {
186
+ const known = map.get(ask.id);
187
+ // An older copy (a route's answer after the socket's) does not win.
188
+ if (!known || timeOf(ask.updatedAt) >= timeOf(known.updatedAt)) map.set(ask.id, ask);
189
+ touched = true;
190
+ }
191
+ if (!touched) return;
192
+ syncQuestionsHeaderButton();
193
+ renderPanel();
194
+ renderQuestionStubs();
195
+ }
196
+
197
+ // โ”€โ”€โ”€ The header icon โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
198
+
199
+ /** ๐Ÿ““'s count and labels for the conversation on screen. */
200
+ export function syncQuestionsHeaderButton() {
201
+ const button = document.getElementById('questions-btn');
202
+ if (!button) return;
203
+ const { pending, urgent } = currentQuestions();
204
+ const counts = { pending: pending.length, urgent };
205
+ const badge = questionsBadge(counts);
206
+ // Available or not; whether it fits is the priority+ pass's call.
207
+ button.hidden = IS_SHARED_VIEW || !pending.length;
208
+ button.dataset.count = IS_SHARED_VIEW ? '0' : String(pending.length);
209
+ button.dataset.urgent = IS_SHARED_VIEW ? '0' : String(urgent);
210
+ const countEl = button.querySelector('.header-icon-count');
211
+ if (countEl) {
212
+ countEl.textContent = badge.text;
213
+ countEl.classList.toggle('is-urgent', badge.urgent);
214
+ }
215
+ const label = questionsAria(counts);
216
+ button.setAttribute('title', label);
217
+ button.setAttribute('aria-label', label);
218
+ button.setAttribute('aria-expanded', panelOpen ? 'true' : 'false');
219
+ notifyHeaderActionsChanged();
220
+ }
221
+
222
+ // โ”€โ”€โ”€ The panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
223
+
224
+ function ensureBackdrop() {
225
+ let backdrop = document.getElementById('questions-panel-backdrop');
226
+ if (backdrop) return backdrop;
227
+ backdrop = document.createElement('div');
228
+ backdrop.id = 'questions-panel-backdrop';
229
+ backdrop.hidden = true;
230
+ // Every tap closes. The click a touch still owes after opening the list
231
+ // from ๏ธ™ never gets here: the ๏ธ™ entry's tap guard (tap-actions.js
232
+ // bindMenuAction) swallows it before it reaches the page.
233
+ backdrop.addEventListener('click', () => closeQuestionsPanel({ focus: false }));
234
+ document.body.appendChild(backdrop);
235
+ return backdrop;
236
+ }
237
+
238
+ function ensurePanel() {
239
+ let panel = document.getElementById('questions-panel');
240
+ if (panel) return panel;
241
+ ensureBackdrop();
242
+ panel = document.createElement('div');
243
+ panel.id = 'questions-panel';
244
+ panel.className = 'questions-panel';
245
+ panel.setAttribute('role', 'dialog');
246
+ panel.setAttribute('aria-label', 'Questions');
247
+ panel.hidden = true;
248
+ document.body.appendChild(panel);
249
+ bindInboxActionHost(panel, { scope: '.questions-panel' });
250
+ panel.addEventListener('click', (event) => {
251
+ const target = event.target instanceof Element ? event.target : null;
252
+ if (target?.closest?.('.questions-panel-close')) {
253
+ event.preventDefault();
254
+ closeQuestionsPanel();
255
+ }
256
+ });
257
+ panel.addEventListener('toggle', (event) => {
258
+ const details = event.target instanceof Element && event.target.matches('details.inbox-item-detail') ? event.target : null;
259
+ const id = details?.closest?.('.inbox-item')?.dataset.itemId || '';
260
+ if (!id || !details.isConnected) return;
261
+ if (details.open) foldedDetails.delete(id);
262
+ else foldedDetails.add(id);
263
+ }, true);
264
+ return panel;
265
+ }
266
+
267
+ // A box to place against, or null: an element that is not drawn has none.
268
+ function drawnRect(element) {
269
+ const rect = element?.getBoundingClientRect?.();
270
+ return rect && (rect.width || rect.height) ? rect : null;
271
+ }
272
+
273
+ function placePanel() {
274
+ const panel = document.getElementById('questions-panel');
275
+ if (!panel || panel.hidden) return;
276
+ const scale = getUiScale();
277
+ const margin = 8 * scale;
278
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
279
+ const viewportHeight = window.visualViewport?.height || window.innerHeight;
280
+ const sheet = isPhone();
281
+ panel.classList.toggle('is-sheet', sheet);
282
+ if (sheet) {
283
+ // Full width under the header; the sides come from the stylesheet.
284
+ const header = drawnRect(document.getElementById('chat-header'));
285
+ const top = Math.round((header ? header.bottom : 0) + 4 * scale);
286
+ panel.style.left = '';
287
+ panel.style.top = `${top}px`;
288
+ panel.style.maxHeight = `${Math.round(Math.max(120 * scale, viewportHeight - top - margin))}px`;
289
+ return;
290
+ }
291
+ // Under ๐Ÿ““, else under ๏ธ™ (๐Ÿ““ went into the menu), right edges lined up.
292
+ // ๐Ÿ““ hiding while the list is open (its last pending ask answered here)
293
+ // leaves the list where it was.
294
+ const rect = drawnRect(panelAnchor)
295
+ || drawnRect(document.getElementById('questions-btn'))
296
+ || lastAnchorRect
297
+ || drawnRect(document.getElementById('chat-actions-menu-btn'));
298
+ if (rect) lastAnchorRect = rect;
299
+ if (!rect) {
300
+ panel.style.top = `${Math.round(margin)}px`;
301
+ panel.style.left = `${Math.round(margin)}px`;
302
+ return;
303
+ }
304
+ const top = Math.round(rect.bottom + 4 * scale);
305
+ panel.style.top = `${top}px`;
306
+ panel.style.maxHeight = `${Math.round(Math.max(120 * scale, viewportHeight - top - margin))}px`;
307
+ panel.style.left = '0px';
308
+ const width = panel.offsetWidth;
309
+ const left = Math.max(margin, Math.min(rect.right - width, viewportWidth - width - margin));
310
+ panel.style.left = `${Math.round(left)}px`;
311
+ }
312
+
313
+ function el(tag, className = '', text = null) {
314
+ const node = document.createElement(tag);
315
+ if (className) node.className = className;
316
+ if (text !== null) node.textContent = text;
317
+ return node;
318
+ }
319
+
320
+ function renderPanel({ force = false } = {}) {
321
+ const panel = document.getElementById('questions-panel');
322
+ if (!panel) return;
323
+ if (!panelOpen) {
324
+ if (!panel.hidden || panel.childElementCount) {
325
+ panel.hidden = true;
326
+ panel.replaceChildren();
327
+ }
328
+ renderedKey = '';
329
+ return;
330
+ }
331
+ const now = Date.now();
332
+ const { pending, urgent, answered } = currentQuestions(now);
333
+ const key = JSON.stringify([
334
+ currentConversationId,
335
+ focusAskId,
336
+ [...pending, ...answered].map((ask) => [ask.id, ask.status, ask.urgency, ask.updatedAt, ask.choice || '', ask.answer || '']),
337
+ ]);
338
+ if (!force && key === renderedKey && panel.childElementCount) return;
339
+ renderedKey = key;
340
+ const focused = captureDrafts(panel);
341
+ const scrollTop = panel.scrollTop;
342
+ const head = el('div', 'questions-panel-head');
343
+ head.append(el('span', 'questions-panel-title', questionsPanelTitle({ pending: pending.length, urgent })));
344
+ const close = el('button', 'questions-panel-close', 'โœ•');
345
+ close.type = 'button';
346
+ close.title = 'Close';
347
+ close.setAttribute('aria-label', 'Close questions');
348
+ head.append(close);
349
+ const body = el('div', 'questions-panel-body');
350
+ const relay = { id: 'self', self: true };
351
+ for (const ask of pending) body.append(buildAskCard(document, ask, { relay, mode: 'panel', now }));
352
+ if (answered.length) {
353
+ const divider = el('div', 'inbox-divider', 'Answered in the last day');
354
+ divider.setAttribute('role', 'separator');
355
+ body.append(divider);
356
+ for (const ask of answered) body.append(buildAskCard(document, ask, { relay, mode: 'panel', now }));
357
+ }
358
+ if (!pending.length && !answered.length) body.append(el('p', 'questions-panel-empty', 'No questions in this conversation.'));
359
+ for (const details of body.querySelectorAll('details.inbox-item-detail')) {
360
+ if (foldedDetails.has(details.closest('.inbox-item')?.dataset.itemId || '')) details.open = false;
361
+ }
362
+ panel.replaceChildren(head, body);
363
+ restoreDrafts(panel, focused);
364
+ panel.hidden = false;
365
+ panel.scrollTop = scrollTop;
366
+ placePanel();
367
+ if (focusAskId) {
368
+ const card = [...panel.querySelectorAll('.inbox-item')].find((node) => node.dataset.itemId === focusAskId);
369
+ if (card) {
370
+ card.classList.add('questions-card-focus');
371
+ // Once: a later re-render leaves the reader's scroll alone.
372
+ if (scrollToFocus) card.scrollIntoView?.({ block: 'nearest' });
373
+ scrollToFocus = false;
374
+ }
375
+ }
376
+ }
377
+
378
+ function syncBackdrop() {
379
+ const backdrop = ensureBackdrop();
380
+ backdrop.hidden = !(panelOpen && isPhone());
381
+ }
382
+
383
+ export function isQuestionsPanelOpen() {
384
+ return panelOpen;
385
+ }
386
+
387
+ /**
388
+ * Opens the list: under `anchor` (๐Ÿ““, or ๏ธ™ for its menu twin); with `askId`
389
+ * that card is marked and scrolled into view.
390
+ */
391
+ export function openQuestionsPanel({ anchor = null, askId = '' } = {}) {
392
+ if (IS_SHARED_VIEW || !currentConversationId) return false;
393
+ ensurePanel();
394
+ if (!panelOpen) panelOpenedAt = Date.now();
395
+ panelOpen = true;
396
+ panelAnchor = anchor || document.getElementById('questions-btn');
397
+ lastAnchorRect = null;
398
+ focusAskId = toText(askId);
399
+ scrollToFocus = !!focusAskId;
400
+ // The keyboard would cover the list on a phone (as the โš™ sheet does).
401
+ if (isPhone()) document.getElementById('msg-input')?.blur?.();
402
+ syncBackdrop();
403
+ renderPanel({ force: true });
404
+ syncQuestionsHeaderButton();
405
+ return true;
406
+ }
407
+
408
+ export function closeQuestionsPanel({ focus = true } = {}) {
409
+ if (!panelOpen) return false;
410
+ const anchor = panelAnchor;
411
+ panelOpen = false;
412
+ panelAnchor = null;
413
+ lastAnchorRect = null;
414
+ focusAskId = '';
415
+ scrollToFocus = false;
416
+ syncBackdrop();
417
+ renderPanel();
418
+ syncQuestionsHeaderButton();
419
+ if (focus) {
420
+ // Back to what opened it; ๐Ÿ““ gone meanwhile (nothing left open): ๏ธ™.
421
+ const target = [anchor, document.getElementById('questions-btn'), document.getElementById('chat-actions-menu-btn')]
422
+ .find((node) => drawnRect(node));
423
+ target?.focus?.({ preventScroll: true });
424
+ }
425
+ return true;
426
+ }
427
+
428
+ export function toggleQuestionsPanel({ anchor = null, askId = '' } = {}) {
429
+ if (panelOpen) return closeQuestionsPanel();
430
+ return openQuestionsPanel({ anchor, askId });
431
+ }
432
+
433
+ /** The Inbox's "open" on an ask: ๐Ÿ““ opens at it once the conversation is on screen. */
434
+ export function requestQuestionsPanelAt(conversationId, askId) {
435
+ const id = toText(conversationId);
436
+ if (!id || !toText(askId) || IS_SHARED_VIEW) return;
437
+ pendingOpen = { conversationId: id, askId: toText(askId) };
438
+ }
439
+
440
+ /**
441
+ * The Inbox's open has run (or failed): a request for a conversation that is
442
+ * not on screen now is dropped, so it never fires on a later visit.
443
+ */
444
+ export function settleQuestionsPanelRequest() {
445
+ if (pendingOpen && pendingOpen.conversationId !== currentConversationId) pendingOpen = null;
446
+ }
447
+
448
+ function consumePendingOpen() {
449
+ if (!pendingOpen || pendingOpen.conversationId !== currentConversationId) return;
450
+ if (!loadedConversations.has(currentConversationId) || loads.has(currentConversationId)) return;
451
+ const { askId } = pendingOpen;
452
+ pendingOpen = null;
453
+ openQuestionsPanel({ askId });
454
+ }
455
+
456
+ // โ”€โ”€โ”€ The stub in the asking turn's bubble โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
457
+
458
+ const STUB_ROW_CLASS = 'msg-ask-stubs';
459
+ const STANDALONE_CLASS = 'relay-ask-stub-container';
460
+
461
+ function buildStub(ask) {
462
+ const stub = document.createElement('button');
463
+ stub.type = 'button';
464
+ stub.className = 'msg-ask-stub';
465
+ stub.dataset.askId = ask.id;
466
+ const text = askStubText(ask);
467
+ const urgent = ask.status === 'pending' && ask.urgency === 'now';
468
+ stub.classList.toggle('is-urgent', urgent);
469
+ stub.classList.toggle('is-closed', ask.status !== 'pending');
470
+ if (urgent) {
471
+ // "urgent" in red, the rest as the bubble's text.
472
+ stub.append(document.createTextNode(text.replace(/urgent$/, '')));
473
+ const word = document.createElement('span');
474
+ word.className = 'msg-ask-stub-urgent';
475
+ word.textContent = 'urgent';
476
+ stub.append(word);
477
+ } else {
478
+ stub.textContent = text;
479
+ }
480
+ stub.title = ask.status === 'pending' ? 'Open this question in ๐Ÿ““' : 'Show this question in ๐Ÿ““';
481
+ return stub;
482
+ }
483
+
484
+ // What a stub row goes before in a bubble: the rows a plan board goes before
485
+ // too (relay-board-view.js placeBoardSection), so the stubs end the reply's
486
+ // text, after its plan board.
487
+ const LIVE_TAIL = ':scope > .dots, :scope > #thinking-activity, :scope > .subagent-bubbles-container';
488
+ const REPLY_TAIL = ':scope > .msg-attachments, :scope > .msg-activity, :scope > .msg-workflow-runs, :scope > .msg-preview-cards, :scope > .msg-bubble-actions';
489
+
490
+ /** The row of stubs in `host` (a bubble, or the standalone container), made when missing and kept in place. */
491
+ function stubRowOf(host, { live = false, standalone = false } = {}) {
492
+ let row = host.querySelector(`:scope > .${STUB_ROW_CLASS}`);
493
+ if (!row) {
494
+ row = document.createElement('div');
495
+ row.className = STUB_ROW_CLASS;
496
+ }
497
+ const before = standalone ? null : host.querySelector(live ? LIVE_TAIL : REPLY_TAIL);
498
+ if (row.parentNode !== host || row.nextSibling !== before) host.insertBefore(row, before);
499
+ return row;
500
+ }
501
+
502
+ function standaloneContainer(el) {
503
+ let wrapper = el.querySelector(`:scope > .${STANDALONE_CLASS}`);
504
+ if (!wrapper) {
505
+ wrapper = document.createElement('div');
506
+ wrapper.className = `msg ${STANDALONE_CLASS}`;
507
+ }
508
+ // At the end of the transcript, before the cards a turn waits on.
509
+ const firstQuestion = el.querySelector(':scope > .relay-question-container');
510
+ if (firstQuestion) {
511
+ if (wrapper.nextSibling !== firstQuestion) el.insertBefore(wrapper, firstQuestion);
512
+ } else if (wrapper.parentNode !== el || wrapper.nextSibling) {
513
+ el.appendChild(wrapper);
514
+ }
515
+ return wrapper;
516
+ }
517
+
518
+ /**
519
+ * Every ๐Ÿ““ stub of the conversation on screen, where it belongs: one per ask,
520
+ * at the end of the bubble of the turn that asked it (its reply, or the live
521
+ * bubble while it runs), oldest first. An ask with no such bubble on the page
522
+ * (asked before 0.9.22, on behalf of another session, carried over by a
523
+ * compaction, or its turn not loaded) stands alone at the transcript's end
524
+ * while it is pending. A cancelled ask leaves none. Idempotent: runs after
525
+ * every render of the transcript, on the live bubble and on every change of
526
+ * an ask; a stub that moved is taken from its old place, never doubled.
527
+ */
528
+ export function renderQuestionStubs() {
529
+ const el = document.getElementById('messages');
530
+ if (!el || IS_SHARED_VIEW) return;
531
+ const conversationId = toText(currentConvId);
532
+ const onScreen = !!conversationId && conversationId === currentConversationId
533
+ && !document.body.classList.contains('inbox-view-active') && !isStatusViewActive();
534
+ const asks = onScreen
535
+ ? asksOf(conversationId)
536
+ .filter((ask) => toText(ask.conversationId) === conversationId && askStubText(ask))
537
+ .sort((a, b) => timeOf(a.createdAt) - timeOf(b.createdAt) || toText(a.id).localeCompare(toText(b.id)))
538
+ : [];
539
+ // Host element โ†’ where it is and the asks it shows, in order.
540
+ const groups = new Map();
541
+ for (const ask of asks) {
542
+ const key = askStubHostKey(ask);
543
+ const found = key ? findTurnBubble(el, key) : null;
544
+ let host = found?.bubble || null;
545
+ if (!host) {
546
+ if (ask.status !== 'pending') continue;
547
+ host = standaloneContainer(el);
548
+ }
549
+ if (!groups.has(host)) groups.set(host, { live: !!found?.live, standalone: !found, list: [] });
550
+ groups.get(host).list.push(ask);
551
+ }
552
+ const keep = new Set();
553
+ for (const [host, { live, standalone, list }] of groups) {
554
+ const row = stubRowOf(host, { live, standalone });
555
+ keep.add(row);
556
+ const rowKey = JSON.stringify(list.map((ask) => [ask.id, askStubKey(ask)]));
557
+ if (row.dataset.stubsKey === rowKey && row.childElementCount === list.length) continue;
558
+ row.dataset.stubsKey = rowKey;
559
+ row.replaceChildren(...list.map(buildStub));
560
+ }
561
+ // A row no longer wanted (its asks moved, closed beyond the list or another
562
+ // conversation's), then a standalone container left empty.
563
+ el.querySelectorAll(`.${STUB_ROW_CLASS}`).forEach((row) => { if (!keep.has(row)) row.remove(); });
564
+ el.querySelectorAll(`:scope > .${STANDALONE_CLASS}`).forEach((wrapper) => {
565
+ if (!wrapper.querySelector('.msg-ask-stub')) wrapper.remove();
566
+ });
567
+ }
568
+
569
+ /** Boot: the icon's button, outside clicks, Escape, re-placing, the Inbox's hooks. */
570
+ export function initQuestionsPanel() {
571
+ if (started || IS_SHARED_VIEW) return;
572
+ started = true;
573
+ setInboxAskHooks({
574
+ onAskUpdated: noteQuestionAskUpdated,
575
+ onOpenAsk: requestQuestionsPanelAt,
576
+ onOpenSettled: settleQuestionsPanelRequest,
577
+ });
578
+ // A stub opens ๐Ÿ““ at its ask.
579
+ document.getElementById('messages')?.addEventListener('click', (event) => {
580
+ const stub = event.target instanceof Element ? event.target.closest('.msg-ask-stub') : null;
581
+ if (!stub) return;
582
+ event.preventDefault();
583
+ event.stopPropagation();
584
+ openQuestionsPanel({ askId: stub.dataset.askId || '' });
585
+ });
586
+ const button = document.getElementById('questions-btn');
587
+ button?.addEventListener('click', (event) => {
588
+ event.preventDefault();
589
+ event.stopPropagation();
590
+ toggleQuestionsPanel({ anchor: button });
591
+ });
592
+ document.addEventListener('click', (event) => {
593
+ if (!panelOpen || isOpeningGestureClick(event, panelOpenedAt)) return;
594
+ // composedPath: an answer may have re-rendered the panel already.
595
+ const path = event.composedPath();
596
+ const panel = document.getElementById('questions-panel');
597
+ // The backdrop answers its own taps (with the opening touch's grace).
598
+ const backdrop = document.getElementById('questions-panel-backdrop');
599
+ if ([panel, backdrop, panelAnchor, button].some((node) => node && path.includes(node))) return;
600
+ closeQuestionsPanel({ focus: false });
601
+ });
602
+ document.addEventListener('keydown', (event) => {
603
+ if (!panelOpen || event.key !== 'Escape') return;
604
+ event.stopPropagation();
605
+ closeQuestionsPanel();
606
+ }, true);
607
+ const replace = () => {
608
+ if (!panelOpen) return;
609
+ // The window changed: what the list hung from is measured anew.
610
+ lastAnchorRect = null;
611
+ syncBackdrop();
612
+ placePanel();
613
+ };
614
+ window.addEventListener('resize', replace);
615
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, replace);
616
+ window.visualViewport?.addEventListener?.('resize', replace);
617
+ // The header re-fits (an icon shown or put into ๏ธ™, the title, the
618
+ // banner): what the list hangs from may have moved. The fit runs in the
619
+ // frame after its notice; the list follows in the one after that.
620
+ const followHeader = () => {
621
+ if (!panelOpen) return;
622
+ requestAnimationFrame(() => requestAnimationFrame(() => {
623
+ if (panelOpen) placePanel();
624
+ }));
625
+ };
626
+ window.addEventListener(HEADER_ACTIONS_CHANGE_EVENT, followHeader);
627
+ if (typeof ResizeObserver === 'function') {
628
+ const observer = new ResizeObserver(followHeader);
629
+ for (const id of ['chat-header', 'chat-header-actions']) {
630
+ const node = document.getElementById(id);
631
+ if (node) observer.observe(node);
632
+ }
633
+ }
634
+ }
@@ -0,0 +1,40 @@
1
+ // ๐Ÿ““ The one-line stub an ask leaves in the bubble of the turn that asked
2
+ // it (questions-panel-ui.js places it). Pure: no DOM.
3
+
4
+ function toText(value) {
5
+ return String(value ?? '').trim();
6
+ }
7
+
8
+ const ANSWER_PREVIEW_MAX = 80;
9
+
10
+ /** The answer as one short line: the choice, else the answer's first 80 characters. */
11
+ export function askStubAnswerText(ask) {
12
+ const choice = toText(ask?.choice);
13
+ if (choice) return choice;
14
+ const answer = toText(String(ask?.answer || '').replace(/\s+/g, ' '));
15
+ return answer.length > ANSWER_PREVIEW_MAX ? `${answer.slice(0, ANSWER_PREVIEW_MAX - 1)}โ€ฆ` : answer;
16
+ }
17
+
18
+ /**
19
+ * The stub's text, or '' for an ask that leaves none (cancelled: its session
20
+ * ended, nobody waits on it; it shows in ๐Ÿ““ only).
21
+ */
22
+ export function askStubText(ask) {
23
+ switch (ask?.status) {
24
+ case 'pending': return ask.urgency === 'now' ? '๐Ÿ““ Question for you ยท urgent' : '๐Ÿ““ Question for you';
25
+ case 'answered': return `๐Ÿ““ Answered: ${askStubAnswerText(ask)}`;
26
+ case 'dismissed': return '๐Ÿ““ Dismissed';
27
+ case 'withdrawn': return '๐Ÿ““ Withdrawn';
28
+ default: return '';
29
+ }
30
+ }
31
+
32
+ /** The queue row of the turn that asked, or null (the stub then stands alone, pending asks only). */
33
+ export function askStubHostKey(ask) {
34
+ return toText(ask?.queueId) || null;
35
+ }
36
+
37
+ /** What a drawn stub shows; a stub is drawn anew only when this changes. */
38
+ export function askStubKey(ask) {
39
+ return JSON.stringify([toText(ask?.status), ask?.urgency === 'now' ? 'now' : 'normal', toText(ask?.choice), askStubAnswerText(ask)]);
40
+ }