@oar-sh/oar 0.9.20 โ†’ 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 (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -1,7 +1,8 @@
1
- // The Inbox in the page: the sidebar's "Inbox" button with its count, the
2
- // view it opens in place of a transcript (like Status), and the card a
3
- // question that does not block shows in its own session. inbox-view.mjs
4
- // builds the DOM; this module loads, keeps and answers.
1
+ // The Inbox in the page: the sidebar's "Inbox" button with its count and
2
+ // the view it opens in place of a transcript (like Status). A session's own
3
+ // questions that don't block are in its ๐Ÿ““ (questions-panel-ui.js), which
4
+ // answers through here. inbox-view.mjs builds the DOM; this module loads,
5
+ // keeps and answers.
5
6
  //
6
7
  // The list comes from GET /api/inbox (this relay and every paired relay that
7
8
  // answers), again on every change of a question here (socket) and every
@@ -15,17 +16,15 @@ import { currentConvId, IS_SHARED_VIEW, setCurrentConv, showTransientRelayNotice
15
16
  import { isStatusViewActive, leaveStatusView } from './status-view.mjs';
16
17
  import { actOnRelayBoard } from './relay-board-view.js';
17
18
  import {
18
- buildAskCard,
19
19
  captureInboxFolds,
20
20
  createCoalescedRefresh,
21
- inboxPendingCount,
21
+ inboxBadge,
22
22
  INBOX_FILTERS,
23
23
  INBOX_FOLD_SELECTOR,
24
24
  noteInboxFold,
25
25
  pruneInboxFolds,
26
26
  renderInbox,
27
27
  restoreInboxFolds,
28
- sessionAsksFromInbox,
29
28
  } from './inbox-view.mjs';
30
29
 
31
30
  const FILTER_STORAGE_KEY = 'oar_inbox_filter';
@@ -47,14 +46,18 @@ const drafts = new Map();
47
46
  // re-renders the same way: every socket change and the minute's poll draw
48
47
  // the cards anew, closed. Forgotten when the item leaves the list.
49
48
  const openFolds = new Set();
50
- // This relay's asks for the session cards: id โ†’ ask (each Inbox load
51
- // replaces it; the socket updates single asks in between).
52
- let sessionAsks = new Map();
53
- let sessionCardKey = '';
54
49
  // Where the Inbox was scrolled when it was last left; the next opening
55
50
  // starts there (the first one at the top). The transcript's own scroll
56
51
  // height is the transcript's, never the Inbox's.
57
52
  let inboxScrollTop = 0;
53
+ // The ๐Ÿ““ panel's side (questions-panel-ui.js), handed in so the two modules
54
+ // do not import each other: an ask the answer routes returned, "open" on an
55
+ // ask's session, and that open having run (or failed).
56
+ let askHooks = { onAskUpdated: null, onOpenAsk: null, onOpenSettled: null };
57
+
58
+ export function setInboxAskHooks(hooks = {}) {
59
+ askHooks = { ...askHooks, ...hooks };
60
+ }
58
61
 
59
62
  function readFilter() {
60
63
  try {
@@ -69,15 +72,24 @@ export function isInboxViewActive() {
69
72
  return inboxActive;
70
73
  }
71
74
 
72
- /** The sidebar button's count: everything pending, here and on paired relays. */
75
+ /**
76
+ * The sidebar button's count: everything pending, here and on paired relays;
77
+ * while an urgent ask waits, the urgent count in red.
78
+ */
73
79
  function syncInboxBadge() {
74
- const count = inboxPendingCount(relays);
80
+ const { text, urgent, pending: count, urgentCount } = inboxBadge(relays);
75
81
  const badge = document.getElementById('inbox-count');
76
82
  const buttonEl = document.getElementById('inbox-btn');
77
- if (badge) badge.textContent = count ? String(count) : '';
83
+ if (badge) {
84
+ badge.textContent = text;
85
+ badge.classList.toggle('is-urgent', urgent);
86
+ }
78
87
  if (buttonEl) {
79
88
  buttonEl.classList.toggle('has-pending', count > 0);
80
- buttonEl.setAttribute('aria-label', count ? `Inbox, ${count} question${count === 1 ? '' : 's'} waiting` : 'Inbox');
89
+ const label = urgent
90
+ ? `Inbox, ${urgentCount} urgent of ${count} waiting`
91
+ : (count ? `Inbox, ${count} question${count === 1 ? '' : 's'} waiting` : 'Inbox');
92
+ buttonEl.setAttribute('aria-label', label);
81
93
  buttonEl.setAttribute('aria-pressed', inboxActive ? 'true' : 'false');
82
94
  }
83
95
  }
@@ -87,16 +99,18 @@ function draftKey(node) {
87
99
  return card ? `${card.dataset.relayId || 'self'}:${card.dataset.itemId || ''}` : '';
88
100
  }
89
101
 
90
- function captureDrafts(host) {
102
+ export function captureDrafts(host) {
103
+ // Only fields typed into: the same ask can be drawn twice (its session's
104
+ // card and the ๐Ÿ““ panel), and the untouched copy must not wipe the draft.
91
105
  for (const field of host.querySelectorAll('.inbox-answer-text')) {
92
106
  const key = draftKey(field);
93
- if (key) drafts.set(key, field.value);
107
+ if (key && field.dataset.draftTouched === '1') drafts.set(key, field.value);
94
108
  }
95
109
  const active = document.activeElement;
96
110
  return active?.classList?.contains('inbox-answer-text') && host.contains(active) ? draftKey(active) : '';
97
111
  }
98
112
 
99
- function restoreDrafts(host, focusedKey) {
113
+ export function restoreDrafts(host, focusedKey) {
100
114
  for (const field of host.querySelectorAll('.inbox-answer-text')) {
101
115
  const key = draftKey(field);
102
116
  if (drafts.has(key)) field.value = drafts.get(key);
@@ -122,8 +136,8 @@ function renderInboxNow({ opening = false } = {}) {
122
136
  host.scrollTop = top;
123
137
  const title = document.getElementById('chat-title');
124
138
  if (title) {
125
- const count = inboxPendingCount(relays);
126
- title.textContent = count ? `Inbox ยท ${count} waiting` : 'Inbox';
139
+ const { pending: count, urgentCount } = inboxBadge(relays);
140
+ title.textContent = count ? `Inbox ยท ${urgentCount ? `${urgentCount} urgent ยท ` : ''}${count} waiting` : 'Inbox';
127
141
  }
128
142
  }
129
143
 
@@ -132,13 +146,10 @@ async function loadInbox() {
132
146
  if (response && Array.isArray(response.relays)) {
133
147
  relays = response.relays;
134
148
  loaded = true;
135
- // Built anew from each load: an ask withdrawn or cancelled meanwhile goes.
136
- sessionAsks = sessionAsksFromInbox(relays);
137
149
  pruneInboxFolds(openFolds, relays);
138
150
  }
139
151
  syncInboxBadge();
140
152
  renderInboxNow();
141
- renderSessionAskCards();
142
153
  return response;
143
154
  }
144
155
 
@@ -160,10 +171,7 @@ function scheduleRefresh() {
160
171
  }
161
172
 
162
173
  /** Socket: an ask of this relay changed (created, answered, dismissed, withdrawn). */
163
- export function noteRelayAskUpdated(payload = {}) {
164
- const ask = payload?.ask;
165
- if (ask?.id) sessionAsks.set(ask.id, ask);
166
- renderSessionAskCards();
174
+ export function noteRelayAskUpdated() {
167
175
  scheduleRefresh();
168
176
  }
169
177
 
@@ -186,12 +194,28 @@ export function leaveInboxView() {
186
194
  return true;
187
195
  }
188
196
 
189
- export async function toggleInboxView() {
197
+ /**
198
+ * Nothing to go back to (opened with nothing open, as after an unknown
199
+ * link): the header and the transcript give way to the empty screen.
200
+ * Loaded on demand: the transcript's module is already in the page.
201
+ */
202
+ async function showNothingOpen() {
203
+ const { clearOpenConversation } = await import('./journal-view.js');
204
+ clearOpenConversation({ history: false });
205
+ }
206
+
207
+ /**
208
+ * Opens the Inbox, or leaves it for the conversation it was opened from.
209
+ * Opened from Status there is none stored: `returnTo` (the session the
210
+ * history stands on) is opened instead, so the transcript never keeps the list.
211
+ */
212
+ export async function toggleInboxView({ returnTo = '' } = {}) {
190
213
  if (inboxActive) {
191
214
  leaveInboxView();
192
- const restoreId = previousConversationId;
215
+ const restoreId = previousConversationId || String(returnTo || '').trim();
193
216
  previousConversationId = '';
194
217
  if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
218
+ else await showNothingOpen();
195
219
  return;
196
220
  }
197
221
  if (isStatusViewActive()) leaveStatusView();
@@ -255,7 +279,9 @@ async function answerAsk(card, data, { choice = '', dismiss = false } = {}) {
255
279
  return false;
256
280
  }
257
281
  drafts.delete(`${relayId}:${id}`);
258
- if (relayId === 'self' && result.response?.question?.id) sessionAsks.set(result.response.question.id, result.response.question);
282
+ if (relayId === 'self' && result.response?.question?.id) {
283
+ try { askHooks.onAskUpdated?.(result.response.question); } catch {}
284
+ }
259
285
  return true;
260
286
  }
261
287
 
@@ -302,9 +328,17 @@ async function handleAction(target) {
302
328
  if (data.inboxAction === 'open') {
303
329
  const id = String(data.sessionId || '').trim();
304
330
  if (!id) return;
331
+ // An ask's session opens with ๐Ÿ““ open at that ask.
332
+ const askId = card?.dataset.kind === 'ask' && (card.dataset.relayId || 'self') === 'self' ? String(card.dataset.itemId || '') : '';
333
+ if (askId) askHooks.onOpenAsk?.(id, askId);
305
334
  leaveInboxView();
306
335
  previousConversationId = '';
307
- await window.openConversation?.(id);
336
+ try {
337
+ await window.openConversation?.(id);
338
+ } finally {
339
+ // Failed, or landed elsewhere: the request to open ๐Ÿ““ there is dropped.
340
+ if (askId) askHooks.onOpenSettled?.(id);
341
+ }
308
342
  return;
309
343
  }
310
344
  const buttons = card ? [...card.querySelectorAll('button')] : [target];
@@ -320,73 +354,46 @@ async function handleAction(target) {
320
354
  } finally {
321
355
  if (!done) for (const node of buttons) node.disabled = false;
322
356
  }
323
- if (done) {
324
- renderSessionAskCards();
325
- await refreshInbox();
326
- }
357
+ if (done) await refreshInbox();
327
358
  }
328
359
 
329
- function bindClicks() {
330
- if (clickBound) return;
331
- clickBound = true;
332
- const host = document.getElementById('messages');
333
- host?.addEventListener('click', (event) => {
360
+ /**
361
+ * The Inbox's buttons, typed answers and folds inside `host`: the transcript
362
+ * (the Inbox view) or another host that draws the
363
+ * same cards (the ๐Ÿ““ panel). `scope`: the selector a click must be inside.
364
+ */
365
+ export function bindInboxActionHost(host, { scope = '.inbox-view' } = {}) {
366
+ if (!host || host.dataset.inboxActionsBound === '1') return;
367
+ host.dataset.inboxActionsBound = '1';
368
+ host.addEventListener('click', (event) => {
334
369
  const target = event.target instanceof Element ? event.target.closest('[data-inbox-action], [data-inbox-filter]') : null;
335
- if (!target || !target.closest('.inbox-view, .relay-ask-container')) return;
370
+ if (!target || !target.closest(scope)) return;
336
371
  event.preventDefault();
337
372
  void handleAction(target);
338
373
  });
339
- host?.addEventListener('input', (event) => {
374
+ host.addEventListener('input', (event) => {
340
375
  const field = event.target instanceof Element && event.target.classList.contains('inbox-answer-text') ? event.target : null;
341
376
  const key = field ? draftKey(field) : '';
342
- if (key) drafts.set(key, field.value);
377
+ if (key) {
378
+ field.dataset.draftTouched = '1';
379
+ drafts.set(key, field.value);
380
+ }
343
381
  });
344
382
  // A fold opened or closed by hand. `toggle` does not bubble: caught on the
345
- // way down. Also covers a session's card that a transcript render dropped
346
- // before renderSessionAskCards drew it again.
347
- host?.addEventListener('toggle', (event) => {
383
+ // way down.
384
+ host.addEventListener('toggle', (event) => {
348
385
  const details = event.target instanceof Element && event.target.matches(INBOX_FOLD_SELECTOR) ? event.target : null;
349
386
  if (details && details.isConnected) noteInboxFold(openFolds, details);
350
387
  }, true);
351
388
  }
352
389
 
353
- // โ”€โ”€โ”€ The card in the session โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
354
-
355
- /**
356
- * The pending asks of the conversation on screen, as cards at the end of its
357
- * transcript (before the cards it waits on). Called with every render of the
358
- * question cards; a no-op while nothing changed.
359
- */
360
- export function renderSessionAskCards() {
361
- const host = document.getElementById('messages');
362
- if (!host || IS_SHARED_VIEW) return;
363
- const id = String(currentConvId || '').trim();
364
- const shown = inboxActive || isStatusViewActive() || !id
365
- ? []
366
- : [...sessionAsks.values()]
367
- .filter((ask) => ask && (ask.conversationId || ask.session?.id) === id && ask.status === 'pending')
368
- .sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
369
- const key = JSON.stringify([id, shown.map((ask) => [ask.id, ask.status])]);
370
- const existing = host.querySelectorAll('.relay-ask-container');
371
- if (key === sessionCardKey && existing.length === shown.length && [...existing].every((node) => node.isConnected)) return;
372
- sessionCardKey = key;
373
- const focused = captureDrafts(host);
374
- existing.forEach((node) => captureInboxFolds(openFolds, node));
375
- existing.forEach((node) => node.remove());
376
- const firstQuestion = host.querySelector('.relay-question-container');
377
- for (const ask of shown) {
378
- const wrapper = document.createElement('div');
379
- wrapper.className = 'msg relay-ask-container';
380
- wrapper.dataset.askId = ask.id;
381
- wrapper.append(buildAskCard(document, { ...ask, session: ask.session || { id } }, { relay: { id: 'self', self: true }, mode: 'session', now: Date.now() }));
382
- if (firstQuestion) host.insertBefore(wrapper, firstQuestion);
383
- else host.append(wrapper);
384
- restoreInboxFolds(openFolds, wrapper);
385
- }
386
- restoreDrafts(host, focused);
390
+ function bindClicks() {
391
+ if (clickBound) return;
392
+ clickBound = true;
393
+ bindInboxActionHost(document.getElementById('messages'));
387
394
  }
388
395
 
389
- /** Boot: the count, the session cards, a refresh every minute (paired relays). */
396
+ /** Boot: the count and a refresh every minute (paired relays). */
390
397
  export function startInbox() {
391
398
  if (started || IS_SHARED_VIEW) return;
392
399
  started = true;
@@ -153,20 +153,6 @@ export function createCoalescedRefresh(load) {
153
153
  return refresh;
154
154
  }
155
155
 
156
- /**
157
- * This relay's asks out of an Inbox load (GET /api/inbox), by id: what the
158
- * session cards show. Built anew from each load, so an ask that was
159
- * withdrawn or cancelled (no longer listed) drops out.
160
- */
161
- export function sessionAsksFromInbox(relays) {
162
- const own = (Array.isArray(relays) ? relays : []).find((relay) => relay?.self === true);
163
- const asks = new Map();
164
- for (const item of Array.isArray(own?.items) ? own.items : []) {
165
- if (item?.kind === 'ask' && toText(item.id)) asks.set(item.id, { ...item, conversationId: item.session?.id });
166
- }
167
- return asks;
168
- }
169
-
170
156
  // The folds a card has: an ask's `Details` and a plan card's `The plan`.
171
157
  export const INBOX_FOLD_SELECTOR = 'details.inbox-item-detail, details.inbox-plan-body';
172
158
 
@@ -226,6 +212,25 @@ export function inboxPendingCount(relays) {
226
212
  .reduce((sum, relay) => sum + (Array.isArray(relay?.items) ? relay.items : []).filter((item) => toText(item?.status) === 'pending').length, 0);
227
213
  }
228
214
 
215
+ /**
216
+ * The sidebar badge: the urgent count, marked, while an urgent item waits;
217
+ * else every pending item. Only asks can be urgent (`urgency: 'now'`); a
218
+ * card a turn waits on or a plan card counts as a normal one.
219
+ */
220
+ export function inboxBadge(relays) {
221
+ let pending = 0;
222
+ let urgent = 0;
223
+ for (const relay of Array.isArray(relays) ? relays : []) {
224
+ for (const item of Array.isArray(relay?.items) ? relay.items : []) {
225
+ if (toText(item?.status) !== 'pending') continue;
226
+ pending += 1;
227
+ if (toText(item.kind) === 'ask' && item.urgency === 'now') urgent += 1;
228
+ }
229
+ }
230
+ if (urgent > 0) return { text: String(urgent), urgent: true, pending, urgentCount: urgent };
231
+ return { text: pending ? String(pending) : '', urgent: false, pending, urgentCount: 0 };
232
+ }
233
+
229
234
  function headLine(doc, item, { relay, now, label }) {
230
235
  const head = el(doc, 'div', 'inbox-item-head');
231
236
  head.append(el(doc, 'span', 'inbox-item-kind', label));
@@ -263,9 +268,17 @@ function sessionLine(doc, item, relay, { withLink = true } = {}) {
263
268
  return line;
264
269
  }
265
270
 
266
- function outcomeLine(doc, item) {
271
+ function outcomeLine(doc, item, mode = 'inbox') {
267
272
  if (item.status === 'dismissed') return el(doc, 'div', 'inbox-item-outcome', 'Dismissed');
273
+ // The ๐Ÿ““ panel lists every close of the last day; the Inbox only these two.
274
+ if (mode === 'panel' && item.status === 'withdrawn') return el(doc, 'div', 'inbox-item-outcome', 'Withdrawn');
275
+ if (mode === 'panel' && item.status === 'cancelled') return el(doc, 'div', 'inbox-item-outcome', 'Cancelled');
268
276
  if (item.status !== 'answered') return null;
277
+ if (mode === 'panel') {
278
+ // The choice, or the answer's first line.
279
+ const short = toText(item.choice) || toText(String(item.answer || '').split(/\r?\n/).find((line) => line.trim()) || '');
280
+ return el(doc, 'div', 'inbox-item-outcome', `Answered: ${short}`);
281
+ }
269
282
  const answer = toText(item.answer) || toText(item.choice);
270
283
  const via = toText(item.answeredVia);
271
284
  return el(doc, 'div', 'inbox-item-outcome', `Answered: ${answer}${via ? ` (from ${via})` : ''}`);
@@ -273,8 +286,9 @@ function outcomeLine(doc, item) {
273
286
 
274
287
  /**
275
288
  * A question that does not block, as a card: in the Inbox (`mode` "inbox",
276
- * with its session and relay) or in its session ("session"). `answerable`:
277
- * the buttons answer it (this relay's, or a paired relay's ask).
289
+ * with its session and relay), in its session ("session") or in the
290
+ * session's ๐Ÿ““ panel ("panel": its Details open). `answerable`: the buttons
291
+ * answer it (this relay's, or a paired relay's ask).
278
292
  */
279
293
  export function buildAskCard(doc, item, { relay = null, mode = 'inbox', now = Date.now(), answerable = true } = {}) {
280
294
  const card = el(doc, 'article', `inbox-item inbox-ask${mode === 'session' ? ' relay-ask-card' : ''}`);
@@ -282,7 +296,8 @@ export function buildAskCard(doc, item, { relay = null, mode = 'inbox', now = Da
282
296
  card.dataset.kind = 'ask';
283
297
  if (relay?.id) card.dataset.relayId = relay.id;
284
298
  card.dataset.status = toText(item.status) || 'pending';
285
- card.append(headLine(doc, item, { relay, now, label: mode === 'session' ? 'Question for you ยท does not block this session' : 'Question' }));
299
+ const label = mode === 'session' ? 'Question for you ยท does not block this session' : (mode === 'panel' ? 'Question for you' : 'Question');
300
+ card.append(headLine(doc, item, { relay, now, label }));
286
301
  if (mode === 'inbox') card.append(sessionLine(doc, item, relay));
287
302
  else if (item.onBehalf && item.askedBy) card.append(sessionLine(doc, item, relay, { withLink: false }));
288
303
  card.append(el(doc, 'p', 'inbox-item-question', item.question || item.prompt || ''));
@@ -295,9 +310,10 @@ export function buildAskCard(doc, item, { relay = null, mode = 'inbox', now = Da
295
310
  details.append(el(doc, 'summary', '', 'Details'));
296
311
  // Plain text, line breaks kept: never parsed as markup.
297
312
  details.append(el(doc, 'pre', 'inbox-item-detail-text', item.detail));
313
+ if (mode === 'panel') details.open = true;
298
314
  card.append(details);
299
315
  }
300
- const outcome = outcomeLine(doc, item);
316
+ const outcome = outcomeLine(doc, item, mode);
301
317
  if (outcome) card.append(outcome);
302
318
  if (item.status === 'pending' && answerable) {
303
319
  const data = { relayId: relay?.id || 'self', itemId: item.id };