papilot 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/README.zh-CN.md +127 -0
  4. package/bin/papilot.mjs +113 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
  7. package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  8. package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
  9. package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  10. package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
  11. package/dist/assets/index-BhQFCMTx.js +131 -0
  12. package/dist/assets/index-CAV3-87U.css +1 -0
  13. package/dist/assets/pdf-view-DJo0X38w.js +598 -0
  14. package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  15. package/dist/favicon.png +0 -0
  16. package/dist/index.html +4 -0
  17. package/examples/papilot-paper/.latex-review.json +4 -0
  18. package/examples/papilot-paper/appendix/proofs.tex +60 -0
  19. package/examples/papilot-paper/appendix/reference.tex +54 -0
  20. package/examples/papilot-paper/main.tex +57 -0
  21. package/examples/papilot-paper/preamble.tex +53 -0
  22. package/examples/papilot-paper/references.bib +158 -0
  23. package/examples/papilot-paper/sections/01-introduction.tex +66 -0
  24. package/examples/papilot-paper/sections/02-using.tex +134 -0
  25. package/examples/papilot-paper/sections/03-background.tex +97 -0
  26. package/examples/papilot-paper/sections/04-model.tex +185 -0
  27. package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
  28. package/examples/papilot-paper/sections/06-building.tex +70 -0
  29. package/examples/papilot-paper/sections/07-geometry.tex +193 -0
  30. package/examples/papilot-paper/sections/08-notes.tex +104 -0
  31. package/examples/papilot-paper/sections/09-agents.tex +106 -0
  32. package/examples/papilot-paper/sections/10-usage.tex +56 -0
  33. package/examples/papilot-paper/sections/11-git.tex +62 -0
  34. package/examples/papilot-paper/sections/12-security.tex +41 -0
  35. package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
  36. package/examples/papilot-paper/sections/14-related.tex +38 -0
  37. package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
  38. package/package.json +85 -4
  39. package/scripts/demo.ts +21 -0
  40. package/scripts/service.ts +35 -0
  41. package/src/notes.ts +40 -0
  42. package/src/server/agents.ts +343 -0
  43. package/src/server/codex.ts +63 -0
  44. package/src/server/frontend.ts +15 -0
  45. package/src/server/main.ts +202 -0
  46. package/src/server/manuscript.ts +337 -0
  47. package/src/server/paths.ts +10 -0
  48. package/src/server/pdf.ts +27 -0
  49. package/src/server/review.ts +53 -0
  50. package/src/server/security.ts +15 -0
  51. package/src/server/store.ts +60 -0
  52. package/src/server/workspace.ts +529 -0
  53. package/src/shared.ts +28 -0
  54. package/vendor/latex-review/LICENSE +21 -0
  55. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  56. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  57. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
  58. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
  59. package/vendor/latex-review/dist/web/index.html +18 -0
  60. package/vendor/latex-review/package-lock.json +3114 -0
  61. package/vendor/latex-review/package.json +49 -0
  62. package/vendor/latex-review/src/cli.ts +494 -0
  63. package/vendor/latex-review/src/client.ts +198 -0
  64. package/vendor/latex-review/src/core/build.ts +296 -0
  65. package/vendor/latex-review/src/core/changes.ts +126 -0
  66. package/vendor/latex-review/src/core/config.ts +270 -0
  67. package/vendor/latex-review/src/core/diff.ts +54 -0
  68. package/vendor/latex-review/src/core/files.ts +96 -0
  69. package/vendor/latex-review/src/core/git.ts +317 -0
  70. package/vendor/latex-review/src/core/outline.ts +314 -0
  71. package/vendor/latex-review/src/core/process.ts +111 -0
  72. package/vendor/latex-review/src/core/publish.ts +339 -0
  73. package/vendor/latex-review/src/core/review.ts +734 -0
  74. package/vendor/latex-review/src/core/store.ts +66 -0
  75. package/vendor/latex-review/src/core/tasks.ts +237 -0
  76. package/vendor/latex-review/src/render/inline.ts +805 -0
  77. package/vendor/latex-review/src/render/locate.ts +481 -0
  78. package/vendor/latex-review/src/render/pdf.ts +281 -0
  79. package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
  80. package/vendor/latex-review/src/render/round.ts +236 -0
  81. package/vendor/latex-review/src/render/worker.ts +10 -0
  82. package/vendor/latex-review/src/runtime.ts +23 -0
  83. package/vendor/latex-review/src/server.ts +401 -0
  84. package/vendor/latex-review/src/types.ts +283 -0
  85. package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
  86. package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
  87. package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
  88. package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
  89. package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
  90. package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
  91. package/vendor/latex-review/src/web/fonts/README.md +12 -0
  92. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  93. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  94. package/vendor/latex-review/src/web/index.html +17 -0
  95. package/vendor/latex-review/src/web/main.tsx +10 -0
  96. package/vendor/latex-review/src/web/model.ts +153 -0
  97. package/vendor/latex-review/src/web/overview.tsx +146 -0
  98. package/vendor/latex-review/src/web/review.tsx +991 -0
  99. package/vendor/latex-review/src/web/style.css +1249 -0
