@erdemtuna/doc-review 0.12.0 → 0.14.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.
Files changed (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1118 -2787
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
@@ -0,0 +1,798 @@
1
+ import { createControllerStore } from "./controller-store.js";
2
+ import { ApiError } from "./chrome-api.js";
3
+ import { acceptedMutationSchema, abandonRequestSchema, contextPageSchema, conversationListRequestSchema, directEditPageSchema, editAttentionPageSchema, directEditContentSchema, receiptLookupSchema, reviewPageListSchema, reviewPageSchema, validateSaveEvidence, reviewerMutationSchema, reviewStatusSchema, submissionHistoryPageSchema, submissionReadSchema, threadPageSchema, } from "./contracts/index.js";
4
+ export const conversationDraft = () => ({
5
+ text: "", intent: "discuss", selectionStart: 0, selectionEnd: 0, composing: false, messageId: null, messageVersion: null,
6
+ });
7
+ const reviewStage = (status) => status.review.state === "ended" ? "ended" : status.work ? "waiting" : "reviewing";
8
+ export function createConversationController(options) {
9
+ const reference = { reviewId: options.reviewId, entryKey: options.entryKey };
10
+ let review = null;
11
+ let status = null;
12
+ let pages = [], threads = [], edits = [], history = [];
13
+ let attentionEdits = [];
14
+ let historyCursor = null, historyDepth = 50;
15
+ let historyLoading = null;
16
+ const contexts = new Map(), drafts = new Map();
17
+ const editBaselines = new Map();
18
+ let draftCancellation = null;
19
+ const savingDrafts = new Set();
20
+ const historyOpened = new Set();
21
+ const contextLoads = new Map(), contextErrors = new Map();
22
+ const submissions = new Map(), collapsed = new Set(), attention = new Set();
23
+ const fingerprints = new Map();
24
+ const lastRecorded = new Map();
25
+ const readingPositions = new Map();
26
+ const readingAnchors = new Map();
27
+ let revealedMessage = null;
28
+ let revealRequest = 0;
29
+ let resolutionGuardId = null;
30
+ let notification = null;
31
+ let note = conversationDraft();
32
+ let newMessage = null;
33
+ let filters = { open: true, resolved: false };
34
+ let revealedThreadId = null;
35
+ let focusId = null, open = false, error = "", notice = "", captureNotice = "", connected = true;
36
+ let host = "feedback";
37
+ let confirmation = null, uncertain = null;
38
+ let busy = false, confirming = false, dispatching = false, loading = false, disposed = false;
39
+ let serial = Promise.resolve(), refreshing = null, refreshAgain = false;
40
+ let selectionKnown = false;
41
+ const writable = () => !!review && review.state === "open" && !uncertain;
42
+ const displayExchanges = (thread) => {
43
+ const items = contexts.get(thread.thread.threadId)?.items ?? (thread.latestExchange ? [thread.latestExchange] : []);
44
+ if (historyOpened.has(thread.thread.threadId))
45
+ return items;
46
+ const recent = new Set(items.filter(item => item.reviewer.submissionId !== null).slice(-2).map(item => item.reviewer.messageId));
47
+ return items.filter(item => recent.has(item.reviewer.messageId) || item.reviewer.submissionId === null);
48
+ };
49
+ function pendingSelection() {
50
+ if (!selectionKnown || !status)
51
+ return null;
52
+ const messages = [], selectedEdits = [];
53
+ const keys = new Set();
54
+ let pendingMessages = 0;
55
+ for (const thread of threads) {
56
+ if (!thread.pendingMessageCount)
57
+ continue;
58
+ const pending = contexts.get(thread.thread.threadId)?.items.filter(({ reviewer }) => reviewer.submissionId === null);
59
+ if (!pending || pending.length !== thread.pendingMessageCount)
60
+ return null;
61
+ pendingMessages += pending.length;
62
+ for (const { reviewer } of pending) {
63
+ messages.push({ threadId: reviewer.threadId, messageId: reviewer.messageId, version: reviewer.version });
64
+ keys.add(thread.thread.pageKey);
65
+ }
66
+ }
67
+ if (pendingMessages !== status.pendingMessageCount || edits.length !== status.pendingEditCount)
68
+ return null;
69
+ for (const edit of edits) {
70
+ selectedEdits.push({ pageKey: edit.pageKey, editId: edit.editId, version: edit.version });
71
+ keys.add(edit.pageKey);
72
+ }
73
+ const overallNote = note.text.trim() ? { body: note.text, intent: note.intent } : undefined;
74
+ if (overallNote)
75
+ keys.add(reference.entryKey);
76
+ return { pageKeys: [...keys], messages, edits: selectedEdits, ...(overallNote ? { overallNote } : {}),
77
+ pendingCount: pendingMessages + edits.length };
78
+ }
79
+ const sendBlocked = () => {
80
+ const keys = new Set([reference.entryKey, ...(pendingSelection()?.pageKeys ?? [])]);
81
+ return status?.blockers.some((blocker) => blocker.targetKeys.some((key) => keys.has(key))) ?? false;
82
+ };
83
+ function selectionSummary() {
84
+ const selected = !loading && connected && !uncertain ? pendingSelection() : null;
85
+ return selected ? { pendingCount: selected.pendingCount, messages: selected.messages.length, edits: selected.edits.length,
86
+ note: !!selected.overallNote, total: selected.messages.length + selected.edits.length + (selected.overallNote ? 1 : 0) } : null;
87
+ }
88
+ const draftCount = () => [...drafts.values(), note, ...(newMessage ? [newMessage.draft] : [])].filter((draft) => draft.text.length > 0).length;
89
+ const store = createControllerStore(() => ({
90
+ review, status, pages, edits, attentionEdits, history, historyCursor,
91
+ inventory: selectionKnown && !loading && connected && !uncertain && status
92
+ ? { openThreads: status.openThreadCount, edits: status.attentionEditCount } : null,
93
+ threads: threads.map((thread) => ({
94
+ ...thread, exchanges: displayExchanges(thread), context: contexts.get(thread.thread.threadId) ?? null,
95
+ draft: drafts.get(thread.thread.threadId) ?? null, expanded: !collapsed.has(thread.thread.threadId),
96
+ attention: attention.has(thread.thread.threadId),
97
+ contextLoading: contextLoads.has(thread.thread.threadId), contextError: contextErrors.get(thread.thread.threadId) ?? "",
98
+ })),
99
+ submissions: [...submissions.entries()].map(([id, value]) => ({ id, value })),
100
+ note, newMessage, filters, focusId, host, open, error, notice, notification, captureNotice, connected, revealedMessage, revealedThreadId,
101
+ resolutionGuard: currentResolutionGuard(),
102
+ confirmation, draftCancellation, uncertain: uncertain ? { operation: uncertain.body.operation, requestId: uncertain.body.requestId, message: uncertain.message } : null,
103
+ busy: busy || confirming || dispatching, loading, savingDraftIds: [...savingDrafts], sendBlocked: sendBlocked(), draftCount: draftCount(), attentionCount: attention.size,
104
+ selection: selectionSummary(), canComment: writable(),
105
+ unsavedMessageDraftCount: [...drafts.values(), ...(newMessage ? [newMessage.draft] : [])].filter((draft) => draft.text.length > 0).length,
106
+ }));
107
+ function publish() { if (!disposed)
108
+ store.publish(); }
109
+ function revealFocusedThread() {
110
+ const current = threads.find((item) => item.thread.threadId === focusId);
111
+ revealedThreadId = current && !filters[current.thread.status] ? current.thread.threadId : null;
112
+ }
113
+ function fail(value) { error = value instanceof Error ? value.message : String(value); publish(); }
114
+ async function post(body, decoder) {
115
+ return decoder.parse(await options.request("/api/conversation", { method: "POST", body: JSON.stringify(body) }));
116
+ }
117
+ function scope(collection, threadId = null) {
118
+ return { ...reference, collection, pageKey: null, threadId, submissionId: null, status: "all" };
119
+ }
120
+ async function page(collection, decoder, query = {}, threadId = null) {
121
+ return post(conversationListRequestSchema.parse({ operation: "list", scope: scope(collection, threadId), query }), decoder);
122
+ }
123
+ async function all(collection, decoder) {
124
+ const items = [];
125
+ let cursor;
126
+ let total, highWater;
127
+ const cursors = new Set();
128
+ do {
129
+ const result = await page(collection, decoder, { limit: 100, ...(cursor ? { cursor } : {}) });
130
+ total ??= result.totalCount;
131
+ highWater ??= result.highWater;
132
+ if (result.totalCount !== total || result.highWater !== highWater || (result.nextCursor && cursors.has(result.nextCursor))) {
133
+ throw new Error(`The ${collection} inventory changed while paging. Refresh the review.`);
134
+ }
135
+ if (result.nextCursor)
136
+ cursors.add(result.nextCursor);
137
+ items.push(...result.items);
138
+ cursor = result.nextCursor ?? undefined;
139
+ } while (cursor);
140
+ if (items.length !== total)
141
+ throw new Error(`The ${collection} inventory is incomplete. Refresh the review.`);
142
+ return items;
143
+ }
144
+ async function readContext(id, minimum = 1, pending = 0, messageId) {
145
+ const latest = await page("context", contextPageSchema, {}, id);
146
+ let result = latest;
147
+ let items = [...latest.items];
148
+ while (result.nextCursor && (items.length < minimum || items.filter((item) => item.reviewer.submissionId === null).length < pending ||
149
+ (messageId && !items.some(item => item.reviewer.messageId === messageId)))) {
150
+ result = await page("context", contextPageSchema, { cursor: result.nextCursor }, id);
151
+ items = [...result.items, ...items];
152
+ }
153
+ return { ...latest, items, nextCursor: result.nextCursor };
154
+ }
155
+ async function readHistory() {
156
+ const oldestLoaded = history.at(-1)?.sequence;
157
+ let result = await page("history", submissionHistoryPageSchema);
158
+ const items = [...result.items];
159
+ // Rebuild a continuous high-water window through every previously loaded item.
160
+ // This bridges missed pages and refreshes older result/comparison summaries too.
161
+ while (result.nextCursor && (items.length < historyDepth ||
162
+ (oldestLoaded !== undefined && items.at(-1).sequence > oldestLoaded))) {
163
+ result = await page("history", submissionHistoryPageSchema, { cursor: result.nextCursor });
164
+ items.push(...result.items);
165
+ }
166
+ return { items: [...new Map(items.map((item) => [item.submissionId, item])).values()]
167
+ .sort((a, b) => b.sequence - a.sequence), nextCursor: result.nextCursor };
168
+ }
169
+ async function refreshOnce() {
170
+ const nextStatus = await post({ operation: "status", ...reference }, reviewStatusSchema);
171
+ if (nextStatus.review.reviewId !== reference.reviewId || nextStatus.review.entryKey !== reference.entryKey)
172
+ throw new Error("Status belongs to another review.");
173
+ const [nextPages, nextThreads, nextEdits, nextHistory, nextAttentionEdits] = await Promise.all([
174
+ all("pages", reviewPageListSchema), all("threads", threadPageSchema), all("edits", directEditPageSchema),
175
+ readHistory(), all("edit-attention", editAttentionPageSchema),
176
+ ]);
177
+ const nextContexts = new Map();
178
+ await Promise.all(nextThreads.map(async (thread) => {
179
+ const id = thread.thread.threadId;
180
+ if (contexts.has(id) || thread.messageCount > 1 || thread.pendingMessageCount > 0) {
181
+ const cached = contexts.get(id), previous = threads.find(item => item.thread.threadId === id);
182
+ const minimum = Math.min(thread.messageCount, thread.pendingMessageCount + 2);
183
+ if (cached && cached.items.length >= minimum && JSON.stringify(previous) === JSON.stringify(thread))
184
+ nextContexts.set(id, cached);
185
+ else
186
+ nextContexts.set(id, await readContext(id, Math.max(cached?.items.length ?? 0, minimum), thread.pendingMessageCount));
187
+ }
188
+ }));
189
+ const nextSubmissions = new Map();
190
+ await Promise.all(nextHistory.items.map(async (item) => {
191
+ const id = item.submissionId, cached = submissions.get(id);
192
+ // Submitted instructions are immutable; delivery/completion changes state.
193
+ // Comparison capture is refreshed independently through history.
194
+ const unchanged = cached && cached.submission.state === item.state &&
195
+ cached.submission.resultId === (item.result?.resultId ?? null);
196
+ nextSubmissions.set(id, unchanged ? cached : await post({ operation: "submission", ...reference, submissionId: id }, submissionReadSchema));
197
+ }));
198
+ const confirmed = await post({ operation: "status", ...reference }, reviewStatusSchema);
199
+ if (JSON.stringify(nextStatus) !== JSON.stringify(confirmed)) {
200
+ refreshAgain = true;
201
+ return;
202
+ }
203
+ if (nextAttentionEdits.length !== nextStatus.attentionEditCount ||
204
+ new Set(nextAttentionEdits.map(item => JSON.stringify([item.edit.editId, item.edit.version]))).size !== nextAttentionEdits.length ||
205
+ nextThreads.filter(item => item.thread.status === "open").length !== nextStatus.openThreadCount) {
206
+ throw new Error("Feedback inventory is incomplete. Refresh to reload the review.");
207
+ }
208
+ if (disposed)
209
+ return;
210
+ const nextStage = reviewStage(nextStatus);
211
+ if (status && reviewStage(status) !== nextStage) {
212
+ const completed = status.work && nextSubmissions.get(status.work.submissionId)?.submission.state === "handled";
213
+ notification = { id: `${nextStatus.review.version}:${nextStage}`,
214
+ message: nextStage === "ended" ? "Review ended." : nextStage === "waiting" ? "Waiting for agent."
215
+ : completed ? "Agent response received. Ready to review." : "Reviewing resumed." };
216
+ }
217
+ review = nextStatus.review;
218
+ status = nextStatus;
219
+ pages = nextPages;
220
+ edits = nextEdits;
221
+ attentionEdits = nextAttentionEdits;
222
+ for (const thread of nextThreads) {
223
+ const id = thread.thread.threadId;
224
+ const exchange = thread.latestExchange;
225
+ // Submission bookkeeping and resolution are not new conversation content.
226
+ const fingerprint = JSON.stringify([thread.messageCount, exchange?.reviewer.messageId,
227
+ exchange?.reviewer.version, exchange?.response]);
228
+ if (fingerprints.has(id) && fingerprints.get(id) !== fingerprint)
229
+ attention.add(id);
230
+ fingerprints.set(id, fingerprint);
231
+ const previous = threads.find(item => item.thread.threadId === id);
232
+ if (thread.thread.status === "resolved" && previous?.thread.status !== "resolved") {
233
+ if (!drafts.has(id)) {
234
+ collapsed.add(id);
235
+ if (host === "adjacent" && focusId === id) {
236
+ revealRequest++;
237
+ open = false;
238
+ focusId = null;
239
+ host = "feedback";
240
+ }
241
+ }
242
+ attention.delete(id);
243
+ }
244
+ else if (thread.thread.status === "open" && previous?.thread.status === "resolved") {
245
+ collapsed.delete(id);
246
+ }
247
+ }
248
+ threads = nextThreads;
249
+ const revealed = threads.find(item => item.thread.threadId === revealedThreadId);
250
+ if (!revealed || filters[revealed.thread.status])
251
+ revealedThreadId = null;
252
+ for (const [id, value] of nextContexts)
253
+ contexts.set(id, value);
254
+ for (const [id, value] of nextSubmissions)
255
+ submissions.set(id, value);
256
+ history = nextHistory.items;
257
+ historyCursor = nextHistory.nextCursor;
258
+ selectionKnown = true;
259
+ publish();
260
+ }
261
+ function refresh() {
262
+ if (refreshing) {
263
+ refreshAgain = true;
264
+ return refreshing;
265
+ }
266
+ loading = true;
267
+ publish();
268
+ refreshing = (async () => {
269
+ do {
270
+ refreshAgain = false;
271
+ await refreshOnce();
272
+ } while (refreshAgain && !disposed);
273
+ })().catch((cause) => { selectionKnown = false; fail(cause); throw cause; }).finally(() => { refreshing = null; loading = false; publish(); });
274
+ return refreshing;
275
+ }
276
+ const draftEqual = (a, b) => a.text === b.text && a.intent === b.intent && a.messageId === b.messageId && !a.composing;
277
+ const getDraft = (id) => id === "new" ? newMessage?.draft : drafts.get(id);
278
+ function dirtyDraft(id, draft) {
279
+ const baseline = editBaselines.get(id);
280
+ return draft.messageId ? !baseline || draft.text !== baseline.text || draft.intent !== baseline.intent : !!draft.text.trim();
281
+ }
282
+ function resolutionBlocker(thread) {
283
+ if (thread.thread.status === "resolved")
284
+ return "";
285
+ const id = thread.thread.threadId;
286
+ if (drafts.has(id))
287
+ return "Finish or close your draft before resolving. Your text and change permission are kept.";
288
+ if (thread.pendingMessageCount)
289
+ return "This conversation has comments that have not been sent. Send them or remove them before resolving.";
290
+ const outstanding = [...submissions.values()].some(({ submission }) => (submission.state === "queued" || submission.state === "delivered") &&
291
+ submission.messages.some(({ message }) => message.threadId === id));
292
+ return outstanding ? "Wait for the agent response before resolving. Resolving cannot cancel accepted work." : "";
293
+ }
294
+ function currentResolutionGuard() {
295
+ const thread = threads.find(item => item.thread.threadId === resolutionGuardId);
296
+ if (!thread)
297
+ return null;
298
+ const message = resolutionBlocker(thread);
299
+ return message ? { threadId: thread.thread.threadId, message } : null;
300
+ }
301
+ function canCancelDraft(id) {
302
+ return writable() && !busy && !confirming && !dispatching && !savingDrafts.has(id) && !getDraft(id)?.composing;
303
+ }
304
+ function discardDraft(id) {
305
+ if (id === "new") {
306
+ newMessage = null;
307
+ if (host === "compose")
308
+ open = false;
309
+ }
310
+ else
311
+ drafts.delete(id);
312
+ editBaselines.delete(id);
313
+ draftCancellation = null;
314
+ publish();
315
+ }
316
+ async function accept(pending) {
317
+ const result = await post(pending.body, acceptedMutationSchema);
318
+ if (result.receipt.reviewId !== reference.reviewId || result.receipt.entryKey !== reference.entryKey ||
319
+ result.receipt.requestId !== pending.body.requestId || result.receipt.operation !== pending.body.operation) {
320
+ throw new Error("Mutation receipt does not match this review and request.");
321
+ }
322
+ if (review)
323
+ review = { ...review, version: result.receipt.value.reviewVersion };
324
+ pending.accepted(result.receipt);
325
+ if (pending.draftId)
326
+ savingDrafts.delete(pending.draftId);
327
+ uncertain = null;
328
+ notice = "";
329
+ // Acceptance is independent of whether the subsequent read succeeds.
330
+ try {
331
+ await refresh();
332
+ }
333
+ catch {
334
+ notice = "Refresh failed; do not repeat accepted work.";
335
+ }
336
+ return result.receipt;
337
+ }
338
+ function mutate(operation, fields = {}, accepted = () => { }, expectedVersion, draftId) {
339
+ const task = serial.then(async () => {
340
+ if (disposed)
341
+ throw new Error("Review connection closed.");
342
+ if (uncertain)
343
+ throw new Error("Reconcile the unconfirmed request before another mutation.");
344
+ if (!review)
345
+ throw new Error("Review has not loaded.");
346
+ const input = { operation, ...reference, requestId: (options.id ?? (() => crypto.randomUUID()))(),
347
+ expectedVersion: expectedVersion ?? review.version, ...fields };
348
+ const body = operation === "abandon" ? abandonRequestSchema.parse(input) : reviewerMutationSchema.parse(input);
349
+ busy = true;
350
+ error = "";
351
+ publish();
352
+ const pending = { body, accepted, draftId, message: "Acceptance is unknown. Check the receipt or retry this exact request; a lookup miss is not rejection." };
353
+ try {
354
+ return await accept(pending);
355
+ }
356
+ catch (cause) {
357
+ if (!(cause instanceof ApiError) || cause.status >= 500)
358
+ uncertain = pending;
359
+ fail(cause);
360
+ if (!uncertain) {
361
+ try {
362
+ await refresh();
363
+ }
364
+ catch { /* Visible refresh error is retained. */ }
365
+ }
366
+ throw cause;
367
+ }
368
+ finally {
369
+ busy = false;
370
+ publish();
371
+ }
372
+ });
373
+ serial = task.then(() => { }, () => { });
374
+ return task;
375
+ }
376
+ async function saveDraft(id) {
377
+ const draft = id === "new" ? newMessage?.draft : drafts.get(id);
378
+ if (!writable() || !draft || draft.composing || !draft.text.trim() || savingDrafts.has(id))
379
+ return;
380
+ savingDrafts.add(id);
381
+ const saved = { ...draft }, target = newMessage;
382
+ const operation = id === "new" ? "create-thread" : draft.messageId ? "update-message" : "reply";
383
+ const fields = {
384
+ body: saved.text, intent: saved.intent,
385
+ ...(id === "new" && target ? { pageKey: target.pageKey, target: target.target } : { threadId: id }),
386
+ ...(saved.messageId ? { messageId: saved.messageId, messageVersion: saved.messageVersion } : {}),
387
+ };
388
+ publish();
389
+ try {
390
+ await mutate(operation, fields, () => {
391
+ const current = id === "new" ? newMessage?.draft : drafts.get(id);
392
+ if (current === draft && draftEqual(draft, saved)) {
393
+ if (id === "new")
394
+ newMessage = null;
395
+ else
396
+ drafts.delete(id);
397
+ editBaselines.delete(id);
398
+ }
399
+ else if (current === draft && saved.messageVersion !== null) {
400
+ draft.messageVersion = saved.messageVersion + 1;
401
+ editBaselines.set(id, { text: saved.text, intent: saved.intent });
402
+ }
403
+ }, undefined, id);
404
+ }
405
+ finally {
406
+ if (uncertain?.draftId !== id)
407
+ savingDrafts.delete(id);
408
+ publish();
409
+ }
410
+ }
411
+ async function send() {
412
+ if (!writable() || busy || confirming || dispatching || note.composing)
413
+ return;
414
+ const selected = pendingSelection();
415
+ if (!selected)
416
+ throw new Error("Pending feedback selection is incomplete. Refresh the review before sending.");
417
+ const sentNote = { ...note };
418
+ dispatching = true;
419
+ publish();
420
+ try {
421
+ await options.barrier();
422
+ await refresh();
423
+ for (const thread of threads) {
424
+ if (!thread.pendingMessageCount)
425
+ continue;
426
+ const context = await readContext(thread.thread.threadId, 1, thread.pendingMessageCount);
427
+ contexts.set(thread.thread.threadId, context);
428
+ }
429
+ const currentSelection = pendingSelection();
430
+ if (!currentSelection || selected.messages.some(message => !currentSelection.messages.some(current => current.threadId === message.threadId && current.messageId === message.messageId && current.version === message.version)) ||
431
+ selected.edits.some(edit => !currentSelection.edits.some(current => current.pageKey === edit.pageKey && current.editId === edit.editId && current.version === edit.version))) {
432
+ throw new Error("Selected feedback changed while preparing Send. Review selection and send again.");
433
+ }
434
+ if (status?.blockers.some((blocker) => blocker.targetKeys.some((key) => key === reference.entryKey || selected.pageKeys.includes(key)))) {
435
+ throw new Error("Send is blocked by outstanding work on this entry or a selected page.");
436
+ }
437
+ if (!selected.messages.length && !selected.edits.length && !selected.overallNote)
438
+ throw new Error("Select saved pending messages or edits, or write an overall note.");
439
+ captureNotice = "";
440
+ try {
441
+ await options.baseline();
442
+ }
443
+ catch (cause) {
444
+ captureNotice = `Comparison baseline unavailable: ${cause instanceof Error ? cause.message : cause}. Feedback delivery is independent.`;
445
+ publish();
446
+ }
447
+ await mutate("send", {
448
+ pageKeys: selected.pageKeys, messages: selected.messages, edits: selected.edits,
449
+ ...(selected.overallNote ? { overallNote: selected.overallNote } : {}),
450
+ }, () => { if (draftEqual(note, sentNote))
451
+ note = conversationDraft(); });
452
+ }
453
+ finally {
454
+ dispatching = false;
455
+ publish();
456
+ }
457
+ }
458
+ async function recordEdit(pageKey, contentInput) {
459
+ let content = directEditContentSchema.parse(contentInput);
460
+ const candidates = edits.filter((edit) => edit.pageKey === pageKey && edit.content.label === content.label && edit.content.kind === content.kind &&
461
+ edit.content.before === content.before && edit.content.before_html === content.before_html);
462
+ if (candidates.length > 1)
463
+ throw new Error("This edit matches multiple pending records. Source identification is required.");
464
+ const prior = candidates[0];
465
+ const retained = [...edits.filter((edit) => edit.pageKey === pageKey),
466
+ ...[...submissions.values()].flatMap((item) => item.submission.edits.filter((edit) => edit.pageKey === pageKey))]
467
+ .flatMap((edit) => edit.content.staged_assets)
468
+ .filter((asset) => content.before_html?.includes(asset.preview_src) || content.after_html?.includes(asset.preview_src));
469
+ content = directEditContentSchema.parse({ ...content,
470
+ staged_assets: [...new Map([...retained, ...content.staged_assets].map((asset) => [asset.id, asset])).values()] });
471
+ return mutate("record-edit", { pageKey, content, ...(prior ? { editId: prior.editId, editVersion: prior.version } : {}) }, (receipt) => { if (receipt.value.editId)
472
+ lastRecorded.set(pageKey, receipt.value.editId); });
473
+ }
474
+ async function saveHtml(pageKey, html, expectedSourceHash) {
475
+ const pending = edits.filter((edit) => edit.pageKey === pageKey && edit.source.state !== "saved");
476
+ const edit = pending.find((item) => item.editId === lastRecorded.get(pageKey));
477
+ if (!edit)
478
+ throw new Error("HTML cannot be saved without an exact pending edit record.");
479
+ const receipt = await mutate("save-edit", { pageKey, editId: edit.editId, editVersion: edit.version, html, expectedSourceHash });
480
+ try {
481
+ const exact = (await all("edits", directEditPageSchema)).find((item) => item.editId === edit.editId &&
482
+ item.pageKey === pageKey && item.reviewId === reference.reviewId && item.version === edit.version);
483
+ const current = await post({ operation: "read-page", ...reference, pageKey }, reviewPageSchema);
484
+ if (!exact || current.page.pageKey !== pageKey || current.reviewId !== reference.reviewId)
485
+ throw new Error("Exact saved edit or page is unavailable.");
486
+ validateSaveEvidence(exact, current.page.sourceHash, current.savePolicy === "writable");
487
+ if (exact.source.state !== "saved")
488
+ throw new Error("Exact save evidence is unavailable.");
489
+ return { hash: exact.source.evidence.sourceHash };
490
+ }
491
+ catch (cause) {
492
+ throw new Error(`Source save accepted (${receipt.requestId}), but current source verification failed. Reload source; do not repeat the write. ${cause instanceof Error ? cause.message : cause}`);
493
+ }
494
+ }
495
+ return {
496
+ getSnapshot: store.getSnapshot, subscribe: store.subscribe, refresh, mutate, recordEdit, saveHtml,
497
+ readingAnchor(id) { return readingAnchors.get(id); },
498
+ rememberReadingAnchor(id, value) {
499
+ if (Number.isFinite(value.offset))
500
+ readingAnchors.set(id, value);
501
+ },
502
+ readingPosition(id, host) { return readingPositions.get(id)?.[host] ?? 0; },
503
+ rememberReadingPosition(id, host, top) {
504
+ if (!Number.isFinite(top) || top < 0)
505
+ return;
506
+ readingPositions.set(id, { feedback: 0, focus: 0, ...readingPositions.get(id), [host]: top });
507
+ },
508
+ reference, get review() { return review; }, get pages() { return pages; },
509
+ get draftsPresent() { return draftCount() > 0; },
510
+ report: fail,
511
+ commands: {
512
+ open(value = true) { revealRequest++; open = value; if (!value)
513
+ revealedThreadId = null; publish(); },
514
+ connected(value) { connected = value; publish(); },
515
+ filter(kind) { revealedThreadId = null; filters = { ...filters, [kind]: !filters[kind] }; publish(); },
516
+ dismissReveal() { revealedThreadId = null; publish(); },
517
+ dismissNotification(id) { if (notification?.id === id) {
518
+ notification = null;
519
+ publish();
520
+ } },
521
+ collapse(id) { if (collapsed.has(id))
522
+ collapsed.delete(id);
523
+ else
524
+ collapsed.add(id); publish(); },
525
+ focus(id) {
526
+ revealRequest++;
527
+ if (!id)
528
+ revealFocusedThread();
529
+ else
530
+ revealedThreadId = null;
531
+ focusId = id;
532
+ host = id ? "focus" : "feedback";
533
+ open = true;
534
+ publish();
535
+ },
536
+ async revealMessage(threadId, messageId) {
537
+ revealedThreadId = null;
538
+ const request = ++revealRequest;
539
+ if (!threads.some(item => item.thread.threadId === threadId))
540
+ throw new Error("This conversation is no longer available in this review.");
541
+ let context = contexts.get(threadId);
542
+ if (!context?.items.some(item => item.reviewer.messageId === messageId)) {
543
+ const previous = context;
544
+ context = await readContext(threadId, 1, 0, messageId);
545
+ if (disposed || request !== revealRequest)
546
+ return;
547
+ if (contexts.get(threadId) !== previous)
548
+ throw new Error("Conversation changed while loading the reply. Try again.");
549
+ contexts.set(threadId, context);
550
+ }
551
+ if (!context.items.some(item => item.reviewer.messageId === messageId))
552
+ throw new Error("The requested reply is unavailable.");
553
+ historyOpened.add(threadId);
554
+ collapsed.delete(threadId);
555
+ readingAnchors.set(threadId, { messageId, offset: 0 });
556
+ revealedMessage = { threadId, messageId };
557
+ focusId = threadId;
558
+ host = "focus";
559
+ open = true;
560
+ attention.delete(threadId);
561
+ publish();
562
+ },
563
+ adjacent(id) {
564
+ revealedThreadId = null;
565
+ revealRequest++;
566
+ if (!threads.some((item) => item.thread.threadId === id))
567
+ throw new Error("Conversation is not in this review.");
568
+ focusId = id;
569
+ host = "adjacent";
570
+ open = true;
571
+ collapsed.delete(id);
572
+ publish();
573
+ },
574
+ fallback() {
575
+ if (host !== "adjacent")
576
+ return;
577
+ revealFocusedThread();
578
+ host = "feedback";
579
+ focusId = null;
580
+ publish();
581
+ },
582
+ markRead(id) { attention.delete(id); publish(); },
583
+ dismissResolutionGuard() { resolutionGuardId = null; publish(); },
584
+ async resolve(id) {
585
+ if (!writable() || busy || confirming || dispatching)
586
+ return;
587
+ const thread = threads.find(item => item.thread.threadId === id);
588
+ if (!thread)
589
+ throw new Error("This conversation is no longer available.");
590
+ if (resolutionBlocker(thread)) {
591
+ resolutionGuardId = id;
592
+ publish();
593
+ return;
594
+ }
595
+ resolutionGuardId = null;
596
+ confirming = true;
597
+ publish();
598
+ const status = thread.thread.status === "open" ? "resolved" : "open";
599
+ try {
600
+ await mutate("set-thread-status", { threadId: id, status }, undefined, review.version);
601
+ }
602
+ finally {
603
+ confirming = false;
604
+ publish();
605
+ }
606
+ },
607
+ compose() { revealRequest++; revealedThreadId = null; if (newMessage) {
608
+ open = true;
609
+ focusId = null;
610
+ host = "compose";
611
+ publish();
612
+ } },
613
+ begin(pageKey, target, contextual = false) {
614
+ revealRequest++;
615
+ if (!writable())
616
+ return false;
617
+ if (newMessage && (dirtyDraft("new", newMessage.draft) || newMessage.draft.composing || savingDrafts.has("new") || draftCancellation === "new")) {
618
+ throw new Error("Save or cancel the existing new-message draft first.");
619
+ }
620
+ revealedThreadId = null;
621
+ newMessage = { pageKey, target, draft: conversationDraft() };
622
+ open = true;
623
+ focusId = null;
624
+ host = contextual ? "compose" : "feedback";
625
+ publish();
626
+ return true;
627
+ },
628
+ reply(id) { if (writable() && !drafts.has(id)) {
629
+ drafts.set(id, conversationDraft());
630
+ collapsed.delete(id);
631
+ publish();
632
+ } },
633
+ edit(message) {
634
+ if (!writable() || message.submissionId !== null)
635
+ return;
636
+ const current = drafts.get(message.threadId);
637
+ if (current && (dirtyDraft(message.threadId, current) || current.composing || savingDrafts.has(message.threadId) || draftCancellation === message.threadId))
638
+ throw new Error("Save or cancel this thread's draft first.");
639
+ editBaselines.set(message.threadId, { text: message.body, intent: message.intent });
640
+ drafts.set(message.threadId, { ...conversationDraft(), text: message.body, intent: message.intent,
641
+ selectionStart: message.body.length, selectionEnd: message.body.length, messageId: message.messageId, messageVersion: message.version });
642
+ publish();
643
+ },
644
+ update(id, value) {
645
+ const draft = id === "note" ? note : id === "new" ? newMessage?.draft : drafts.get(id);
646
+ if (review?.state === "open" && draft) {
647
+ Object.assign(draft, value);
648
+ publish();
649
+ }
650
+ },
651
+ cancelDraft(id) {
652
+ const draft = getDraft(id);
653
+ if (!draft || !canCancelDraft(id))
654
+ return;
655
+ if (dirtyDraft(id, draft)) {
656
+ draftCancellation = id;
657
+ publish();
658
+ }
659
+ else
660
+ discardDraft(id);
661
+ },
662
+ keepEditing() { draftCancellation = null; publish(); },
663
+ discardDraft() {
664
+ if (draftCancellation !== null && canCancelDraft(draftCancellation))
665
+ discardDraft(draftCancellation);
666
+ },
667
+ saveDraft, send, refresh,
668
+ earlier(id) {
669
+ const running = contextLoads.get(id);
670
+ if (running)
671
+ return running;
672
+ contextErrors.delete(id);
673
+ const work = (async () => {
674
+ const current = contexts.get(id);
675
+ if (!current) {
676
+ const context = await readContext(id, 50);
677
+ if (contexts.has(id))
678
+ throw new Error("Conversation changed while loading earlier replies. Try again.");
679
+ contexts.set(id, context);
680
+ }
681
+ else if (historyOpened.has(id) || current.items.length === displayExchanges(threads.find(thread => thread.thread.threadId === id)).length) {
682
+ if (current.nextCursor) {
683
+ const next = await page("context", contextPageSchema, { cursor: current.nextCursor }, id);
684
+ if (contexts.get(id) !== current)
685
+ throw new Error("Conversation changed while loading earlier replies. Try again.");
686
+ contexts.set(id, { ...current, items: [...new Map([...next.items, ...current.items].map(item => [item.reviewer.messageId, item])).values()],
687
+ nextCursor: next.nextCursor });
688
+ }
689
+ }
690
+ historyOpened.add(id);
691
+ })().catch(cause => {
692
+ contextErrors.set(id, cause instanceof Error ? cause.message : String(cause));
693
+ throw cause;
694
+ }).finally(() => { contextLoads.delete(id); publish(); });
695
+ contextLoads.set(id, work);
696
+ publish();
697
+ return work;
698
+ },
699
+ historyEarlier() {
700
+ if (historyLoading)
701
+ return historyLoading;
702
+ if (!historyCursor)
703
+ return Promise.resolve();
704
+ historyDepth = history.length + 50;
705
+ historyLoading = refresh().finally(() => { historyLoading = null; });
706
+ return historyLoading;
707
+ },
708
+ navigate: options.navigate, jump: options.jump,
709
+ confirm(action, id = "") {
710
+ if (!review || busy || confirming || dispatching || uncertain)
711
+ return;
712
+ if (action !== "abandon" && !writable())
713
+ return;
714
+ const thread = threads.find((item) => item.thread.threadId === id);
715
+ const work = submissions.get(id)?.submission;
716
+ if ((action === "resolve" || action === "delete") && drafts.get(id)?.text)
717
+ throw new Error("Save or cancel this thread's local draft before changing its status.");
718
+ confirmation = { action, id, version: action === "abandon" ? work?.version ?? 0 : review.version,
719
+ description: action === "end" ? `End review ${review.reviewId} for every tab. Accepted source work continues. Saved-unsent items stay read-only here and never transfer to a new review. ${draftCount()} local drafts are not durable.`
720
+ : action === "abandon" ? `Abandon submission ${id} in review ${review.reviewId}. Stop the old agent and check source before proceeding. This stops redelivery and releases exclusion; it cannot cancel external work or undo file writes.`
721
+ : action === "revert" ? "Revert only source writes owned by this review. Exact saved-unsent edit records remain as source-pending feedback."
722
+ : action === "delete" ? "Delete this never-submitted thread? Saved messages in it will be removed."
723
+ : `${thread?.thread.status === "resolved" ? "Reopen" : "Resolve"} this thread? Closing or collapsing a thread never resolves it.` };
724
+ publish();
725
+ },
726
+ cancelConfirmation() { if (!busy && !confirming) {
727
+ confirmation = null;
728
+ publish();
729
+ } },
730
+ async confirmAction() {
731
+ const owned = confirmation;
732
+ if (!owned || busy || confirming || dispatching || uncertain || !review)
733
+ return;
734
+ confirming = true;
735
+ publish();
736
+ try {
737
+ if (owned.action === "end") {
738
+ await options.barrier();
739
+ await mutate("end", { confirmUnsentReadOnly: true }, () => { confirmation = null; }, owned.version);
740
+ }
741
+ else if (owned.action === "abandon") {
742
+ await mutate("abandon", { submissionId: owned.id, confirmExternalWorkMayContinue: true, reason: owned.description }, () => { confirmation = null; }, owned.version);
743
+ }
744
+ else if (owned.action === "revert") {
745
+ await options.revert();
746
+ confirmation = null;
747
+ publish();
748
+ }
749
+ else {
750
+ if (drafts.get(owned.id)?.text)
751
+ throw new Error("Save or cancel the local draft first.");
752
+ const thread = threads.find((item) => item.thread.threadId === owned.id);
753
+ await mutate(owned.action === "delete" ? "delete-thread" : "set-thread-status", { threadId: owned.id, ...(owned.action === "resolve" ? { status: thread?.thread.status === "open" ? "resolved" : "open" } : {}) }, () => { confirmation = null; }, owned.version);
754
+ }
755
+ }
756
+ finally {
757
+ confirming = false;
758
+ publish();
759
+ }
760
+ },
761
+ async reconcile(retry) {
762
+ const pending = uncertain;
763
+ if (!pending || busy)
764
+ return;
765
+ busy = true;
766
+ publish();
767
+ let retrying = false;
768
+ try {
769
+ const lookup = await post({ operation: "receipt", ...reference, requestId: pending.body.requestId }, receiptLookupSchema);
770
+ if (lookup.state === "not-found" && !retry) {
771
+ pending.message = "Receipt not found. Acceptance remains unknown; do not create a new request or repeat source work.";
772
+ return;
773
+ }
774
+ retrying = true;
775
+ await accept(pending);
776
+ }
777
+ catch (cause) {
778
+ fail(cause);
779
+ if (retrying && cause instanceof ApiError && cause.status < 500 && cause.status !== 401 && cause.status !== 403) {
780
+ if (pending.draftId)
781
+ savingDrafts.delete(pending.draftId);
782
+ uncertain = null;
783
+ notice = "The exact retry was rejected. Local conversation drafts are retained; inspect source separately before retrying a failed write.";
784
+ try {
785
+ await refresh();
786
+ }
787
+ catch { /* The authoritative read error remains visible. */ }
788
+ }
789
+ }
790
+ finally {
791
+ busy = false;
792
+ publish();
793
+ }
794
+ },
795
+ },
796
+ dispose() { disposed = true; store.dispose(); },
797
+ };
798
+ }