@erdemtuna/doc-review 0.7.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 +117 -0
- package/package.json +57 -0
- package/src/SKILL.md +143 -0
- package/src/anchor-text.js +115 -0
- package/src/chrome-client.js +986 -0
- package/src/chrome-session.js +15 -0
- package/src/chrome.css +458 -0
- package/src/chrome.html +83 -0
- package/src/cli.js +341 -0
- package/src/click-target.js +26 -0
- package/src/editing.js +99 -0
- package/src/frame-channel.js +44 -0
- package/src/frame-policy.js +16 -0
- package/src/html-transform.js +62 -0
- package/src/markdown.js +87 -0
- package/src/paths.js +87 -0
- package/src/sdk.js +1520 -0
- package/src/serialize.js +46 -0
- package/src/server-entry.js +26 -0
- package/src/server-lock.js +101 -0
- package/src/server.js +1268 -0
- package/src/setup.js +117 -0
- package/src/state.js +491 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-review chrome. Owns the rail UI and every call to the local server.
|
|
3
|
+
* It never touches the artifact DOM directly — the SDK does that, over
|
|
4
|
+
* postMessage, because the artifact iframe lives on the other loopback
|
|
5
|
+
* hostname: a separate origin that can never reach this page or its token.
|
|
6
|
+
*/
|
|
7
|
+
import { tidy } from "./anchor-text.js";
|
|
8
|
+
import { newestComments, pageUrl, replacePage } from "./chrome-session.js";
|
|
9
|
+
import { externalHref } from "./editing.js";
|
|
10
|
+
import { framePolicy } from "./frame-policy.js";
|
|
11
|
+
|
|
12
|
+
const $ = (id) => document.getElementById(id);
|
|
13
|
+
const frame = $("frame");
|
|
14
|
+
|
|
15
|
+
const state = {
|
|
16
|
+
sessionId: document.body.dataset.session,
|
|
17
|
+
token: document.body.dataset.token,
|
|
18
|
+
key: null,
|
|
19
|
+
page: null,
|
|
20
|
+
compose: null,
|
|
21
|
+
active: null,
|
|
22
|
+
agent: "idle",
|
|
23
|
+
save: "idle",
|
|
24
|
+
savedAt: "",
|
|
25
|
+
sent: false,
|
|
26
|
+
orphans: new Set(),
|
|
27
|
+
pollCommand: "",
|
|
28
|
+
editsExpanded: false,
|
|
29
|
+
others: [],
|
|
30
|
+
scroll: { x: 0, y: 0 },
|
|
31
|
+
reloading: false,
|
|
32
|
+
dynamic: false,
|
|
33
|
+
framePolicy: null,
|
|
34
|
+
renderGeneration: 0,
|
|
35
|
+
renderId: null,
|
|
36
|
+
frameCapability: null,
|
|
37
|
+
frameLoading: true,
|
|
38
|
+
pendingInitialLoad: false,
|
|
39
|
+
readyGeneration: null,
|
|
40
|
+
frameReadyTimer: null,
|
|
41
|
+
frameReadyRetries: 0,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Most reviewers drive an agent from a chat (Claude Code, Codex, Cursor), not
|
|
46
|
+
* a bare terminal — so the handoff is a prompt the agent can act on, with the
|
|
47
|
+
* poll command embedded for anyone who does live in a shell.
|
|
48
|
+
*/
|
|
49
|
+
function handoffPrompt(pollCommand) {
|
|
50
|
+
const cmd = String(pollCommand || "").trim();
|
|
51
|
+
if (!cmd) return "";
|
|
52
|
+
return `Run \`${cmd} --timeout 600\`, apply the feedback it returns, then run its exact \`next_step\` command with \`--ack <batch_id>\` until I end the review.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------- server
|
|
56
|
+
|
|
57
|
+
async function api(path, options) {
|
|
58
|
+
const res = await fetch(path, {
|
|
59
|
+
...options,
|
|
60
|
+
headers: { "content-type": "application/json", "x-doc-review-token": state.token, ...(options && options.headers) },
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const detail = await res.json().catch(() => ({}));
|
|
64
|
+
const error = new Error(detail.error || `Request failed (${res.status})`);
|
|
65
|
+
error.status = res.status;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
return res.json();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Keep the reviewed app on a different loopback origin from the review shell.
|
|
72
|
+
// This gives route-aware frameworks a real origin without exposing the parent UI.
|
|
73
|
+
const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
|
74
|
+
const ARTIFACT_ORIGIN = `${location.protocol}//${ARTIFACT_HOST}:${location.port}`;
|
|
75
|
+
const FRAME_READY_TIMEOUT_MS = 5000;
|
|
76
|
+
|
|
77
|
+
// URL reviews keep a real origin. File reviews use an opaque sandbox origin,
|
|
78
|
+
// so postMessage requires "*" while the source-window check remains exact.
|
|
79
|
+
const toFrame = (message) => {
|
|
80
|
+
if (state.frameLoading || !state.frameCapability || !frame.contentWindow) return;
|
|
81
|
+
frame.contentWindow.postMessage(
|
|
82
|
+
{
|
|
83
|
+
...message,
|
|
84
|
+
capability: state.frameCapability,
|
|
85
|
+
generation: state.renderGeneration,
|
|
86
|
+
pageKey: state.key,
|
|
87
|
+
},
|
|
88
|
+
state.framePolicy?.targetOrigin || ARTIFACT_ORIGIN
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function suspendFrame() {
|
|
93
|
+
clearTimeout(state.frameReadyTimer);
|
|
94
|
+
state.frameReadyTimer = null;
|
|
95
|
+
state.frameCapability = null;
|
|
96
|
+
state.renderId = null;
|
|
97
|
+
state.frameLoading = true;
|
|
98
|
+
state.readyGeneration = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function beginFrameTransition() {
|
|
102
|
+
state.renderGeneration += 1;
|
|
103
|
+
suspendFrame();
|
|
104
|
+
return state.renderGeneration;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function registerFrame(key, generation) {
|
|
108
|
+
const registered = await api(`/api/session/${state.sessionId}/render`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
body: JSON.stringify({ key, generation }),
|
|
111
|
+
});
|
|
112
|
+
if (state.key !== key || state.renderGeneration !== generation) return false;
|
|
113
|
+
state.renderId = registered.renderId;
|
|
114
|
+
state.frameCapability = registered.capability;
|
|
115
|
+
state.frameLoading = true;
|
|
116
|
+
state.pendingInitialLoad = true;
|
|
117
|
+
frame.src = `${ARTIFACT_ORIGIN}${registered.path}`;
|
|
118
|
+
state.frameReadyTimer = setTimeout(() => {
|
|
119
|
+
if (state.key !== key || state.renderGeneration !== generation || !state.frameLoading) return;
|
|
120
|
+
if (state.frameReadyRetries < 1) {
|
|
121
|
+
state.frameReadyRetries += 1;
|
|
122
|
+
rotateCurrentFrame();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
suspendFrame();
|
|
126
|
+
toast("The reviewed page did not finish loading. Reload it after fixing the source or local server.");
|
|
127
|
+
}, FRAME_READY_TIMEOUT_MS);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function rotateCurrentFrame() {
|
|
132
|
+
if (!state.key) return;
|
|
133
|
+
const key = state.key;
|
|
134
|
+
const generation = beginFrameTransition();
|
|
135
|
+
void registerFrame(key, generation).catch((err) => {
|
|
136
|
+
if (state.key !== key || state.renderGeneration !== generation) return;
|
|
137
|
+
suspendFrame();
|
|
138
|
+
toast(err.message);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ask the SDK to ship anything still sitting in its debounce windows, and
|
|
144
|
+
* wait until it has. Navigating away without this drops the last moments of
|
|
145
|
+
* typing. The timeout covers a torn-down or never-booted frame.
|
|
146
|
+
*/
|
|
147
|
+
let flushWaiter = null;
|
|
148
|
+
function flushFrame() {
|
|
149
|
+
if (state.frameLoading || !state.frameCapability) return Promise.resolve();
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const settle = () => {
|
|
152
|
+
if (flushWaiter !== settle) return;
|
|
153
|
+
flushWaiter = null;
|
|
154
|
+
resolve();
|
|
155
|
+
};
|
|
156
|
+
flushWaiter = settle;
|
|
157
|
+
toFrame({ type: "eh:flush" });
|
|
158
|
+
setTimeout(settle, 400);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function loadPage(key, { reload = true } = {}) {
|
|
163
|
+
const returning = state.page;
|
|
164
|
+
const generation = beginFrameTransition();
|
|
165
|
+
state.frameReadyRetries = 0;
|
|
166
|
+
state.key = key;
|
|
167
|
+
const page = await api(pageUrl(key, state.sessionId));
|
|
168
|
+
if (state.key !== key || state.renderGeneration !== generation) return;
|
|
169
|
+
replacePage(state, page);
|
|
170
|
+
state.framePolicy = framePolicy(state.page, ARTIFACT_ORIGIN);
|
|
171
|
+
frame.setAttribute("sandbox", state.framePolicy.sandbox);
|
|
172
|
+
state.orphans = new Set();
|
|
173
|
+
state.compose = null;
|
|
174
|
+
state.active = null;
|
|
175
|
+
state.sent = false;
|
|
176
|
+
state.dynamic = false;
|
|
177
|
+
state.baseHash = null;
|
|
178
|
+
clearTimeout(retryTimer);
|
|
179
|
+
if (reload) {
|
|
180
|
+
state.reloading = true;
|
|
181
|
+
await registerFrame(key, generation);
|
|
182
|
+
}
|
|
183
|
+
render();
|
|
184
|
+
// Coming back to a dev-server page shows the app's own copy again, without
|
|
185
|
+
// the direct edits — which reads as data loss unless we say what happened.
|
|
186
|
+
const edits = state.page.edits ? state.page.edits.length : 0;
|
|
187
|
+
if (returning && state.page.feedbackOnly && edits > 0) {
|
|
188
|
+
toast(`This page renders from your dev server — ${edits} ${edits === 1 ? "edit is" : "edits are"} queued for the agent`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// -------------------------------------------------------------------- clock
|
|
193
|
+
|
|
194
|
+
function ago(ts) {
|
|
195
|
+
const secs = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
196
|
+
if (secs < 45) return "just now";
|
|
197
|
+
const mins = Math.round(secs / 60);
|
|
198
|
+
if (mins < 60) return `${mins}m`;
|
|
199
|
+
const hours = Math.round(mins / 60);
|
|
200
|
+
if (hours < 24) return `${hours}h`;
|
|
201
|
+
return `${Math.round(hours / 24)}d`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
205
|
+
|
|
206
|
+
// -------------------------------------------------------------------- render
|
|
207
|
+
|
|
208
|
+
function render() {
|
|
209
|
+
const page = state.page;
|
|
210
|
+
if (!page) return;
|
|
211
|
+
document.title = page.filename || 'doc-review';
|
|
212
|
+
|
|
213
|
+
const comments = newestComments(page.comments);
|
|
214
|
+
const edits = page.edits || [];
|
|
215
|
+
|
|
216
|
+
$("count").textContent = String(comments.length);
|
|
217
|
+
$("empty").hidden = comments.length > 0 || !!state.compose;
|
|
218
|
+
|
|
219
|
+
// --- compose
|
|
220
|
+
const composeWrap = $("compose");
|
|
221
|
+
if (state.compose) {
|
|
222
|
+
composeWrap.hidden = false;
|
|
223
|
+
$("composeKind").textContent = state.compose.kind === "element" ? "Element" : "Selection";
|
|
224
|
+
$("composeQuote").textContent = tidy(state.compose.quote, 260);
|
|
225
|
+
} else {
|
|
226
|
+
composeWrap.hidden = true;
|
|
227
|
+
$("composeText").value = "";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- comment cards
|
|
231
|
+
const list = $("cards");
|
|
232
|
+
list.textContent = "";
|
|
233
|
+
for (const comment of comments) {
|
|
234
|
+
const card = document.createElement("div");
|
|
235
|
+
card.className = `comment${state.active === comment.id ? " active" : ""}`;
|
|
236
|
+
card.dataset.id = comment.id;
|
|
237
|
+
|
|
238
|
+
const head = document.createElement("div");
|
|
239
|
+
head.className = "comment-head";
|
|
240
|
+
|
|
241
|
+
const who = document.createElement("span");
|
|
242
|
+
who.className = "who";
|
|
243
|
+
who.append("You");
|
|
244
|
+
const sep = document.createElement("span");
|
|
245
|
+
sep.className = "sep";
|
|
246
|
+
sep.textContent = "·";
|
|
247
|
+
const when = document.createElement("span");
|
|
248
|
+
when.className = "when";
|
|
249
|
+
when.textContent = ago(comment.updatedAt || comment.createdAt);
|
|
250
|
+
who.append(sep, when);
|
|
251
|
+
|
|
252
|
+
if (comment.correction) {
|
|
253
|
+
const badge = document.createElement("span");
|
|
254
|
+
badge.className = "badge correction";
|
|
255
|
+
badge.textContent = "correction";
|
|
256
|
+
who.append(badge);
|
|
257
|
+
} else if (comment.updatedAt) {
|
|
258
|
+
const badge = document.createElement("span");
|
|
259
|
+
badge.className = "badge";
|
|
260
|
+
badge.textContent = "edited";
|
|
261
|
+
who.append(badge);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (state.orphans.has(comment.id)) {
|
|
265
|
+
const badge = document.createElement("span");
|
|
266
|
+
badge.className = "badge";
|
|
267
|
+
badge.textContent = "orphaned";
|
|
268
|
+
who.append(badge);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const jump = document.createElement("button");
|
|
272
|
+
jump.type = "button";
|
|
273
|
+
jump.className = "jump";
|
|
274
|
+
jump.textContent = "Jump to";
|
|
275
|
+
jump.addEventListener("click", (event) => {
|
|
276
|
+
event.stopPropagation();
|
|
277
|
+
setActive(comment.id, true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const edit = document.createElement("button");
|
|
281
|
+
edit.type = "button";
|
|
282
|
+
edit.className = "jump edit-comment";
|
|
283
|
+
edit.textContent = "Edit";
|
|
284
|
+
edit.addEventListener("click", (event) => {
|
|
285
|
+
event.stopPropagation();
|
|
286
|
+
editComment(card, body, comment);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const remove = document.createElement("button");
|
|
290
|
+
remove.type = "button";
|
|
291
|
+
remove.className = "remove";
|
|
292
|
+
remove.title = "Delete comment";
|
|
293
|
+
remove.setAttribute("aria-label", "Delete comment");
|
|
294
|
+
remove.textContent = "✕";
|
|
295
|
+
remove.addEventListener("click", async (event) => {
|
|
296
|
+
event.stopPropagation();
|
|
297
|
+
toFrame({ type: "eh:remove", id: comment.id });
|
|
298
|
+
state.page = (await api(`/api/page/${state.key}/comment/${comment.id}`, { method: "DELETE" })).page;
|
|
299
|
+
render();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const quote = document.createElement("p");
|
|
303
|
+
quote.className = "quote";
|
|
304
|
+
quote.textContent = tidy(comment.quote, 140);
|
|
305
|
+
|
|
306
|
+
const body = document.createElement("p");
|
|
307
|
+
body.className = "body";
|
|
308
|
+
body.textContent = comment.feedback;
|
|
309
|
+
body.title = "Click to edit";
|
|
310
|
+
body.addEventListener("click", (event) => {
|
|
311
|
+
event.stopPropagation();
|
|
312
|
+
editComment(card, body, comment);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
head.append(who, jump, edit, remove);
|
|
316
|
+
|
|
317
|
+
card.append(head, quote, body);
|
|
318
|
+
card.addEventListener("click", () => setActive(comment.id, false));
|
|
319
|
+
list.append(card);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- your edits
|
|
323
|
+
const box = $("editsBox");
|
|
324
|
+
box.hidden = edits.length === 0;
|
|
325
|
+
if (edits.length) {
|
|
326
|
+
$("editCount").textContent = String(edits.length);
|
|
327
|
+
const rows = $("editList");
|
|
328
|
+
rows.textContent = "";
|
|
329
|
+
const LIMIT = 5;
|
|
330
|
+
const shown = state.editsExpanded ? edits : edits.slice(0, LIMIT);
|
|
331
|
+
for (const edit of shown) {
|
|
332
|
+
const row = document.createElement("div");
|
|
333
|
+
row.className = `edit-row${edit.kind === "deleted" ? " deleted" : ""}`;
|
|
334
|
+
const pip = document.createElement("span");
|
|
335
|
+
pip.className = "pip";
|
|
336
|
+
const label = document.createElement("span");
|
|
337
|
+
label.className = "label";
|
|
338
|
+
label.textContent = edit.label;
|
|
339
|
+
const kind = document.createElement("span");
|
|
340
|
+
kind.className = "kind";
|
|
341
|
+
kind.textContent = edit.kind;
|
|
342
|
+
row.append(pip, label, kind);
|
|
343
|
+
rows.append(row);
|
|
344
|
+
}
|
|
345
|
+
if (edits.length > LIMIT) {
|
|
346
|
+
const more = document.createElement("button");
|
|
347
|
+
more.type = "button";
|
|
348
|
+
more.className = "edit-more";
|
|
349
|
+
more.textContent = state.editsExpanded ? "Show fewer" : `${edits.length - LIMIT} more…`;
|
|
350
|
+
more.addEventListener("click", () => {
|
|
351
|
+
state.editsExpanded = !state.editsExpanded;
|
|
352
|
+
render();
|
|
353
|
+
});
|
|
354
|
+
rows.append(more);
|
|
355
|
+
}
|
|
356
|
+
renderSave();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- pages you left feedback on but are not looking at
|
|
360
|
+
const others = state.others || [];
|
|
361
|
+
const othersBox = $("othersBox");
|
|
362
|
+
othersBox.hidden = others.length === 0;
|
|
363
|
+
if (others.length) {
|
|
364
|
+
$("othersCount").textContent = String(others.length);
|
|
365
|
+
const list = $("othersList");
|
|
366
|
+
list.textContent = "";
|
|
367
|
+
for (const other of others) {
|
|
368
|
+
const row = document.createElement("button");
|
|
369
|
+
row.type = "button";
|
|
370
|
+
row.className = "edit-row other-row";
|
|
371
|
+
const label = document.createElement("span");
|
|
372
|
+
label.className = "label";
|
|
373
|
+
label.textContent = other.filename;
|
|
374
|
+
const count = document.createElement("span");
|
|
375
|
+
count.className = "kind";
|
|
376
|
+
count.textContent = String(other.count);
|
|
377
|
+
row.append(label, count);
|
|
378
|
+
row.addEventListener("click", async () => {
|
|
379
|
+
await flushFrame();
|
|
380
|
+
suspendFrame();
|
|
381
|
+
await api(`/api/session/${state.sessionId}/goto`, {
|
|
382
|
+
method: "POST",
|
|
383
|
+
body: JSON.stringify({ key: other.key }),
|
|
384
|
+
});
|
|
385
|
+
state.scroll = { x: 0, y: 0 };
|
|
386
|
+
await loadPage(other.key);
|
|
387
|
+
});
|
|
388
|
+
list.append(row);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- send
|
|
393
|
+
const otherTotal = others.reduce((sum, o) => sum + o.count, 0);
|
|
394
|
+
const total = comments.length + edits.length + otherTotal;
|
|
395
|
+
// An overall note is sendable on its own — the server already accepts
|
|
396
|
+
// note-only batches; the button must not stay dead while one is typed.
|
|
397
|
+
const hasNote = $("note").value.trim().length > 0;
|
|
398
|
+
const send = $("send");
|
|
399
|
+
const delivered = state.agent === "working";
|
|
400
|
+
const stranded = state.agent === "stranded";
|
|
401
|
+
const busy = delivered || stranded || state.sent;
|
|
402
|
+
send.disabled = (total === 0 && !hasNote) || busy;
|
|
403
|
+
send.textContent = delivered
|
|
404
|
+
? "Feedback delivered"
|
|
405
|
+
: stranded
|
|
406
|
+
? "Sent — agent is not listening"
|
|
407
|
+
: state.sent
|
|
408
|
+
? "Sent — waiting for agent"
|
|
409
|
+
: total
|
|
410
|
+
? `Send ${total} to agent`
|
|
411
|
+
: hasNote
|
|
412
|
+
? "Send note to agent"
|
|
413
|
+
: "Nothing to send yet";
|
|
414
|
+
if (!send.disabled) {
|
|
415
|
+
const key = document.createElement("span");
|
|
416
|
+
key.className = "key";
|
|
417
|
+
key.textContent = "⌘⏎";
|
|
418
|
+
send.append(" ", key);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// After sending, say what happens next. If nothing is polling, the loop would
|
|
422
|
+
// otherwise dead-end silently, so hand over the exact command to run.
|
|
423
|
+
$("agentLine").hidden = !delivered;
|
|
424
|
+
$("agentText").textContent = "Feedback delivered — page reloads when fixes land";
|
|
425
|
+
|
|
426
|
+
// Server-authoritative, so it survives a browser refresh.
|
|
427
|
+
$("handoff").hidden = !stranded;
|
|
428
|
+
if (stranded) $("handoffCmd").textContent = handoffPrompt(state.pollCommand || page.pollCommand);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function renderSave() {
|
|
432
|
+
const line = $("saveLine");
|
|
433
|
+
if (state.page && state.page.kind === "url") {
|
|
434
|
+
line.className = "save-line dynamic";
|
|
435
|
+
$("saveText").textContent = "Localhost page — your direct edits go to the agent for source updates";
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (state.page && state.page.markdown) {
|
|
439
|
+
line.className = "save-line dynamic";
|
|
440
|
+
$("saveText").textContent = "Markdown source — edits go to the agent as feedback";
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (state.dynamic) {
|
|
444
|
+
// The page's own scripts render it, so writing the live DOM back would
|
|
445
|
+
// corrupt the file. Edits still reach the agent as feedback.
|
|
446
|
+
line.className = "save-line dynamic";
|
|
447
|
+
$("saveText").textContent = "Live page — edits go to the agent, the file is left alone";
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
line.className = `save-line ${state.save === "saving" ? "saving" : state.save === "failed" ? "failed" : ""}`;
|
|
451
|
+
const name = state.page ? state.page.filename : "";
|
|
452
|
+
if (state.save === "saving") $("saveText").textContent = `Saving to ${name}…`;
|
|
453
|
+
else if (state.save === "failed") $("saveText").textContent = "Couldn't save — retrying…";
|
|
454
|
+
else $("saveText").textContent = state.savedAt ? `Saved to ${name} · ${state.savedAt}` : `Saved to ${name}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Swap a comment's text for a textarea until the new wording is committed. */
|
|
458
|
+
function editComment(card, body, comment) {
|
|
459
|
+
if (card.querySelector("textarea")) return;
|
|
460
|
+
const input = document.createElement("textarea");
|
|
461
|
+
input.className = "body-edit";
|
|
462
|
+
input.rows = 3;
|
|
463
|
+
input.value = comment.feedback;
|
|
464
|
+
let done = false;
|
|
465
|
+
const finish = () => {
|
|
466
|
+
done = true;
|
|
467
|
+
render();
|
|
468
|
+
};
|
|
469
|
+
const commit = async () => {
|
|
470
|
+
if (done) return;
|
|
471
|
+
done = true;
|
|
472
|
+
const feedback = input.value.trim();
|
|
473
|
+
if (!feedback || feedback === comment.feedback) return render();
|
|
474
|
+
try {
|
|
475
|
+
const result = await api(`/api/page/${state.key}/comment/${comment.id}`, {
|
|
476
|
+
method: "PATCH",
|
|
477
|
+
body: JSON.stringify({ feedback }),
|
|
478
|
+
});
|
|
479
|
+
state.page = result.page;
|
|
480
|
+
if (result.delivery === "updated-pending") {
|
|
481
|
+
toast("Updated the feedback waiting for your agent");
|
|
482
|
+
} else if (result.delivery === "correction") {
|
|
483
|
+
state.sent = false;
|
|
484
|
+
toFrame({ type: "eh:remove", id: comment.id });
|
|
485
|
+
toFrame({ type: "eh:anchors", comments: state.page.comments });
|
|
486
|
+
toast("Saved as a correction — send it after the current batch is acknowledged");
|
|
487
|
+
} else {
|
|
488
|
+
state.sent = false;
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
toast(err.message);
|
|
492
|
+
}
|
|
493
|
+
render();
|
|
494
|
+
};
|
|
495
|
+
input.addEventListener("keydown", (event) => {
|
|
496
|
+
event.stopPropagation();
|
|
497
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
498
|
+
event.preventDefault();
|
|
499
|
+
commit();
|
|
500
|
+
}
|
|
501
|
+
if (event.key === "Escape") {
|
|
502
|
+
event.preventDefault();
|
|
503
|
+
finish();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
input.addEventListener("blur", commit);
|
|
507
|
+
input.addEventListener("click", (event) => event.stopPropagation());
|
|
508
|
+
body.replaceWith(input);
|
|
509
|
+
input.focus();
|
|
510
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function toast(message) {
|
|
514
|
+
const el = document.createElement("div");
|
|
515
|
+
el.className = "toast";
|
|
516
|
+
el.textContent = message;
|
|
517
|
+
document.body.append(el);
|
|
518
|
+
setTimeout(() => el.remove(), 3200);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function setActive(id, scroll) {
|
|
522
|
+
state.active = id;
|
|
523
|
+
toFrame({ type: "eh:activate", id, scroll: !!scroll });
|
|
524
|
+
render();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ------------------------------------------------------------------ compose
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The card deliberately does not steal focus. The selection stays live in the
|
|
531
|
+
* document so you can type over it or delete it; click the card when you want
|
|
532
|
+
* to comment on it instead.
|
|
533
|
+
*/
|
|
534
|
+
async function openCompose(detail) {
|
|
535
|
+
if (state.compose && $("composeText").value.trim()) await commitCompose();
|
|
536
|
+
state.compose = detail;
|
|
537
|
+
render();
|
|
538
|
+
toFrame({ type: "eh:composeOpen" });
|
|
539
|
+
$("composeText").value = "";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function cancelCompose() {
|
|
543
|
+
if (!state.compose) return;
|
|
544
|
+
state.compose = null;
|
|
545
|
+
toFrame({ type: "eh:cancel" });
|
|
546
|
+
render();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function commitCompose() {
|
|
550
|
+
const compose = state.compose;
|
|
551
|
+
const feedback = $("composeText").value.trim();
|
|
552
|
+
if (!compose || !feedback) return;
|
|
553
|
+
const result = await api(`/api/page/${state.key}/comment`, {
|
|
554
|
+
method: "POST",
|
|
555
|
+
body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.anchor, feedback }),
|
|
556
|
+
});
|
|
557
|
+
toFrame({ type: "eh:commit", id: result.comment.id });
|
|
558
|
+
state.compose = null;
|
|
559
|
+
state.page = result.page;
|
|
560
|
+
state.active = result.comment.id;
|
|
561
|
+
state.sent = false;
|
|
562
|
+
render();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// -------------------------------------------------------------------- saving
|
|
566
|
+
|
|
567
|
+
let retryTimer = null;
|
|
568
|
+
let saveAttempts = 0;
|
|
569
|
+
|
|
570
|
+
/** A fresh serialization from the SDK always starts a fresh attempt budget. */
|
|
571
|
+
function saveNow(html) {
|
|
572
|
+
saveAttempts = 0;
|
|
573
|
+
return saveHtml(html, state.key);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function saveHtml(html, key) {
|
|
577
|
+
clearTimeout(retryTimer);
|
|
578
|
+
// A retry that outlived a page switch must never write into the new page.
|
|
579
|
+
if (key !== state.key) return;
|
|
580
|
+
if (!state.baseHash) {
|
|
581
|
+
// The on-disk baseline hasn't arrived yet; wait for it rather than write blind.
|
|
582
|
+
saveAttempts += 1;
|
|
583
|
+
if (saveAttempts <= 20) retryTimer = setTimeout(() => saveHtml(html, key), 500);
|
|
584
|
+
else {
|
|
585
|
+
state.save = "failed";
|
|
586
|
+
renderSave();
|
|
587
|
+
}
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
const result = await api(`/api/page/${key}/save`, { method: "POST", body: JSON.stringify({ html, baseHash: state.baseHash }) });
|
|
592
|
+
state.baseHash = result.hash || null;
|
|
593
|
+
state.save = "saved";
|
|
594
|
+
state.savedAt = clock();
|
|
595
|
+
saveAttempts = 0;
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (err.status === 409) {
|
|
598
|
+
// Someone else — usually the agent — wrote the file first. Their version
|
|
599
|
+
// arrives via the reload event; this save is abandoned, not retried.
|
|
600
|
+
state.baseHash = null;
|
|
601
|
+
state.save = "idle";
|
|
602
|
+
saveAttempts = 0;
|
|
603
|
+
renderSave();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
saveAttempts += 1;
|
|
607
|
+
state.save = "failed";
|
|
608
|
+
if (saveAttempts < 5) retryTimer = setTimeout(() => saveHtml(html, key), 2000);
|
|
609
|
+
else toast("Couldn't save — your edits still reach the agent as feedback");
|
|
610
|
+
}
|
|
611
|
+
renderSave();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ------------------------------------------------------------ frame messages
|
|
615
|
+
|
|
616
|
+
window.addEventListener("message", async (event) => {
|
|
617
|
+
if (!frame.contentWindow || event.source !== frame.contentWindow) return;
|
|
618
|
+
if (!state.framePolicy || event.origin !== state.framePolicy.incomingOrigin) return;
|
|
619
|
+
const msg = event.data || {};
|
|
620
|
+
if (
|
|
621
|
+
!state.frameCapability ||
|
|
622
|
+
msg.capability !== state.frameCapability ||
|
|
623
|
+
msg.generation !== state.renderGeneration ||
|
|
624
|
+
msg.pageKey !== state.key
|
|
625
|
+
) return;
|
|
626
|
+
if (state.frameLoading && msg.type !== "eh:ready") return;
|
|
627
|
+
|
|
628
|
+
switch (msg.type) {
|
|
629
|
+
case "eh:ready": {
|
|
630
|
+
if (state.readyGeneration === state.renderGeneration) return;
|
|
631
|
+
clearTimeout(state.frameReadyTimer);
|
|
632
|
+
state.frameReadyTimer = null;
|
|
633
|
+
state.frameReadyRetries = 0;
|
|
634
|
+
state.readyGeneration = state.renderGeneration;
|
|
635
|
+
state.frameLoading = false;
|
|
636
|
+
const readyCapability = state.frameCapability;
|
|
637
|
+
void api(`/api/session/${state.sessionId}/render/${state.renderId}/ready`, {
|
|
638
|
+
method: "POST",
|
|
639
|
+
body: JSON.stringify({
|
|
640
|
+
capability: readyCapability,
|
|
641
|
+
generation: state.renderGeneration,
|
|
642
|
+
pageKey: state.key,
|
|
643
|
+
}),
|
|
644
|
+
}).catch(() => {});
|
|
645
|
+
toFrame({ type: "eh:anchors", comments: state.page ? state.page.comments : [] });
|
|
646
|
+
if (state.reloading) {
|
|
647
|
+
toFrame({ type: "eh:restoreScroll", x: state.scroll.x, y: state.scroll.y });
|
|
648
|
+
state.reloading = false;
|
|
649
|
+
}
|
|
650
|
+
if (state.page && (state.page.markdown || state.page.feedbackOnly)) {
|
|
651
|
+
// Rendered sources are editable here but never serialized over their source.
|
|
652
|
+
toFrame({ type: "eh:feedbackOnly" });
|
|
653
|
+
} else {
|
|
654
|
+
// Hand the SDK the on-disk HTML so it can spot self-rendering pages.
|
|
655
|
+
api(`/api/page/${state.key}/raw`)
|
|
656
|
+
.then((raw) => {
|
|
657
|
+
state.baseHash = raw.hash || null;
|
|
658
|
+
toFrame({ type: "eh:raw", html: raw.html });
|
|
659
|
+
})
|
|
660
|
+
.catch(() => {});
|
|
661
|
+
}
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
case "eh:compose":
|
|
665
|
+
await openCompose({ kind: msg.kind, quote: msg.quote, anchor: msg.anchor });
|
|
666
|
+
break;
|
|
667
|
+
case "eh:dismiss":
|
|
668
|
+
if (!$("composeText").value.trim()) cancelCompose();
|
|
669
|
+
break;
|
|
670
|
+
case "eh:activate":
|
|
671
|
+
setActive(msg.id, false);
|
|
672
|
+
break;
|
|
673
|
+
case "eh:anchorStatus":
|
|
674
|
+
state.orphans = new Set(msg.orphaned || []);
|
|
675
|
+
render();
|
|
676
|
+
break;
|
|
677
|
+
case "eh:notInView":
|
|
678
|
+
toast("That comment is not visible in this view");
|
|
679
|
+
break;
|
|
680
|
+
case "eh:edit":
|
|
681
|
+
state.page = (await api(`/api/page/${state.key}/edit`, {
|
|
682
|
+
method: "POST",
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
label: msg.label,
|
|
685
|
+
kind: msg.kind,
|
|
686
|
+
before: msg.before,
|
|
687
|
+
after: msg.after,
|
|
688
|
+
before_html: msg.before_html,
|
|
689
|
+
after_html: msg.after_html,
|
|
690
|
+
moved_after: msg.moved_after,
|
|
691
|
+
moved_before: msg.moved_before,
|
|
692
|
+
staged_assets: msg.staged_assets,
|
|
693
|
+
}),
|
|
694
|
+
})).page;
|
|
695
|
+
state.sent = false;
|
|
696
|
+
render();
|
|
697
|
+
break;
|
|
698
|
+
case "eh:asset":
|
|
699
|
+
try {
|
|
700
|
+
const saved = await fetch(`/api/page/${state.key}/asset?type=${encodeURIComponent(msg.assetType || "")}`, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
headers: { "content-type": "application/octet-stream", "x-doc-review-token": state.token },
|
|
703
|
+
body: msg.bytes,
|
|
704
|
+
});
|
|
705
|
+
const data = await saved.json();
|
|
706
|
+
if (!saved.ok) throw new Error(data.error || "could not save the pasted image");
|
|
707
|
+
toFrame({ type: "eh:assetSaved", id: msg.id, src: data.src, stagedId: data.stagedId });
|
|
708
|
+
} catch (err) {
|
|
709
|
+
toast(err.message);
|
|
710
|
+
toFrame({ type: "eh:assetFailed", id: msg.id });
|
|
711
|
+
}
|
|
712
|
+
break;
|
|
713
|
+
case "eh:saving":
|
|
714
|
+
state.save = "saving";
|
|
715
|
+
renderSave();
|
|
716
|
+
break;
|
|
717
|
+
case "eh:html":
|
|
718
|
+
await saveNow(msg.html);
|
|
719
|
+
break;
|
|
720
|
+
case "eh:clean":
|
|
721
|
+
// Serialization matched what is already on disk; nothing to write.
|
|
722
|
+
state.save = state.savedAt ? "saved" : "idle";
|
|
723
|
+
renderSave();
|
|
724
|
+
break;
|
|
725
|
+
case "eh:dynamic":
|
|
726
|
+
state.dynamic = true;
|
|
727
|
+
renderSave();
|
|
728
|
+
break;
|
|
729
|
+
case "eh:flushed":
|
|
730
|
+
if (flushWaiter) flushWaiter();
|
|
731
|
+
break;
|
|
732
|
+
case "eh:scroll":
|
|
733
|
+
state.scroll = { x: msg.x, y: msg.y };
|
|
734
|
+
break;
|
|
735
|
+
case "eh:external": {
|
|
736
|
+
// This side is what actually calls window.open, so it re-checks the
|
|
737
|
+
// scheme rather than trusting the frame: a javascript: or data: URL
|
|
738
|
+
// arriving here would run on this origin, next to the token.
|
|
739
|
+
const external = externalHref(msg.href, location.href);
|
|
740
|
+
if (external) window.open(external, "_blank", "noopener");
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
case "eh:navigate":
|
|
744
|
+
try {
|
|
745
|
+
const result = await api(`/api/session/${state.sessionId}/navigate`, {
|
|
746
|
+
method: "POST",
|
|
747
|
+
body: JSON.stringify({ href: msg.href }),
|
|
748
|
+
});
|
|
749
|
+
state.scroll = { x: 0, y: 0 };
|
|
750
|
+
await loadPage(result.key);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
toast(err.message);
|
|
753
|
+
}
|
|
754
|
+
break;
|
|
755
|
+
default:
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------------- rail wiring
|
|
761
|
+
|
|
762
|
+
$("composeAdd").addEventListener("click", commitCompose);
|
|
763
|
+
$("composeCancel").addEventListener("click", cancelCompose);
|
|
764
|
+
|
|
765
|
+
// Clicking anywhere on the card is the "I meant to comment" gesture.
|
|
766
|
+
$("compose").addEventListener("mousedown", (event) => {
|
|
767
|
+
if (event.target.closest("button")) return;
|
|
768
|
+
event.preventDefault();
|
|
769
|
+
$("composeText").focus();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
$("composeText").addEventListener("keydown", (event) => {
|
|
773
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
774
|
+
event.preventDefault();
|
|
775
|
+
commitCompose();
|
|
776
|
+
}
|
|
777
|
+
if (event.key === "Escape") {
|
|
778
|
+
event.preventDefault();
|
|
779
|
+
cancelCompose();
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
$("send").addEventListener("click", async () => {
|
|
784
|
+
try {
|
|
785
|
+
await api(`/api/page/${state.key}/send`, {
|
|
786
|
+
method: "POST",
|
|
787
|
+
body: JSON.stringify({ sessionId: state.sessionId, note: $("note").value.trim() }),
|
|
788
|
+
});
|
|
789
|
+
$("note").value = "";
|
|
790
|
+
state.sent = true;
|
|
791
|
+
render();
|
|
792
|
+
} catch (err) {
|
|
793
|
+
toast(err.message);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
$("revert").addEventListener("click", async () => {
|
|
798
|
+
const count = state.page.edits.length;
|
|
799
|
+
if (!window.confirm(`Discard all ${count} of your edits?`)) return;
|
|
800
|
+
// Stop the SDK's debounced save and our own retries first, so a queued save
|
|
801
|
+
// can't land after the revert and write the edits straight back.
|
|
802
|
+
toFrame({ type: "eh:abortSave" });
|
|
803
|
+
clearTimeout(retryTimer);
|
|
804
|
+
state.baseHash = null;
|
|
805
|
+
try {
|
|
806
|
+
state.page = (await api(`/api/page/${state.key}/revert`, { method: "POST" })).page;
|
|
807
|
+
state.save = "idle";
|
|
808
|
+
state.savedAt = "";
|
|
809
|
+
render();
|
|
810
|
+
} catch (err) {
|
|
811
|
+
toast(err.message);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
/** The session is over: freeze the page and say so. Feedback is already safe. */
|
|
816
|
+
function showEnded() {
|
|
817
|
+
if (document.querySelector(".ended")) return;
|
|
818
|
+
if (events) events.close();
|
|
819
|
+
clearTimeout(retryTimer);
|
|
820
|
+
const overlay = document.createElement("div");
|
|
821
|
+
overlay.className = "ended";
|
|
822
|
+
const title = document.createElement("h2");
|
|
823
|
+
title.textContent = "Review ended";
|
|
824
|
+
const line = document.createElement("p");
|
|
825
|
+
line.textContent = "Unsent feedback is saved and ships next time you review this page. You can close this tab.";
|
|
826
|
+
overlay.append(title, line);
|
|
827
|
+
document.body.append(overlay);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
$("endReview").addEventListener("click", async () => {
|
|
831
|
+
const page = state.page;
|
|
832
|
+
const otherTotal = (state.others || []).reduce((sum, o) => sum + o.count, 0);
|
|
833
|
+
const unsent = page ? (page.comments || []).length + (page.edits || []).length + otherTotal : 0;
|
|
834
|
+
const message = unsent
|
|
835
|
+
? `End this review? ${unsent} unsent ${unsent === 1 ? "item" : "items"} will be kept for next time.`
|
|
836
|
+
: "End this review? The waiting agent will be told to stop polling.";
|
|
837
|
+
if (!window.confirm(message)) return;
|
|
838
|
+
// Ship anything still sitting in the SDK's debounce windows first.
|
|
839
|
+
await flushFrame();
|
|
840
|
+
try {
|
|
841
|
+
await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
|
|
842
|
+
showEnded();
|
|
843
|
+
} catch (err) {
|
|
844
|
+
toast(err.message);
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
$("handoffCopy").addEventListener("click", async (event) => {
|
|
849
|
+
const button = event.currentTarget;
|
|
850
|
+
try {
|
|
851
|
+
await navigator.clipboard.writeText($("handoffCmd").textContent);
|
|
852
|
+
button.textContent = "Copied";
|
|
853
|
+
setTimeout(() => {
|
|
854
|
+
button.textContent = "Copy prompt";
|
|
855
|
+
}, 1600);
|
|
856
|
+
} catch {
|
|
857
|
+
toast("Couldn't copy — select the prompt and copy it manually");
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
$("note").addEventListener("input", (event) => {
|
|
862
|
+
const el = event.target;
|
|
863
|
+
el.style.height = "auto";
|
|
864
|
+
el.style.height = `${Math.min(el.scrollHeight + 2, window.innerHeight * 0.4)}px`;
|
|
865
|
+
render(); // keep the send button in step with note-only feedback
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
$("handle").addEventListener("click", () => {
|
|
869
|
+
const collapsed = document.body.classList.toggle("collapsed");
|
|
870
|
+
const handle = $("handle");
|
|
871
|
+
handle.textContent = collapsed ? "‹" : "›";
|
|
872
|
+
handle.title = collapsed ? "Show comments panel" : "Hide comments panel";
|
|
873
|
+
handle.setAttribute("aria-label", handle.title);
|
|
874
|
+
try {
|
|
875
|
+
localStorage.setItem("doc-review:collapsed", collapsed ? "1" : "0");
|
|
876
|
+
} catch {}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
$("theme").addEventListener("click", () => {
|
|
880
|
+
const dark = document.documentElement.dataset.theme !== "dark";
|
|
881
|
+
applyTheme(dark);
|
|
882
|
+
try {
|
|
883
|
+
localStorage.setItem("doc-review:theme", dark ? "dark" : "light");
|
|
884
|
+
} catch {}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
function applyTheme(dark) {
|
|
888
|
+
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
889
|
+
const button = $("theme");
|
|
890
|
+
button.textContent = dark ? "☀" : "☾";
|
|
891
|
+
button.title = dark ? "Switch chrome to light" : "Switch chrome to dark";
|
|
892
|
+
button.setAttribute("aria-label", button.title);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
document.addEventListener("keydown", (event) => {
|
|
896
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
897
|
+
if (meta && event.key === "Enter") {
|
|
898
|
+
event.preventDefault();
|
|
899
|
+
if (!$("send").disabled) $("send").click();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
// ⌘S is reassurance only: flush pending keystrokes, never a state change.
|
|
903
|
+
if (meta && event.key.toLowerCase() === "s") {
|
|
904
|
+
event.preventDefault();
|
|
905
|
+
toFrame({ type: "eh:flush" });
|
|
906
|
+
renderSave();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (event.key === "Escape" && state.compose) cancelCompose();
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// ------------------------------------------------------------------ events
|
|
913
|
+
|
|
914
|
+
let events = null;
|
|
915
|
+
|
|
916
|
+
function connect() {
|
|
917
|
+
const source = new EventSource(`/events/${state.sessionId}`);
|
|
918
|
+
events = source;
|
|
919
|
+
// Another window on this session hit End review.
|
|
920
|
+
source.addEventListener("ended", () => showEnded());
|
|
921
|
+
source.addEventListener("reload", () => {
|
|
922
|
+
const key = state.key;
|
|
923
|
+
const generation = beginFrameTransition();
|
|
924
|
+
state.frameReadyRetries = 0;
|
|
925
|
+
const hadEdits = state.page ? state.page.edits.length : 0;
|
|
926
|
+
state.reloading = true;
|
|
927
|
+
state.dynamic = false;
|
|
928
|
+
// The file on disk changed: queued saves are based on the old version.
|
|
929
|
+
state.baseHash = null;
|
|
930
|
+
clearTimeout(retryTimer);
|
|
931
|
+
api(pageUrl(key, state.sessionId)).then((page) => {
|
|
932
|
+
if (state.key !== key || state.renderGeneration !== generation) return;
|
|
933
|
+
replacePage(state, page);
|
|
934
|
+
state.save = "idle";
|
|
935
|
+
state.savedAt = "";
|
|
936
|
+
render();
|
|
937
|
+
// The agent's version wins, so say so rather than losing the rows silently.
|
|
938
|
+
if (hadEdits && page.edits.length === 0) {
|
|
939
|
+
toast(`Agent rewrote ${hadEdits} ${hadEdits === 1 ? "block" : "blocks"} you had edited`);
|
|
940
|
+
}
|
|
941
|
+
void registerFrame(key, generation).catch((err) => toast(err.message));
|
|
942
|
+
}).catch((err) => {
|
|
943
|
+
if (state.key === key && state.renderGeneration === generation) toast(err.message);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
source.addEventListener("agent", (event) => {
|
|
947
|
+
state.agent = JSON.parse(event.data).state;
|
|
948
|
+
render();
|
|
949
|
+
});
|
|
950
|
+
source.addEventListener("refresh", async () => {
|
|
951
|
+
const key = state.key;
|
|
952
|
+
const page = await api(pageUrl(key, state.sessionId));
|
|
953
|
+
if (state.key !== key) return;
|
|
954
|
+
replacePage(state, page);
|
|
955
|
+
state.sent = false;
|
|
956
|
+
render();
|
|
957
|
+
});
|
|
958
|
+
source.onerror = () => {
|
|
959
|
+
/* EventSource reconnects on its own. */
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// -------------------------------------------------------------------- start
|
|
964
|
+
|
|
965
|
+
(async function start() {
|
|
966
|
+
try {
|
|
967
|
+
applyTheme(localStorage.getItem("doc-review:theme") === "dark");
|
|
968
|
+
if (localStorage.getItem("doc-review:collapsed") === "1") $("handle").click();
|
|
969
|
+
} catch {}
|
|
970
|
+
|
|
971
|
+
const bootstrap = await api(`/api/session/${state.sessionId}/page`).catch(() => null);
|
|
972
|
+
if (bootstrap && bootstrap.page) state.pollCommand = bootstrap.page.pollCommand;
|
|
973
|
+
if (bootstrap && Number.isSafeInteger(bootstrap.generation)) state.renderGeneration = bootstrap.generation;
|
|
974
|
+
const key = bootstrap ? bootstrap.key : new URLSearchParams(location.search).get("key");
|
|
975
|
+
await loadPage(key);
|
|
976
|
+
connect();
|
|
977
|
+
})();
|
|
978
|
+
|
|
979
|
+
frame.addEventListener("load", () => {
|
|
980
|
+
if (!state.renderId || !state.frameCapability) return;
|
|
981
|
+
if (state.pendingInitialLoad) {
|
|
982
|
+
state.pendingInitialLoad = false;
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
rotateCurrentFrame();
|
|
986
|
+
});
|