@@ -0,0 +1,991 @@
1
+ import {
2
+ Fragment,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react";
9
+ import type {
10
+ ActionKind,
11
+ ChangeBlock,
12
+ PageState,
13
+ RoundDetail,
14
+ Strip,
15
+ Unit,
16
+ VisualUnit,
17
+ } from "../types.ts";
18
+ import { PdfStrip } from "./PdfStrip.tsx";
19
+ import { ChangeMap } from "./ChangeMap.tsx";
20
+ import { STATUS } from "./overview.tsx";
21
+ import {
22
+ buildStops,
23
+ copyReference,
24
+ describeLocation,
25
+ nextPending,
26
+ pendingUnits,
27
+ progress,
28
+ resolvedSide,
29
+ stopState,
30
+ visibleStop,
31
+ wordDiff,
32
+ } from "./model.ts";
33
+ const KIND = {
34
+ text: "Text",
35
+ layout: "Layout",
36
+ position: "Moved",
37
+ identical: "No visible change",
38
+ unmapped: "Source only",
39
+ };
40
+ const PHASE: Record<string, string> = {
41
+ snapshot: "Taking a snapshot",
42
+ build: "Compiling LaTeX",
43
+ render: "Rendering the diff",
44
+ };
45
+ type Composer = {
46
+ changes: string[];
47
+ stop: number;
48
+ key: string;
49
+ };
50
+ function readStorage<T>(key: string, fallback: T): T {
51
+ try {
52
+ return JSON.parse(localStorage.getItem(key) ?? "null") ?? fallback;
53
+ } catch {
54
+ return fallback;
55
+ }
56
+ }
57
+ function store(key: string, value: unknown) {
58
+ try {
59
+ localStorage.setItem(key, JSON.stringify(value));
60
+ } catch {
61
+ /* Storage is optional. */
62
+ }
63
+ }
64
+ function jobText(job: PageState["job"]) {
65
+ return job?.status === "queued"
66
+ ? job.ahead
67
+ ? `Queued behind ${job.ahead} other rounds…`
68
+ : "Queued…"
69
+ : `${PHASE[job?.phase ?? ""] || "Starting"}…`;
70
+ }
71
+ export function ReviewApp({
72
+ task,
73
+ pinned,
74
+ }: {
75
+ task: string;
76
+ pinned: number | null;
77
+ }) {
78
+ const [state, setState] = useState<PageState | null>(null),
79
+ [detail, setDetail] = useState<RoundDetail | null>(null),
80
+ [current, setCurrent] = useState(-1),
81
+ [busy, setBusy] = useState(false),
82
+ [online, setOnline] = useState(true),
83
+ [error, setError] = useState(""),
84
+ [toast, setToast] = useState(""),
85
+ [openSource, setOpenSource] = useState<Set<number>>(new Set()),
86
+ [composer, setComposer] = useState<Composer | null>(null),
87
+ [draft, setDraft] = useState(""),
88
+ [help, setHelp] = useState(false);
89
+ const paper = useRef<HTMLElement>(null),
90
+ epoch = useRef(0),
91
+ scrollUntil = useRef(0),
92
+ snapshot = useRef({ state, detail, busy, composer, current });
93
+ snapshot.current = { state, detail, busy, composer, current };
94
+ const units = useMemo(
95
+ () => new Map(detail?.units.map((u) => [u.id, u]) ?? []),
96
+ [detail],
97
+ ),
98
+ stops = useMemo(() => (detail ? buildStops(detail) : []), [detail]),
99
+ locations = useMemo(
100
+ () =>
101
+ stops.map((stop) =>
102
+ describeLocation(
103
+ stop.units.map((id) => units.get(id)!).filter(Boolean),
104
+ ),
105
+ ),
106
+ [stops, units],
107
+ ),
108
+ decisions = state?.decisions ?? {},
109
+ count = progress(decisions),
110
+ undecided = Object.keys(decisions).filter(
111
+ (id) => decisions[id] === "pending",
112
+ ),
113
+ latest = Boolean(
114
+ detail && state && detail.round === state.latest && !state.closed,
115
+ );
116
+ const actionLock = (kind: ActionKind) =>
117
+ kind === "comment" ? state?.commentLock : state?.decisionLock;
118
+ function blocked(kind: ActionKind): boolean {
119
+ const current = snapshot.current.state;
120
+ const reason = kind === "comment" ? current?.commentLock : current?.decisionLock;
121
+ if (reason) setToast(reason);
122
+ return Boolean(reason);
123
+ }
124
+ async function api<T>(route: string, body?: unknown): Promise<T> {
125
+ const response = await fetch(
126
+ `/api/tasks/${encodeURIComponent(task)}${route}`,
127
+ body === undefined
128
+ ? {}
129
+ : {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Type": "application/json",
133
+ "X-Review-Token": snapshot.current.state?.csrf ?? "",
134
+ },
135
+ body: JSON.stringify(body),
136
+ },
137
+ );
138
+ const value = await response.json();
139
+ if (!response.ok) throw new Error(value.error || `HTTP ${response.status}`);
140
+ return value as T;
141
+ }
142
+ function select(index: number, scroll = true) {
143
+ if (index < 0 || index >= stops.length) return;
144
+ setCurrent(index);
145
+ store(`latex-review:${task}`, {
146
+ round: detail?.round,
147
+ stop: stops[index].key,
148
+ });
149
+ if (scroll) {
150
+ scrollUntil.current = Date.now() + 800;
151
+ requestAnimationFrame(() => {
152
+ const article = paper.current?.querySelector<HTMLElement>(
153
+ `[data-stop="${index}"]`,
154
+ );
155
+ article?.scrollIntoView({ block: "start", behavior: "smooth" });
156
+ article?.focus({ preventScroll: true });
157
+ });
158
+ }
159
+ }
160
+ useEffect(() => {
161
+ let cancelled = false,
162
+ timer: ReturnType<typeof setTimeout>;
163
+ async function poll() {
164
+ const requestedEpoch = epoch.current;
165
+ try {
166
+ const previous = snapshot.current,
167
+ number = pinned || previous.detail?.round,
168
+ next = await api<PageState>(
169
+ `/state${number ? `?round=${number}` : ""}`,
170
+ );
171
+ if (
172
+ cancelled ||
173
+ epoch.current !== requestedEpoch ||
174
+ snapshot.current.busy
175
+ )
176
+ return;
177
+ setOnline(true);
178
+ setError("");
179
+ const target = pinned || next.latest;
180
+ if (
181
+ target &&
182
+ (target !== previous.detail?.round ||
183
+ (next.revision && next.revision !== previous.detail?.after)) &&
184
+ !snapshot.current.composer
185
+ ) {
186
+ const [loadedState, loaded] = await Promise.all([
187
+ api<PageState>(`/state?round=${target}`),
188
+ api<RoundDetail>(`/rounds/${target}`),
189
+ ]);
190
+ if (
191
+ cancelled ||
192
+ epoch.current !== requestedEpoch ||
193
+ snapshot.current.busy
194
+ )
195
+ return;
196
+ const nextStops = buildStops(loaded),
197
+ saved = readStorage<{ round?: number; stop?: string }>(
198
+ `latex-review:${task}`,
199
+ {},
200
+ );
201
+ let index =
202
+ saved.round === target
203
+ ? nextStops.findIndex((s) => s.key === saved.stop)
204
+ : -1;
205
+ if (
206
+ index < 0 ||
207
+ !pendingUnits(nextStops[index], loadedState.decisions).length
208
+ )
209
+ index = nextPending(nextStops, loadedState.decisions, index);
210
+ if (index < 0) index = nextStops.length ? 0 : -1;
211
+ setState(loadedState);
212
+ setDetail(loaded);
213
+ setCurrent(index);
214
+ setOpenSource(new Set());
215
+ if (previous.detail) setToast(`Round ${target} is ready`);
216
+ } else setState(next);
217
+ } catch (e) {
218
+ if (!cancelled) {
219
+ setOnline(false);
220
+ setError((e as Error).message);
221
+ }
222
+ } finally {
223
+ if (!cancelled)
224
+ timer = setTimeout(poll, document.hidden ? 10000 : 1500);
225
+ }
226
+ }
227
+ void poll();
228
+ return () => {
229
+ cancelled = true;
230
+ clearTimeout(timer);
231
+ };
232
+ }, [task, pinned]);
233
+ useEffect(() => {
234
+ if (detail && current >= 0)
235
+ requestAnimationFrame(() =>
236
+ paper.current
237
+ ?.querySelector<HTMLElement>(`[data-stop="${current}"]`)
238
+ ?.scrollIntoView({ block: "start" }),
239
+ );
240
+ }, [detail?.round]);
241
+ useEffect(() => {
242
+ if (state) document.title = `${state.title} · LaTeX Review`;
243
+ }, [state?.title]);
244
+ useEffect(() => {
245
+ if (!toast) return;
246
+ const timer = setTimeout(() => setToast(""), 6000);
247
+ return () => clearTimeout(timer);
248
+ }, [toast]);
249
+ function beginComposer(changes = pendingUnits(stops[current], decisions)) {
250
+ if (!latest || !changes.length || busy || state?.closed || blocked("comment")) return;
251
+ const key = `latex-review:${task}:draft:${detail?.round}:${changes.join(",")}`;
252
+ setDraft(readStorage(key, ""));
253
+ setComposer({ changes, stop: current, key });
254
+ }
255
+ function closeComposer(sent = false) {
256
+ if (composer) store(composer.key, sent ? "" : draft);
257
+ setComposer(null);
258
+ }
259
+ async function decide(
260
+ kind: ActionKind,
261
+ changes = pendingUnits(stops[current], decisions),
262
+ body = "",
263
+ ) {
264
+ if (!changes.length || snapshot.current.busy || !latest || !detail || blocked(kind)) return;
265
+ epoch.current++;
266
+ setBusy(true);
267
+ snapshot.current.busy = true;
268
+ try {
269
+ const next = await api<PageState>("/decide", {
270
+ id: crypto.randomUUID(),
271
+ action: kind,
272
+ round: detail.round,
273
+ changes,
274
+ body,
275
+ });
276
+ setState(next);
277
+ if (kind === "comment") closeComposer(true);
278
+ const target = pendingUnits(stops[current], next.decisions).length
279
+ ? current
280
+ : nextPending(stops, next.decisions, current);
281
+ if (target >= 0 && target !== current) select(target);
282
+ if (target < 0)
283
+ setToast("This round is reviewed. Your decisions are saved.");
284
+ } catch (e) {
285
+ setToast((e as Error).message);
286
+ } finally {
287
+ epoch.current++;
288
+ setBusy(false);
289
+ snapshot.current.busy = false;
290
+ }
291
+ }
292
+ async function undo() {
293
+ if (!state?.undo || snapshot.current.busy || blocked("accept")) return;
294
+ epoch.current++;
295
+ setBusy(true);
296
+ snapshot.current.busy = true;
297
+ try {
298
+ const next = await api<PageState & { restored: string[] }>("/undo", {
299
+ id: state.undo.id,
300
+ });
301
+ setState(next);
302
+ const target = stops.findIndex((s) =>
303
+ s.units.some((id) => next.restored.includes(id)),
304
+ );
305
+ if (target >= 0) select(target);
306
+ setToast("Decision undone.");
307
+ } catch (e) {
308
+ setToast((e as Error).message);
309
+ } finally {
310
+ epoch.current++;
311
+ setBusy(false);
312
+ snapshot.current.busy = false;
313
+ }
314
+ }
315
+ async function submitComposer() {
316
+ if (!composer || !draft.trim() || busy) return;
317
+ await decide("comment", composer.changes, draft.trim());
318
+ }
319
+ function toggleSource(index: number) {
320
+ setOpenSource((previous) => {
321
+ const next = new Set(previous);
322
+ if (next.has(index)) next.delete(index);
323
+ else next.add(index);
324
+ return next;
325
+ });
326
+ }
327
+ async function copy(index: number) {
328
+ if (!detail || !state) return;
329
+ const selected = window.getSelection()?.toString().trim() ?? "",
330
+ known = stops[index].units.map((id) => units.get(id)!).filter(Boolean);
331
+ try {
332
+ await navigator.clipboard.writeText(
333
+ copyReference(
334
+ state.title,
335
+ detail.round,
336
+ index + 1,
337
+ describeLocation(known),
338
+ selected,
339
+ ),
340
+ );
341
+ setToast(
342
+ selected
343
+ ? "Copied selected text with file, lines and PDF page."
344
+ : "Copied the change location.",
345
+ );
346
+ } catch (e) {
347
+ setToast(
348
+ "Clipboard unavailable. Select the PDF text and use your browser’s Copy command.",
349
+ );
350
+ }
351
+ }
352
+ useEffect(() => {
353
+ const listener = (event: KeyboardEvent) => {
354
+ const target = event.target as HTMLElement,
355
+ typing =
356
+ target.matches("input,textarea,select") || target.isContentEditable;
357
+ if (event.key === "Escape") {
358
+ if (help) setHelp(false);
359
+ else if (composer) closeComposer();
360
+ return;
361
+ }
362
+ if (
363
+ composer &&
364
+ (event.metaKey || event.ctrlKey) &&
365
+ event.key === "Enter"
366
+ ) {
367
+ event.preventDefault();
368
+ void submitComposer();
369
+ return;
370
+ }
371
+ if (typing || help) return;
372
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
373
+ event.preventDefault();
374
+ void undo();
375
+ return;
376
+ }
377
+ if (
378
+ event.metaKey ||
379
+ event.ctrlKey ||
380
+ event.altKey ||
381
+ composer ||
382
+ window.getSelection()?.toString()
383
+ )
384
+ return;
385
+ const key = event.key.toLowerCase();
386
+ if (
387
+ [
388
+ "a",
389
+ "r",
390
+ "c",
391
+ "j",
392
+ "k",
393
+ "arrowright",
394
+ "arrowleft",
395
+ "n",
396
+ "s",
397
+ "u",
398
+ "?",
399
+ ].includes(key)
400
+ )
401
+ event.preventDefault();
402
+ if (key === "a") void decide("accept");
403
+ else if (key === "r") void decide("reject");
404
+ else if (key === "c") beginComposer();
405
+ else if (key === "j" || key === "arrowright") select(current + 1);
406
+ else if (key === "k" || key === "arrowleft") select(current - 1);
407
+ else if (key === "n") select(nextPending(stops, decisions, current));
408
+ else if (key === "s") toggleSource(current);
409
+ else if (key === "u") void undo();
410
+ else if (key === "?") setHelp(true);
411
+ };
412
+ window.addEventListener("keydown", listener);
413
+ return () => window.removeEventListener("keydown", listener);
414
+ }, [state, detail, current, busy, composer, draft, help]);
415
+ function onScroll() {
416
+ if (
417
+ !paper.current ||
418
+ composer ||
419
+ Date.now() < scrollUntil.current ||
420
+ window.getSelection()?.toString()
421
+ )
422
+ return;
423
+ const box = paper.current.getBoundingClientRect(),
424
+ regions = [
425
+ ...paper.current.querySelectorAll<HTMLElement>("[data-stop]"),
426
+ ].map((article) => {
427
+ const b = article.getBoundingClientRect();
428
+ return {
429
+ index: Number(article.dataset.stop),
430
+ top: b.top,
431
+ bottom: b.bottom,
432
+ };
433
+ });
434
+ const index = visibleStop(regions, current, box.top + 60, box.bottom - 100);
435
+ if (index !== current) select(index, false);
436
+ }
437
+ const actionBar = (index: number) => {
438
+ if (index !== current || !state) return null;
439
+ const pending = pendingUnits(stops[index], decisions),
440
+ remaining = nextPending(stops, decisions);
441
+ return (
442
+ <div className="dock">
443
+ <nav className="actions" aria-label="Review the current change">
444
+ {stops.length > 1 && (
445
+ <>
446
+ <button
447
+ className="nav"
448
+ aria-label="Previous change"
449
+ aria-keyshortcuts="k ArrowLeft"
450
+ title="Previous change (K or ←)"
451
+ disabled={index === 0}
452
+ onClick={() => select(index - 1)}
453
+ >
454
+ ‹
455
+ </button>
456
+ <span className="position">
457
+ {index + 1} / {stops.length}
458
+ </span>
459
+ </>
460
+ )}
461
+ {pending.length ? (
462
+ <>
463
+ {(["accept", "reject", "comment"] as const).map((kind) => (
464
+ <button
465
+ className={kind}
466
+ key={kind}
467
+ aria-keyshortcuts={kind[0]}
468
+ title={actionLock(kind) || `${kind[0].toUpperCase() + kind.slice(1)} (${kind[0].toUpperCase()})`}
469
+ aria-describedby={actionLock(kind) ? "review-lock" : undefined}
470
+ disabled={busy || !latest || Boolean(actionLock(kind))}
471
+ onClick={() =>
472
+ kind === "comment" ? beginComposer() : void decide(kind)
473
+ }
474
+ >
475
+ {kind[0].toUpperCase() + kind.slice(1)}
476
+ {pending.length > 1 && kind !== "comment"
477
+ ? ` (${pending.length})`
478
+ : ""}
479
+ </button>
480
+ ))}
481
+ </>
482
+ ) : remaining >= 0 ? (
483
+ <button
484
+ className="skip"
485
+ aria-keyshortcuts="n"
486
+ onClick={() => select(nextPending(stops, decisions, current))}
487
+ >
488
+ Next undecided
489
+ </button>
490
+ ) : (
491
+ <span className="complete">
492
+ {latest ? "Review complete" : "Earlier round"}
493
+ </span>
494
+ )}
495
+ {stops.length > 1 && (
496
+ <button
497
+ className="nav"
498
+ aria-label="Next change"
499
+ aria-keyshortcuts="j ArrowRight"
500
+ title="Next change (J or →)"
501
+ disabled={index === stops.length - 1}
502
+ onClick={() => select(index + 1)}
503
+ >
504
+ ›
505
+ </button>
506
+ )}
507
+ <button
508
+ className="undo"
509
+ title={state.decisionLock || undefined}
510
+ aria-describedby={state.decisionLock ? "review-lock" : undefined}
511
+ disabled={!state.undo || busy || Boolean(state.decisionLock)}
512
+ onClick={() => void undo()}
513
+ >
514
+ Undo
515
+ </button>
516
+ </nav>
517
+ </div>
518
+ );
519
+ };
520
+ function change(
521
+ block: ChangeBlock,
522
+ index: number,
523
+ inline: boolean,
524
+ ): ReactNode {
525
+ const known = block.units.map((id) => units.get(id)!).filter(Boolean),
526
+ resolved = resolvedSide(block.units, decisions),
527
+ status = stopState(block.units, decisions),
528
+ source =
529
+ openSource.has(index) ||
530
+ block.identical ||
531
+ (!block.before.length && !block.after.length),
532
+ remarks = [
533
+ ...new Set(
534
+ block.units.map((id) => state?.comments[id]).filter(Boolean),
535
+ ),
536
+ ],
537
+ location = describeLocation(known),
538
+ kindLabel = block.reflow
539
+ ? "Reflow"
540
+ : [...new Set(known.map((u) => u.visual ?? "unmapped"))]
541
+ .filter((kind) => kind !== "text")
542
+ .map((kind) => KIND[kind])
543
+ .join(", ");
544
+ const images = (items: Strip[], side: "before" | "after") => (
545
+ <div
546
+ className={`side ${side}${block.identical ? " identical" : ""}`}
547
+ aria-label={side === "before" ? "Before" : "After"}
548
+ >
549
+ {items.map((image) => (
550
+ <PdfStrip
551
+ key={image.src}
552
+ image={image}
553
+ base={detail!.files}
554
+ label={side === "before" ? "Before" : "After"}
555
+ clean={Boolean(resolved)}
556
+ />
557
+ ))}
558
+ </div>
559
+ );
560
+ return (
561
+ <article
562
+ key={index}
563
+ className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}`}
564
+ data-stop={index}
565
+ data-state={status}
566
+ data-resolved={resolved}
567
+ tabIndex={-1}
568
+ onClick={() => {
569
+ if (index !== current && !composer) select(index, false);
570
+ }}
571
+ >
572
+ <header className="change-head">
573
+ <span className="change-number">Change {index + 1}</span>
574
+ <span className="change-where" title={location}>
575
+ {location}
576
+ </span>
577
+ {actionBar(index)}
578
+ {kindLabel && <span className="change-kind">{kindLabel}</span>}
579
+ {status !== "pending" && (
580
+ <span className="badge">
581
+ {status[0].toUpperCase() + status.slice(1)}
582
+ </span>
583
+ )}
584
+ <button
585
+ className="link copy-reference"
586
+ title="Copy selected text with this change’s file, line numbers and PDF page"
587
+ onPointerDown={(e) => e.preventDefault()}
588
+ onClick={() => void copy(index)}
589
+ >
590
+ Copy reference
591
+ </button>
592
+ <button
593
+ className={`link ${source ? "active" : ""}`}
594
+ title="Show the LaTeX source (S)"
595
+ onClick={() => toggleSource(index)}
596
+ >
597
+ {block.units.length > 1
598
+ ? `${block.units.length} edits · LaTeX`
599
+ : "LaTeX"}
600
+ </button>
601
+ </header>
602
+ {known.some((u) => u.history?.length) && (
603
+ <div className="histories">
604
+ {known
605
+ .flatMap((u) => u.history ?? [])
606
+ .map((h, i) => (
607
+ <p key={i}>
608
+ <strong>
609
+ Revised after your comment in round {h.round}:{" "}
610
+ </strong>
611
+ “{h.comment}”
612
+ </p>
613
+ ))}
614
+ </div>
615
+ )}
616
+ {!block.identical &&
617
+ !block.reflow &&
618
+ block.before.length > 0 &&
619
+ images(block.before, "before")}
620
+ {block.after.length > 0 && images(block.after, "after")}
621
+ {block.identical && (
622
+ <p className="caption">
623
+ No visible change in the PDF; see the LaTeX source.
624
+ </p>
625
+ )}
626
+ {block.reflow && (
627
+ <p className="caption">Continued: only line breaks moved here.</p>
628
+ )}
629
+ {remarks.length > 0 && (
630
+ <div className="remark">
631
+ {remarks.map((r, i) => (
632
+ <p key={i}>
633
+ <strong>Your comment: </strong>
634
+ {r}
635
+ </p>
636
+ ))}
637
+ </div>
638
+ )}
639
+ {source && (
640
+ <div className="source">
641
+ {known.map((unit) => (
642
+ <div
643
+ className="source-unit"
644
+ data-state={decisions[unit.id]}
645
+ key={unit.id}
646
+ >
647
+ <div className="source-title">
648
+ <span>
649
+ {unit.file} · lines {unit.new_start}–{unit.new_end}
650
+ </span>
651
+ {known.length > 1 && (
652
+ <span className="unit-actions">
653
+ <span className="unit-state">
654
+ {decisions[unit.id] !== "pending"
655
+ ? decisions[unit.id]
656
+ : ""}
657
+ </span>
658
+ {decisions[unit.id] === "pending" &&
659
+ (["accept", "reject", "comment"] as const).map(
660
+ (kind) => (
661
+ <button
662
+ className={kind}
663
+ key={kind}
664
+ title={actionLock(kind) || undefined}
665
+ aria-describedby={actionLock(kind) ? "review-lock" : undefined}
666
+ disabled={busy || !latest || Boolean(actionLock(kind))}
667
+ onClick={() =>
668
+ kind === "comment"
669
+ ? beginComposer([unit.id])
670
+ : void decide(kind, [unit.id])
671
+ }
672
+ >
673
+ {kind[0].toUpperCase() + kind.slice(1)}
674
+ </button>
675
+ ),
676
+ )}
677
+ </span>
678
+ )}
679
+ </div>
680
+ {unit.kind === "binary" ? (
681
+ <p>Binary file changed.</p>
682
+ ) : (
683
+ <pre className="source-code">
684
+ {wordDiff(unit.old_text, unit.new_text).map((part, i) =>
685
+ part.type === "del" ? (
686
+ <del key={i}>{part.text}</del>
687
+ ) : part.type === "ins" ? (
688
+ <ins key={i}>{part.text}</ins>
689
+ ) : (
690
+ <Fragment key={i}>{part.text}</Fragment>
691
+ ),
692
+ )}
693
+ </pre>
694
+ )}
695
+ {unit.truncated && (
696
+ <p className="muted">Long change: the source is shortened.</p>
697
+ )}
698
+ </div>
699
+ ))}
700
+ </div>
701
+ )}
702
+ </article>
703
+ );
704
+ }
705
+ let index = 0;
706
+ return (
707
+ <>
708
+ <header className="bar">
709
+ <a className="ghost overview-link" href="/">
710
+ ← All reviews
711
+ </a>
712
+ <div className="identity">
713
+ <h1>{state?.title ?? "LaTeX Review"}</h1>
714
+ <span className="muted">
715
+ {detail
716
+ ? `Round ${detail.round}${detail.round !== state?.latest ? " (earlier round)" : ""}`
717
+ : ""}
718
+ </span>
719
+ </div>
720
+ {count.total > 0 && (
721
+ <div className="meter">
722
+ <span>
723
+ {count.decided} of {count.total} decided
724
+ </span>
725
+ </div>
726
+ )}
727
+ <div className="bar-end">
728
+ {/* Batch decisions cover only undecided changes; accepted,
729
+ rejected and commented changes keep their state. A single
730
+ undecided change is decided from its own bar. */}
731
+ {count.pending > 1 && (
732
+ <button
733
+ className="ghost accept accept-all"
734
+ disabled={busy || !latest || Boolean(composer) || Boolean(state?.decisionLock)}
735
+ title={state?.decisionLock || `Accept the ${count.pending} undecided ${count.pending === 1 ? "change" : "changes"} in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.`}
736
+ aria-describedby={state?.decisionLock ? "review-lock" : undefined}
737
+ onClick={() => void decide("accept", undecided)}
738
+ >
739
+ Accept all ({count.pending})
740
+ </button>
741
+ )}
742
+ {count.pending > 1 && (
743
+ <button
744
+ className="ghost reject reject-all"
745
+ disabled={busy || !latest || Boolean(composer) || Boolean(state?.decisionLock)}
746
+ title={state?.decisionLock || `Reject the ${count.pending} undecided ${count.pending === 1 ? "change" : "changes"} in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.`}
747
+ aria-describedby={state?.decisionLock ? "review-lock" : undefined}
748
+ onClick={() => void decide("reject", undecided)}
749
+ >
750
+ Reject all ({count.pending})
751
+ </button>
752
+ )}
753
+ {(!online || state?.closed || state?.state !== "reviewing") && (
754
+ <span
755
+ className="status"
756
+ role="status"
757
+ data-state={!online ? "offline" : state?.state}
758
+ >
759
+ {!online
760
+ ? "Reconnecting…"
761
+ : state?.closed
762
+ ? "Finished"
763
+ : state?.state === "building"
764
+ ? jobText(state.job)
765
+ : STATUS[state?.state ?? ""]}
766
+ </span>
767
+ )}
768
+ <button
769
+ className="ghost round"
770
+ aria-label="Keyboard shortcuts"
771
+ onClick={() => setHelp(true)}
772
+ >
773
+ ?
774
+ </button>
775
+ </div>
776
+ </header>
777
+ <div className="review-body">
778
+ <main id="paper" ref={paper} tabIndex={-1} onScroll={onScroll}>
779
+ {(state?.commentLock || state?.decisionLock) && (
780
+ <div className="banner" id="review-lock" role="status">
781
+ {state.commentLock || state.decisionLock}
782
+ </div>
783
+ )}
784
+ {state?.closed && !state.commentLock && (
785
+ <div className="banner">
786
+ This review is finished. Accepted changes are in your manuscript.
787
+ </div>
788
+ )}
789
+ {state?.state === "build_failed" && (
790
+ <div className="banner">
791
+ <strong>The latest publish failed: </strong>
792
+ {state.job?.error}
793
+ {state.job?.details && (
794
+ <pre className="details">{state.job.details.join("\n")}</pre>
795
+ )}
796
+ </div>
797
+ )}
798
+ {state?.state === "building" && detail && !state.decisionLock && (
799
+ <div className="banner">
800
+ The agent is publishing a new round. You can keep reviewing; the
801
+ new round will include your decisions.
802
+ </div>
803
+ )}
804
+ {detail && state?.latest && detail.round !== state.latest && (
805
+ <div className="banner">
806
+ You are viewing round {detail.round}.{" "}
807
+ <a href={`?task=${encodeURIComponent(task)}`}>
808
+ Go to round {state.latest} →
809
+ </a>
810
+ </div>
811
+ )}
812
+ {detail?.warnings.length ? (
813
+ <div className="banner">
814
+ <details>
815
+ <summary>{detail.warnings.length} new LaTeX warnings</summary>
816
+ <pre className="details">{detail.warnings.join("\n")}</pre>
817
+ </details>
818
+ </div>
819
+ ) : null}
820
+ {detail &&
821
+ count.total > 0 &&
822
+ !count.pending &&
823
+ state?.state !== "building" && (
824
+ <div className="banner">
825
+ Round {detail.round} is reviewed:{" "}
826
+ {(["accepted", "rejected", "commented"] as const)
827
+ .filter((k) => count[k])
828
+ .map((k) => `${count[k]} ${k}`)
829
+ .join(", ")}
830
+ .{" "}
831
+ {count.commented
832
+ ? "The agent will revise your comments."
833
+ : "The review remains available for inspection and undo."}
834
+ </div>
835
+ )}
836
+ <div className="document">
837
+ {detail ? (
838
+ <>
839
+ {detail.sections.map((section) => (
840
+ <section className="part" key={section.node}>
841
+ <h2 className="part-title">
842
+ <span className="number">{section.number}</span>
843
+ {section.title}
844
+ </h2>
845
+ <div className="sheet">
846
+ {section.blocks.map((block, b) =>
847
+ block.kind === "context" ? (
848
+ <div className="context" key={`context-${b}`}>
849
+ <PdfStrip
850
+ image={block.image}
851
+ base={detail.files}
852
+ label="Unchanged text"
853
+ />
854
+ </div>
855
+ ) : block.kind === "gap" ? (
856
+ <div
857
+ className="fold"
858
+ key={`gap-${b}`}
859
+ title="Unchanged text is folded"
860
+ >
861
+ ⋯
862
+ </div>
863
+ ) : (
864
+ change(block, index++, true)
865
+ ),
866
+ )}
867
+ </div>
868
+ </section>
869
+ ))}
870
+ {detail.loose.length > 0 && (
871
+ <section className="part">
872
+ <h2 className="part-title">
873
+ {detail.sections.length ? "Other changes" : "Changes"}
874
+ </h2>
875
+ {detail.loose.map((block) => change(block, index++, false))}
876
+ </section>
877
+ )}
878
+ {!stops.length && (
879
+ <p className="empty">This round has no changes.</p>
880
+ )}
881
+ </>
882
+ ) : error ? (
883
+ <p className="empty" role="alert">
884
+ {error}
885
+ </p>
886
+ ) : (
887
+ <div className="waiting">
888
+ <div className="pulse" />
889
+ <p>
890
+ {state?.state === "building"
891
+ ? jobText(state.job)
892
+ : "Waiting for the agent to publish its changes."}
893
+ </p>
894
+ <p className="muted">This page updates by itself.</p>
895
+ </div>
896
+ )}
897
+ </div>
898
+ </main>
899
+ <ChangeMap
900
+ scrollRoot={paper}
901
+ stops={stops}
902
+ locations={locations}
903
+ decisions={decisions}
904
+ current={current}
905
+ disabled={Boolean(composer)}
906
+ onSelect={select}
907
+ />
908
+ </div>
909
+ {composer && (
910
+ <form
911
+ className="composer"
912
+ onSubmit={(e) => {
913
+ e.preventDefault();
914
+ void submitComposer();
915
+ }}
916
+ >
917
+ <label htmlFor="composer-text">
918
+ Comment on change {composer.stop + 1}
919
+ </label>
920
+ <textarea
921
+ id="composer-text"
922
+ autoFocus
923
+ rows={4}
924
+ maxLength={20000}
925
+ value={draft}
926
+ onChange={(e) => {
927
+ setDraft(e.target.value);
928
+ store(composer.key, e.target.value);
929
+ }}
930
+ placeholder="What should the agent change here?"
931
+ />
932
+ <div className="composer-row">
933
+ <span className="muted">⌘↵ send · Esc cancel</span>
934
+ <button type="button" onClick={() => closeComposer()}>
935
+ Cancel
936
+ </button>
937
+ <button
938
+ type="submit"
939
+ title={state?.commentLock || undefined}
940
+ aria-describedby={state?.commentLock ? "review-lock" : undefined}
941
+ disabled={busy || !draft.trim() || Boolean(state?.commentLock)}
942
+ >
943
+ Send
944
+ </button>
945
+ </div>
946
+ </form>
947
+ )}
948
+ {toast && (
949
+ <div className="toast" role="alert">
950
+ {toast}
951
+ </div>
952
+ )}
953
+ {help && (
954
+ <div className="help-backdrop" onClick={() => setHelp(false)}>
955
+ <dialog
956
+ open
957
+ aria-labelledby="help-title"
958
+ onClick={(e) => e.stopPropagation()}
959
+ >
960
+ <h2 id="help-title">Keyboard shortcuts</h2>
961
+ <dl>
962
+ {[
963
+ ["A", "Accept"],
964
+ ["R", "Reject"],
965
+ ["C", "Comment"],
966
+ ["J / K or → / ←", "Next / previous change"],
967
+ ["N", "Next undecided"],
968
+ ["S", "Show LaTeX source"],
969
+ ["⌘Z / U", "Undo"],
970
+ ["⌘C / Ctrl+C", "Copy selected PDF text"],
971
+ ].map(([key, label]) => (
972
+ <Fragment key={key}>
973
+ <dt>
974
+ <kbd>{key}</kbd>
975
+ </dt>
976
+ <dd>{label}</dd>
977
+ </Fragment>
978
+ ))}
979
+ </dl>
980
+ <p className="muted">
981
+ Select text directly in the PDF preview. Copy reference includes
982
+ the selection and its file, line numbers and page. Review
983
+ shortcuts pause while text is selected.
984
+ </p>
985
+ <button onClick={() => setHelp(false)}>Close</button>
986
+ </dialog>
987
+ </div>
988
+ )}
989
+ </>
990
+ );
991
+ }