@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,202 @@
1
+ // The live poll's diet: the page polls its conversation every 900 ms, and a
2
+ // finished reply's tool lines and thoughts do not change, so the poll names
3
+ // the replies whose details it already holds and the relay leaves those out.
4
+ // Pure: the relay's route uses the param side, the page the store side.
5
+ //
6
+ // The param (`heldDetails`) is `id~version,id~version,โ€ฆ`: an assistant
7
+ // message id and the version of its details the page holds. The version
8
+ // (`detailsVersion` on every assistant message of a full payload) changes
9
+ // with any row that arrives or changes after the turn (a late sub-agent
10
+ // line, a thought update, a history rebuild), and the relay then sends the
11
+ // details again. A held message comes back as `detailsHeld: true` with empty
12
+ // `activities` and `thoughts`, which the page fills from its store.
13
+
14
+ export const HELD_DETAILS_MAX_IDS = 200;
15
+ export const HELD_DETAILS_MAX_BYTES = 8 * 1024;
16
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
17
+ // The shape of a held reply's activities and thoughts. Bump it whenever the
18
+ // relay changes how those arrays are built for a finished reply (a cap, a
19
+ // sanitiser, a new field): every page then holds a stale version and gets
20
+ // each reply in full once.
21
+ export const HELD_DETAILS_SHAPE = 's1';
22
+ // `s1.<tool rows>.<their highest row id>.<thought rows>.<their highest row
23
+ // id>.<fingerprint of the thoughts' text and done>`. Anything else (a version
24
+ // from before this form) is dropped, so the reply goes out in full.
25
+ const VERSION_PATTERN = /^s\d{1,3}\.\d{1,12}\.\d{1,15}\.\d{1,12}\.\d{1,15}\.[0-9a-f]{8}$/;
26
+
27
+ const nonNegative = (value) => Math.max(0, Math.trunc(Number(value) || 0));
28
+
29
+ /**
30
+ * A finished reply's details version. Tool rows are only ever inserted, so
31
+ * their count and highest row id see every change. A thought row is updated
32
+ * in place as its text streams in and when it is done, so the thoughts also
33
+ * carry a fingerprint of their content (`thoughtFingerprint`).
34
+ */
35
+ export function detailsVersion({ activityCount = 0, activityMaxRowId = 0, thoughtCount = 0, thoughtMaxRowId = 0, thoughtHash = '00000000' } = {}) {
36
+ const hash = /^[0-9a-f]{8}$/.test(String(thoughtHash)) ? String(thoughtHash) : '00000000';
37
+ return [HELD_DETAILS_SHAPE, nonNegative(activityCount), nonNegative(activityMaxRowId), nonNegative(thoughtCount), nonNegative(thoughtMaxRowId), hash].join('.');
38
+ }
39
+
40
+ /**
41
+ * The fingerprint of one reply's thought rows: FNV-1a (32 bit) over each
42
+ * row's id, done and text, in row id order. Any change to a thought's text or
43
+ * done changes it, two in the same millisecond included.
44
+ */
45
+ export function thoughtFingerprint(rows) {
46
+ let hash = 0x811c9dc5;
47
+ const mix = (text) => {
48
+ for (let i = 0; i < text.length; i += 1) {
49
+ hash ^= text.charCodeAt(i);
50
+ hash = Math.imul(hash, 0x01000193) >>> 0;
51
+ }
52
+ };
53
+ const ordered = [...(Array.isArray(rows) ? rows : [])].sort((a, b) => Number(a?.rowid) - Number(b?.rowid));
54
+ for (const row of ordered) mix(`${nonNegative(row?.rowid)}\u0001${Number(row?.done) ? 1 : 0}\u0001${String(row?.text ?? '')}\u0002`);
55
+ return hash.toString(16).padStart(8, '0');
56
+ }
57
+
58
+ /**
59
+ * The relay's reading of the param: a Map id โ†’ version, or null when it
60
+ * names nothing usable or is too big (over 8 KB or 200 entries: the poll
61
+ * then gets the full payload). Entries that are not `id~version` with an id
62
+ * of [A-Za-z0-9_-]{1,64} are dropped.
63
+ */
64
+ export function parseHeldDetailsParam(raw) {
65
+ if (raw === undefined || raw === null) return null;
66
+ const text = Array.isArray(raw) ? '' : String(raw);
67
+ if (!text || text.length > HELD_DETAILS_MAX_BYTES) return null;
68
+ const parts = text.split(',');
69
+ if (parts.length > HELD_DETAILS_MAX_IDS) return null;
70
+ const held = new Map();
71
+ for (const part of parts) {
72
+ const at = part.indexOf('~');
73
+ if (at <= 0) continue;
74
+ const id = part.slice(0, at);
75
+ const version = part.slice(at + 1);
76
+ if (!ID_PATTERN.test(id) || !VERSION_PATTERN.test(version)) continue;
77
+ held.set(id, version);
78
+ }
79
+ return held.size ? held : null;
80
+ }
81
+
82
+ /**
83
+ * The page's side: what it holds of one open conversation's finished
84
+ * replies. Filled only from conversation payloads fetched over HTTP (never
85
+ * from a socket's reply: the turn-end fallback carries the live list, not
86
+ * the finished one), and only with what it is shown.
87
+ */
88
+ export function createHeldDetailsStore() {
89
+ let conversationId = '';
90
+ // id โ†’ { version, activities, thoughts }, in the payload's message order.
91
+ let held = new Map();
92
+ // A poll whose answer named a reply the page does not hold is dropped; the
93
+ // next poll goes without the param. Two such drops in a row and the param
94
+ // stays off until the next full load.
95
+ let fullNext = false;
96
+ let disabled = false;
97
+ let dropStreak = 0;
98
+
99
+ function forConversation(id) {
100
+ const key = String(id || '');
101
+ if (key !== conversationId) {
102
+ conversationId = key;
103
+ held = new Map();
104
+ fullNext = false;
105
+ disabled = false;
106
+ dropStreak = 0;
107
+ }
108
+ }
109
+
110
+ function record(messages) {
111
+ for (const message of Array.isArray(messages) ? messages : []) {
112
+ if (message?.role !== 'assistant' || message.detailsHeld) continue;
113
+ const id = String(message.id || '');
114
+ const version = String(message.detailsVersion || '');
115
+ if (!ID_PATTERN.test(id) || !VERSION_PATTERN.test(version)) continue;
116
+ held.delete(id);
117
+ held.set(id, {
118
+ version,
119
+ activities: Array.isArray(message.activities) ? message.activities : [],
120
+ thoughts: Array.isArray(message.thoughts) ? message.thoughts : [],
121
+ });
122
+ }
123
+ }
124
+
125
+ return {
126
+ /**
127
+ * A payload was applied. `full`: a load of the whole window (opening,
128
+ * reconnecting, a reload), which replaces what is held; otherwise (a
129
+ * poll, a page of older history) its replies are added. `shownIds`, when
130
+ * given, is every message on screen now: entries for others go.
131
+ */
132
+ noteApplied(id, payload, { full = false, shownIds = null } = {}) {
133
+ forConversation(id);
134
+ if (full) {
135
+ held = new Map();
136
+ fullNext = false;
137
+ disabled = false;
138
+ dropStreak = 0;
139
+ }
140
+ record(payload?.messages);
141
+ if (shownIds) {
142
+ const shown = new Set(Array.from(shownIds, (value) => String(value)));
143
+ for (const key of [...held.keys()]) if (!shown.has(key)) held.delete(key);
144
+ }
145
+ },
146
+
147
+ /** The param for the next poll of `id`, or '' to ask for everything. */
148
+ param(id) {
149
+ forConversation(id);
150
+ if (fullNext) {
151
+ fullNext = false;
152
+ return '';
153
+ }
154
+ if (disabled || !held.size) return '';
155
+ // The newest first, as many as fit in 8 KB and 200 entries.
156
+ const parts = [];
157
+ let bytes = 0;
158
+ for (const [key, entry] of [...held.entries()].reverse()) {
159
+ const part = `${key}~${entry.version}`;
160
+ if (parts.length >= HELD_DETAILS_MAX_IDS || bytes + part.length + 1 > HELD_DETAILS_MAX_BYTES) break;
161
+ parts.push(part);
162
+ bytes += part.length + 1;
163
+ }
164
+ return parts.join(',');
165
+ },
166
+
167
+ /**
168
+ * Fill the held replies of a poll's answer from the store, in place.
169
+ * Returns false (the answer must be dropped) when it names one the store
170
+ * does not hold.
171
+ */
172
+ restore(id, payload) {
173
+ forConversation(id);
174
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
175
+ const missing = messages.some((message) => message?.detailsHeld && !held.has(String(message.id || '')));
176
+ if (missing) {
177
+ dropStreak += 1;
178
+ if (dropStreak >= 2) disabled = true;
179
+ else fullNext = true;
180
+ return false;
181
+ }
182
+ let restored = 0;
183
+ for (const message of messages) {
184
+ if (!message?.detailsHeld) continue;
185
+ const entry = held.get(String(message.id));
186
+ message.activities = entry.activities;
187
+ message.thoughts = entry.thoughts;
188
+ message.detailsVersion = entry.version;
189
+ delete message.detailsHeld;
190
+ restored += 1;
191
+ }
192
+ // Only a poll the store answered for ends a run of drops; the full
193
+ // poll after a drop does not.
194
+ if (restored) dropStreak = 0;
195
+ return true;
196
+ },
197
+
198
+ /** For tests and the page's own checks. */
199
+ size() { return held.size; },
200
+ has(messageId) { return held.has(String(messageId || '')); },
201
+ };
202
+ }
@@ -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
 
