spectater 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +113 -2
- package/bin/spectater.js +376 -0
- package/package.json +59 -4
- package/skills/spectater/SKILL.md +13 -0
- package/src/agent.js +184 -0
- package/src/changes.js +82 -0
- package/src/config.js +44 -0
- package/src/discovery.js +158 -0
- package/src/files.js +25 -0
- package/src/hooks.js +145 -0
- package/src/markdown.js +120 -0
- package/src/queue.js +29 -0
- package/src/review.js +402 -0
- package/src/server.js +291 -0
- package/src/storage.js +381 -0
- package/src/watch.js +113 -0
- package/web/app.js +705 -0
- package/web/favicon.svg +14 -0
- package/web/index.html +158 -0
- package/web/style.css +647 -0
- package/web/theme.js +45 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id);
|
|
2
|
+
const route = location.pathname;
|
|
3
|
+
let token = location.hash.slice(1);
|
|
4
|
+
try {
|
|
5
|
+
token ||= sessionStorage.getItem(route);
|
|
6
|
+
if (token) sessionStorage.setItem(route, token);
|
|
7
|
+
} catch {
|
|
8
|
+
/* The private URL still authorizes this page when storage is denied. */
|
|
9
|
+
}
|
|
10
|
+
history.replaceState(null, '', route);
|
|
11
|
+
let state,
|
|
12
|
+
selectedId,
|
|
13
|
+
selection,
|
|
14
|
+
mode = 'rendered',
|
|
15
|
+
busy = false;
|
|
16
|
+
let editingId,
|
|
17
|
+
anchorRect,
|
|
18
|
+
undoDeletedId,
|
|
19
|
+
draftReady = false;
|
|
20
|
+
const draftKey = `${route}:draft`;
|
|
21
|
+
let streamActive = false,
|
|
22
|
+
streamStarting = false,
|
|
23
|
+
listeners = 0;
|
|
24
|
+
let comparisonKey,
|
|
25
|
+
comparisonRequest = 0,
|
|
26
|
+
automatic = false,
|
|
27
|
+
watchError;
|
|
28
|
+
function acceptState(next) {
|
|
29
|
+
if (!state || next.revision >= state.revision) state = next;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function watchReview() {
|
|
33
|
+
if (streamActive || streamStarting) return;
|
|
34
|
+
streamStarting = true;
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(`${route}/events`, {
|
|
37
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok || !response.body)
|
|
40
|
+
throw new Error('Live updates unavailable. Use Refresh source to reconnect.');
|
|
41
|
+
streamActive = true;
|
|
42
|
+
streamStarting = false;
|
|
43
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
44
|
+
let pending = '';
|
|
45
|
+
while (true) {
|
|
46
|
+
const { value, done } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
pending += value;
|
|
49
|
+
if (pending.length > 65536) throw new Error('Invalid live-update response.');
|
|
50
|
+
while (true) {
|
|
51
|
+
const end = pending.indexOf('\n');
|
|
52
|
+
if (end < 0) break;
|
|
53
|
+
const event = JSON.parse(pending.slice(0, end));
|
|
54
|
+
pending = pending.slice(end + 1);
|
|
55
|
+
if (event.listeners !== undefined) {
|
|
56
|
+
listeners = event.listeners;
|
|
57
|
+
displayPresence();
|
|
58
|
+
}
|
|
59
|
+
if (event.type === 'watch' || event.type === 'ready') {
|
|
60
|
+
automatic = event.automatic;
|
|
61
|
+
watchError = event.error || event.watchError;
|
|
62
|
+
displaySourceStatus();
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
['refresh', 'activity', 'lifecycle', 'ready'].includes(event.type) &&
|
|
66
|
+
event.revision > state.revision
|
|
67
|
+
) {
|
|
68
|
+
const before = JSON.stringify([
|
|
69
|
+
state.documents.map((d) => [d.id, d.hash]),
|
|
70
|
+
state.artifacts,
|
|
71
|
+
]);
|
|
72
|
+
const scroll = { x: scrollX, y: scrollY };
|
|
73
|
+
acceptState(await api('state'));
|
|
74
|
+
if (
|
|
75
|
+
before !== JSON.stringify([state.documents.map((d) => [d.id, d.hash]), state.artifacts])
|
|
76
|
+
) {
|
|
77
|
+
display();
|
|
78
|
+
window.scrollTo(scroll.x, scroll.y);
|
|
79
|
+
announce('Plan refreshed. Draft text kept.');
|
|
80
|
+
} else {
|
|
81
|
+
displayFeedback();
|
|
82
|
+
displayChanges();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Error('Live updates disconnected. Use Refresh source to reconnect.');
|
|
88
|
+
} catch (error) {
|
|
89
|
+
listeners = 0;
|
|
90
|
+
displayPresence();
|
|
91
|
+
$('source-status').textContent = 'Live connection unavailable. Refresh source to reconnect.';
|
|
92
|
+
announce(error.message, true);
|
|
93
|
+
} finally {
|
|
94
|
+
streamActive = false;
|
|
95
|
+
streamStarting = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function announce(text, error = false) {
|
|
100
|
+
$('message').textContent = text;
|
|
101
|
+
$('message').classList.toggle('error', error);
|
|
102
|
+
$('message').classList.toggle('quiet', text === 'Ready.');
|
|
103
|
+
}
|
|
104
|
+
async function api(suffix, input) {
|
|
105
|
+
const response = await fetch(`${route}/${suffix}`, {
|
|
106
|
+
method: input ? 'POST' : 'GET',
|
|
107
|
+
headers: {
|
|
108
|
+
Authorization: `Bearer ${token}`,
|
|
109
|
+
...(input ? { 'Content-Type': 'application/json' } : {}),
|
|
110
|
+
},
|
|
111
|
+
...(input ? { body: JSON.stringify(input) } : {}),
|
|
112
|
+
});
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
if (!response.ok) throw new Error(data.error || 'Review request failed.');
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
function element(tag, text, className) {
|
|
118
|
+
const el = document.createElement(tag);
|
|
119
|
+
el.textContent = text;
|
|
120
|
+
if (className) el.className = className;
|
|
121
|
+
return el;
|
|
122
|
+
}
|
|
123
|
+
function button(text, handler) {
|
|
124
|
+
const el = element('button', text);
|
|
125
|
+
el.type = 'button';
|
|
126
|
+
el.addEventListener('click', handler);
|
|
127
|
+
return el;
|
|
128
|
+
}
|
|
129
|
+
function documentNow() {
|
|
130
|
+
return state?.documents.find((d) => d.id === selectedId);
|
|
131
|
+
}
|
|
132
|
+
function persistDraft() {
|
|
133
|
+
if (!draftReady) return;
|
|
134
|
+
try {
|
|
135
|
+
if ($('comment').value || editingId || selection)
|
|
136
|
+
sessionStorage.setItem(
|
|
137
|
+
draftKey,
|
|
138
|
+
JSON.stringify({ body: $('comment').value, selection, editingId }),
|
|
139
|
+
);
|
|
140
|
+
else sessionStorage.removeItem(draftKey);
|
|
141
|
+
} catch {
|
|
142
|
+
/* Saved comments remain durable when draft caching is unavailable. */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function updateDraftButton() {
|
|
146
|
+
$('resume-draft').hidden =
|
|
147
|
+
Boolean(state?.endedAt) ||
|
|
148
|
+
!($('comment').value || editingId) ||
|
|
149
|
+
$('comment-popover').matches(':popover-open');
|
|
150
|
+
}
|
|
151
|
+
function closeComposer() {
|
|
152
|
+
$('comment-popover').hidePopover();
|
|
153
|
+
updateDraftButton();
|
|
154
|
+
persistDraft();
|
|
155
|
+
}
|
|
156
|
+
function openComposer(rect = anchorRect) {
|
|
157
|
+
const popover = $('comment-popover');
|
|
158
|
+
$('comment-title').textContent = editingId ? 'Edit comment' : 'Add comment';
|
|
159
|
+
$('composer-error').hidden = true;
|
|
160
|
+
if (!popover.matches(':popover-open')) popover.showPopover();
|
|
161
|
+
const box = popover.getBoundingClientRect();
|
|
162
|
+
const left = rect ? rect.left : (innerWidth - box.width) / 2;
|
|
163
|
+
let top = rect ? rect.bottom + 8 : (innerHeight - box.height) / 2;
|
|
164
|
+
if (rect && top + box.height > innerHeight - 16) top = rect.top - box.height - 8;
|
|
165
|
+
popover.style.left = `${Math.max(16, Math.min(left, innerWidth - box.width - 16))}px`;
|
|
166
|
+
popover.style.top = `${Math.max(16, Math.min(top, innerHeight - box.height - 16))}px`;
|
|
167
|
+
$('comment').focus({ preventScroll: true });
|
|
168
|
+
updateDraftButton();
|
|
169
|
+
persistDraft();
|
|
170
|
+
}
|
|
171
|
+
function resetSelection() {
|
|
172
|
+
selection = null;
|
|
173
|
+
anchorRect = null;
|
|
174
|
+
$('selection').textContent = $('comment').value
|
|
175
|
+
? 'Draft kept. Select text again.'
|
|
176
|
+
: 'Select text to comment.';
|
|
177
|
+
$('save').disabled = !editingId;
|
|
178
|
+
closeComposer();
|
|
179
|
+
}
|
|
180
|
+
function editComment(comment, rect) {
|
|
181
|
+
editingId = comment.id;
|
|
182
|
+
selection = null;
|
|
183
|
+
anchorRect = rect;
|
|
184
|
+
$('comment').value = comment.body;
|
|
185
|
+
$('selection').textContent = `Original selection: “${comment.quote}”`;
|
|
186
|
+
$('save').disabled = false;
|
|
187
|
+
openComposer(rect);
|
|
188
|
+
}
|
|
189
|
+
function restoreDraft() {
|
|
190
|
+
try {
|
|
191
|
+
const draft = JSON.parse(sessionStorage.getItem(draftKey) || 'null');
|
|
192
|
+
if (draft && typeof draft.body === 'string' && draft.body.length <= 10000) {
|
|
193
|
+
$('comment').value = draft.body;
|
|
194
|
+
editingId = state.comments.some((c) => c.id === draft.editingId)
|
|
195
|
+
? draft.editingId
|
|
196
|
+
: undefined;
|
|
197
|
+
const d = state.documents.find(
|
|
198
|
+
(d) => d.id === draft.selection?.documentId && d.hash === draft.selection?.hash,
|
|
199
|
+
);
|
|
200
|
+
if (d) {
|
|
201
|
+
if (selectedId !== d.id) {
|
|
202
|
+
selectedId = d.id;
|
|
203
|
+
displayDocument();
|
|
204
|
+
}
|
|
205
|
+
selection = draft.selection;
|
|
206
|
+
}
|
|
207
|
+
$('save').disabled = !(selection || editingId);
|
|
208
|
+
$('selection').textContent = selection
|
|
209
|
+
? `${selection.precision === 'exact' ? 'Exact source' : 'Enclosing source span'}: “${selection.quote}”`
|
|
210
|
+
: 'Draft kept. Select text again.';
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
/* Draft recovery is optional; server-side comments are authoritative. */
|
|
214
|
+
}
|
|
215
|
+
draftReady = true;
|
|
216
|
+
updateDraftButton();
|
|
217
|
+
}
|
|
218
|
+
function displayDocument() {
|
|
219
|
+
const d = documentNow();
|
|
220
|
+
resetSelection();
|
|
221
|
+
$('document-name').textContent = d?.path || 'No readable artifacts';
|
|
222
|
+
$('revision').textContent = d?.hash
|
|
223
|
+
? `SHA-256 ${d.hash}`
|
|
224
|
+
: 'This artifact has no supported Markdown snapshot.';
|
|
225
|
+
$('source').textContent = d?.source || '';
|
|
226
|
+
$('source-lines').textContent = Array.from(
|
|
227
|
+
{ length: (d?.source || '').split('\n').length },
|
|
228
|
+
(_, i) => i + 1,
|
|
229
|
+
).join('\n');
|
|
230
|
+
$('rendered').innerHTML =
|
|
231
|
+
d?.html ||
|
|
232
|
+
'<p>Choose an existing Markdown artifact. Missing and unsupported artifacts appear in navigation.</p>';
|
|
233
|
+
$('source').hidden = mode !== 'source';
|
|
234
|
+
$('rendered').hidden = mode !== 'rendered';
|
|
235
|
+
$('source-view').hidden = mode !== 'source';
|
|
236
|
+
$('source-mode').setAttribute('aria-pressed', String(mode === 'source'));
|
|
237
|
+
$('rendered-mode').setAttribute('aria-pressed', String(mode === 'rendered'));
|
|
238
|
+
}
|
|
239
|
+
function displaySourceStatus() {
|
|
240
|
+
$('source-status').textContent = state?.endedAt
|
|
241
|
+
? 'Source watching paused'
|
|
242
|
+
: watchError || (automatic ? 'Source updates automatically' : 'Refresh source manually');
|
|
243
|
+
}
|
|
244
|
+
function displayPresence() {
|
|
245
|
+
if (state?.endedAt) {
|
|
246
|
+
$('listener-status').textContent = 'Review ended · saved feedback retained';
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
$('listener-status').textContent = listeners
|
|
250
|
+
? 'Feedback listener waiting'
|
|
251
|
+
: 'No agent waiting · submissions stay saved';
|
|
252
|
+
}
|
|
253
|
+
function display() {
|
|
254
|
+
$('change').textContent = state.change;
|
|
255
|
+
$('identity').textContent = `${state.schemaName} · ${state.source}`;
|
|
256
|
+
$('location').textContent = state.changeRoot;
|
|
257
|
+
$('artifacts').replaceChildren();
|
|
258
|
+
$('artifact-statuses').replaceChildren();
|
|
259
|
+
if (!documentNow()) selectedId = state.documents.find((d) => d.supported)?.id;
|
|
260
|
+
for (const artifact of state.artifacts) {
|
|
261
|
+
const group = element('div', '', 'artifact-group');
|
|
262
|
+
$('artifact-statuses').append(element('p', `${artifact.id} · ${artifact.status}`));
|
|
263
|
+
const docs = state.documents.filter((d) => d.artifact === artifact.id);
|
|
264
|
+
if (docs.length > 1) group.append(element('p', artifact.id, 'artifact-status'));
|
|
265
|
+
for (const d of docs) {
|
|
266
|
+
const el = button(`${d.path}${d.supported ? '' : ' (unsupported)'}`, () => {
|
|
267
|
+
selectedId = d.id;
|
|
268
|
+
display();
|
|
269
|
+
});
|
|
270
|
+
el.className = 'artifact';
|
|
271
|
+
if (d.id === selectedId) el.setAttribute('aria-current', 'page');
|
|
272
|
+
group.append(el);
|
|
273
|
+
}
|
|
274
|
+
$('artifacts').append(group);
|
|
275
|
+
}
|
|
276
|
+
if (!state.documents.length)
|
|
277
|
+
$('artifacts').append(element('p', 'No existing artifacts.', 'note'));
|
|
278
|
+
displayDocument();
|
|
279
|
+
displayFeedback();
|
|
280
|
+
displayChanges();
|
|
281
|
+
}
|
|
282
|
+
function displayFeedback() {
|
|
283
|
+
displayPresence();
|
|
284
|
+
displaySourceStatus();
|
|
285
|
+
$('save').disabled = Boolean(state.endedAt) || !(selection || editingId);
|
|
286
|
+
if (state.endedAt) closeComposer();
|
|
287
|
+
$('comments').replaceChildren();
|
|
288
|
+
const deliveredIds = new Set(
|
|
289
|
+
state.batches
|
|
290
|
+
.filter(
|
|
291
|
+
(batch) => state.queue?.[batch.id]?.status === 'claimed' || state.activity?.[batch.id],
|
|
292
|
+
)
|
|
293
|
+
.flatMap((batch) => batch.commentIds),
|
|
294
|
+
);
|
|
295
|
+
for (const c of state.comments) {
|
|
296
|
+
const section = element('section', '', 'comment');
|
|
297
|
+
section.append(
|
|
298
|
+
element('h3', `${c.status} · ${c.state}`),
|
|
299
|
+
element('p', c.path, 'note'),
|
|
300
|
+
element('blockquote', c.quote),
|
|
301
|
+
element('p', c.body),
|
|
302
|
+
);
|
|
303
|
+
const original = element('details', '');
|
|
304
|
+
original.append(element('summary', 'Original source context'));
|
|
305
|
+
const snapshot = state.snapshots[c.hash];
|
|
306
|
+
original.append(
|
|
307
|
+
element('p', `Revision ${c.hash}; ${c.precision} offsets [${c.start}, ${c.end})`, 'note'),
|
|
308
|
+
element('pre', snapshot?.source.slice(Math.max(0, c.start - 120), c.end + 120) || c.slice),
|
|
309
|
+
);
|
|
310
|
+
section.append(original);
|
|
311
|
+
if (!state.endedAt) {
|
|
312
|
+
section.append(
|
|
313
|
+
button(c.state === 'open' ? 'Resolve' : 'Reopen', () =>
|
|
314
|
+
action({ action: 'resolve', id: c.id }),
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
const delivered = deliveredIds.has(c.id);
|
|
318
|
+
section.append(
|
|
319
|
+
button(delivered ? 'Send correction' : 'Edit', (event) =>
|
|
320
|
+
editComment(c, event.currentTarget.getBoundingClientRect()),
|
|
321
|
+
),
|
|
322
|
+
button('Delete', () => action({ action: 'delete', id: c.id })),
|
|
323
|
+
);
|
|
324
|
+
if (c.status === 'stale')
|
|
325
|
+
section.append(
|
|
326
|
+
button('Reattach to suggested text', () => action({ action: 'reattach', id: c.id })),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (c.status === 'orphaned')
|
|
330
|
+
section.append(
|
|
331
|
+
element(
|
|
332
|
+
'p',
|
|
333
|
+
'No unique anchor. Keep this feedback or select source and add a new comment.',
|
|
334
|
+
'note',
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
$('comments').append(section);
|
|
338
|
+
}
|
|
339
|
+
$('comment-hint').hidden = state.comments.length > 0;
|
|
340
|
+
$('undo-delete').hidden =
|
|
341
|
+
Boolean(state.endedAt) || !state.deletedComments.some((c) => c.id === undoDeletedId);
|
|
342
|
+
$('deleted-comments').hidden = !state.deletedComments.length;
|
|
343
|
+
$('deleted-list').replaceChildren();
|
|
344
|
+
for (const c of state.deletedComments) {
|
|
345
|
+
const row = element('div', '', 'deleted-comment');
|
|
346
|
+
row.append(element('p', c.body));
|
|
347
|
+
if (!state.endedAt)
|
|
348
|
+
row.append(button('Restore', () => action({ action: 'restore', id: c.id })));
|
|
349
|
+
$('deleted-list').append(row);
|
|
350
|
+
}
|
|
351
|
+
const editing = state.batches.find((batch) => state.queue?.[batch.id]?.status === 'editing');
|
|
352
|
+
$('queue-editing').hidden = !editing;
|
|
353
|
+
$('queue-editing').textContent = editing
|
|
354
|
+
? `Editing batch ${editing.sequence} · delivery paused. Edit saved comments, then update.`
|
|
355
|
+
: '';
|
|
356
|
+
$('submit').textContent = editing ? 'Update queued batch' : 'Submit batch';
|
|
357
|
+
updateSubmit();
|
|
358
|
+
$('send-end').hidden = Boolean(state.endedAt || state.endRequestedAt);
|
|
359
|
+
$('end-pending').hidden = !state.endRequestedAt || Boolean(state.endedAt);
|
|
360
|
+
$('agent-message').disabled = Boolean(state.endedAt);
|
|
361
|
+
$('review-lifecycle').textContent = state.endedAt ? 'Resume review' : 'End review';
|
|
362
|
+
$('refresh').disabled = Boolean(state.endedAt);
|
|
363
|
+
$('comment-hint').hidden ||= Boolean(state.endedAt);
|
|
364
|
+
$('receipts').replaceChildren();
|
|
365
|
+
const history = element('details', '', 'batch-history');
|
|
366
|
+
const historyCount = state.batches.filter((batch) =>
|
|
367
|
+
['cancelled', 'superseded'].includes(state.queue?.[batch.id]?.status),
|
|
368
|
+
).length;
|
|
369
|
+
history.append(element('summary', `History · ${historyCount}`));
|
|
370
|
+
for (const batch of [...state.batches].reverse()) {
|
|
371
|
+
const receipt = element('div', '', 'receipt');
|
|
372
|
+
receipt.append(
|
|
373
|
+
button(`Batch ${batch.sequence} · ${batch.count} items`, () => showBatch(batch.id)),
|
|
374
|
+
);
|
|
375
|
+
const activity = state.activity?.[batch.id];
|
|
376
|
+
const queue = state.queue?.[batch.id];
|
|
377
|
+
const handedOff = queue?.status === 'claimed' || Boolean(activity);
|
|
378
|
+
const replacement = state.batches.find((item) => item.id === queue?.replacementId);
|
|
379
|
+
const status =
|
|
380
|
+
queue?.status === 'cancelled'
|
|
381
|
+
? 'Cancelled'
|
|
382
|
+
: queue?.status === 'superseded'
|
|
383
|
+
? `Replaced by batch ${replacement.sequence}`
|
|
384
|
+
: queue?.status === 'editing'
|
|
385
|
+
? 'Editing · delivery paused'
|
|
386
|
+
: activity?.status === 'replied'
|
|
387
|
+
? 'Agent replied'
|
|
388
|
+
: activity?.status === 'working'
|
|
389
|
+
? 'Agent working'
|
|
390
|
+
: activity
|
|
391
|
+
? 'Received by agent CLI'
|
|
392
|
+
: handedOff
|
|
393
|
+
? 'Handed to agent CLI'
|
|
394
|
+
: 'Saved · queued for agent';
|
|
395
|
+
receipt.append(element('p', status, 'note'));
|
|
396
|
+
if (!state.endedAt && !handedOff && !['cancelled', 'superseded'].includes(queue?.status)) {
|
|
397
|
+
const controls = element('div', '', 'queue-actions');
|
|
398
|
+
if (!editing && queue?.status !== 'editing') {
|
|
399
|
+
const edit = button('Edit queued', () => action({ action: 'edit-batch', id: batch.id }));
|
|
400
|
+
edit.setAttribute('aria-label', `Edit queued batch ${batch.sequence}`);
|
|
401
|
+
controls.append(edit);
|
|
402
|
+
}
|
|
403
|
+
const cancel = button('Cancel submission', () =>
|
|
404
|
+
action({ action: 'cancel-batch', id: batch.id }),
|
|
405
|
+
);
|
|
406
|
+
cancel.setAttribute('aria-label', `Cancel batch ${batch.sequence}`);
|
|
407
|
+
controls.append(cancel);
|
|
408
|
+
receipt.append(controls);
|
|
409
|
+
}
|
|
410
|
+
for (const reply of activity?.replies || []) {
|
|
411
|
+
const response = element('div', '', 'agent-reply');
|
|
412
|
+
response.append(
|
|
413
|
+
element('p', reply.body),
|
|
414
|
+
element('time', new Date(reply.at).toLocaleString(), 'note'),
|
|
415
|
+
);
|
|
416
|
+
receipt.append(response);
|
|
417
|
+
}
|
|
418
|
+
(['cancelled', 'superseded'].includes(queue?.status) ? history : $('receipts')).append(receipt);
|
|
419
|
+
}
|
|
420
|
+
if (historyCount) $('receipts').append(history);
|
|
421
|
+
$('export-details').hidden = !state.batches.length;
|
|
422
|
+
}
|
|
423
|
+
function displayChanges() {
|
|
424
|
+
const key = JSON.stringify(state.changes);
|
|
425
|
+
if (key === comparisonKey) return;
|
|
426
|
+
comparisonKey = key;
|
|
427
|
+
$('change-diff').hidden = true;
|
|
428
|
+
$('change-diff').textContent = '';
|
|
429
|
+
$('changed-files').replaceChildren();
|
|
430
|
+
$('changes-note').textContent = state.changes.length
|
|
431
|
+
? 'Compared with the previous source update.'
|
|
432
|
+
: state.hasPreviousSource
|
|
433
|
+
? 'No source differences.'
|
|
434
|
+
: 'A comparison appears after the first source update.';
|
|
435
|
+
for (const change of state.changes) {
|
|
436
|
+
$('changed-files').append(
|
|
437
|
+
button(`${change.path} · ${change.status}`, () => loadComparison(change.id)),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async function loadComparison(id) {
|
|
442
|
+
const key = comparisonKey,
|
|
443
|
+
request = ++comparisonRequest;
|
|
444
|
+
$('change-diff').hidden = false;
|
|
445
|
+
$('change-diff').textContent = 'Loading comparison…';
|
|
446
|
+
try {
|
|
447
|
+
const change = await api(`changes/${id}`);
|
|
448
|
+
if (comparisonKey === key && request === comparisonRequest)
|
|
449
|
+
$('change-diff').textContent =
|
|
450
|
+
`${change.path} · ${change.status}\n\n${change.diff || 'No textual differences.'}`;
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (comparisonKey === key && request === comparisonRequest)
|
|
453
|
+
$('change-diff').textContent = error.message;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async function operation(fn) {
|
|
457
|
+
if (busy) return false;
|
|
458
|
+
busy = true;
|
|
459
|
+
document.body.setAttribute('aria-busy', 'true');
|
|
460
|
+
try {
|
|
461
|
+
await fn();
|
|
462
|
+
return true;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
announce(error.message, true);
|
|
465
|
+
if ($('comment-popover').matches(':popover-open')) {
|
|
466
|
+
$('composer-error').textContent = error.message;
|
|
467
|
+
$('composer-error').hidden = false;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
} finally {
|
|
471
|
+
busy = false;
|
|
472
|
+
document.body.setAttribute('aria-busy', 'false');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function action(input) {
|
|
476
|
+
return operation(async () => {
|
|
477
|
+
acceptState(await api('action', { ...input, revision: state.revision }));
|
|
478
|
+
if (input.action === 'comment' || input.action === 'edit') {
|
|
479
|
+
$('comment').value = '';
|
|
480
|
+
editingId = undefined;
|
|
481
|
+
selection = null;
|
|
482
|
+
persistDraft();
|
|
483
|
+
}
|
|
484
|
+
if (input.action === 'delete') undoDeletedId = input.id;
|
|
485
|
+
if (input.action === 'submit') $('agent-message').value = '';
|
|
486
|
+
if (input.action === 'end') closeComposer();
|
|
487
|
+
display();
|
|
488
|
+
announce(
|
|
489
|
+
input.action === 'end'
|
|
490
|
+
? 'Review ended. Saved feedback retained.'
|
|
491
|
+
: input.action === 'resume'
|
|
492
|
+
? 'Review resumed.'
|
|
493
|
+
: input.action === 'submit'
|
|
494
|
+
? state.endedAt
|
|
495
|
+
? 'Review ended.'
|
|
496
|
+
: input.end
|
|
497
|
+
? 'Batch saved. The review ends once the agent receives it.'
|
|
498
|
+
: 'Batch saved.'
|
|
499
|
+
: input.action === 'cancel-batch'
|
|
500
|
+
? 'Submission cancelled. Comments kept.'
|
|
501
|
+
: input.action === 'edit-batch'
|
|
502
|
+
? 'Delivery paused. Edit saved comments, then update the queued batch.'
|
|
503
|
+
: input.action === 'delete'
|
|
504
|
+
? 'Comment deleted.'
|
|
505
|
+
: 'Saved.',
|
|
506
|
+
);
|
|
507
|
+
if (input.action === 'submit' && state.batches.length)
|
|
508
|
+
await showBatch(state.batches.at(-1).id, false);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
async function showBatch(id, open = true) {
|
|
512
|
+
try {
|
|
513
|
+
$('export').value = (await api(`batch/${id}`)).markdown;
|
|
514
|
+
const batch = state.batches.find((item) => item.id === id);
|
|
515
|
+
$('retrieval-command').textContent =
|
|
516
|
+
`spectater feedback ${state.change} --batch ${batch.sequence} --format markdown`;
|
|
517
|
+
$('export-details').hidden = false;
|
|
518
|
+
$('export-details').open = open;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
announce(error.message, true);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function offset(container, node, offset) {
|
|
524
|
+
const range = document.createRange();
|
|
525
|
+
range.selectNodeContents(container);
|
|
526
|
+
range.setEnd(node, offset);
|
|
527
|
+
return range.toString().length;
|
|
528
|
+
}
|
|
529
|
+
function captureSelection() {
|
|
530
|
+
if (busy || !state || state.endedAt) return;
|
|
531
|
+
const selected = window.getSelection();
|
|
532
|
+
if (!selected.rangeCount || selected.isCollapsed) return;
|
|
533
|
+
const range = selected.getRangeAt(0),
|
|
534
|
+
d = documentNow();
|
|
535
|
+
const area = mode === 'source' ? $('source') : $('rendered');
|
|
536
|
+
if (!area.contains(range.startContainer) || !area.contains(range.endContainer)) return;
|
|
537
|
+
let start, end, precision;
|
|
538
|
+
if (mode === 'source') {
|
|
539
|
+
start = offset(area, range.startContainer, range.startOffset);
|
|
540
|
+
end = offset(area, range.endContainer, range.endOffset);
|
|
541
|
+
precision = 'exact';
|
|
542
|
+
} else {
|
|
543
|
+
const parts = [];
|
|
544
|
+
const common = range.commonAncestorContainer;
|
|
545
|
+
const scope = common.nodeType === Node.ELEMENT_NODE ? common : common.parentElement;
|
|
546
|
+
const spans = scope.matches('[data-start]') ? [scope] : scope.querySelectorAll('[data-start]');
|
|
547
|
+
for (const span of spans) {
|
|
548
|
+
if (!range.intersectsNode(span)) continue;
|
|
549
|
+
const bounds = document.createRange();
|
|
550
|
+
bounds.selectNodeContents(span);
|
|
551
|
+
const overlap = range.cloneRange();
|
|
552
|
+
if (range.compareBoundaryPoints(Range.START_TO_START, bounds) < 0)
|
|
553
|
+
overlap.setStart(bounds.startContainer, bounds.startOffset);
|
|
554
|
+
if (range.compareBoundaryPoints(Range.END_TO_END, bounds) > 0)
|
|
555
|
+
overlap.setEnd(bounds.endContainer, bounds.endOffset);
|
|
556
|
+
if (!overlap.collapsed && overlap.toString().length) parts.push({ span, overlap });
|
|
557
|
+
}
|
|
558
|
+
const first = parts[0],
|
|
559
|
+
last = parts.at(-1);
|
|
560
|
+
let unmapped = !first;
|
|
561
|
+
if (first) {
|
|
562
|
+
const before = range.cloneRange();
|
|
563
|
+
before.setEnd(first.overlap.startContainer, first.overlap.startOffset);
|
|
564
|
+
const after = range.cloneRange();
|
|
565
|
+
after.setStart(last.overlap.endContainer, last.overlap.endOffset);
|
|
566
|
+
unmapped = Boolean(before.toString().trim() || after.toString().trim());
|
|
567
|
+
}
|
|
568
|
+
if (unmapped) {
|
|
569
|
+
resetSelection();
|
|
570
|
+
$('selection').textContent = 'This selection has unmapped content. Use Source.';
|
|
571
|
+
announce($('selection').textContent);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
start = Number(first.span.dataset.start);
|
|
575
|
+
end = Number(last.span.dataset.end);
|
|
576
|
+
if (first.span.dataset.precision === 'exact')
|
|
577
|
+
start += offset(first.span, first.overlap.startContainer, first.overlap.startOffset);
|
|
578
|
+
if (last.span.dataset.precision === 'exact')
|
|
579
|
+
end =
|
|
580
|
+
Number(last.span.dataset.start) +
|
|
581
|
+
offset(last.span, last.overlap.endContainer, last.overlap.endOffset);
|
|
582
|
+
precision =
|
|
583
|
+
parts.length === 1 && first.span.dataset.precision === 'exact' ? 'exact' : 'enclosing';
|
|
584
|
+
}
|
|
585
|
+
if (range.toString().length > 10000 || end - start > 10000) {
|
|
586
|
+
resetSelection();
|
|
587
|
+
announce('Selection exceeds 10,000 characters. Select a shorter passage.', true);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
selection = { documentId: d.id, hash: d.hash, start, end, precision, quote: range.toString() };
|
|
591
|
+
editingId = undefined;
|
|
592
|
+
anchorRect =
|
|
593
|
+
[...range.getClientRects()]
|
|
594
|
+
.filter((rect) => rect.width && rect.height && rect.bottom > 0 && rect.top < innerHeight)
|
|
595
|
+
.at(-1) || range.getBoundingClientRect();
|
|
596
|
+
$('selection').textContent =
|
|
597
|
+
`${precision === 'exact' ? 'Exact source' : 'Enclosing source span'}: “${selection.quote}”`;
|
|
598
|
+
$('save').disabled = false;
|
|
599
|
+
// Open after the gesture's click so native light-dismiss does not immediately close it.
|
|
600
|
+
requestAnimationFrame(() => {
|
|
601
|
+
if (selection) openComposer();
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
document.addEventListener('mouseup', captureSelection);
|
|
605
|
+
document.addEventListener('keyup', (event) => {
|
|
606
|
+
if (
|
|
607
|
+
event.key === 'Shift' ||
|
|
608
|
+
(!event.shiftKey &&
|
|
609
|
+
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
|
|
610
|
+
)
|
|
611
|
+
captureSelection();
|
|
612
|
+
});
|
|
613
|
+
function saveComment() {
|
|
614
|
+
if (editingId) return action({ action: 'edit', id: editingId, body: $('comment').value });
|
|
615
|
+
if (selection) return action({ action: 'comment', ...selection, body: $('comment').value });
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
$('comment-form').addEventListener('submit', (event) => {
|
|
619
|
+
event.preventDefault();
|
|
620
|
+
void saveComment();
|
|
621
|
+
});
|
|
622
|
+
$('comment').addEventListener('input', persistDraft);
|
|
623
|
+
$('comment-popover').addEventListener('toggle', updateDraftButton);
|
|
624
|
+
$('resume-draft').addEventListener('click', () => openComposer());
|
|
625
|
+
$('undo').addEventListener('click', () => action({ action: 'restore', id: undoDeletedId }));
|
|
626
|
+
window.addEventListener('resize', closeComposer);
|
|
627
|
+
$('review-lifecycle').addEventListener('click', () => {
|
|
628
|
+
if (!state.endedAt && $('comment').value)
|
|
629
|
+
return announce('Save or clear the unsaved draft before ending the review.', true);
|
|
630
|
+
if (!state.endedAt) editingId = undefined;
|
|
631
|
+
void action({ action: state.endedAt ? 'resume' : 'end' });
|
|
632
|
+
});
|
|
633
|
+
function updateSubmit() {
|
|
634
|
+
$('submit').disabled =
|
|
635
|
+
Boolean(state.endedAt) || !(state.pendingCommentIds.length || $('agent-message').value.trim());
|
|
636
|
+
}
|
|
637
|
+
function submit(end = false) {
|
|
638
|
+
const message = $('agent-message').value.trim();
|
|
639
|
+
return action({ action: 'submit', ...(message ? { message } : {}), ...(end ? { end } : {}) });
|
|
640
|
+
}
|
|
641
|
+
$('submit').addEventListener('click', () => submit());
|
|
642
|
+
$('send-end').addEventListener('click', () => {
|
|
643
|
+
if ($('comment').value)
|
|
644
|
+
return announce('Save or clear the unsaved draft before ending the review.', true);
|
|
645
|
+
void submit(true);
|
|
646
|
+
});
|
|
647
|
+
$('agent-message').addEventListener('input', updateSubmit);
|
|
648
|
+
$('agent-message').addEventListener('keydown', (event) => {
|
|
649
|
+
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
|
|
650
|
+
event.preventDefault();
|
|
651
|
+
if (!$('submit').disabled) void submit();
|
|
652
|
+
});
|
|
653
|
+
$('comment').addEventListener('keydown', async (event) => {
|
|
654
|
+
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
|
|
655
|
+
event.preventDefault();
|
|
656
|
+
if ($('save').disabled || !(await saveComment())) return;
|
|
657
|
+
if ((event.metaKey || event.ctrlKey) && !$('submit').disabled) await submit();
|
|
658
|
+
});
|
|
659
|
+
$('refresh').addEventListener('click', () =>
|
|
660
|
+
operation(async () => {
|
|
661
|
+
acceptState(await api('refresh', {}));
|
|
662
|
+
display();
|
|
663
|
+
announce('Refreshed. Select text again.');
|
|
664
|
+
void watchReview();
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
for (const name of ['rendered', 'source'])
|
|
668
|
+
$(`${name}-mode`).addEventListener('click', () => {
|
|
669
|
+
mode = name;
|
|
670
|
+
displayDocument();
|
|
671
|
+
});
|
|
672
|
+
$('export-details').addEventListener('toggle', () => {
|
|
673
|
+
if ($('export-details').open && !$('export').value && state?.batches.length)
|
|
674
|
+
void showBatch(state.batches.at(-1).id);
|
|
675
|
+
});
|
|
676
|
+
$('copy').addEventListener('click', async () => {
|
|
677
|
+
try {
|
|
678
|
+
if (!$('export').value) throw new Error('Submit or open a batch first.');
|
|
679
|
+
await navigator.clipboard.writeText($('export').value);
|
|
680
|
+
announce('Export copied.');
|
|
681
|
+
} catch {
|
|
682
|
+
$('export').focus();
|
|
683
|
+
$('export').select();
|
|
684
|
+
announce('Clipboard unavailable. Copy the selected export or download it.');
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
$('download').addEventListener('click', () => {
|
|
688
|
+
if (!$('export').value) return announce('Submit or open a batch first.');
|
|
689
|
+
const url = URL.createObjectURL(new Blob([$('export').value], { type: 'text/markdown' }));
|
|
690
|
+
const link = document.createElement('a');
|
|
691
|
+
link.href = url;
|
|
692
|
+
link.download = 'spectater-feedback.md';
|
|
693
|
+
link.click();
|
|
694
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
695
|
+
});
|
|
696
|
+
operation(async () => {
|
|
697
|
+
state = await api('state');
|
|
698
|
+
mode = state.defaultView;
|
|
699
|
+
automatic = state.automatic;
|
|
700
|
+
watchError = state.watchError;
|
|
701
|
+
display();
|
|
702
|
+
restoreDraft();
|
|
703
|
+
announce('Ready.');
|
|
704
|
+
void watchReview();
|
|
705
|
+
});
|