@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.
- package/CHANGELOG.md +113 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +9 -4
- package/server/claude-worker/claude-session-worker-main.mjs +10 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
- package/server/cursor-worker/cursor-session-worker.mjs +9 -1
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +1 -0
- package/server/grok-worker/grok-session-worker.mjs +10 -1
- package/server/grok-worker/grok-turn-runner.mjs +1 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +7 -0
- package/server/public/app/ask-user-view.js +4 -3
- package/server/public/app/bootstrap.js +62 -4
- package/server/public/app/chat-actions-menu-state.mjs +2 -0
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-view.js +306 -49
- package/server/public/app/header-priority-actions.mjs +5 -1
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/inbox-ui.js +70 -79
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +17 -0
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/session-history-ui.js +1 -0
- package/server/public/app/settings-modal.js +6 -0
- package/server/public/app/socket-handlers.js +42 -11
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +8 -6
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/index.html +391 -214
- package/server/repositories/message-repository.mjs +10 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/messages-routes.mjs +35 -14
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +76 -5
- package/server/server-runtime.mjs +178 -131
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +143 -15
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +26 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- 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
|
|
2
|
-
// view it opens in place of a transcript (like Status)
|
|
3
|
-
//
|
|
4
|
-
// builds the DOM; this module loads,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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(
|
|
370
|
+
if (!target || !target.closest(scope)) return;
|
|
352
371
|
event.preventDefault();
|
|
353
372
|
void handleAction(target);
|
|
354
373
|
});
|
|
355
|
-
host
|
|
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)
|
|
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.
|
|
362
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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)
|
|
277
|
-
*
|
|
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
|
-
|
|
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?.();
|