@@ -271,7 +279,9 @@ async function answerAsk(card, data, { choice = '', dismiss = false } = {}) {
271
279
  return false;
272
280
  }
273
281
  drafts.delete(`${relayId}:${id}`);
274
- 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
+ }
275
285
  return true;
276
286
  }
277
287
 
@@ -318,9 +328,17 @@ async function handleAction(target) {
318
328
  if (data.inboxAction === 'open') {
319
329
  const id = String(data.sessionId || '').trim();
320
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);
321
334
  leaveInboxView();
322
335
  previousConversationId = '';
323
- 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
+ }
324
342
  return;
325
343
  }
326
344
  const buttons = card ? [...card.querySelectorAll('button')] : [target];
@@ -336,73 +354,46 @@ async function handleAction(target) {
336
354
  } finally {
337
355
  if (!done) for (const node of buttons) node.disabled = false;
338
356
  }
339
- if (done) {
340
- renderSessionAskCards();
341
- await refreshInbox();
342
- }
357
+ if (done) await refreshInbox();
343
358
  }
344
359
 
345
- function bindClicks() {
346
- if (clickBound) return;
347
- clickBound = true;
348
- const host = document.getElementById('messages');
349
- 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) => {
350
369
  const target = event.target instanceof Element ? event.target.closest('[data-inbox-action], [data-inbox-filter]') : null;
351
- if (!target || !target.closest('.inbox-view, .relay-ask-container')) return;
370
+ if (!target || !target.closest(scope)) return;
352
371
  event.preventDefault();
353
372
  void handleAction(target);
354
373
  });
