@erdemtuna/doc-review 0.7.0 → 0.8.1

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.
@@ -1,13 +1,30 @@
1
1
  /**
2
- * doc-review chrome. Owns the rail UI and every call to the local server.
2
+ * doc-review chrome. Owns the toolbar, contextual surfaces, drawer, and every
3
+ * call to the local server.
3
4
  * It never touches the artifact DOM directly — the SDK does that, over
4
5
  * postMessage, because the artifact iframe lives on the other loopback
5
6
  * hostname: a separate origin that can never reach this page or its token.
6
7
  */
7
- import { tidy } from "./anchor-text.js";
8
- import { newestComments, pageUrl, replacePage } from "./chrome-session.js";
8
+ import { tidyMiddle } from "./anchor-text.js";
9
+ import {
10
+ clearOwned,
11
+ createCommentUi,
12
+ migrateCommentUi,
13
+ mutationIsCurrent,
14
+ ownConfirmation,
15
+ ownEdit,
16
+ ownMenu,
17
+ newestComments,
18
+ pageUrl,
19
+ reconcileCommentUi,
20
+ replacePage,
21
+ } from "./chrome-session.js";
22
+ import { sanitizeClientRects, sanitizeClipRect, sanitizeRelation } from "./comment-target.js";
9
23
  import { externalHref } from "./editing.js";
10
24
  import { framePolicy } from "./frame-policy.js";
25
+ import { createIcon } from "./icons.js";
26
+ import { alignedCardPosition, placeContextualSurface, visibleViewport } from "./positioning.js";
27
+ import { normalizeReviewMode, reviewConfiguration } from "./review-mode.js";
11
28
 
12
29
  const $ = (id) => document.getElementById(id);
13
30
  const frame = $("frame");
