@oar-sh/oar 0.9.15 → 0.9.16
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.
- package/CHANGELOG.md +85 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +568 -42
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// The Inbox, as DOM: every pending question across this relay and its paired
|
|
2
|
+
// relays (GET /api/inbox), grouped by relay, newest first; what was answered
|
|
3
|
+
// or dismissed in the last day under a divider. And the card a question that
|
|
4
|
+
// does not block (remote_relay `ask`) shows in its own session.
|
|
5
|
+
//
|
|
6
|
+
// Every text here is written by an agent, some on other relays: it goes into
|
|
7
|
+
// the page through textContent only, never as HTML or a link. Buttons carry
|
|
8
|
+
// what they act on in data attributes; inbox-ui.js handles the clicks.
|
|
9
|
+
//
|
|
10
|
+
// The browser cannot import shared/ (only server/public is served): the few
|
|
11
|
+
// values of shared/relay-asks.mjs this needs are kept here, and
|
|
12
|
+
// inbox-view.test.mjs asserts they match.
|
|
13
|
+
|
|
14
|
+
export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
|
|
15
|
+
export const INBOX_ANSWERED_KEEP_MS = 24 * 60 * 60_000;
|
|
16
|
+
export const INBOX_FILTERS = Object.freeze(['all', 'mine', 'agents']);
|
|
17
|
+
|
|
18
|
+
const CLOSED_STATUSES = new Set(['answered', 'dismissed']);
|
|
19
|
+
|
|
20
|
+
function toText(value) {
|
|
21
|
+
return String(value ?? '').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function el(doc, tag, className = '', text = null) {
|
|
25
|
+
const node = doc.createElement(tag);
|
|
26
|
+
if (className) node.className = className;
|
|
27
|
+
if (text !== null && text !== undefined) node.textContent = String(text);
|
|
28
|
+
return node;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function button(doc, className, label, data = {}) {
|
|
32
|
+
const node = el(doc, 'button', className, label);
|
|
33
|
+
node.type = 'button';
|
|
34
|
+
for (const [key, value] of Object.entries(data)) {
|
|
35
|
+
if (value !== undefined && value !== null && value !== '') node.dataset[key] = String(value);
|
|
36
|
+
}
|
|
37
|
+
return node;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** "just now", "5 min", "3 h", "2 d" since `createdAt`. */
|
|
41
|
+
export function formatInboxAge(createdAt, now = Date.now()) {
|
|
42
|
+
const at = Date.parse(toText(createdAt));
|
|
43
|
+
if (!Number.isFinite(at)) return '';
|
|
44
|
+
const minutes = Math.max(0, Math.floor((now - at) / 60_000));
|
|
45
|
+
if (minutes < 1) return 'just now';
|
|
46
|
+
if (minutes < 60) return `${minutes} min`;
|
|
47
|
+
const hours = Math.floor(minutes / 60);
|
|
48
|
+
if (hours < 48) return `${hours} h`;
|
|
49
|
+
return `${Math.floor(hours / 24)} d`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A link that opens one session in a relay's web UI (remoteConversationUrl's copy). */
|
|
53
|
+
export function inboxSessionUrl(relayUrl, sessionId) {
|
|
54
|
+
const base = toText(relayUrl).replace(/\/+$/, '');
|
|
55
|
+
const id = toText(sessionId);
|
|
56
|
+
if (!base || !/^https?:\/\//i.test(base)) return '';
|
|
57
|
+
return id ? `${base}/?conv=${encodeURIComponent(id)}` : `${base}/`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The filter: "mine" are the questions in sessions the owner started,
|
|
62
|
+
* "agents" those in sessions an agent started.
|
|
63
|
+
*/
|
|
64
|
+
export function inboxFilterMatches(item, filter = 'all') {
|
|
65
|
+
if (filter === 'mine') return item?.session?.startedBy !== 'agent';
|
|
66
|
+
if (filter === 'agents') return item?.session?.startedBy === 'agent';
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isClosed(item) {
|
|
71
|
+
return CLOSED_STATUSES.has(toText(item?.status));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function newestFirst(a, b) {
|
|
75
|
+
return toText(b?.createdAt).localeCompare(toText(a?.createdAt));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The Inbox's groups: per relay (this one first, then the paired ones as
|
|
80
|
+
* listed), its pending items newest first and its items closed in the last
|
|
81
|
+
* day; a relay that did not answer shows as such, with nothing listed.
|
|
82
|
+
*/
|
|
83
|
+
export function inboxSections(relays, { filter = 'all', now = Date.now() } = {}) {
|
|
84
|
+
return (Array.isArray(relays) ? relays : []).map((relay) => {
|
|
85
|
+
const items = (Array.isArray(relay?.items) ? relay.items : []).filter((item) => item && toText(item.id) && inboxFilterMatches(item, filter));
|
|
86
|
+
const pending = items.filter((item) => toText(item.status) === 'pending').sort(newestFirst);
|
|
87
|
+
const closed = items
|
|
88
|
+
.filter((item) => isClosed(item) && now - (Date.parse(toText(item.answeredAt || item.createdAt)) || 0) <= INBOX_ANSWERED_KEEP_MS)
|
|
89
|
+
.sort((a, b) => toText(b.answeredAt || b.createdAt).localeCompare(toText(a.answeredAt || a.createdAt)));
|
|
90
|
+
return {
|
|
91
|
+
relay: {
|
|
92
|
+
id: toText(relay?.id),
|
|
93
|
+
name: toText(relay?.name) || toText(relay?.id),
|
|
94
|
+
self: relay?.self === true,
|
|
95
|
+
url: toText(relay?.url),
|
|
96
|
+
reachable: relay?.reachable !== false,
|
|
97
|
+
error: toText(relay?.error),
|
|
98
|
+
},
|
|
99
|
+
pending,
|
|
100
|
+
closed,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A load that never runs twice at once, and never misses a change: a call
|
|
107
|
+
* while one runs is answered by that one and asks for one more after it
|
|
108
|
+
* (once, however many calls came meanwhile). Resolves with the last load's
|
|
109
|
+
* result.
|
|
110
|
+
*/
|
|
111
|
+
export function createCoalescedRefresh(load) {
|
|
112
|
+
let running = null;
|
|
113
|
+
let again = false;
|
|
114
|
+
function refresh() {
|
|
115
|
+
if (running) {
|
|
116
|
+
again = true;
|
|
117
|
+
return running;
|
|
118
|
+
}
|
|
119
|
+
running = (async () => {
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
do {
|
|
123
|
+
again = false;
|
|
124
|
+
result = await load();
|
|
125
|
+
} while (again);
|
|
126
|
+
return result;
|
|
127
|
+
} finally {
|
|
128
|
+
running = null;
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
return running;
|
|
132
|
+
}
|
|
133
|
+
return refresh;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* This relay's asks out of an Inbox load (GET /api/inbox), by id: what the
|
|
138
|
+
* session cards show. Built anew from each load, so an ask that was
|
|
139
|
+
* withdrawn or cancelled (no longer listed) drops out.
|
|
140
|
+
*/
|
|
141
|
+
export function sessionAsksFromInbox(relays) {
|
|
142
|
+
const own = (Array.isArray(relays) ? relays : []).find((relay) => relay?.self === true);
|
|
143
|
+
const asks = new Map();
|
|
144
|
+
for (const item of Array.isArray(own?.items) ? own.items : []) {
|
|
145
|
+
if (item?.kind === 'ask' && toText(item.id)) asks.set(item.id, { ...item, conversationId: item.session?.id });
|
|
146
|
+
}
|
|
147
|
+
return asks;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Pending items across every relay (the sidebar's badge), whatever the filter. */
|
|
151
|
+
export function inboxPendingCount(relays) {
|
|
152
|
+
return (Array.isArray(relays) ? relays : [])
|
|
153
|
+
.reduce((sum, relay) => sum + (Array.isArray(relay?.items) ? relay.items : []).filter((item) => toText(item?.status) === 'pending').length, 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function headLine(doc, item, { relay, now, label }) {
|
|
157
|
+
const head = el(doc, 'div', 'inbox-item-head');
|
|
158
|
+
head.append(el(doc, 'span', 'inbox-item-kind', label));
|
|
159
|
+
if (item.urgency === 'now') head.append(el(doc, 'span', 'inbox-item-urgent', 'urgent'));
|
|
160
|
+
const age = formatInboxAge(item.createdAt, now);
|
|
161
|
+
if (age) head.append(el(doc, 'span', 'inbox-item-age', age));
|
|
162
|
+
if (relay && !relay.self) head.append(el(doc, 'span', 'inbox-item-relay', relay.name));
|
|
163
|
+
return head;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sessionLine(doc, item, relay, { withLink = true } = {}) {
|
|
167
|
+
const line = el(doc, 'div', 'inbox-item-session');
|
|
168
|
+
const title = toText(item?.session?.title) || 'Untitled session';
|
|
169
|
+
if (withLink && relay?.self) {
|
|
170
|
+
line.append(button(doc, 'inbox-open-session', title, { inboxAction: 'open', sessionId: item?.session?.id }));
|
|
171
|
+
} else if (withLink && relay && !relay.self) {
|
|
172
|
+
const url = inboxSessionUrl(relay.url, item?.session?.id);
|
|
173
|
+
if (url) {
|
|
174
|
+
const link = el(doc, 'a', 'inbox-open-there', title);
|
|
175
|
+
link.href = url;
|
|
176
|
+
link.target = '_blank';
|
|
177
|
+
link.rel = 'noopener noreferrer';
|
|
178
|
+
line.append(link);
|
|
179
|
+
} else {
|
|
180
|
+
line.append(el(doc, 'span', 'inbox-session-title', title));
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
line.append(el(doc, 'span', 'inbox-session-title', title));
|
|
184
|
+
}
|
|
185
|
+
const asker = item?.askedBy;
|
|
186
|
+
if (item?.onBehalf && asker) {
|
|
187
|
+
const who = [toText(asker.title) || toText(asker.conversationId), toText(asker.relay) ? `on ${toText(asker.relay)}` : ''].filter(Boolean).join(' ');
|
|
188
|
+
if (who) line.append(el(doc, 'span', 'inbox-item-asker', `asked by ${who}`));
|
|
189
|
+
}
|
|
190
|
+
return line;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function outcomeLine(doc, item) {
|
|
194
|
+
if (item.status === 'dismissed') return el(doc, 'div', 'inbox-item-outcome', 'Dismissed');
|
|
195
|
+
if (item.status !== 'answered') return null;
|
|
196
|
+
const answer = toText(item.answer) || toText(item.choice);
|
|
197
|
+
const via = toText(item.answeredVia);
|
|
198
|
+
return el(doc, 'div', 'inbox-item-outcome', `Answered: ${answer}${via ? ` (from ${via})` : ''}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A question that does not block, as a card: in the Inbox (`mode` "inbox",
|
|
203
|
+
* with its session and relay) or in its session ("session"). `answerable`:
|
|
204
|
+
* the buttons answer it (this relay's, or a paired relay's ask).
|
|
205
|
+
*/
|
|
206
|
+
export function buildAskCard(doc, item, { relay = null, mode = 'inbox', now = Date.now(), answerable = true } = {}) {
|
|
207
|
+
const card = el(doc, 'article', `inbox-item inbox-ask${mode === 'session' ? ' relay-ask-card' : ''}`);
|
|
208
|
+
card.dataset.itemId = toText(item.id);
|
|
209
|
+
card.dataset.kind = 'ask';
|
|
210
|
+
if (relay?.id) card.dataset.relayId = relay.id;
|
|
211
|
+
card.dataset.status = toText(item.status) || 'pending';
|
|
212
|
+
card.append(headLine(doc, item, { relay, now, label: mode === 'session' ? 'Question for you · does not block this session' : 'Question' }));
|
|
213
|
+
if (mode === 'inbox') card.append(sessionLine(doc, item, relay));
|
|
214
|
+
else if (item.onBehalf && item.askedBy) card.append(sessionLine(doc, item, relay, { withLink: false }));
|
|
215
|
+
card.append(el(doc, 'p', 'inbox-item-question', item.question || item.prompt || ''));
|
|
216
|
+
const options = Array.isArray(item.options) ? item.options.map(toText).filter(Boolean) : [];
|
|
217
|
+
const recommendation = toText(item.recommendation);
|
|
218
|
+
const recommendedOption = options.find((option) => option === recommendation) || '';
|
|
219
|
+
if (recommendation && !recommendedOption) card.append(el(doc, 'p', 'inbox-item-recommendation', `Recommended: ${recommendation}`));
|
|
220
|
+
if (toText(item.detail)) {
|
|
221
|
+
const details = el(doc, 'details', 'inbox-item-detail');
|
|
222
|
+
details.append(el(doc, 'summary', '', 'Details'));
|
|
223
|
+
// Plain text, line breaks kept: never parsed as markup.
|
|
224
|
+
details.append(el(doc, 'pre', 'inbox-item-detail-text', item.detail));
|
|
225
|
+
card.append(details);
|
|
226
|
+
}
|
|
227
|
+
const outcome = outcomeLine(doc, item);
|
|
228
|
+
if (outcome) card.append(outcome);
|
|
229
|
+
if (item.status === 'pending' && answerable) {
|
|
230
|
+
const data = { relayId: relay?.id || 'self', itemId: item.id };
|
|
231
|
+
if (options.length) {
|
|
232
|
+
const row = el(doc, 'div', 'inbox-item-options');
|
|
233
|
+
for (const option of options) {
|
|
234
|
+
const choice = button(doc, `inbox-choice${option === recommendedOption ? ' recommended' : ''}`, option, { ...data, inboxAction: 'choice', choice: option });
|
|
235
|
+
if (option === recommendedOption) {
|
|
236
|
+
choice.append(el(doc, 'span', 'inbox-recommended-tag', 'recommended'));
|
|
237
|
+
choice.setAttribute('aria-label', `${option} (recommended)`);
|
|
238
|
+
}
|
|
239
|
+
row.append(choice);
|
|
240
|
+
}
|
|
241
|
+
card.append(row);
|
|
242
|
+
}
|
|
243
|
+
const reply = el(doc, 'div', 'inbox-item-reply');
|
|
244
|
+
const field = el(doc, 'textarea', 'inbox-answer-text');
|
|
245
|
+
field.rows = 2;
|
|
246
|
+
field.placeholder = options.length ? 'Or type an answer…' : 'Your answer…';
|
|
247
|
+
field.setAttribute('aria-label', 'Answer');
|
|
248
|
+
field.dataset.itemId = toText(item.id);
|
|
249
|
+
reply.append(field);
|
|
250
|
+
const actions = el(doc, 'div', 'inbox-item-actions');
|
|
251
|
+
actions.append(button(doc, 'inbox-answer', 'Answer', { ...data, inboxAction: 'answer' }));
|
|
252
|
+
actions.append(button(doc, 'inbox-dismiss', 'Dismiss', { ...data, inboxAction: 'dismiss' }));
|
|
253
|
+
reply.append(actions);
|
|
254
|
+
card.append(reply);
|
|
255
|
+
}
|
|
256
|
+
return card;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A card a turn waits on (ask_user) or an approval card, in the Inbox. Here:
|
|
261
|
+
* a plain one is answered like in its session (choices, or typed text); one
|
|
262
|
+
* with fields or several picks opens its session. A paired relay's is never
|
|
263
|
+
* answered from here: "Open there".
|
|
264
|
+
*/
|
|
265
|
+
export function buildCardItem(doc, item, { relay = null, now = Date.now() } = {}) {
|
|
266
|
+
const approval = item.kind === 'approval';
|
|
267
|
+
const card = el(doc, 'article', `inbox-item inbox-card${approval ? ' inbox-approval' : ''}`);
|
|
268
|
+
card.dataset.itemId = toText(item.id);
|
|
269
|
+
card.dataset.kind = toText(item.kind) || 'ask_user';
|
|
270
|
+
if (relay?.id) card.dataset.relayId = relay.id;
|
|
271
|
+
card.dataset.status = toText(item.status) || 'pending';
|
|
272
|
+
card.append(headLine(doc, item, { relay, now, label: approval ? 'Approval · the session waits' : 'Question · the session waits' }));
|
|
273
|
+
card.append(sessionLine(doc, item, relay));
|
|
274
|
+
if (toText(item.header)) card.append(el(doc, 'div', 'inbox-item-header', item.header));
|
|
275
|
+
if (toText(item.question)) card.append(el(doc, 'p', 'inbox-item-question', item.question));
|
|
276
|
+
else if (approval) card.append(el(doc, 'p', 'inbox-item-question', 'An Allow / Deny card on that relay.'));
|
|
277
|
+
const outcome = outcomeLine(doc, item);
|
|
278
|
+
if (outcome) card.append(outcome);
|
|
279
|
+
if (item.status !== 'pending') return card;
|
|
280
|
+
if (!relay?.self) {
|
|
281
|
+
const url = inboxSessionUrl(relay?.url, item?.session?.id);
|
|
282
|
+
const actions = el(doc, 'div', 'inbox-item-actions');
|
|
283
|
+
if (url) {
|
|
284
|
+
const link = el(doc, 'a', 'inbox-open-there inbox-open-there-button', 'Open there');
|
|
285
|
+
link.href = url;
|
|
286
|
+
link.target = '_blank';
|
|
287
|
+
link.rel = 'noopener noreferrer';
|
|
288
|
+
actions.append(link);
|
|
289
|
+
} else {
|
|
290
|
+
actions.append(el(doc, 'span', 'inbox-item-note', 'Answer it on that relay.'));
|
|
291
|
+
}
|
|
292
|
+
card.append(actions);
|
|
293
|
+
return card;
|
|
294
|
+
}
|
|
295
|
+
const options = Array.isArray(item.options) ? item.options.map(toText).filter(Boolean) : [];
|
|
296
|
+
const data = { relayId: 'self', itemId: item.id, sessionId: item?.session?.id, sdkSessionId: item.sdkSessionId || '' };
|
|
297
|
+
if (item.structured || item.multiSelect) {
|
|
298
|
+
const actions = el(doc, 'div', 'inbox-item-actions');
|
|
299
|
+
actions.append(button(doc, 'inbox-open-session-button', 'Open the session to answer', { inboxAction: 'open', sessionId: item?.session?.id }));
|
|
300
|
+
card.append(actions);
|
|
301
|
+
return card;
|
|
302
|
+
}
|
|
303
|
+
if (options.length) {
|
|
304
|
+
const row = el(doc, 'div', 'inbox-item-options');
|
|
305
|
+
for (const option of options) row.append(button(doc, 'inbox-choice', option, { ...data, inboxAction: 'card-choice', choice: option }));
|
|
306
|
+
card.append(row);
|
|
307
|
+
}
|
|
308
|
+
if (item.allowFreeform !== false || !options.length) {
|
|
309
|
+
const reply = el(doc, 'div', 'inbox-item-reply');
|
|
310
|
+
const field = el(doc, 'textarea', 'inbox-answer-text');
|
|
311
|
+
field.rows = 2;
|
|
312
|
+
field.placeholder = 'Your answer…';
|
|
313
|
+
field.setAttribute('aria-label', 'Answer');
|
|
314
|
+
field.dataset.itemId = toText(item.id);
|
|
315
|
+
reply.append(field);
|
|
316
|
+
const actions = el(doc, 'div', 'inbox-item-actions');
|
|
317
|
+
actions.append(button(doc, 'inbox-answer', 'Answer', { ...data, inboxAction: 'card-answer' }));
|
|
318
|
+
reply.append(actions);
|
|
319
|
+
card.append(reply);
|
|
320
|
+
}
|
|
321
|
+
return card;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildItem(doc, item, relay, now) {
|
|
325
|
+
if (item.kind === 'ask') return buildAskCard(doc, item, { relay, now, mode: 'inbox', answerable: true });
|
|
326
|
+
return buildCardItem(doc, item, { relay, now });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The whole Inbox: the filter, then one group per relay. */
|
|
330
|
+
export function renderInbox(doc, { relays = [], filter = 'all', now = Date.now(), loading = false } = {}) {
|
|
331
|
+
const root = el(doc, 'section', 'status-view inbox-view');
|
|
332
|
+
root.setAttribute('aria-label', 'Inbox');
|
|
333
|
+
const bar = el(doc, 'div', 'inbox-filter');
|
|
334
|
+
bar.setAttribute('role', 'group');
|
|
335
|
+
bar.setAttribute('aria-label', 'Show');
|
|
336
|
+
for (const [value, label] of [['all', 'All'], ['mine', 'Mine'], ['agents', 'Agents']]) {
|
|
337
|
+
const option = button(doc, `inbox-filter-option${filter === value ? ' active' : ''}`, label, { inboxFilter: value });
|
|
338
|
+
option.setAttribute('aria-pressed', filter === value ? 'true' : 'false');
|
|
339
|
+
bar.append(option);
|
|
340
|
+
}
|
|
341
|
+
root.append(bar);
|
|
342
|
+
const sections = inboxSections(relays, { filter, now });
|
|
343
|
+
let anything = false;
|
|
344
|
+
for (const section of sections) {
|
|
345
|
+
const group = el(doc, 'section', 'inbox-relay');
|
|
346
|
+
group.dataset.relayId = section.relay.id;
|
|
347
|
+
const heading = el(doc, 'h2', 'inbox-relay-name', section.relay.self ? `${section.relay.name} (this relay)` : section.relay.name);
|
|
348
|
+
group.append(heading);
|
|
349
|
+
if (!section.relay.reachable) {
|
|
350
|
+
group.append(el(doc, 'p', 'inbox-relay-unreachable', `Not reachable${section.relay.error ? `: ${section.relay.error}` : ''}`));
|
|
351
|
+
root.append(group);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!section.pending.length && !section.closed.length) {
|
|
355
|
+
if (!section.relay.self) continue;
|
|
356
|
+
group.append(el(doc, 'p', 'inbox-empty', loading ? 'Loading…' : 'Nothing waits for you here.'));
|
|
357
|
+
}
|
|
358
|
+
for (const item of section.pending) group.append(buildItem(doc, item, section.relay, now));
|
|
359
|
+
if (section.closed.length) {
|
|
360
|
+
const divider = el(doc, 'div', 'inbox-divider', 'Answered in the last day');
|
|
361
|
+
divider.setAttribute('role', 'separator');
|
|
362
|
+
group.append(divider);
|
|
363
|
+
for (const item of section.closed) group.append(buildItem(doc, item, section.relay, now));
|
|
364
|
+
}
|
|
365
|
+
anything = anything || section.pending.length > 0 || section.closed.length > 0;
|
|
366
|
+
root.append(group);
|
|
367
|
+
}
|
|
368
|
+
if (!anything && filter !== 'all') root.append(el(doc, 'p', 'inbox-empty', 'Nothing matches this filter.'));
|
|
369
|
+
return root;
|
|
370
|
+
}
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
setSummaryModalLeaveHandler,
|
|
30
30
|
conversationGroups,
|
|
31
31
|
mergeConversationGroups,
|
|
32
|
+
forgetKeptEmptyConversationGroups,
|
|
32
33
|
readConversationGroupFoldChoices,
|
|
33
34
|
pruneConversationGroups,
|
|
34
35
|
hasSessionWorkerStatus,
|
|
@@ -113,6 +114,7 @@ import {
|
|
|
113
114
|
conversationProviderIndicatorLabel,
|
|
114
115
|
} from './conversation-provider-indicator.mjs';
|
|
115
116
|
import { leaveStatusView } from './status-view.mjs';
|
|
117
|
+
import { leaveInboxView } from './inbox-ui.js';
|
|
116
118
|
import {
|
|
117
119
|
CLAUDE_CLOUD_PROVIDER,
|
|
118
120
|
buildCloudSourceWarnings,
|
|
@@ -132,6 +134,7 @@ import {
|
|
|
132
134
|
} from './claude-cloud-ui.mjs';
|
|
133
135
|
import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
|
|
134
136
|
import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
|
|
137
|
+
import { relayScheduleMarkerHtml } from './relay-schedules-ui.js';
|
|
135
138
|
import {
|
|
136
139
|
normalizeConversationFilter,
|
|
137
140
|
conversationMatchesFilter,
|
|
@@ -378,6 +381,9 @@ export async function refreshConversations(options = {}) {
|
|
|
378
381
|
if (!knownConversationIds.has(id)) delete conversations[id];
|
|
379
382
|
}
|
|
380
383
|
}
|
|
384
|
+
// The owner's kept empty groups come with the first page only: the page
|
|
385
|
+
// names the ones still kept.
|
|
386
|
+
forgetKeptEmptyConversationGroups();
|
|
381
387
|
mergeConversationGroups(r.groups);
|
|
382
388
|
applyConversationPage(r.conversations || []);
|
|
383
389
|
conversationRowsFetchedSinceRefresh = Array.isArray(r.conversations) ? r.conversations.length : 0;
|
|
@@ -613,6 +619,8 @@ function paintConvList() {
|
|
|
613
619
|
const items = arrangeConversationList(inView, conversationGroups, {
|
|
614
620
|
matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
|
|
615
621
|
groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
|
|
622
|
+
// An owner's group an agent emptied shows in the live list, empty.
|
|
623
|
+
emptyGroups: !conversationListArchivedView,
|
|
616
624
|
isOpen: (groupId, group) => {
|
|
617
625
|
const described = describeGroupForHead(group, viewOf);
|
|
618
626
|
const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
|
|
@@ -690,6 +698,8 @@ function paintConvList() {
|
|
|
690
698
|
: renderConversationOriginMarkerHtml(c, { conversations });
|
|
691
699
|
// "watched": a session another conversation watches by id (relay events).
|
|
692
700
|
const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
|
|
701
|
+
// "scheduled": a schedule fires into this session (remote_relay schedule).
|
|
702
|
+
const scheduledMarkerHtml = relayScheduleMarkerHtml(c.id);
|
|
693
703
|
const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
|
|
694
704
|
const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
|
|
695
705
|
// A head row shows the name the group was given by hand; until then the
|
|
@@ -700,7 +710,7 @@ function paintConvList() {
|
|
|
700
710
|
return `
|
|
701
711
|
<div class="conv-item worker-ui-${view.visualState}${view.working ? ' conv-working' : ''}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}${groupClass}" data-conversation-id="${c.id}"${groupAttr} onclick="openConversation('${c.id}')">
|
|
702
712
|
<div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
|
|
703
|
-
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${providerIndicatorHtml}</div>
|
|
713
|
+
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${scheduledMarkerHtml}${providerIndicatorHtml}</div>
|
|
704
714
|
${c.archived
|
|
705
715
|
? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
|
|
706
716
|
: `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
|
|
@@ -711,6 +721,15 @@ function paintConvList() {
|
|
|
711
721
|
// archived or deleted): a head line of its own, with the name and the pill.
|
|
712
722
|
const syntheticHeadHtml = (item) => {
|
|
713
723
|
const name = groupDisplayName(item.group.id);
|
|
724
|
+
// An owner's group an agent emptied: no members, nothing to fold; its
|
|
725
|
+
// menu (right-click or long-press) renames or dissolves it.
|
|
726
|
+
if (item.group.empty) {
|
|
727
|
+
return `
|
|
728
|
+
<div class="conv-item conv-group-head conv-group-synthetic conv-group-empty worker-ui-offline" data-group-id="${escHtml(item.group.id)}" title="An agent moved the last session out of this group; rename or dissolve it from its menu.">
|
|
729
|
+
<div class="conv-title">${escHtml(name)}</div>
|
|
730
|
+
<div class="conv-meta"><span class="conv-meta-primary">group · empty</span></div>
|
|
731
|
+
</div>`;
|
|
732
|
+
}
|
|
714
733
|
const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
|
|
715
734
|
return `
|
|
716
735
|
<div class="conv-item conv-group-head conv-group-synthetic worker-ui-${described.summary.strongest === 'waiting' ? 'question' : 'offline'}" data-group-id="${escHtml(item.group.id)}" onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">
|
|
@@ -1059,7 +1078,9 @@ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript
|
|
|
1059
1078
|
}
|
|
1060
1079
|
|
|
1061
1080
|
export async function openConversation(id, options = {}) {
|
|
1062
|
-
|
|
1081
|
+
// The Inbox owns the transcript like the status view does.
|
|
1082
|
+
const didLeaveInboxView = leaveInboxView();
|
|
1083
|
+
const didLeaveStatusView = leaveStatusView() || didLeaveInboxView;
|
|
1063
1084
|
document.getElementById('input-area')?.removeAttribute('hidden');
|
|
1064
1085
|
const previousConversationId = String(currentConvId || '').trim();
|
|
1065
1086
|
const nextConversationId = String(id || '').trim();
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
renderWatchedBadgeHtml,
|
|
11
11
|
renderWatchingPanelHtml,
|
|
12
12
|
watchersOf,
|
|
13
|
+
watchingChipHint,
|
|
13
14
|
watchingChipText,
|
|
14
15
|
watchingSummary,
|
|
15
16
|
} from './relay-event-view.mjs';
|
|
@@ -164,15 +165,16 @@ export function syncRelayEventHeader() {
|
|
|
164
165
|
}
|
|
165
166
|
const summary = watchingSummary(subscriptions, id);
|
|
166
167
|
const chipText = watchingChipText(summary);
|
|
168
|
+
const chipHint = chipText ? watchingChipHint(summary) : '';
|
|
167
169
|
const watchers = watchersOf(subscriptions, id, { includeAll: true });
|
|
168
170
|
if (!chipText) panelOpen = false;
|
|
169
|
-
const key = JSON.stringify([id, chipText, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
|
|
171
|
+
const key = JSON.stringify([id, chipText, chipHint, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
|
|
170
172
|
if (key === renderedChipKey) return;
|
|
171
173
|
renderedChipKey = key;
|
|
172
174
|
const parts = [];
|
|
173
175
|
if (chipText) {
|
|
174
176
|
const where = summary.relays.length ? ` (also on ${summary.relays.join(', ')})` : '';
|
|
175
|
-
parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}</button>`);
|
|
177
|
+
parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}${chipHint ? ` <span class="relay-watch-hint">${escHtml(chipHint)}</span>` : ''}</button>`);
|
|
176
178
|
}
|
|
177
179
|
const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
|
|
178
180
|
if (badge) parts.push(badge);
|
|
@@ -28,6 +28,7 @@ const EVENT_TYPES = new Set([
|
|
|
28
28
|
]);
|
|
29
29
|
const URGENT_TYPES = new Set(['question_asked', 'error']);
|
|
30
30
|
const DIGEST_PATTERN = /^Digest of \d+ events?/;
|
|
31
|
+
const QUIET_PATTERN = /^Held during quiet hours/;
|
|
31
32
|
const REPLAYED_PATTERN = /^Replayed: \d+ events?/;
|
|
32
33
|
const LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
|
|
33
34
|
const LINE_PREFIX = '[relay event] ';
|
|
@@ -63,15 +64,17 @@ export function isRelayEventMessage(message) {
|
|
|
63
64
|
/**
|
|
64
65
|
* The event lines of an event message without the lead, the types they
|
|
65
66
|
* carry in order, how many events (a digest's "… and N more" counted),
|
|
66
|
-
* whether it is a digest,
|
|
67
|
-
* while the link to it was down. Any text
|
|
67
|
+
* whether it is a digest, whether quiet hours held it, and whether it
|
|
68
|
+
* replays what a paired relay kept while the link to it was down. Any text
|
|
69
|
+
* renders: a stray one as one line.
|
|
68
70
|
*/
|
|
69
71
|
export function parseRelayEventMessageText(text) {
|
|
70
72
|
const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
|
|
71
73
|
const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
|
|
72
|
-
const digest = DIGEST_PATTERN.test(
|
|
74
|
+
const digest = body.some((line) => DIGEST_PATTERN.test(line));
|
|
75
|
+
const quiet = body.some((line) => QUIET_PATTERN.test(line));
|
|
73
76
|
const replayed = body.some((line) => REPLAYED_PATTERN.test(line));
|
|
74
|
-
const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
|
|
77
|
+
const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !QUIET_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
|
|
75
78
|
const types = [];
|
|
76
79
|
let count = 0;
|
|
77
80
|
for (const line of lines) {
|
|
@@ -84,7 +87,7 @@ export function parseRelayEventMessageText(text) {
|
|
|
84
87
|
const more = /^\[relay event\] … and (\d+) more$/.exec(line);
|
|
85
88
|
if (more) count += Number(more[1]) || 0;
|
|
86
89
|
}
|
|
87
|
-
return { lines, types, count: count || lines.length, digest, replayed };
|
|
90
|
+
return { lines, types, count: count || lines.length, digest, quiet, replayed };
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/** The collapsed row's one line: "3 events · question, reply". */
|
|
@@ -92,7 +95,8 @@ export function relayEventSummaryText(parsed) {
|
|
|
92
95
|
const count = Number(parsed?.count) || 0;
|
|
93
96
|
const labels = (parsed?.types || []).map((type) => TYPE_LABELS[type] || type);
|
|
94
97
|
const events = `${count} event${count === 1 ? '' : 's'}`;
|
|
95
|
-
const
|
|
98
|
+
const tags = [parsed?.quiet ? 'After quiet hours' : '', parsed?.digest ? 'Digest' : ''].filter(Boolean);
|
|
99
|
+
const head = [...tags, events].join(' · ');
|
|
96
100
|
return labels.length ? `${head} · ${labels.join(', ')}` : head;
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -175,11 +179,23 @@ export function watchersOf(subscriptions, conversationId, { includeAll = true }
|
|
|
175
179
|
const watchers = new Set();
|
|
176
180
|
for (const sub of activeList(subscriptions)) {
|
|
177
181
|
if (sub.watcher === id || isOnPairedRelay(sub)) continue;
|
|
178
|
-
|
|
182
|
+
const watches = sub.scope === 'all'
|
|
183
|
+
? includeAll && !(Array.isArray(sub.exclude) && sub.exclude.includes(id))
|
|
184
|
+
: sessionIdsOf(sub).includes(id);
|
|
185
|
+
if (watches) watchers.add(watcherKey(sub));
|
|
179
186
|
}
|
|
180
187
|
return [...watchers];
|
|
181
188
|
}
|
|
182
189
|
|
|
190
|
+
/** "digest 30 min" / "quiet 23:00–06:00": what a subscription's options hold back, or ''. */
|
|
191
|
+
export function subscriptionOptionHints(sub) {
|
|
192
|
+
const quiet = sub?.quietHours && typeof sub.quietHours === 'object' ? sub.quietHours : null;
|
|
193
|
+
return [
|
|
194
|
+
Number(sub?.digestMinutes) > 0 ? `digest ${Number(sub.digestMinutes)} min` : '',
|
|
195
|
+
quiet ? `quiet ${String(quiet.from || '')}–${String(quiet.to || '')}` : '',
|
|
196
|
+
].filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
export function watchingChipText(summary) {
|
|
184
200
|
if (!summary?.subscriptions?.length) return '';
|
|
185
201
|
if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
|
|
@@ -188,6 +204,11 @@ export function watchingChipText(summary) {
|
|
|
188
204
|
return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
|
|
189
205
|
}
|
|
190
206
|
|
|
207
|
+
/** The chip's small hint: the options of the conversation's subscriptions, each named once (at most two). */
|
|
208
|
+
export function watchingChipHint(summary) {
|
|
209
|
+
return [...new Set((summary?.subscriptions || []).flatMap(subscriptionOptionHints))].slice(0, 2).join(' · ');
|
|
210
|
+
}
|
|
211
|
+
|
|
191
212
|
function conversationTitle(conversations, id) {
|
|
192
213
|
const title = String(conversations?.[id]?.title || '').trim();
|
|
193
214
|
return title || `session ${String(id).slice(0, 8)}`;
|
|
@@ -203,9 +224,14 @@ function describeSubscription(sub, conversations) {
|
|
|
203
224
|
const what = sub.scope === 'all'
|
|
204
225
|
? (paired ? `All sessions of ${relay}` : 'All sessions')
|
|
205
226
|
: (paired ? `${relay} · ${listed}` : (listed || 'This relay'));
|
|
227
|
+
const quiet = sub.quietHours && typeof sub.quietHours === 'object' ? sub.quietHours : null;
|
|
228
|
+
const excluded = Array.isArray(sub.exclude) ? sub.exclude.length : 0;
|
|
206
229
|
const extras = [
|
|
207
230
|
sub.once ? 'once' : '',
|
|
208
231
|
sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
|
|
232
|
+
sub.digestMinutes ? `digest ${sub.digestMinutes} min` : '',
|
|
233
|
+
quiet ? `quiet ${quiet.from}–${quiet.to} ${quiet.tz}` : '',
|
|
234
|
+
excluded ? `${excluded} left out` : '',
|
|
209
235
|
].filter(Boolean);
|
|
210
236
|
return { what, events: (sub.events || []).join(', '), extras: extras.join(' · ') };
|
|
211
237
|
}
|