355
- host?.addEventListener('input', (event) => {
374
+ host.addEventListener('input', (event) => {
356
375
  const field = event.target instanceof Element && event.target.classList.contains('inbox-answer-text') ? event.target : null;
357
376
  const key = field ? draftKey(field) : '';
358
- if (key) drafts.set(key, field.value);
377
+ if (key) {
378
+ field.dataset.draftTouched = '1';
379
+ drafts.set(key, field.value);
380
+ }
359
381
  });
360
382
  // A fold opened or closed by hand. `toggle` does not bubble: caught on the
361
- // way down. Also covers a session's card that a transcript render dropped
362
- // before renderSessionAskCards drew it again.
363
- host?.addEventListener('toggle', (event) => {
383
+ // way down.
384
+ host.addEventListener('toggle', (event) => {
364
385
  const details = event.target instanceof Element && event.target.matches(INBOX_FOLD_SELECTOR) ? event.target : null;
365
386
  if (details && details.isConnected) noteInboxFold(openFolds, details);
366
387
  }, true);
367
388
  }
368
389
 
369
- // โ”€โ”€โ”€ The card in the session โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
370
-
371
- /**
372
- * The pending asks of the conversation on screen, as cards at the end of its
373
- * transcript (before the cards it waits on). Called with every render of the
374
- * question cards; a no-op while nothing changed.
375
- */
376
- export function renderSessionAskCards() {
377
- const host = document.getElementById('messages');
378
- if (!host || IS_SHARED_VIEW) return;
379
- const id = String(currentConvId || '').trim();
380
- const shown = inboxActive || isStatusViewActive() || !id
381
- ? []
382
- : [...sessionAsks.values()]
383
- .filter((ask) => ask && (ask.conversationId || ask.session?.id) === id && ask.status === 'pending')
384
- .sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
385
- const key = JSON.stringify([id, shown.map((ask) => [ask.id, ask.status])]);
386
- const existing = host.querySelectorAll('.relay-ask-container');
387
- if (key === sessionCardKey && existing.length === shown.length && [...existing].every((node) => node.isConnected)) return;
388
- sessionCardKey = key;
389
- const focused = captureDrafts(host);
390
- existing.forEach((node) => captureInboxFolds(openFolds, node));
391
- existing.forEach((node) => node.remove());
392
- const firstQuestion = host.querySelector('.relay-question-container');
393
- for (const ask of shown) {
394
- const wrapper = document.createElement('div');
395
- wrapper.className = 'msg relay-ask-container';
396
- wrapper.dataset.askId = ask.id;
397
- wrapper.append(buildAskCard(document, { ...ask, session: ask.session || { id } }, { relay: { id: 'self', self: true }, mode: 'session', now: Date.now() }));
398
- if (firstQuestion) host.insertBefore(wrapper, firstQuestion);
399
- else host.append(wrapper);
400
- restoreInboxFolds(openFolds, wrapper);
401
- }
402
- restoreDrafts(host, focused);
390
+ function bindClicks() {
391
+ if (clickBound) return;
392
+ clickBound = true;
393
+ bindInboxActionHost(document.getElementById('messages'));
403
394
  }
404
395
 
405
- /** Boot: the count, the session cards, a refresh every minute (paired relays). */
396
+ /** Boot: the count and a refresh every minute (paired relays). */
406
397
  export function startInbox() {
407
398
  if (started || IS_SHARED_VIEW) return;
408
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 };
@@ -100,6 +100,7 @@ import {
100
100
  setConversationTaskStallMinutes,
101
101
  } from './background-tasks-view.mjs';
102
102
  import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
103
+ import { setQuestionsConversation } from './questions-panel-ui.js';
103
104
  import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
104
105
  import { setUsageLimitConversation } from './usage-limit-ui.js';
105
106
  import { mergeConversationPreviews } from './preview-cards.mjs';
@@ -109,6 +110,11 @@ import { clearAttachments, setRepoBrowserSessionInfo, loadRepoBrowserTree, getRe
109
110
  import { buildKnownCwdOptions, normalizeKnownCwdPath } from './known-cwd-options.mjs';
110
111
  import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
111
112
  import { conversationPayloadCache, payloadHasMoreOlder, windowPageInfo } from './conversation-payload-cache.mjs';
113
+ import { createHeldDetailsStore } from './held-details.mjs';
114
+
115
+ // The finished replies' details this page holds of the open conversation
116
+ // (the live poll's `heldDetails`, held-details.mjs).
117
+ export const heldDetailsStore = createHeldDetailsStore();
112
118
  import { loadKeepingLiveRows, shouldKeepTranscriptOnReload } from './transcript-reload-freshness.mjs';
113
119
  import { createInfiniteLoader } from './infinite-loader.js';
114
120
  import {
@@ -993,7 +999,15 @@ export function applyLoadedConversationState(id, response, {
993
999
  mergeFrom = null,
994
1000
  windowPayload = null,
995
1001
  backgroundTasksAsOf = null,
1002
+ fromPoll = false,
996
1003
  } = {}) {
1004
+ // What the page holds of the finished replies, for the live poll's
1005
+ // `heldDetails`: a full load replaces it, a poll or an older page adds to
1006
+ // it, and a poll trims it to the rows on screen.
1007
+ if (response && !IS_SHARED_VIEW) {
1008
+ const shown = fromPoll ? (response.messages || []).map((message) => message?.id) : null;
1009
+ heldDetailsStore.noteApplied(id, response, { full: !fromPoll && !keepTranscript && !mergeFrom, shownIds: shown });
1010
+ }
997
1011
  if (!response) {
998
1012
  if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
999
1013
  setRepoBrowserSessionInfo('', '');
@@ -1089,6 +1103,8 @@ export function applyLoadedConversationState(id, response, {
1089
1103
  ? mergeMessagesIntoTranscript(mergeFrom, response.messages, response)
1090
1104
  : renderMessages(response.messages, !restoreScroll, response);
1091
1105
  }
1106
+ // ๐Ÿ““: a newly opened conversation loads its questions (a no-op on the poll).
1107
+ setQuestionsConversation(id);
1092
1108
  hydrateConversationDraft(id, {
1093
1109
  draftText: response.draftText,
1094
1110
  draftAttachments: response.draftAttachments,
@@ -1178,6 +1194,7 @@ function applyCachedConversationState(id, response, { viewAnchor = null } = {})
1178
1194
  if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
1179
1195
  setHiddenMessagesConversation(id);
1180
1196
  const didRenderMessages = renderMessages(response.messages, false, response);
1197
+ setQuestionsConversation(id);
1181
1198
  // The panel shows this conversation's tasks as the socket keeps them.
1182
1199
  setBackgroundTasksConversation(id);
1183
1200
  window.syncChatTitleControls?.();