@@ -18,7 +35,18 @@ const state = {
18
35
  key: null,
19
36
  page: null,
20
37
  compose: null,
21
- active: null,
38
+ composeLifecycle: "closed",
39
+ composePlacement: "hidden",
40
+ target: null,
41
+ activeSavedCommentId: null,
42
+ activeGeometry: new Map(),
43
+ commentUi: createCommentUi(),
44
+ pageEpoch: 0,
45
+ reviewMode: "view",
46
+ savePolicy: "writable",
47
+ modeApplying: false,
48
+ modeMenuOpen: false,
49
+ drawerOpen: false,
22
50
  agent: "idle",
23
51
  save: "idle",
24
52
  savedAt: "",
@@ -39,8 +67,180 @@ const state = {
39
67
  readyGeneration: null,
40
68
  frameReadyTimer: null,
41
69
  frameReadyRetries: 0,
70
+ configurationGeneration: null,
71
+ saveConflict: false,
42
72
  };
43
73
 
74
+ const diagnostic = (event, detail = {}) => {
75
+ console.info("[doc-review]", { event, ...detail });
76
+ };
77
+
78
+ const patchFlights = new Map();
79
+ const deleteFlights = new Map();
80
+ let requestedFocus = null;
81
+ let editFocusRequested = false;
82
+ let skipEditCaptureOnce = false;
83
+
84
+ function advancePageEpoch(reason) {
85
+ state.pageEpoch += 1;
86
+ diagnostic("page-epoch-advanced", { epoch: state.pageEpoch, reason });
87
+ return state.pageEpoch;
88
+ }
89
+
90
+ function commentById(id) {
91
+ return state.page?.comments?.find((comment) => comment.id === id) || null;
92
+ }
93
+
94
+ function domId(value) {
95
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.codePointAt(0).toString(16)}-`);
96
+ }
97
+
98
+ function controlId(commentId, surface, action) {
99
+ return `comment-${surface}-${domId(commentId)}-${action}`;
100
+ }
101
+
102
+ function requestControlFocus(id) {
103
+ requestedFocus = id;
104
+ }
105
+
106
+ function editTextarea(commentId) {
107
+ const root = state.drawerOpen ? $("cards") : $("alignedCard");
108
+ return root?.querySelector(`textarea[data-comment-edit="${CSS.escape(commentId)}"]`) || null;
109
+ }
110
+
111
+ function captureEditState() {
112
+ const edit = state.commentUi.edit;
113
+ if (!edit) return false;
114
+ const input = editTextarea(edit.commentId);
115
+ if (!input) return false;
116
+ edit.draft = input.value;
117
+ edit.selectionStart = input.selectionStart;
118
+ edit.selectionEnd = input.selectionEnd;
119
+ return document.activeElement === input;
120
+ }
121
+
122
+ function restoreTransientFocus(editWasFocused = false) {
123
+ const focusId = requestedFocus;
124
+ requestedFocus = null;
125
+ const shouldFocusEdit = editWasFocused || editFocusRequested;
126
+ requestAnimationFrame(() => {
127
+ if (focusId) {
128
+ document.getElementById(focusId)?.focus();
129
+ return;
130
+ }
131
+ const edit = state.commentUi.edit;
132
+ if (!shouldFocusEdit || !edit) return;
133
+ const input = editTextarea(edit.commentId);
134
+ if (!input || input.closest("[hidden]")) {
135
+ editFocusRequested = true;
136
+ return;
137
+ }
138
+ editFocusRequested = false;
139
+ input.focus();
140
+ input.setSelectionRange(edit.selectionStart, edit.selectionEnd);
141
+ });
142
+ }
143
+
144
+ function moveTransientSurface(surface) {
145
+ const menu = state.commentUi.menu;
146
+ if (menu) {
147
+ menu.surface = surface;
148
+ menu.triggerId = controlId(menu.commentId, surface, "more");
149
+ }
150
+ if (state.commentUi.confirmation) state.commentUi.confirmation.surface = surface;
151
+ }
152
+
153
+ function announce(message) {
154
+ const region = $("liveRegion");
155
+ region.textContent = "";
156
+ requestAnimationFrame(() => {
157
+ region.textContent = message;
158
+ });
159
+ }
160
+
161
+ function replaceIcon(container, name, title = "") {
162
+ container.textContent = "";
163
+ container.append(createIcon(name, { title }));
164
+ }
165
+
166
+ function installStaticIcons() {
167
+ document.querySelectorAll("[data-icon]").forEach((container) => {
168
+ replaceIcon(container, container.dataset.icon);
169
+ });
170
+ replaceIcon($("modeChevron"), "chevronDown");
171
+ replaceIcon($("composeClose"), "x");
172
+ replaceIcon($("drawerClose"), "x");
173
+ }
174
+
175
+ function openModeMenu() {
176
+ if (state.modeMenuOpen) return;
177
+ state.modeMenuOpen = true;
178
+ $("modeMenu").hidden = false;
179
+ $("modeButton").setAttribute("aria-expanded", "true");
180
+ toFrame({ type: "eh:modeMenuState", open: true });
181
+ $("modeMenu").querySelector(`[data-mode="${state.reviewMode}"]`)?.focus();
182
+ }
183
+
184
+ function closeModeMenu(reason = "dismissed", { restoreFocus = false } = {}) {
185
+ if (!state.modeMenuOpen) return false;
186
+ state.modeMenuOpen = false;
187
+ $("modeMenu").hidden = true;
188
+ $("modeButton").setAttribute("aria-expanded", "false");
189
+ toFrame({ type: "eh:modeMenuState", open: false });
190
+ diagnostic("mode-menu-close", { reason });
191
+ if (restoreFocus) $("modeButton").focus();
192
+ return true;
193
+ }
194
+
195
+ function openDrawer() {
196
+ captureEditState();
197
+ skipEditCaptureOnce = true;
198
+ state.drawerOpen = true;
199
+ moveTransientSurface("drawer");
200
+ editFocusRequested = !!state.commentUi.edit;
201
+ render();
202
+ if (!state.commentUi.edit) requestAnimationFrame(() => $("commentsSection").focus());
203
+ }
204
+
205
+ function closeDrawer() {
206
+ if (!state.drawerOpen) return;
207
+ captureEditState();
208
+ skipEditCaptureOnce = true;
209
+ state.drawerOpen = false;
210
+ const transientOwner =
211
+ state.commentUi.edit?.commentId ||
212
+ state.commentUi.confirmation?.commentId ||
213
+ state.commentUi.menu?.commentId ||
214
+ null;
215
+ if (transientOwner && commentById(transientOwner)) {
216
+ state.activeSavedCommentId = transientOwner;
217
+ moveTransientSurface("aligned");
218
+ toFrame({ type: "eh:activate", id: transientOwner, scroll: false });
219
+ } else if (state.activeSavedCommentId) {
220
+ moveTransientSurface("aligned");
221
+ }
222
+ editFocusRequested = !!state.commentUi.edit;
223
+ render();
224
+ if (!state.commentUi.edit) $("commentsButton").focus();
225
+ }
226
+
227
+ function setComposeLifecycle(next, reason) {
228
+ if (state.composeLifecycle === next) return;
229
+ const from = state.composeLifecycle;
230
+ state.composeLifecycle = next;
231
+ const submitting = next === "submitting";
232
+ $("composeCancel").disabled = submitting;
233
+ $("composeClose").disabled = submitting;
234
+ diagnostic("composer-lifecycle-transition", { from, to: next, reason });
235
+ }
236
+
237
+ function setComposePlacement(next) {
238
+ if (state.composePlacement === next) return;
239
+ const from = state.composePlacement;
240
+ state.composePlacement = next;
241
+ diagnostic("placement-transition", { from, to: next });
242
+ }
243
+
44
244
  /**
45
245
  * Most reviewers drive an agent from a chat (Claude Code, Codex, Cursor), not
46
246
  * a bare terminal — so the handoff is a prompt the agent can act on, with the
@@ -68,6 +268,102 @@ async function api(path, options) {
68
268
  return res.json();
69
269
  }
70
270
 
271
+ const editPipelines = new Map();
272
+ const editBacklogs = new Map();
273
+ const editErrors = new Map();
274
+
275
+ function editIdentity(payload) {
276
+ return `${String(payload.label || "")}\u0000${String(payload.kind || "")}`;
277
+ }
278
+
279
+ function editBacklog(key) {
280
+ let backlog = editBacklogs.get(key);
281
+ if (!backlog) {
282
+ backlog = new Map();
283
+ editBacklogs.set(key, backlog);
284
+ }
285
+ return backlog;
286
+ }
287
+
288
+ function persistEdit(key, payload) {
289
+ const identity = editIdentity(payload);
290
+ const backlog = editBacklog(key);
291
+ backlog.set(identity, payload);
292
+ const previous = editPipelines.get(key) || Promise.resolve();
293
+ const current = previous
294
+ .catch(() => {})
295
+ .then(async () => {
296
+ try {
297
+ const result = await api(`/api/page/${key}/edit`, {
298
+ method: "POST",
299
+ body: JSON.stringify(payload),
300
+ });
301
+ if (backlog.get(identity) === payload) backlog.delete(identity);
302
+ editErrors.delete(key);
303
+ if (state.key === key) {
304
+ state.page = result.page;
305
+ state.sent = false;
306
+ render();
307
+ }
308
+ return true;
309
+ } catch (err) {
310
+ editErrors.set(key, err);
311
+ if (state.key === key) {
312
+ toast(`${err.message}. Your edit is still queued locally.`);
313
+ announce("An edit could not be saved. Retry before leaving the page or sending feedback.");
314
+ }
315
+ return false;
316
+ }
317
+ });
318
+ editPipelines.set(key, current);
319
+ void current.finally(() => {
320
+ if (editPipelines.get(key) === current) editPipelines.delete(key);
321
+ });
322
+ return current;
323
+ }
324
+
325
+ async function settleEditPersistence(key) {
326
+ const inFlight = editPipelines.get(key);
327
+ if (inFlight) await inFlight;
328
+ const backlog = editBacklogs.get(key);
329
+ if (backlog?.size) {
330
+ const retry = [...backlog.values()];
331
+ editErrors.delete(key);
332
+ for (const payload of retry) persistEdit(key, payload);
333
+ const retried = editPipelines.get(key);
334
+ if (retried) await retried;
335
+ }
336
+ if (editBacklogs.get(key)?.size) {
337
+ throw editErrors.get(key) || new Error("An edit could not be saved");
338
+ }
339
+ }
340
+
341
+ function reconcilePage(page, { syncAnchors = false, reason = "mutation", advance = reason !== "mutation" } = {}) {
342
+ const previousIds = new Set((state.page?.comments || []).map((comment) => comment.id));
343
+ if (advance) advancePageEpoch(reason);
344
+ replacePage(state, page);
345
+ const commentIds = new Set((page.comments || []).map((comment) => comment.id));
346
+ for (const id of state.activeGeometry.keys()) {
347
+ if (!commentIds.has(id)) state.activeGeometry.delete(id);
348
+ }
349
+ if (state.activeSavedCommentId && !commentIds.has(state.activeSavedCommentId)) {
350
+ state.activeSavedCommentId = null;
351
+ }
352
+ const removedTransient = reconcileCommentUi(state.commentUi, page.comments || []);
353
+ state.orphans = new Set([...state.orphans].filter((id) => commentIds.has(id)));
354
+ if (syncAnchors && !state.frameLoading) {
355
+ toFrame({ type: "eh:anchors", comments: page.comments || [] });
356
+ }
357
+ if (reason === "acknowledgement") {
358
+ const removed = [...previousIds].filter((id) => !commentIds.has(id));
359
+ if (removed.length) {
360
+ announce(`${removed.length === 1 ? "Comment" : "Comments"} delivered and removed`);
361
+ if (removedTransient.size) requestAnimationFrame(() => (state.drawerOpen ? $("commentsSection") : frame).focus());
362
+ }
363
+ }
364
+ diagnostic("page-reconciled", { reason, epoch: state.pageEpoch });
365
+ }
366
+
71
367
  // Keep the reviewed app on a different loopback origin from the review shell.
72
368
  // This gives route-aware frameworks a real origin without exposing the parent UI.
73
369
  const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
@@ -96,6 +392,8 @@ function suspendFrame() {
96
392
  state.renderId = null;
97
393
  state.frameLoading = true;
98
394
  state.readyGeneration = null;
395
+ state.configurationGeneration = null;
396
+ frame.removeAttribute("data-sdk-ready");
99
397
  }
100
398
 
101
399
  function beginFrameTransition() {
@@ -145,33 +443,90 @@ function rotateCurrentFrame() {
145
443
  * typing. The timeout covers a torn-down or never-booted frame.
146
444
  */
147
445
  let flushWaiter = null;
148
- function flushFrame() {
149
- if (state.frameLoading || !state.frameCapability) return Promise.resolve();
446
+ let configurationWaiter = null;
447
+ async function flushFrame() {
448
+ const key = state.key;
449
+ if (!state.frameLoading && state.frameCapability) {
450
+ await new Promise((resolve) => {
451
+ const settle = () => {
452
+ if (flushWaiter !== settle) return;
453
+ flushWaiter = null;
454
+ resolve();
455
+ };
456
+ flushWaiter = settle;
457
+ toFrame({ type: "eh:flush" });
458
+ setTimeout(settle, 400);
459
+ });
460
+ }
461
+ if (key) await settleEditPersistence(key);
462
+ }
463
+
464
+ function configureFrame() {
465
+ if (state.frameLoading || !state.frameCapability) return Promise.resolve(false);
466
+ const configuration = reviewConfiguration(state.page, state.reviewMode);
467
+ state.savePolicy = configuration.savePolicy;
150
468
  return new Promise((resolve) => {
151
- const settle = () => {
152
- if (flushWaiter !== settle) return;
153
- flushWaiter = null;
154
- resolve();
469
+ const settle = (applied) => {
470
+ if (configurationWaiter?.settle !== settle) return;
471
+ configurationWaiter = null;
472
+ resolve(applied);
155
473
  };
156
- flushWaiter = settle;
157
- toFrame({ type: "eh:flush" });
158
- setTimeout(settle, 400);
474
+ configurationWaiter = { ...configuration, settle };
475
+ toFrame({ type: "eh:configureReview", ...configuration });
476
+ setTimeout(() => settle(false), 3000);
159
477
  });
160
478
  }
161
479
 
480
+ async function setReviewMode(nextMode) {
481
+ const next = normalizeReviewMode(nextMode);
482
+ closeModeMenu("selection");
483
+ if (next === state.reviewMode || state.modeApplying) return;
484
+ state.modeApplying = true;
485
+ render();
486
+ try {
487
+ if (state.reviewMode === "edit" && next === "view") {
488
+ await flushFrame();
489
+ await activeSavePromise;
490
+ if (state.saveConflict) {
491
+ toast("Resolve the save conflict before leaving Edit");
492
+ announce("Save conflict. Edit mode remains active.");
493
+ return;
494
+ }
495
+ }
496
+ state.reviewMode = next;
497
+ const applied = await configureFrame();
498
+ if (!applied) throw new Error("The reviewed page did not confirm the mode change");
499
+ diagnostic("mode-change", { mode: next, savePolicy: state.savePolicy });
500
+ announce(`${next === "edit" ? "Edit" : "View"} mode active`);
501
+ } catch (err) {
502
+ state.reviewMode = next === "edit" ? "view" : "edit";
503
+ toast(`${err.message}. Stay in ${state.reviewMode === "edit" ? "Edit" : "View"} and retry.`);
504
+ } finally {
505
+ state.modeApplying = false;
506
+ render();
507
+ }
508
+ }
509
+
162
510
  async function loadPage(key, { reload = true } = {}) {
163
511
  const returning = state.page;
512
+ advancePageEpoch(returning ? "navigation" : "reload");
164
513
  const generation = beginFrameTransition();
165
514
  state.frameReadyRetries = 0;
166
515
  state.key = key;
167
516
  const page = await api(pageUrl(key, state.sessionId));
168
517
  if (state.key !== key || state.renderGeneration !== generation) return;
169
- replacePage(state, page);
518
+ reconcilePage(page, { reason: "reload", advance: false });
519
+ state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
170
520
  state.framePolicy = framePolicy(state.page, ARTIFACT_ORIGIN);
171
521
  frame.setAttribute("sandbox", state.framePolicy.sandbox);
172
522
  state.orphans = new Set();
173
523
  state.compose = null;
174
- state.active = null;
524
+ setComposeLifecycle("closed", "page-change");
525
+ setComposePlacement("hidden");
526
+ state.target = null;
527
+ state.activeSavedCommentId = null;
528
+ state.activeGeometry.clear();
529
+ state.commentUi = createCommentUi();
175
530
  state.sent = false;
176
531
  state.dynamic = false;
177
532
  state.baseHash = null;
@@ -208,116 +563,51 @@ const clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute:
208
563
  function render() {
209
564
  const page = state.page;
210
565
  if (!page) return;
566
+ const editWasFocused = skipEditCaptureOnce ? false : captureEditState();
567
+ skipEditCaptureOnce = false;
211
568
  document.title = page.filename || 'doc-review';
212
569
 
213
570
  const comments = newestComments(page.comments);
214
571
  const edits = page.edits || [];
215
572
 
216
573
  $("count").textContent = String(comments.length);
574
+ $("toolbarCount").textContent = String(comments.length);
217
575
  $("empty").hidden = comments.length > 0 || !!state.compose;
576
+ $("modeLabel").textContent = state.reviewMode === "edit" ? "Edit" : "View";
577
+ replaceIcon($("modeIcon"), state.reviewMode === "edit" ? "pencil" : "eye");
578
+ $("modeButton").disabled = state.modeApplying;
579
+ for (const item of $("modeMenu").querySelectorAll("[data-mode]")) {
580
+ const checked = item.dataset.mode === state.reviewMode;
581
+ item.setAttribute("aria-checked", String(checked));
582
+ const check = item.querySelector(".menu-check");
583
+ check.textContent = "";
584
+ if (checked) check.append(createIcon("check"));
585
+ }
586
+ $("drawer").classList.toggle("open", state.drawerOpen);
587
+ $("drawer").setAttribute("aria-hidden", String(!state.drawerOpen));
588
+ $("commentsButton").setAttribute("aria-expanded", String(state.drawerOpen));
589
+ $("drawerBackdrop").hidden = !state.drawerOpen;
218
590
 
219
591
  // --- compose
220
592
  const composeWrap = $("compose");
221
593
  if (state.compose) {
222
594
  composeWrap.hidden = false;
223
595
  $("composeKind").textContent = state.compose.kind === "element" ? "Element" : "Selection";
224
- $("composeQuote").textContent = tidy(state.compose.quote, 260);
596
+ $("composeQuote").textContent = tidyMiddle(state.compose.quote, 260);
597
+ requestAnimationFrame(positionCompose);
225
598
  } else {
226
599
  composeWrap.hidden = true;
227
600
  $("composeText").value = "";
601
+ $("composeError").hidden = true;
228
602
  }
229
603
 
230
604
  // --- comment cards
231
605
  const list = $("cards");
232
606
  list.textContent = "";
233
607
  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);
608
+ list.append(renderCommentCard(comment, "drawer"));
320
609
  }
610
+ renderAlignedCard(comments);
321
611
 
322
612
  // --- your edits
323
613
  const box = $("editsBox");
@@ -376,14 +666,18 @@ function render() {
376
666
  count.textContent = String(other.count);
377
667
  row.append(label, count);
378
668
  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);
669
+ try {
670
+ await flushFrame();
671
+ suspendFrame();
672
+ await api(`/api/session/${state.sessionId}/goto`, {
673
+ method: "POST",
674
+ body: JSON.stringify({ key: other.key }),
675
+ });
676
+ state.scroll = { x: 0, y: 0 };
677
+ await loadPage(other.key);
678
+ } catch (err) {
679
+ toast(`${err.message}. Stay on this page and retry.`);
680
+ }
387
681
  });
388
682
  list.append(row);
389
683
  }
@@ -411,13 +705,6 @@ function render() {
411
705
  : hasNote
412
706
  ? "Send note to agent"
413
707
  : "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
708
  // After sending, say what happens next. If nothing is polling, the loop would
422
709
  // otherwise dead-end silently, so hand over the exact command to run.
423
710
  $("agentLine").hidden = !delivered;
@@ -426,6 +713,506 @@ function render() {
426
713
  // Server-authoritative, so it survives a browser refresh.
427
714
  $("handoff").hidden = !stranded;
428
715
  if (stranded) $("handoffCmd").textContent = handoffPrompt(state.pollCommand || page.pollCommand);
716
+ restoreTransientFocus(editWasFocused);
717
+ }
718
+
719
+ function positionCompose() {
720
+ if (!state.compose || state.composeLifecycle === "closed") {
721
+ setComposePlacement("hidden");
722
+ return;
723
+ }
724
+ const viewport = visibleViewport();
725
+ const frameRect = frame.getBoundingClientRect();
726
+ const surface = $("compose");
727
+ if (state.compose.relation !== "unavailable") surface.hidden = false;
728
+ const placement = placeContextualSurface(state.compose, {
729
+ frameRect,
730
+ viewport,
731
+ surfaceWidth: Math.min(340, viewport.width - 24),
732
+ surfaceHeight: Math.max(190, surface.offsetHeight),
733
+ narrow: matchMedia("(max-width: 720px)").matches,
734
+ toolbarHeight: 48,
735
+ });
736
+ surface.classList.toggle("sheet", placement.kind === "sheet");
737
+ surface.classList.toggle("edge-top", placement.kind === "edge-top");
738
+ surface.classList.toggle("edge-bottom", placement.kind === "edge-bottom");
739
+ setComposePlacement(placement.kind);
740
+ const edge = placement.kind === "edge-top" || placement.kind === "edge-bottom";
741
+ $("composeDirection").hidden = !edge;
742
+ $("composeDirectionText").textContent = placement.kind === "edge-top"
743
+ ? "Selection is above"
744
+ : placement.kind === "edge-bottom"
745
+ ? "Selection is below"
746
+ : "";
747
+ surface.hidden = placement.kind === "hidden";
748
+ if (placement.kind !== "sheet" && placement.kind !== "hidden") {
749
+ surface.style.left = `${placement.left}px`;
750
+ surface.style.top = `${placement.top}px`;
751
+ surface.style.width = `${placement.width}px`;
752
+ } else {
753
+ surface.style.left = "";
754
+ surface.style.top = "";
755
+ surface.style.width = "";
756
+ }
757
+ }
758
+
759
+ function makeAction(label, className = "card-action") {
760
+ const button = document.createElement("button");
761
+ button.type = "button";
762
+ button.className = className;
763
+ button.textContent = label;
764
+ return button;
765
+ }
766
+
767
+ function makeWho(comment, surface) {
768
+ const who = document.createElement("span");
769
+ who.className = "who";
770
+ who.append("You");
771
+ if (surface === "drawer") {
772
+ const sep = document.createElement("span");
773
+ sep.className = "sep";
774
+ sep.textContent = "·";
775
+ const when = document.createElement("span");
776
+ when.className = "when";
777
+ when.textContent = ago(comment.updatedAt || comment.createdAt);
778
+ who.append(sep, when);
779
+ }
780
+ if (comment.correction) {
781
+ const badge = document.createElement("span");
782
+ badge.className = "badge correction";
783
+ badge.textContent = "correction";
784
+ who.append(badge);
785
+ } else if (comment.updatedAt) {
786
+ const badge = document.createElement("span");
787
+ badge.className = "badge";
788
+ badge.textContent = "edited";
789
+ who.append(badge);
790
+ }
791
+ if (state.orphans.has(comment.id)) {
792
+ const badge = document.createElement("span");
793
+ badge.className = "badge";
794
+ badge.textContent = "orphaned";
795
+ who.append(badge);
796
+ }
797
+ return who;
798
+ }
799
+
800
+ function commentUiKind(commentId) {
801
+ if (state.commentUi.edit?.commentId === commentId) return "edit";
802
+ if (state.commentUi.confirmation?.commentId === commentId) return "confirmation";
803
+ if (state.commentUi.menu?.commentId === commentId) return "menu";
804
+ return "normal";
805
+ }
806
+
807
+ function renderMore(comment, surface, open, disabled = false) {
808
+ const triggerId = controlId(comment.id, surface, "more");
809
+ const menuId = controlId(comment.id, surface, "menu");
810
+ const trigger = makeAction("More", "card-action more-trigger");
811
+ trigger.id = triggerId;
812
+ trigger.setAttribute("aria-haspopup", "menu");
813
+ trigger.setAttribute("aria-expanded", String(open));
814
+ trigger.setAttribute("aria-controls", menuId);
815
+ trigger.disabled = disabled;
816
+ trigger.prepend(createIcon("moreHorizontal", { size: 14 }));
817
+ trigger.addEventListener("click", (event) => {
818
+ event.stopPropagation();
819
+ if (open) {
820
+ closeCommentMenu({ restoreFocus: true });
821
+ } else {
822
+ if (!ownMenu(state.commentUi, comment.id, surface, triggerId)) {
823
+ focusCurrentCommentEdit();
824
+ return;
825
+ }
826
+ toFrame({ type: "eh:modeMenuState", open: true });
827
+ requestControlFocus(controlId(comment.id, surface, "delete-item"));
828
+ render();
829
+ }
830
+ });
831
+ return trigger;
832
+ }
833
+
834
+ function renderCommentCard(comment, surface) {
835
+ const card = document.createElement(surface === "aligned" ? "article" : "div");
836
+ if (surface === "drawer") {
837
+ card.className = `comment${state.activeSavedCommentId === comment.id ? " active" : ""}`;
838
+ }
839
+ card.dataset.id = comment.id;
840
+ card.dataset.surface = surface;
841
+
842
+ const kind = commentUiKind(comment.id);
843
+ const editOwnedElsewhere = !!(
844
+ state.commentUi.edit &&
845
+ state.commentUi.edit.commentId !== comment.id
846
+ );
847
+ const head = document.createElement("div");
848
+ head.className = "comment-head";
849
+ head.append(makeWho(comment, surface));
850
+
851
+ if (kind === "normal") {
852
+ if (surface === "drawer") {
853
+ const jump = makeAction("Jump to");
854
+ jump.disabled = editOwnedElsewhere;
855
+ jump.addEventListener("click", (event) => {
856
+ event.stopPropagation();
857
+ setActive(comment.id, true);
858
+ });
859
+ head.append(jump);
860
+ }
861
+ const edit = makeAction("Edit");
862
+ edit.id = controlId(comment.id, surface, "edit");
863
+ edit.setAttribute("aria-label", "Edit comment");
864
+ edit.disabled = editOwnedElsewhere;
865
+ edit.addEventListener("click", (event) => {
866
+ event.stopPropagation();
867
+ startCommentEdit(comment, surface);
868
+ });
869
+ head.append(edit);
870
+ if (surface === "aligned") {
871
+ const close = makeAction("Close");
872
+ close.setAttribute("aria-label", "Close comment card");
873
+ close.addEventListener("click", (event) => {
874
+ event.stopPropagation();
875
+ dismissActiveComment("close", { restoreFocus: true });
876
+ });
877
+ head.append(close);
878
+ }
879
+ head.append(renderMore(comment, surface, false, editOwnedElsewhere));
880
+ } else if (kind === "menu") {
881
+ head.append(renderMore(comment, surface, true));
882
+ }
883
+
884
+ const quote = document.createElement("p");
885
+ quote.className = "quote";
886
+ quote.textContent = tidyMiddle(comment.quote, 140);
887
+ card.append(head, quote);
888
+
889
+ if (kind === "edit") {
890
+ const edit = state.commentUi.edit;
891
+ const input = document.createElement("textarea");
892
+ input.className = "body-edit";
893
+ input.rows = 3;
894
+ input.value = edit.draft;
895
+ input.readOnly = edit.status === "saving";
896
+ input.dataset.commentEdit = comment.id;
897
+ input.setAttribute("aria-label", "Edit comment text");
898
+ input.addEventListener("input", () => {
899
+ edit.draft = input.value;
900
+ edit.selectionStart = input.selectionStart;
901
+ edit.selectionEnd = input.selectionEnd;
902
+ edit.validation = "";
903
+ });
904
+ const rememberSelection = () => {
905
+ edit.selectionStart = input.selectionStart;
906
+ edit.selectionEnd = input.selectionEnd;
907
+ };
908
+ input.addEventListener("select", rememberSelection);
909
+ input.addEventListener("keyup", rememberSelection);
910
+ input.addEventListener("click", (event) => {
911
+ event.stopPropagation();
912
+ rememberSelection();
913
+ });
914
+ input.addEventListener("compositionstart", () => {
915
+ edit.composing = true;
916
+ });
917
+ input.addEventListener("compositionend", () => {
918
+ edit.composing = false;
919
+ rememberSelection();
920
+ });
921
+ input.addEventListener("keydown", (event) => {
922
+ event.stopPropagation();
923
+ rememberSelection();
924
+ if (event.key === "Enter" && !event.shiftKey) {
925
+ if (edit.composing || event.isComposing) return;
926
+ event.preventDefault();
927
+ void saveCommentEdit();
928
+ } else if (event.key === "Escape") {
929
+ event.preventDefault();
930
+ cancelCommentEdit();
931
+ }
932
+ });
933
+ card.append(input);
934
+
935
+ const helper = document.createElement("p");
936
+ helper.className = "edit-help";
937
+ helper.textContent = "Enter to save · Shift+Enter for new line";
938
+ card.append(helper);
939
+ if (edit.validation) {
940
+ const validation = document.createElement("p");
941
+ validation.className = "comment-validation";
942
+ validation.setAttribute("role", "alert");
943
+ validation.textContent = edit.validation;
944
+ card.append(validation);
945
+ }
946
+ const actions = document.createElement("div");
947
+ actions.className = "comment-edit-actions";
948
+ const save = makeAction(edit.status === "saving" ? "Saving…" : "Save", "btn-primary");
949
+ save.disabled = edit.status === "saving";
950
+ save.addEventListener("click", (event) => {
951
+ event.stopPropagation();
952
+ void saveCommentEdit();
953
+ });
954
+ const cancel = makeAction("Cancel", "btn-ghost");
955
+ cancel.disabled = edit.status === "saving";
956
+ cancel.addEventListener("click", (event) => {
957
+ event.stopPropagation();
958
+ cancelCommentEdit();
959
+ });
960
+ actions.append(save, cancel);
961
+ card.append(actions);
962
+ } else if (kind === "confirmation") {
963
+ const confirmation = state.commentUi.confirmation;
964
+ const prompt = document.createElement("p");
965
+ prompt.className = "delete-prompt";
966
+ prompt.textContent = "Delete this comment?";
967
+ const actions = document.createElement("div");
968
+ actions.className = "delete-actions";
969
+ const cancel = makeAction("Cancel", "btn-ghost");
970
+ cancel.disabled = confirmation.status === "deleting";
971
+ cancel.addEventListener("click", (event) => {
972
+ event.stopPropagation();
973
+ cancelDeleteConfirmation();
974
+ });
975
+ const remove = makeAction(confirmation.status === "deleting" ? "Deleting…" : "Delete", "btn-danger");
976
+ remove.id = controlId(comment.id, surface, "confirm-delete");
977
+ remove.disabled = confirmation.status === "deleting";
978
+ remove.addEventListener("click", (event) => {
979
+ event.stopPropagation();
980
+ void deleteComment(comment.id);
981
+ });
982
+ actions.append(cancel, remove);
983
+ card.append(prompt, actions);
984
+ } else {
985
+ const body = document.createElement("p");
986
+ body.className = "body";
987
+ body.textContent = comment.feedback;
988
+ body.title = "Click to edit";
989
+ if (!editOwnedElsewhere) {
990
+ body.addEventListener("click", (event) => {
991
+ event.stopPropagation();
992
+ startCommentEdit(comment, surface);
993
+ });
994
+ }
995
+ card.append(body);
996
+ }
997
+
998
+ if (kind === "menu") {
999
+ const menu = document.createElement("div");
1000
+ menu.id = controlId(comment.id, surface, "menu");
1001
+ menu.className = "comment-menu";
1002
+ menu.setAttribute("role", "menu");
1003
+ menu.setAttribute("aria-labelledby", controlId(comment.id, surface, "more"));
1004
+ const remove = makeAction("Delete", "comment-menu-item");
1005
+ remove.id = controlId(comment.id, surface, "delete-item");
1006
+ remove.setAttribute("role", "menuitem");
1007
+ remove.prepend(createIcon("trash", { size: 14 }));
1008
+ remove.addEventListener("click", (event) => {
1009
+ event.stopPropagation();
1010
+ toFrame({ type: "eh:modeMenuState", open: false });
1011
+ if (!ownConfirmation(state.commentUi, comment.id, surface)) {
1012
+ focusCurrentCommentEdit();
1013
+ return;
1014
+ }
1015
+ requestControlFocus(controlId(comment.id, surface, "confirm-delete"));
1016
+ render();
1017
+ });
1018
+ menu.append(remove);
1019
+ card.append(menu);
1020
+ }
1021
+
1022
+ if (surface === "drawer") {
1023
+ card.addEventListener("click", () => {
1024
+ if (kind === "normal" && !editOwnedElsewhere) setActive(comment.id, false);
1025
+ });
1026
+ }
1027
+ return card;
1028
+ }
1029
+
1030
+ function positionAlignedCard() {
1031
+ const host = $("alignedCard");
1032
+ const comment = commentById(state.activeSavedCommentId);
1033
+ const geometry = comment ? state.activeGeometry.get(comment.id) : null;
1034
+ if (!comment || state.drawerOpen || matchMedia("(max-width: 720px)").matches || !geometry?.visible) {
1035
+ host.hidden = true;
1036
+ return false;
1037
+ }
1038
+ const viewport = visibleViewport();
1039
+ const position = alignedCardPosition(geometry.rects, {
1040
+ frameRect: frame.getBoundingClientRect(),
1041
+ viewport,
1042
+ });
1043
+ if (!position) {
1044
+ host.hidden = true;
1045
+ return false;
1046
+ }
1047
+ host.style.left = `${position.left}px`;
1048
+ host.style.top = `${position.top}px`;
1049
+ host.hidden = false;
1050
+ return true;
1051
+ }
1052
+
1053
+ function renderAlignedCard(comments) {
1054
+ const host = $("alignedCard");
1055
+ host.textContent = "";
1056
+ const comment = comments.find((item) => item.id === state.activeSavedCommentId);
1057
+ if (!comment) {
1058
+ host.hidden = true;
1059
+ return;
1060
+ }
1061
+ const card = renderCommentCard(comment, "aligned");
1062
+ while (card.firstChild) host.append(card.firstChild);
1063
+ positionAlignedCard();
1064
+ }
1065
+
1066
+ async function deleteComment(id) {
1067
+ if (deleteFlights.has(id)) return deleteFlights.get(id);
1068
+ const confirmation = state.commentUi.confirmation;
1069
+ if (!confirmation || confirmation.commentId !== id || confirmation.status === "deleting") return false;
1070
+ confirmation.status = "deleting";
1071
+ render();
1072
+ const startEpoch = state.pageEpoch;
1073
+ const flight = (async () => {
1074
+ try {
1075
+ const result = await api(`/api/page/${state.key}/comment/${id}`, { method: "DELETE" });
1076
+ if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) return false;
1077
+ reconcilePage(result.page, { reason: "mutation" });
1078
+ toFrame({ type: "eh:remove", id });
1079
+ state.activeGeometry.delete(id);
1080
+ if (state.activeSavedCommentId === id) state.activeSavedCommentId = null;
1081
+ render();
1082
+ return true;
1083
+ } catch (err) {
1084
+ if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
1085
+ if (state.commentUi.confirmation?.commentId === id) state.commentUi.confirmation.status = "idle";
1086
+ toast(err.message);
1087
+ render();
1088
+ }
1089
+ return false;
1090
+ }
1091
+ })().finally(() => {
1092
+ if (deleteFlights.get(id) === flight) deleteFlights.delete(id);
1093
+ });
1094
+ deleteFlights.set(id, flight);
1095
+ return flight;
1096
+ }
1097
+
1098
+ function cancelDeleteConfirmation() {
1099
+ const confirmation = state.commentUi.confirmation;
1100
+ if (!confirmation || confirmation.status === "deleting") return false;
1101
+ const triggerId = controlId(confirmation.commentId, confirmation.surface, "more");
1102
+ clearOwned(state.commentUi, "confirmation");
1103
+ requestControlFocus(triggerId);
1104
+ render();
1105
+ return true;
1106
+ }
1107
+
1108
+ function startCommentEdit(comment, surface) {
1109
+ if (!ownEdit(state.commentUi, comment, surface)) {
1110
+ focusCurrentCommentEdit();
1111
+ return false;
1112
+ }
1113
+ editFocusRequested = true;
1114
+ render();
1115
+ return true;
1116
+ }
1117
+
1118
+ function focusCurrentCommentEdit() {
1119
+ editFocusRequested = true;
1120
+ announce("Save or cancel the current comment edit first");
1121
+ requestAnimationFrame(() => {
1122
+ const edit = state.commentUi.edit;
1123
+ editTextarea(edit?.commentId)?.focus();
1124
+ });
1125
+ }
1126
+
1127
+ function cancelCommentEdit() {
1128
+ const edit = state.commentUi.edit;
1129
+ if (!edit || edit.status === "saving") return false;
1130
+ const surface = state.drawerOpen ? "drawer" : edit.originSurface;
1131
+ const focusId = controlId(edit.commentId, surface, "edit");
1132
+ clearOwned(state.commentUi, "edit");
1133
+ requestControlFocus(focusId);
1134
+ render();
1135
+ return true;
1136
+ }
1137
+
1138
+ function saveCommentEdit() {
1139
+ const edit = state.commentUi.edit;
1140
+ if (!edit || edit.status === "saving") return edit ? patchFlights.get(edit.commentId) : false;
1141
+ captureEditState();
1142
+ const feedback = edit.draft.trim();
1143
+ if (!feedback) {
1144
+ edit.validation = "Comment text is required.";
1145
+ editFocusRequested = true;
1146
+ render();
1147
+ return false;
1148
+ }
1149
+ if (feedback === edit.original) {
1150
+ clearOwned(state.commentUi, "edit");
1151
+ requestControlFocus(controlId(edit.commentId, state.drawerOpen ? "drawer" : edit.originSurface, "edit"));
1152
+ render();
1153
+ return true;
1154
+ }
1155
+ if (patchFlights.has(edit.commentId)) return patchFlights.get(edit.commentId);
1156
+ edit.status = "saving";
1157
+ edit.validation = "";
1158
+ const id = edit.commentId;
1159
+ const startEpoch = state.pageEpoch;
1160
+ editFocusRequested = true;
1161
+ render();
1162
+ const flight = executeCommentPatch({ ...edit, draft: feedback }, startEpoch).finally(() => {
1163
+ if (patchFlights.get(id) === flight) patchFlights.delete(id);
1164
+ });
1165
+ patchFlights.set(id, flight);
1166
+ return flight;
1167
+ }
1168
+
1169
+ async function executeCommentPatch(edit, startEpoch) {
1170
+ const id = edit.commentId;
1171
+ try {
1172
+ const commentIndex = state.page.comments.findIndex((item) => item.id === id);
1173
+ const previousGeometry = state.activeGeometry.get(id);
1174
+ const wasOrphaned = state.orphans.has(id);
1175
+ const remainsActive = state.activeSavedCommentId === id;
1176
+ const result = await api(`/api/page/${state.key}/comment/${id}`, {
1177
+ method: "PATCH",
1178
+ body: JSON.stringify({ feedback: edit.draft }),
1179
+ });
1180
+ if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) return false;
1181
+ const replacement = commentIndex >= 0 ? result.page.comments[commentIndex] : null;
1182
+ if (replacement && replacement.id !== id) {
1183
+ migrateCommentUi(state.commentUi, id, replacement.id);
1184
+ if (remainsActive && previousGeometry) state.activeGeometry.set(replacement.id, previousGeometry);
1185
+ if (remainsActive) state.activeSavedCommentId = replacement.id;
1186
+ if (wasOrphaned) state.orphans.add(replacement.id);
1187
+ }
1188
+ reconcilePage(result.page, { reason: "mutation" });
1189
+ clearOwned(state.commentUi, "edit");
1190
+ if (result.delivery === "updated-pending") {
1191
+ toast("Updated the feedback waiting for your agent");
1192
+ } else if (result.delivery === "correction") {
1193
+ state.sent = false;
1194
+ toFrame({ type: "eh:remove", id });
1195
+ toFrame({ type: "eh:anchors", comments: state.page.comments });
1196
+ if (remainsActive && replacement) toFrame({ type: "eh:activate", id: replacement.id, scroll: false });
1197
+ toast("Saved as a correction — send it after the current batch is acknowledged");
1198
+ } else {
1199
+ state.sent = false;
1200
+ }
1201
+ render();
1202
+ return true;
1203
+ } catch (err) {
1204
+ if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
1205
+ const current = state.commentUi.edit;
1206
+ if (current?.commentId === id) {
1207
+ current.status = "idle";
1208
+ current.validation = "";
1209
+ editFocusRequested = true;
1210
+ }
1211
+ toast(err.message);
1212
+ render();
1213
+ }
1214
+ return false;
1215
+ }
429
1216
  }
430
1217
 
431
1218
  function renderSave() {
@@ -454,62 +1241,6 @@ function renderSave() {
454
1241
  else $("saveText").textContent = state.savedAt ? `Saved to ${name} · ${state.savedAt}` : `Saved to ${name}`;
455
1242
  }
456
1243
 
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
1244
  function toast(message) {
514
1245
  const el = document.createElement("div");
515
1246
  el.className = "toast";
@@ -519,64 +1250,150 @@ function toast(message) {
519
1250
  }
520
1251
 
521
1252
  function setActive(id, scroll) {
522
- state.active = id;
1253
+ state.activeSavedCommentId = id;
523
1254
  toFrame({ type: "eh:activate", id, scroll: !!scroll });
524
1255
  render();
525
1256
  }
526
1257
 
1258
+ function dismissActiveComment(reason, { restoreFocus = false } = {}) {
1259
+ const id = state.activeSavedCommentId;
1260
+ if (!id) return false;
1261
+ state.activeSavedCommentId = null;
1262
+ state.activeGeometry.delete(id);
1263
+ toFrame({ type: "eh:deactivateComment" });
1264
+ render();
1265
+ announce("Comment card closed");
1266
+ diagnostic("saved-card-deactivated", { reason });
1267
+ if (restoreFocus) frame.focus();
1268
+ return true;
1269
+ }
1270
+
527
1271
  // ------------------------------------------------------------------ compose
528
1272
 
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
1273
  async function openCompose(detail) {
535
- if (state.compose && $("composeText").value.trim()) await commitCompose();
1274
+ if (state.compose && state.compose.generation === detail.generation) {
1275
+ if (state.composeLifecycle === "closed") setComposeLifecycle("open", "accepted");
1276
+ $("composeText").focus();
1277
+ return true;
1278
+ }
1279
+ if (state.compose && $("composeText").value.trim()) {
1280
+ const submitted = await commitCompose();
1281
+ if (!submitted) {
1282
+ diagnostic("comment-retarget-blocked", { reason: "submit-failed" });
1283
+ return false;
1284
+ }
1285
+ } else if (state.compose) {
1286
+ cancelCompose({ restoreFocus: false, preserveRetarget: true });
1287
+ }
1288
+ dismissActiveComment("composer-open");
536
1289
  state.compose = detail;
537
- render();
538
- toFrame({ type: "eh:composeOpen" });
1290
+ setComposeLifecycle("open", "accepted");
539
1291
  $("composeText").value = "";
1292
+ $("composeError").hidden = true;
1293
+ $("composeAddLabel").textContent = "Comment";
1294
+ render();
1295
+ requestAnimationFrame(() => {
1296
+ positionCompose();
1297
+ $("composeText").focus();
1298
+ });
1299
+ announce(`Comment dialog opened for ${detail.kind === "element" ? "element" : "selected text"}`);
1300
+ return true;
540
1301
  }
541
1302
 
542
- function cancelCompose() {
543
- if (!state.compose) return;
1303
+ function cancelCompose({ restoreFocus = true, preserveRetarget = false } = {}) {
1304
+ if (!state.compose || state.composeLifecycle === "submitting") return false;
1305
+ const generation = state.compose.generation;
1306
+ const discardThroughGeneration = Math.max(
1307
+ generation,
1308
+ Number(state.compose.rejectedTargetGeneration) || generation
1309
+ );
544
1310
  state.compose = null;
545
- toFrame({ type: "eh:cancel" });
1311
+ setComposeLifecycle("closed", "cancel");
1312
+ setComposePlacement("hidden");
1313
+ toFrame({
1314
+ type: "eh:cancel",
1315
+ targetGeneration: generation,
1316
+ discardThroughGeneration,
1317
+ restoreFocus,
1318
+ preserveRetarget,
1319
+ });
546
1320
  render();
1321
+ return true;
547
1322
  }
548
1323
 
549
- async function commitCompose() {
1324
+ let composeSubmitPromise = null;
1325
+ function commitCompose() {
1326
+ if (composeSubmitPromise) {
1327
+ diagnostic("comment-submit-suppressed", { reason: "in-flight" });
1328
+ return composeSubmitPromise;
1329
+ }
1330
+ composeSubmitPromise = executeComposeSubmit().finally(() => {
1331
+ composeSubmitPromise = null;
1332
+ });
1333
+ return composeSubmitPromise;
1334
+ }
1335
+
1336
+ async function executeComposeSubmit() {
550
1337
  const compose = state.compose;
551
1338
  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();
1339
+ if (!compose || !feedback) return false;
1340
+ const button = $("composeAdd");
1341
+ button.disabled = true;
1342
+ setComposeLifecycle("submitting", "submit");
1343
+ diagnostic("comment-submit-executed");
1344
+ $("composeError").hidden = true;
1345
+ try {
1346
+ const result = await api(`/api/page/${state.key}/comment`, {
1347
+ method: "POST",
1348
+ body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.anchor, feedback }),
1349
+ });
1350
+ toFrame({
1351
+ type: "eh:commit",
1352
+ id: result.comment.id,
1353
+ targetGeneration: compose.generation,
1354
+ restoreFocus: true,
1355
+ });
1356
+ state.compose = null;
1357
+ setComposeLifecycle("closed", "submitted");
1358
+ setComposePlacement("hidden");
1359
+ state.page = result.page;
1360
+ state.sent = false;
1361
+ render();
1362
+ announce("Comment added");
1363
+ return true;
1364
+ } catch (err) {
1365
+ $("composeError").textContent = `${err.message}. Retry or cancel.`;
1366
+ $("composeError").hidden = false;
1367
+ $("composeAddLabel").textContent = "Retry";
1368
+ diagnostic("comment-request-failure", { status: err.status || 0 });
1369
+ announce("Comment could not be saved. Your draft is still here.");
1370
+ setComposeLifecycle("open", "submit-failed");
1371
+ $("compose").classList.remove("pass-through");
1372
+ requestAnimationFrame(() => $("composeText").focus());
1373
+ return false;
1374
+ } finally {
1375
+ button.disabled = false;
1376
+ }
563
1377
  }
564
1378
 
565
1379
  // -------------------------------------------------------------------- saving
566
1380
 
567
1381
  let retryTimer = null;
568
1382
  let saveAttempts = 0;
1383
+ let activeSavePromise = Promise.resolve(true);
569
1384
 
570
1385
  /** A fresh serialization from the SDK always starts a fresh attempt budget. */
571
1386
  function saveNow(html) {
572
1387
  saveAttempts = 0;
573
- return saveHtml(html, state.key);
1388
+ state.saveConflict = false;
1389
+ activeSavePromise = saveHtml(html, state.key);
1390
+ return activeSavePromise;
574
1391
  }
575
1392
 
576
1393
  async function saveHtml(html, key) {
577
1394
  clearTimeout(retryTimer);
578
1395
  // A retry that outlived a page switch must never write into the new page.
579
- if (key !== state.key) return;
1396
+ if (key !== state.key) return false;
580
1397
  if (!state.baseHash) {
581
1398
  // The on-disk baseline hasn't arrived yet; wait for it rather than write blind.
582
1399
  saveAttempts += 1;
@@ -585,7 +1402,7 @@ async function saveHtml(html, key) {
585
1402
  state.save = "failed";
586
1403
  renderSave();
587
1404
  }
588
- return;
1405
+ return false;
589
1406
  }
590
1407
  try {
591
1408
  const result = await api(`/api/page/${key}/save`, { method: "POST", body: JSON.stringify({ html, baseHash: state.baseHash }) });
@@ -593,26 +1410,56 @@ async function saveHtml(html, key) {
593
1410
  state.save = "saved";
594
1411
  state.savedAt = clock();
595
1412
  saveAttempts = 0;
1413
+ renderSave();
1414
+ return true;
596
1415
  } catch (err) {
597
1416
  if (err.status === 409) {
598
1417
  // Someone else — usually the agent — wrote the file first. Their version
599
1418
  // arrives via the reload event; this save is abandoned, not retried.
600
1419
  state.baseHash = null;
601
1420
  state.save = "idle";
1421
+ state.saveConflict = true;
602
1422
  saveAttempts = 0;
603
1423
  renderSave();
604
- return;
1424
+ announce("Save conflict. Edit mode remains active so you can reload safely.");
1425
+ diagnostic("save-conflict");
1426
+ return false;
605
1427
  }
606
1428
  saveAttempts += 1;
607
1429
  state.save = "failed";
608
1430
  if (saveAttempts < 5) retryTimer = setTimeout(() => saveHtml(html, key), 2000);
609
1431
  else toast("Couldn't save — your edits still reach the agent as feedback");
1432
+ return false;
610
1433
  }
611
- renderSave();
612
1434
  }
613
1435
 
614
1436
  // ------------------------------------------------------------ frame messages
615
1437
 
1438
+ function targetPayload(msg) {
1439
+ const viewport = {
1440
+ width: Number(msg.viewport?.width),
1441
+ height: Number(msg.viewport?.height),
1442
+ };
1443
+ const generation = Number(msg.targetGeneration);
1444
+ const rawRelation = String(msg.relation || "");
1445
+ if (!["visible", "above", "below", "unavailable"].includes(rawRelation)) return null;
1446
+ const relation = sanitizeRelation(rawRelation);
1447
+ const clip = sanitizeClipRect(msg.clip, viewport);
1448
+ const rects = sanitizeClientRects(msg.rects, viewport);
1449
+ if (!Number.isSafeInteger(generation) || generation < 1 || !clip) return null;
1450
+ if (relation === "visible" && !rects.length) return null;
1451
+ return {
1452
+ kind: msg.kind === "element" ? "element" : "selection",
1453
+ quote: String(msg.quote || ""),
1454
+ anchor: msg.anchor || null,
1455
+ rects,
1456
+ generation,
1457
+ relation,
1458
+ clip,
1459
+ horizontal: Number.isFinite(msg.horizontal) ? Number(msg.horizontal) : null,
1460
+ };
1461
+ }
1462
+
616
1463
  window.addEventListener("message", async (event) => {
617
1464
  if (!frame.contentWindow || event.source !== frame.contentWindow) return;
618
1465
  if (!state.framePolicy || event.origin !== state.framePolicy.incomingOrigin) return;
@@ -633,6 +1480,7 @@ window.addEventListener("message", async (event) => {
633
1480
  state.frameReadyRetries = 0;
634
1481
  state.readyGeneration = state.renderGeneration;
635
1482
  state.frameLoading = false;
1483
+ frame.dataset.sdkReady = "true";
636
1484
  const readyCapability = state.frameCapability;
637
1485
  void api(`/api/session/${state.sessionId}/render/${state.renderId}/ready`, {
638
1486
  method: "POST",
@@ -647,10 +1495,10 @@ window.addEventListener("message", async (event) => {
647
1495
  toFrame({ type: "eh:restoreScroll", x: state.scroll.x, y: state.scroll.y });
648
1496
  state.reloading = false;
649
1497
  }
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 {
1498
+ const configuration = reviewConfiguration(state.page, state.reviewMode);
1499
+ state.savePolicy = configuration.savePolicy;
1500
+ toFrame({ type: "eh:configureReview", ...configuration });
1501
+ if (configuration.savePolicy === "writable") {
654
1502
  // Hand the SDK the on-disk HTML so it can spot self-rendering pages.
655
1503
  api(`/api/page/${state.key}/raw`)
656
1504
  .then((raw) => {
@@ -661,8 +1509,90 @@ window.addEventListener("message", async (event) => {
661
1509
  }
662
1510
  break;
663
1511
  }
664
- case "eh:compose":
665
- await openCompose({ kind: msg.kind, quote: msg.quote, anchor: msg.anchor });
1512
+ case "eh:configurationApplied":
1513
+ state.configurationGeneration = state.renderGeneration;
1514
+ if (
1515
+ configurationWaiter &&
1516
+ msg.mode === configurationWaiter.mode &&
1517
+ msg.savePolicy === configurationWaiter.savePolicy
1518
+ ) configurationWaiter.settle(true);
1519
+ announce(`${state.reviewMode === "edit" ? "Edit" : "View"} mode active`);
1520
+ break;
1521
+ case "eh:target": {
1522
+ const detail = targetPayload(msg);
1523
+ if (!detail) return;
1524
+ if (state.target && detail.generation < state.target.generation) return;
1525
+ state.target = detail;
1526
+ break;
1527
+ }
1528
+ case "eh:openComment": {
1529
+ const requestedGeneration = Number(msg.targetGeneration);
1530
+ const detail = targetPayload(msg);
1531
+ let accepted = false;
1532
+ if (detail && (!state.target || detail.generation >= state.target.generation)) {
1533
+ accepted = await openCompose(detail);
1534
+ }
1535
+ if (!accepted && state.compose) {
1536
+ state.compose.rejectedTargetGeneration = Math.max(
1537
+ Number(state.compose.rejectedTargetGeneration) || 0,
1538
+ requestedGeneration
1539
+ );
1540
+ }
1541
+ toFrame({
1542
+ type: "eh:commentOpenResult",
1543
+ accepted,
1544
+ requestedGeneration,
1545
+ targetGeneration: state.compose?.generation || null,
1546
+ });
1547
+ break;
1548
+ }
1549
+ case "eh:targetGeometry":
1550
+ if (state.compose && msg.targetGeneration !== state.compose.generation) {
1551
+ diagnostic("geometry-rejected", { reason: "stale" });
1552
+ break;
1553
+ }
1554
+ if (state.compose) {
1555
+ const geometry = targetPayload({ ...msg, kind: state.compose.kind });
1556
+ if (!geometry) {
1557
+ diagnostic("geometry-rejected", { reason: "invalid" });
1558
+ break;
1559
+ }
1560
+ state.compose.rects = geometry.rects;
1561
+ state.compose.relation = geometry.relation;
1562
+ state.compose.clip = geometry.clip;
1563
+ state.compose.horizontal = geometry.horizontal ?? state.compose.horizontal;
1564
+ positionCompose();
1565
+ }
1566
+ break;
1567
+ case "eh:commentGeometry": {
1568
+ if (!msg.id) break;
1569
+ const id = String(msg.id);
1570
+ if (!state.page?.comments?.some((comment) => comment.id === id)) {
1571
+ state.activeGeometry.delete(id);
1572
+ break;
1573
+ }
1574
+ const rects = sanitizeClientRects(msg.rects, {
1575
+ width: Number(msg.viewport?.width),
1576
+ height: Number(msg.viewport?.height),
1577
+ });
1578
+ state.activeGeometry.set(id, {
1579
+ rects,
1580
+ visible: msg.visible !== false && rects.length > 0,
1581
+ });
1582
+ if (state.activeSavedCommentId === id) {
1583
+ positionAlignedCard();
1584
+ restoreTransientFocus(false);
1585
+ }
1586
+ break;
1587
+ }
1588
+ case "eh:revealTargetResult":
1589
+ if (!state.compose || msg.targetGeneration !== state.compose.generation) break;
1590
+ announce(msg.success ? "Selection revealed" : "Selection is no longer available");
1591
+ diagnostic(msg.success ? "reveal-target-succeeded" : "reveal-target-failed");
1592
+ break;
1593
+ case "eh:interaction":
1594
+ closeModeMenu("frame-interaction");
1595
+ closeCommentMenu();
666
1596
  break;
667
1597
  case "eh:dismiss":
668
1598
  if (!$("composeText").value.trim()) cancelCompose();
@@ -677,23 +1607,21 @@ window.addEventListener("message", async (event) => {
677
1607
  case "eh:notInView":
678
1608
  toast("That comment is not visible in this view");
679
1609
  break;
1610
+ case "eh:formBlocked":
1611
+ toast(String(msg.reason || "This form cannot be submitted from View mode"));
1612
+ break;
680
1613
  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();
1614
+ void persistEdit(state.key, {
1615
+ label: msg.label,
1616
+ kind: msg.kind,
1617
+ before: msg.before,
1618
+ after: msg.after,
1619
+ before_html: msg.before_html,
1620
+ after_html: msg.after_html,
1621
+ moved_after: msg.moved_after,
1622
+ moved_before: msg.moved_before,
1623
+ staged_assets: msg.staged_assets,
1624
+ });
697
1625
  break;
698
1626
  case "eh:asset":
699
1627
  try {
@@ -742,6 +1670,7 @@ window.addEventListener("message", async (event) => {
742
1670
  }
743
1671
  case "eh:navigate":
744
1672
  try {
1673
+ await flushFrame();
745
1674
  const result = await api(`/api/session/${state.sessionId}/navigate`, {
746
1675
  method: "POST",
747
1676
  body: JSON.stringify({ href: msg.href }),
@@ -757,31 +1686,100 @@ window.addEventListener("message", async (event) => {
757
1686
  }
758
1687
  });
759
1688
 
760
- // ---------------------------------------------------------------- rail wiring
1689
+ // --------------------------------------------------------------- UI wiring
1690
+
1691
+ let composeComposing = false;
761
1692
 
762
1693
  $("composeAdd").addEventListener("click", commitCompose);
763
1694
  $("composeCancel").addEventListener("click", cancelCompose);
1695
+ $("composeClose").addEventListener("click", cancelCompose);
1696
+ $("composeReveal").addEventListener("click", () => {
1697
+ if (!state.compose) return;
1698
+ diagnostic("reveal-target-requested");
1699
+ toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
1700
+ });
764
1701
 
765
- // Clicking anywhere on the card is the "I meant to comment" gesture.
766
1702
  $("compose").addEventListener("mousedown", (event) => {
767
1703
  if (event.target.closest("button")) return;
768
- event.preventDefault();
769
- $("composeText").focus();
1704
+ if (event.target !== $("composeText")) $("composeText").focus();
770
1705
  });
771
1706
 
1707
+ $("composeText").addEventListener("compositionstart", () => {
1708
+ composeComposing = true;
1709
+ });
1710
+ $("composeText").addEventListener("compositionend", () => {
1711
+ composeComposing = false;
1712
+ });
772
1713
  $("composeText").addEventListener("keydown", (event) => {
773
- if (event.key === "Enter" && !event.shiftKey) {
1714
+ if (event.key === "Enter") {
1715
+ event.stopPropagation();
1716
+ if (composeComposing || event.isComposing || event.shiftKey) return;
774
1717
  event.preventDefault();
775
- commitCompose();
1718
+ void commitCompose();
776
1719
  }
777
1720
  if (event.key === "Escape") {
1721
+ event.stopPropagation();
778
1722
  event.preventDefault();
779
1723
  cancelCompose();
780
1724
  }
781
1725
  });
782
1726
 
1727
+ $("modeButton").addEventListener("click", () => {
1728
+ if ($("modeMenu").hidden) openModeMenu();
1729
+ else closeModeMenu("trigger", { restoreFocus: true });
1730
+ });
1731
+ $("modeMenu").addEventListener("click", (event) => {
1732
+ const item = event.target.closest("[data-mode]");
1733
+ if (item) void setReviewMode(item.dataset.mode);
1734
+ });
1735
+ $("modeMenu").addEventListener("keydown", (event) => {
1736
+ const items = [...$("modeMenu").querySelectorAll("[data-mode]")];
1737
+ const current = items.indexOf(document.activeElement);
1738
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1739
+ event.preventDefault();
1740
+ const delta = event.key === "ArrowDown" ? 1 : -1;
1741
+ items[(current + delta + items.length) % items.length].focus();
1742
+ } else if (event.key === "Escape") {
1743
+ event.preventDefault();
1744
+ event.stopPropagation();
1745
+ closeModeMenu("escape", { restoreFocus: true });
1746
+ }
1747
+ });
1748
+ $("commentsButton").addEventListener("click", openDrawer);
1749
+ $("drawerClose").addEventListener("click", closeDrawer);
1750
+ $("drawerBackdrop").addEventListener("click", closeDrawer);
1751
+
1752
+ const dismissModeMenuOutside = (event) => {
1753
+ if (!state.modeMenuOpen || event.target.closest(".mode-control")) return;
1754
+ closeModeMenu(event.type === "focusin" ? "parent-focus" : "parent-pointer");
1755
+ };
1756
+ document.addEventListener("pointerdown", dismissModeMenuOutside, true);
1757
+ document.addEventListener("focusin", dismissModeMenuOutside, true);
1758
+
1759
+ function closeCommentMenu({ restoreFocus = false } = {}) {
1760
+ const menu = state.commentUi.menu;
1761
+ if (!menu) return false;
1762
+ clearOwned(state.commentUi, "menu");
1763
+ toFrame({ type: "eh:modeMenuState", open: false });
1764
+ if (restoreFocus) requestControlFocus(menu.triggerId);
1765
+ render();
1766
+ return true;
1767
+ }
1768
+
1769
+ const dismissCommentMenuOutside = (event) => {
1770
+ const menu = state.commentUi.menu;
1771
+ if (!menu) return;
1772
+ const menuElement = document.getElementById(controlId(menu.commentId, menu.surface, "menu"));
1773
+ const trigger = document.getElementById(menu.triggerId);
1774
+ if (menuElement?.contains(event.target) || trigger?.contains(event.target)) return;
1775
+ closeCommentMenu();
1776
+ };
1777
+ document.addEventListener("pointerdown", dismissCommentMenuOutside, true);
1778
+ document.addEventListener("focusin", dismissCommentMenuOutside, true);
1779
+
783
1780
  $("send").addEventListener("click", async () => {
784
1781
  try {
1782
+ await flushFrame();
785
1783
  await api(`/api/page/${state.key}/send`, {
786
1784
  method: "POST",
787
1785
  body: JSON.stringify({ sessionId: state.sessionId, note: $("note").value.trim() }),
@@ -803,6 +1801,9 @@ $("revert").addEventListener("click", async () => {
803
1801
  clearTimeout(retryTimer);
804
1802
  state.baseHash = null;
805
1803
  try {
1804
+ await settleEditPersistence(state.key).catch(() => {});
1805
+ editBacklogs.delete(state.key);
1806
+ editErrors.delete(state.key);
806
1807
  state.page = (await api(`/api/page/${state.key}/revert`, { method: "POST" })).page;
807
1808
  state.save = "idle";
808
1809
  state.savedAt = "";
@@ -835,9 +1836,9 @@ $("endReview").addEventListener("click", async () => {
835
1836
  ? `End this review? ${unsent} unsent ${unsent === 1 ? "item" : "items"} will be kept for next time.`
836
1837
  : "End this review? The waiting agent will be told to stop polling.";
837
1838
  if (!window.confirm(message)) return;
838
- // Ship anything still sitting in the SDK's debounce windows first.
839
- await flushFrame();
840
1839
  try {
1840
+ // Ship anything still sitting in the SDK's debounce windows first.
1841
+ await flushFrame();
841
1842
  await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
842
1843
  showEnded();
843
1844
  } catch (err) {
@@ -865,17 +1866,6 @@ $("note").addEventListener("input", (event) => {
865
1866
  render(); // keep the send button in step with note-only feedback
866
1867
  });
867
1868
 
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
1869
  $("theme").addEventListener("click", () => {
880
1870
  const dark = document.documentElement.dataset.theme !== "dark";
881
1871
  applyTheme(dark);
@@ -887,18 +1877,13 @@ $("theme").addEventListener("click", () => {
887
1877
  function applyTheme(dark) {
888
1878
  document.documentElement.dataset.theme = dark ? "dark" : "light";
889
1879
  const button = $("theme");
890
- button.textContent = dark ? "☀" : "☾";
891
1880
  button.title = dark ? "Switch chrome to light" : "Switch chrome to dark";
892
1881
  button.setAttribute("aria-label", button.title);
1882
+ replaceIcon(button, dark ? "sun" : "moon");
893
1883
  }
894
1884
 
895
1885
  document.addEventListener("keydown", (event) => {
896
1886
  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
1887
  // ⌘S is reassurance only: flush pending keystrokes, never a state change.
903
1888
  if (meta && event.key.toLowerCase() === "s") {
904
1889
  event.preventDefault();
@@ -906,7 +1891,25 @@ document.addEventListener("keydown", (event) => {
906
1891
  renderSave();
907
1892
  return;
908
1893
  }
909
- if (event.key === "Escape" && state.compose) cancelCompose();
1894
+ if (event.key === "Tab" && state.commentUi.menu) {
1895
+ closeCommentMenu();
1896
+ return;
1897
+ }
1898
+ if (event.key !== "Escape") return;
1899
+ let handled = false;
1900
+ if (state.commentUi.confirmation) handled = cancelDeleteConfirmation();
1901
+ else if (state.commentUi.menu) handled = closeCommentMenu({ restoreFocus: true });
1902
+ else if (state.commentUi.edit) handled = cancelCommentEdit();
1903
+ else if (state.compose) handled = cancelCompose();
1904
+ else if (state.modeMenuOpen) handled = closeModeMenu("escape", { restoreFocus: true });
1905
+ else if (state.drawerOpen) {
1906
+ closeDrawer();
1907
+ handled = true;
1908
+ } else if (state.activeSavedCommentId) handled = dismissActiveComment("escape", { restoreFocus: true });
1909
+ if (handled) {
1910
+ event.preventDefault();
1911
+ event.stopPropagation();
1912
+ }
910
1913
  });
911
1914
 
912
1915
  // ------------------------------------------------------------------ events
@@ -920,6 +1923,7 @@ function connect() {
920
1923
  source.addEventListener("ended", () => showEnded());
921
1924
  source.addEventListener("reload", () => {
922
1925
  const key = state.key;
1926
+ advancePageEpoch("reload");
923
1927
  const generation = beginFrameTransition();
924
1928
  state.frameReadyRetries = 0;
925
1929
  const hadEdits = state.page ? state.page.edits.length : 0;
@@ -949,9 +1953,10 @@ function connect() {
949
1953
  });
950
1954
  source.addEventListener("refresh", async () => {
951
1955
  const key = state.key;
1956
+ advancePageEpoch("acknowledgement");
952
1957
  const page = await api(pageUrl(key, state.sessionId));
953
1958
  if (state.key !== key) return;
954
- replacePage(state, page);
1959
+ reconcilePage(page, { syncAnchors: true, reason: "acknowledgement", advance: false });
955
1960
  state.sent = false;
956
1961
  render();
957
1962
  });
@@ -963,9 +1968,9 @@ function connect() {
963
1968
  // -------------------------------------------------------------------- start
964
1969
 
965
1970
  (async function start() {
1971
+ installStaticIcons();
966
1972
  try {
967
1973
  applyTheme(localStorage.getItem("doc-review:theme") === "dark");
968
- if (localStorage.getItem("doc-review:collapsed") === "1") $("handle").click();
969
1974
  } catch {}
970
1975
 
971
1976
  const bootstrap = await api(`/api/session/${state.sessionId}/page`).catch(() => null);
@@ -976,6 +1981,15 @@ function connect() {
976
1981
  connect();
977
1982
  })();
978
1983
 
1984
+ window.addEventListener("resize", () => {
1985
+ positionCompose();
1986
+ if (state.activeSavedCommentId) positionAlignedCard();
1987
+ });
1988
+ if (window.visualViewport) {
1989
+ window.visualViewport.addEventListener("resize", positionCompose);
1990
+ window.visualViewport.addEventListener("scroll", positionCompose);
1991
+ }
1992
+
979
1993
  frame.addEventListener("load", () => {
980
1994
  if (!state.renderId || !state.frameCapability) return;
981
1995
  if (state.pendingInitialLoad) {
@@ -984,3 +1998,9 @@ frame.addEventListener("load", () => {
984
1998
  }
985
1999
  rotateCurrentFrame();
986
2000
  });
2001
+
2002
+ frame.addEventListener("focus", () => {
2003
+ closeCommentMenu();
2004
+ if (state.compose) $("compose").classList.add("pass-through");
2005
+ });
2006
+ $("compose").addEventListener("focusin", () => $("compose").classList.remove("pass-through"));