papilot 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +16 -4
  2. package/README.zh-CN.md +15 -3
  3. package/bin/doctor.mjs +239 -0
  4. package/bin/papilot.mjs +96 -87
  5. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/assets/index-CqhlgnOu.css +1 -0
  65. package/dist/assets/index-Czf1eCno.js +185 -0
  66. package/dist/assets/math-DEcVZfaU.css +1 -0
  67. package/dist/assets/math-Ebyrobf9.js +263 -0
  68. package/dist/assets/{pdf-view-DJo0X38w.js → pdf-view-BhJZr4qw.js} +18 -18
  69. package/dist/index.html +11 -2
  70. package/examples/papilot-paper/preamble.tex +17 -11
  71. package/examples/papilot-paper/sections/02-using.tex +3 -3
  72. package/package.json +12 -2
  73. package/scripts/service.ts +8 -2
  74. package/skills/check-math.md +12 -0
  75. package/skills/consistency.md +11 -0
  76. package/skills/latex-warnings.md +12 -0
  77. package/skills/polish.md +13 -0
  78. package/skills/proofread.md +12 -0
  79. package/skills/reviewer-response.md +11 -0
  80. package/skills/shorten.md +12 -0
  81. package/src/conversation.ts +37 -0
  82. package/src/notes.ts +5 -0
  83. package/src/server/agents.ts +4 -3
  84. package/src/server/codex.ts +2 -2
  85. package/src/server/main.ts +21 -7
  86. package/src/server/paths.ts +2 -1
  87. package/src/server/review.ts +1 -1
  88. package/src/server/service-environment.ts +22 -0
  89. package/src/server/skills.ts +57 -0
  90. package/src/server/workspace.ts +43 -19
  91. package/src/shared.ts +10 -2
  92. package/vendor/latex-review/dist/web/assets/index-Db7pq4Cj.css +1 -0
  93. package/vendor/latex-review/dist/web/assets/index-DqkrTw0t.js +19 -0
  94. package/vendor/latex-review/dist/web/index.html +2 -2
  95. package/vendor/latex-review/src/client.ts +10 -3
  96. package/vendor/latex-review/src/core/build.ts +53 -7
  97. package/vendor/latex-review/src/core/outline.ts +6 -3
  98. package/vendor/latex-review/src/core/publish.ts +8 -6
  99. package/vendor/latex-review/src/runtime.ts +14 -0
  100. package/vendor/latex-review/src/server.ts +2 -1
  101. package/vendor/latex-review/src/types.ts +16 -1
  102. package/vendor/latex-review/src/web/ChangeMap.tsx +21 -9
  103. package/vendor/latex-review/src/web/PdfStrip.tsx +5 -1
  104. package/vendor/latex-review/src/web/i18n.ts +48 -0
  105. package/vendor/latex-review/src/web/model.ts +23 -4
  106. package/vendor/latex-review/src/web/overview.tsx +25 -20
  107. package/vendor/latex-review/src/web/review.tsx +387 -143
  108. package/vendor/latex-review/src/web/style.css +106 -0
  109. package/vendor/latex-review/src/web/zh-CN.ts +186 -0
  110. package/dist/assets/index-BhQFCMTx.js +0 -131
  111. package/dist/assets/index-CAV3-87U.css +0 -1
  112. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +0 -1
  113. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +0 -19
@@ -18,7 +18,9 @@ import type {
18
18
  import { PdfStrip } from "./PdfStrip.tsx";
19
19
  import { ChangeMap } from "./ChangeMap.tsx";
20
20
  import { STATUS } from "./overview.tsx";
21
+ import { t, tn, withLanguage } from "./i18n.ts";
21
22
  import {
23
+ STATE,
22
24
  buildStops,
23
25
  copyReference,
24
26
  describeLocation,
@@ -37,6 +39,11 @@ const KIND = {
37
39
  identical: "No visible change",
38
40
  unmapped: "Source only",
39
41
  };
42
+ const ACTION: Record<string, string> = {
43
+ accept: "Accept",
44
+ reject: "Reject",
45
+ comment: "Comment",
46
+ };
40
47
  const PHASE: Record<string, string> = {
41
48
  snapshot: "Taking a snapshot",
42
49
  build: "Compiling LaTeX",
@@ -64,9 +71,13 @@ function store(key: string, value: unknown) {
64
71
  function jobText(job: PageState["job"]) {
65
72
  return job?.status === "queued"
66
73
  ? job.ahead
67
- ? `Queued behind ${job.ahead} other rounds…`
68
- : "Queued…"
69
- : `${PHASE[job?.phase ?? ""] || "Starting"}…`;
74
+ ? tn(
75
+ job.ahead,
76
+ "Queued behind {n} other round…",
77
+ "Queued behind {n} other rounds…",
78
+ )
79
+ : t("Queued…")
80
+ : `${t(PHASE[job?.phase ?? ""] || "Starting")}…`;
70
81
  }
71
82
  export function ReviewApp({
72
83
  task,
@@ -85,7 +96,10 @@ export function ReviewApp({
85
96
  [openSource, setOpenSource] = useState<Set<number>>(new Set()),
86
97
  [composer, setComposer] = useState<Composer | null>(null),
87
98
  [draft, setDraft] = useState(""),
88
- [help, setHelp] = useState(false);
99
+ [help, setHelp] = useState(false),
100
+ [history, setHistory] = useState(
101
+ Boolean(pinned) || new URLSearchParams(location.search).has("history"),
102
+ );
89
103
  const paper = useRef<HTMLElement>(null),
90
104
  epoch = useRef(0),
91
105
  scrollUntil = useRef(0),
@@ -101,6 +115,7 @@ export function ReviewApp({
101
115
  stops.map((stop) =>
102
116
  describeLocation(
103
117
  stop.units.map((id) => units.get(id)!).filter(Boolean),
118
+ t,
104
119
  ),
105
120
  ),
106
121
  [stops, units],
@@ -113,12 +128,34 @@ export function ReviewApp({
113
128
  latest = Boolean(
114
129
  detail && state && detail.round === state.latest && !state.closed,
115
130
  );
116
- const actionLock = (kind: ActionKind) =>
117
- kind === "comment" ? state?.commentLock : state?.decisionLock;
131
+ const navigation = stops
132
+ .map((stop, index) => ({ stop, index }))
133
+ .filter(({ stop }) => history || pendingUnits(stop, decisions).length)
134
+ .map(({ index }) => index);
135
+ const adjacent = (index: number, direction: number) =>
136
+ navigation[navigation.indexOf(index) + direction] ?? -1;
137
+ const undoChanges = state?.undo
138
+ ? stops.flatMap((stop, index) =>
139
+ stop.units.some((id) => state.undo!.changes.includes(id))
140
+ ? [index + 1]
141
+ : [],
142
+ )
143
+ : [];
144
+ useEffect(() => {
145
+ if (history || composer) return;
146
+ if (!navigation.includes(current)) setCurrent(navigation[0] ?? -1);
147
+ }, [decisions, history, composer, detail]);
148
+ // Lock reasons come from the host in English; known ones are translated.
149
+ const actionLock = (kind: ActionKind) => {
150
+ const reason =
151
+ kind === "comment" ? state?.commentLock : state?.decisionLock;
152
+ return reason ? t(reason) : reason;
153
+ };
118
154
  function blocked(kind: ActionKind): boolean {
119
155
  const current = snapshot.current.state;
120
- const reason = kind === "comment" ? current?.commentLock : current?.decisionLock;
121
- if (reason) setToast(reason);
156
+ const reason =
157
+ kind === "comment" ? current?.commentLock : current?.decisionLock;
158
+ if (reason) setToast(t(reason));
122
159
  return Boolean(reason);
123
160
  }
124
161
  async function api<T>(route: string, body?: unknown): Promise<T> {
@@ -207,12 +244,19 @@ export function ReviewApp({
207
244
  !pendingUnits(nextStops[index], loadedState.decisions).length
208
245
  )
209
246
  index = nextPending(nextStops, loadedState.decisions, index);
210
- if (index < 0) index = nextStops.length ? 0 : -1;
247
+ if (index < 0) index = pinned && nextStops.length ? 0 : -1;
248
+ if (
249
+ previous.detail &&
250
+ loaded.round !== previous.detail.round &&
251
+ !pinned
252
+ )
253
+ setHistory(false);
211
254
  setState(loadedState);
212
255
  setDetail(loaded);
213
256
  setCurrent(index);
214
257
  setOpenSource(new Set());
215
- if (previous.detail) setToast(`Round ${target} is ready`);
258
+ if (previous.detail)
259
+ setToast(t("Round {round} is ready", { round: target }));
216
260
  } else setState(next);
217
261
  } catch (e) {
218
262
  if (!cancelled) {
@@ -247,7 +291,14 @@ export function ReviewApp({
247
291
  return () => clearTimeout(timer);
248
292
  }, [toast]);
249
293
  function beginComposer(changes = pendingUnits(stops[current], decisions)) {
250
- if (!latest || !changes.length || busy || state?.closed || blocked("comment")) return;
294
+ if (
295
+ !latest ||
296
+ !changes.length ||
297
+ busy ||
298
+ state?.closed ||
299
+ blocked("comment")
300
+ )
301
+ return;
251
302
  const key = `latex-review:${task}:draft:${detail?.round}:${changes.join(",")}`;
252
303
  setDraft(readStorage(key, ""));
253
304
  setComposer({ changes, stop: current, key });
@@ -261,7 +312,14 @@ export function ReviewApp({
261
312
  changes = pendingUnits(stops[current], decisions),
262
313
  body = "",
263
314
  ) {
264
- if (!changes.length || snapshot.current.busy || !latest || !detail || blocked(kind)) return;
315
+ if (
316
+ !changes.length ||
317
+ snapshot.current.busy ||
318
+ !latest ||
319
+ !detail ||
320
+ blocked(kind)
321
+ )
322
+ return;
265
323
  epoch.current++;
266
324
  setBusy(true);
267
325
  snapshot.current.busy = true;
@@ -279,8 +337,10 @@ export function ReviewApp({
279
337
  ? current
280
338
  : nextPending(stops, next.decisions, current);
281
339
  if (target >= 0 && target !== current) select(target);
282
- if (target < 0)
283
- setToast("This round is reviewed. Your decisions are saved.");
340
+ if (target < 0) {
341
+ if (!history) setCurrent(-1);
342
+ setToast(t("All changes reviewed. Your decisions are saved."));
343
+ }
284
344
  } catch (e) {
285
345
  setToast((e as Error).message);
286
346
  } finally {
@@ -290,7 +350,8 @@ export function ReviewApp({
290
350
  }
291
351
  }
292
352
  async function undo() {
293
- if (!state?.undo || snapshot.current.busy || blocked("accept")) return;
353
+ if (!state?.undo || !latest || snapshot.current.busy || blocked("accept"))
354
+ return;
294
355
  epoch.current++;
295
356
  setBusy(true);
296
357
  snapshot.current.busy = true;
@@ -302,8 +363,11 @@ export function ReviewApp({
302
363
  const target = stops.findIndex((s) =>
303
364
  s.units.some((id) => next.restored.includes(id)),
304
365
  );
366
+ setHistory(false);
305
367
  if (target >= 0) select(target);
306
- setToast("Decision undone.");
368
+ setToast(
369
+ t("Last decision undone. The restored changes need review again."),
370
+ );
307
371
  } catch (e) {
308
372
  setToast((e as Error).message);
309
373
  } finally {
@@ -340,12 +404,14 @@ export function ReviewApp({
340
404
  );
341
405
  setToast(
342
406
  selected
343
- ? "Copied selected text with file, lines and PDF page."
344
- : "Copied the change location.",
407
+ ? t("Copied selected text with file, lines and PDF page.")
408
+ : t("Copied the change location."),
345
409
  );
346
410
  } catch (e) {
347
411
  setToast(
348
- "Clipboard unavailable. Select the PDF text and use your browser’s Copy command.",
412
+ t(
413
+ "Clipboard unavailable. Select the PDF text and use your browser’s Copy command.",
414
+ ),
349
415
  );
350
416
  }
351
417
  }
@@ -402,8 +468,10 @@ export function ReviewApp({
402
468
  if (key === "a") void decide("accept");
403
469
  else if (key === "r") void decide("reject");
404
470
  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);
471
+ else if (key === "j" || key === "arrowright")
472
+ select(adjacent(current, 1));
473
+ else if (key === "k" || key === "arrowleft")
474
+ select(adjacent(current, -1));
407
475
  else if (key === "n") select(nextPending(stops, decisions, current));
408
476
  else if (key === "s") toggleSource(current);
409
477
  else if (key === "u") void undo();
@@ -411,7 +479,7 @@ export function ReviewApp({
411
479
  };
412
480
  window.addEventListener("keydown", listener);
413
481
  return () => window.removeEventListener("keydown", listener);
414
- }, [state, detail, current, busy, composer, draft, help]);
482
+ }, [state, detail, current, busy, composer, draft, help, history]);
415
483
  function onScroll() {
416
484
  if (
417
485
  !paper.current ||
@@ -421,16 +489,16 @@ export function ReviewApp({
421
489
  )
422
490
  return;
423
491
  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
- });
492
+ regions = [...paper.current.querySelectorAll<HTMLElement>("[data-stop]")]
493
+ .filter((article) => navigation.includes(Number(article.dataset.stop)))
494
+ .map((article) => {
495
+ const b = article.getBoundingClientRect();
496
+ return {
497
+ index: Number(article.dataset.stop),
498
+ top: b.top,
499
+ bottom: b.bottom,
500
+ };
501
+ });
434
502
  const index = visibleStop(regions, current, box.top + 60, box.bottom - 100);
435
503
  if (index !== current) select(index, false);
436
504
  }
@@ -440,21 +508,21 @@ export function ReviewApp({
440
508
  remaining = nextPending(stops, decisions);
441
509
  return (
442
510
  <div className="dock">
443
- <nav className="actions" aria-label="Review the current change">
444
- {stops.length > 1 && (
511
+ <nav className="actions" aria-label={t("Review the current change")}>
512
+ {navigation.length > 1 && (
445
513
  <>
446
514
  <button
447
515
  className="nav"
448
- aria-label="Previous change"
516
+ aria-label={t("Previous change")}
449
517
  aria-keyshortcuts="k ArrowLeft"
450
- title="Previous change (K or ←)"
451
- disabled={index === 0}
452
- onClick={() => select(index - 1)}
518
+ title={t("Previous change (K or ←)")}
519
+ disabled={adjacent(index, -1) < 0}
520
+ onClick={() => select(adjacent(index, -1))}
453
521
  >
454
522
  ‹
455
523
  </button>
456
524
  <span className="position">
457
- {index + 1} / {stops.length}
525
+ {navigation.indexOf(index) + 1} / {navigation.length}
458
526
  </span>
459
527
  </>
460
528
  )}
@@ -465,14 +533,19 @@ export function ReviewApp({
465
533
  className={kind}
466
534
  key={kind}
467
535
  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}
536
+ title={
537
+ actionLock(kind) ||
538
+ `${t(ACTION[kind])} (${kind[0].toUpperCase()})`
539
+ }
540
+ aria-describedby={
541
+ actionLock(kind) ? "review-lock" : undefined
542
+ }
470
543
  disabled={busy || !latest || Boolean(actionLock(kind))}
471
544
  onClick={() =>
472
545
  kind === "comment" ? beginComposer() : void decide(kind)
473
546
  }
474
547
  >
475
- {kind[0].toUpperCase() + kind.slice(1)}
548
+ {t(ACTION[kind])}
476
549
  {pending.length > 1 && kind !== "comment"
477
550
  ? ` (${pending.length})`
478
551
  : ""}
@@ -485,34 +558,25 @@ export function ReviewApp({
485
558
  aria-keyshortcuts="n"
486
559
  onClick={() => select(nextPending(stops, decisions, current))}
487
560
  >
488
- Next undecided
561
+ {t("Next undecided")}
489
562
  </button>
490
563
  ) : (
491
564
  <span className="complete">
492
- {latest ? "Review complete" : "Earlier round"}
565
+ {latest ? t("Review complete") : t("Earlier round")}
493
566
  </span>
494
567
  )}
495
- {stops.length > 1 && (
568
+ {navigation.length > 1 && (
496
569
  <button
497
570
  className="nav"
498
- aria-label="Next change"
571
+ aria-label={t("Next change")}
499
572
  aria-keyshortcuts="j ArrowRight"
500
- title="Next change (J or →)"
501
- disabled={index === stops.length - 1}
502
- onClick={() => select(index + 1)}
573
+ title={t("Next change (J or →)")}
574
+ disabled={adjacent(index, 1) < 0}
575
+ onClick={() => select(adjacent(index, 1))}
503
576
  >
504
577
  ›
505
578
  </button>
506
579
  )}
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
580
  </nav>
517
581
  </div>
518
582
  );
@@ -523,8 +587,12 @@ export function ReviewApp({
523
587
  inline: boolean,
524
588
  ): ReactNode {
525
589
  const known = block.units.map((id) => units.get(id)!).filter(Boolean),
526
- resolved = resolvedSide(block.units, decisions),
527
590
  status = stopState(block.units, decisions),
591
+ reviewed = !pendingUnits(stops[index], decisions).length,
592
+ resolved = history
593
+ ? undefined
594
+ : resolvedSide(block.units, decisions) ||
595
+ (status === "commented" ? "after" : undefined),
528
596
  source =
529
597
  openSource.has(index) ||
530
598
  block.identical ||
@@ -534,24 +602,24 @@ export function ReviewApp({
534
602
  block.units.map((id) => state?.comments[id]).filter(Boolean),
535
603
  ),
536
604
  ],
537
- location = describeLocation(known),
605
+ location = describeLocation(known, t),
538
606
  kindLabel = block.reflow
539
- ? "Reflow"
607
+ ? t("Reflow")
540
608
  : [...new Set(known.map((u) => u.visual ?? "unmapped"))]
541
609
  .filter((kind) => kind !== "text")
542
- .map((kind) => KIND[kind])
610
+ .map((kind) => t(KIND[kind]))
543
611
  .join(", ");
544
612
  const images = (items: Strip[], side: "before" | "after") => (
545
613
  <div
546
614
  className={`side ${side}${block.identical ? " identical" : ""}`}
547
- aria-label={side === "before" ? "Before" : "After"}
615
+ aria-label={side === "before" ? t("Before") : t("After")}
548
616
  >
549
617
  {items.map((image) => (
550
618
  <PdfStrip
551
619
  key={image.src}
552
620
  image={image}
553
621
  base={detail!.files}
554
- label={side === "before" ? "Before" : "After"}
622
+ label={side === "before" ? t("Before") : t("After")}
555
623
  clean={Boolean(resolved)}
556
624
  />
557
625
  ))}
@@ -560,42 +628,66 @@ export function ReviewApp({
560
628
  return (
561
629
  <article
562
630
  key={index}
563
- className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}`}
631
+ className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}${reviewed && !history ? " reviewed-change" : ""}${reviewed && !history && !resolved ? " mixed-reviewed" : ""}`}
564
632
  data-stop={index}
565
633
  data-state={status}
566
634
  data-resolved={resolved}
567
635
  tabIndex={-1}
568
636
  onClick={() => {
569
- if (index !== current && !composer) select(index, false);
637
+ if (index !== current && !composer && (history || !reviewed))
638
+ select(index, false);
570
639
  }}
571
640
  >
641
+ {reviewed && !history && !resolved && (
642
+ <p className="reviewed-placeholder">
643
+ <button
644
+ className="link"
645
+ onClick={() => {
646
+ setHistory(true);
647
+ select(index, false);
648
+ }}
649
+ >
650
+ {t("Reviewed changes · View in History")}
651
+ </button>
652
+ </p>
653
+ )}
572
654
  <header className="change-head">
573
- <span className="change-number">Change {index + 1}</span>
574
- <span className="change-where" title={location}>
655
+ {/* The English number and location are kept for tools that quote
656
+ them to an agent, whatever the interface language. */}
657
+ <span className="change-number" data-change={index + 1}>
658
+ {t("Change {n}", { n: index + 1 })}
659
+ </span>
660
+ <span
661
+ className="change-where"
662
+ title={location}
663
+ data-location={describeLocation(known)}
664
+ >
575
665
  {location}
576
666
  </span>
577
667
  {actionBar(index)}
578
668
  {kindLabel && <span className="change-kind">{kindLabel}</span>}
579
669
  {status !== "pending" && (
580
670
  <span className="badge">
581
- {status[0].toUpperCase() + status.slice(1)}
671
+ {t(STATE[status] ?? status)}
582
672
  </span>
583
673
  )}
584
674
  <button
585
675
  className="link copy-reference"
586
- title="Copy selected text with this change’s file, line numbers and PDF page"
676
+ title={t(
677
+ "Copy selected text with this change’s file, line numbers and PDF page",
678
+ )}
587
679
  onPointerDown={(e) => e.preventDefault()}
588
680
  onClick={() => void copy(index)}
589
681
  >
590
- Copy reference
682
+ {t("Copy reference")}
591
683
  </button>
592
684
  <button
593
685
  className={`link ${source ? "active" : ""}`}
594
- title="Show the LaTeX source (S)"
686
+ title={t("Show the LaTeX source (S)")}
595
687
  onClick={() => toggleSource(index)}
596
688
  >
597
689
  {block.units.length > 1
598
- ? `${block.units.length} edits · LaTeX`
690
+ ? t("{n} edits · LaTeX", { n: block.units.length })
599
691
  : "LaTeX"}
600
692
  </button>
601
693
  </header>
@@ -606,7 +698,9 @@ export function ReviewApp({
606
698
  .map((h, i) => (
607
699
  <p key={i}>
608
700
  <strong>
609
- Revised after your comment in round {h.round}:{" "}
701
+ {t("Revised after your comment in round {round}: ", {
702
+ round: h.round,
703
+ })}
610
704
  </strong>
611
705
  “{h.comment}”
612
706
  </p>
@@ -620,17 +714,19 @@ export function ReviewApp({
620
714
  {block.after.length > 0 && images(block.after, "after")}
621
715
  {block.identical && (
622
716
  <p className="caption">
623
- No visible change in the PDF; see the LaTeX source.
717
+ {t("No visible change in the PDF; see the LaTeX source.")}
624
718
  </p>
625
719
  )}
626
720
  {block.reflow && (
627
- <p className="caption">Continued: only line breaks moved here.</p>
721
+ <p className="caption">
722
+ {t("Continued: only line breaks moved here.")}
723
+ </p>
628
724
  )}
629
725
  {remarks.length > 0 && (
630
726
  <div className="remark">
631
727
  {remarks.map((r, i) => (
632
728
  <p key={i}>
633
- <strong>Your comment: </strong>
729
+ <strong>{t("Your comment: ")}</strong>
634
730
  {r}
635
731
  </p>
636
732
  ))}
@@ -646,13 +742,17 @@ export function ReviewApp({
646
742
  >
647
743
  <div className="source-title">
648
744
  <span>
649
- {unit.file} · lines {unit.new_start}–{unit.new_end}
745
+ {unit.file} ·{" "}
746
+ {t("lines {start}–{end}", {
747
+ start: unit.new_start,
748
+ end: unit.new_end,
749
+ })}
650
750
  </span>
651
751
  {known.length > 1 && (
652
752
  <span className="unit-actions">
653
753
  <span className="unit-state">
654
754
  {decisions[unit.id] !== "pending"
655
- ? decisions[unit.id]
755
+ ? t(STATE[decisions[unit.id]]).toLowerCase()
656
756
  : ""}
657
757
  </span>
658
758
  {decisions[unit.id] === "pending" &&
@@ -662,15 +762,19 @@ export function ReviewApp({
662
762
  className={kind}
663
763
  key={kind}
664
764
  title={actionLock(kind) || undefined}
665
- aria-describedby={actionLock(kind) ? "review-lock" : undefined}
666
- disabled={busy || !latest || Boolean(actionLock(kind))}
765
+ aria-describedby={
766
+ actionLock(kind) ? "review-lock" : undefined
767
+ }
768
+ disabled={
769
+ busy || !latest || Boolean(actionLock(kind))
770
+ }
667
771
  onClick={() =>
668
772
  kind === "comment"
669
773
  ? beginComposer([unit.id])
670
774
  : void decide(kind, [unit.id])
671
775
  }
672
776
  >
673
- {kind[0].toUpperCase() + kind.slice(1)}
777
+ {t(ACTION[kind])}
674
778
  </button>
675
779
  ),
676
780
  )}
@@ -678,7 +782,7 @@ export function ReviewApp({
678
782
  )}
679
783
  </div>
680
784
  {unit.kind === "binary" ? (
681
- <p>Binary file changed.</p>
785
+ <p>{t("Binary file changed.")}</p>
682
786
  ) : (
683
787
  <pre className="source-code">
684
788
  {wordDiff(unit.old_text, unit.new_text).map((part, i) =>
@@ -693,7 +797,9 @@ export function ReviewApp({
693
797
  </pre>
694
798
  )}
695
799
  {unit.truncated && (
696
- <p className="muted">Long change: the source is shortened.</p>
800
+ <p className="muted">
801
+ {t("Long change: the source is shortened.")}
802
+ </p>
697
803
  )}
698
804
  </div>
699
805
  ))}
@@ -706,21 +812,25 @@ export function ReviewApp({
706
812
  return (
707
813
  <>
708
814
  <header className="bar">
709
- <a className="ghost overview-link" href="/">
710
- ← All reviews
815
+ <a className="ghost overview-link" href={withLanguage("/")}>
816
+ ← {t("All reviews")}
711
817
  </a>
712
818
  <div className="identity">
713
819
  <h1>{state?.title ?? "LaTeX Review"}</h1>
714
820
  <span className="muted">
715
821
  {detail
716
- ? `Round ${detail.round}${detail.round !== state?.latest ? " (earlier round)" : ""}`
822
+ ? t("Round {round}", { round: detail.round }) +
823
+ (detail.round !== state?.latest ? t(" (earlier round)") : "")
717
824
  : ""}
718
825
  </span>
719
826
  </div>
720
827
  {count.total > 0 && (
721
828
  <div className="meter">
722
829
  <span>
723
- {count.decided} of {count.total} decided
830
+ {t("{decided} of {total} decided", {
831
+ decided: count.decided,
832
+ total: count.total,
833
+ })}
724
834
  </span>
725
835
  </div>
726
836
  )}
@@ -731,64 +841,169 @@ export function ReviewApp({
731
841
  {count.pending > 1 && (
732
842
  <button
733
843
  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.`}
844
+ disabled={
845
+ busy ||
846
+ !latest ||
847
+ Boolean(composer) ||
848
+ Boolean(state?.decisionLock)
849
+ }
850
+ title={
851
+ state?.decisionLock ||
852
+ tn(
853
+ count.pending,
854
+ "Accept the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
855
+ "Accept the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
856
+ )
857
+ }
736
858
  aria-describedby={state?.decisionLock ? "review-lock" : undefined}
737
859
  onClick={() => void decide("accept", undecided)}
738
860
  >
739
- Accept all ({count.pending})
861
+ {t("Accept all ({n})", { n: count.pending })}
740
862
  </button>
741
863
  )}
742
864
  {count.pending > 1 && (
743
865
  <button
744
866
  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.`}
867
+ disabled={
868
+ busy ||
869
+ !latest ||
870
+ Boolean(composer) ||
871
+ Boolean(state?.decisionLock)
872
+ }
873
+ title={
874
+ state?.decisionLock ||
875
+ tn(
876
+ count.pending,
877
+ "Reject the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
878
+ "Reject the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
879
+ )
880
+ }
747
881
  aria-describedby={state?.decisionLock ? "review-lock" : undefined}
748
882
  onClick={() => void decide("reject", undecided)}
749
883
  >
750
- Reject all ({count.pending})
884
+ {t("Reject all ({n})", { n: count.pending })}
751
885
  </button>
752
886
  )}
753
- {(!online || state?.closed || state?.state !== "reviewing") && (
887
+ {/* "All decided" would repeat the meter's "N of N decided". */}
888
+ {(!online ||
889
+ state?.closed ||
890
+ !["reviewing", "done"].includes(state?.state ?? "")) && (
754
891
  <span
755
892
  className="status"
756
893
  role="status"
757
894
  data-state={!online ? "offline" : state?.state}
758
895
  >
759
896
  {!online
760
- ? "Reconnecting…"
897
+ ? t("Reconnecting…")
761
898
  : state?.closed
762
- ? "Finished"
899
+ ? t("Finished")
763
900
  : state?.state === "building"
764
901
  ? jobText(state.job)
765
- : STATUS[state?.state ?? ""]}
902
+ : t(STATUS[state?.state ?? ""] ?? "")}
766
903
  </span>
767
904
  )}
905
+ {/* History is a toolbar action; its own row appears only while
906
+ browsing earlier decisions. */}
907
+ {detail && !history && (
908
+ <button
909
+ className="ghost history"
910
+ title={t("Earlier decisions and rounds")}
911
+ onClick={() => {
912
+ setHistory(true);
913
+ if (current < 0 && stops.length) setCurrent(0);
914
+ }}
915
+ >
916
+ {t("History")}
917
+ </button>
918
+ )}
768
919
  <button
769
920
  className="ghost round"
770
- aria-label="Keyboard shortcuts"
921
+ aria-label={t("Keyboard shortcuts")}
771
922
  onClick={() => setHelp(true)}
772
923
  >
773
924
  ?
774
925
  </button>
775
926
  </div>
776
927
  </header>
928
+ {detail && history && (
929
+ <nav className="review-mode" aria-label={t("Review view")}>
930
+ <label>
931
+ {t("History")}{" "}
932
+ <select
933
+ aria-label={t("Review round")}
934
+ value={detail.round}
935
+ onChange={(e) => {
936
+ location.href = withLanguage(
937
+ `?task=${encodeURIComponent(task)}&round=${e.target.value}&history=1`,
938
+ );
939
+ }}
940
+ >
941
+ {Array.from(
942
+ { length: state?.latest || detail.round },
943
+ (_, i) => i + 1,
944
+ )
945
+ .reverse()
946
+ .map((round) => (
947
+ <option key={round} value={round}>
948
+ {t("Round {round}", { round })}
949
+ {round === state?.latest ? t(" (latest)") : ""}
950
+ </option>
951
+ ))}
952
+ </select>
953
+ </label>
954
+ {state?.undo && (
955
+ <button
956
+ className="ghost undo"
957
+ disabled={!latest || busy || Boolean(state.decisionLock)}
958
+ title={
959
+ state.decisionLock ||
960
+ t("Undo {action} · {changes}", {
961
+ action: t(ACTION[state.undo.kind] ?? state.undo.kind).toLowerCase(),
962
+ changes: tn(
963
+ undoChanges.length,
964
+ "Change {list}",
965
+ "Changes {list}",
966
+ { list: undoChanges.join(", ") },
967
+ ),
968
+ })
969
+ }
970
+ onClick={() => void undo()}
971
+ >
972
+ {t("Undo last decision")}
973
+ </button>
974
+ )}
975
+ <button
976
+ className="ghost"
977
+ onClick={() => {
978
+ if (pinned)
979
+ location.href = withLanguage(`?task=${encodeURIComponent(task)}`);
980
+ else {
981
+ setHistory(false);
982
+ setCurrent(nextPending(stops, decisions));
983
+ }
984
+ }}
985
+ >
986
+ {t("Back to review")}
987
+ </button>
988
+ </nav>
989
+ )}
777
990
  <div className="review-body">
778
991
  <main id="paper" ref={paper} tabIndex={-1} onScroll={onScroll}>
779
992
  {(state?.commentLock || state?.decisionLock) && (
780
993
  <div className="banner" id="review-lock" role="status">
781
- {state.commentLock || state.decisionLock}
994
+ {t((state.commentLock || state.decisionLock)!)}
782
995
  </div>
783
996
  )}
784
997
  {state?.closed && !state.commentLock && (
785
998
  <div className="banner">
786
- This review is finished. Accepted changes are in your manuscript.
999
+ {t(
1000
+ "This review is finished. Accepted changes are in your manuscript.",
1001
+ )}
787
1002
  </div>
788
1003
  )}
789
1004
  {state?.state === "build_failed" && (
790
1005
  <div className="banner">
791
- <strong>The latest publish failed: </strong>
1006
+ <strong>{t("The latest publish failed: ")}</strong>
792
1007
  {state.job?.error}
793
1008
  {state.job?.details && (
794
1009
  <pre className="details">{state.job.details.join("\n")}</pre>
@@ -797,42 +1012,70 @@ export function ReviewApp({
797
1012
  )}
798
1013
  {state?.state === "building" && detail && !state.decisionLock && (
799
1014
  <div className="banner">
800
- The agent is publishing a new round. You can keep reviewing; the
801
- new round will include your decisions.
1015
+ {t(
1016
+ "The agent is publishing a new round. You can keep reviewing; the new round will include your decisions.",
1017
+ )}
802
1018
  </div>
803
1019
  )}
804
1020
  {detail && state?.latest && detail.round !== state.latest && (
805
1021
  <div className="banner">
806
- You are viewing round {detail.round}.{" "}
807
- <a href={`?task=${encodeURIComponent(task)}`}>
808
- Go to round {state.latest} →
1022
+ {t("You are viewing round {round}.", { round: detail.round })}{" "}
1023
+ <a href={withLanguage(`?task=${encodeURIComponent(task)}`)}>
1024
+ {t("Go to round {round} →", { round: state.latest })}
809
1025
  </a>
810
1026
  </div>
811
1027
  )}
812
1028
  {detail?.warnings.length ? (
813
1029
  <div className="banner">
814
1030
  <details>
815
- <summary>{detail.warnings.length} new LaTeX warnings</summary>
1031
+ <summary>
1032
+ {tn(
1033
+ detail.warnings.length,
1034
+ "{n} new LaTeX warning",
1035
+ "{n} new LaTeX warnings",
1036
+ )}
1037
+ </summary>
816
1038
  <pre className="details">{detail.warnings.join("\n")}</pre>
817
1039
  </details>
818
1040
  </div>
819
1041
  ) : 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
- )}
1042
+ {detail && !history && !count.pending && (
1043
+ <div className="review-complete" role="status">
1044
+ <strong>
1045
+ {t(
1046
+ state?.workspace?.status === "waiting_input"
1047
+ ? "Agent needs your input"
1048
+ : state?.workspace?.status === "queued"
1049
+ ? "Feedback queued"
1050
+ : state?.workspace?.status === "running"
1051
+ ? "Feedback sent · Agent is working"
1052
+ : state?.workspace?.status === "building" ||
1053
+ state?.state === "building"
1054
+ ? "Preparing the next review…"
1055
+ : "All changes reviewed",
1056
+ )}
1057
+ </strong>
1058
+ <span>
1059
+ {t(
1060
+ state?.workspace?.status === "waiting_input"
1061
+ ? "Open Chat to answer the agent's question."
1062
+ : ["running", "queued", "building"].includes(
1063
+ state?.workspace?.status || "",
1064
+ ) || state?.state === "building"
1065
+ ? "New changes will appear here when ready."
1066
+ : state?.workspace?.comments
1067
+ ? "Your comments are saved. Send feedback so the agent can revise."
1068
+ : state?.workspace?.accepted
1069
+ ? "Accepted changes are in your manuscript."
1070
+ : state?.workspace?.unsent && !count.accepted
1071
+ ? "Your decisions are saved. Send them to the agent to continue."
1072
+ : count.commented && !state?.workspace
1073
+ ? "Your comments are saved for the agent."
1074
+ : "There are no new changes to review. Previous decisions are available in History.",
1075
+ )}
1076
+ </span>
1077
+ </div>
1078
+ )}
836
1079
  <div className="document">
837
1080
  {detail ? (
838
1081
  <>
@@ -849,14 +1092,14 @@ export function ReviewApp({
849
1092
  <PdfStrip
850
1093
  image={block.image}
851
1094
  base={detail.files}
852
- label="Unchanged text"
1095
+ label={t("Unchanged text")}
853
1096
  />
854
1097
  </div>
855
1098
  ) : block.kind === "gap" ? (
856
1099
  <div
857
1100
  className="fold"
858
1101
  key={`gap-${b}`}
859
- title="Unchanged text is folded"
1102
+ title={t("Unchanged text is folded")}
860
1103
  >
861
1104
  ⋯
862
1105
  </div>
@@ -870,13 +1113,13 @@ export function ReviewApp({
870
1113
  {detail.loose.length > 0 && (
871
1114
  <section className="part">
872
1115
  <h2 className="part-title">
873
- {detail.sections.length ? "Other changes" : "Changes"}
1116
+ {detail.sections.length ? t("Other changes") : t("Changes")}
874
1117
  </h2>
875
1118
  {detail.loose.map((block) => change(block, index++, false))}
876
1119
  </section>
877
1120
  )}
878
1121
  {!stops.length && (
879
- <p className="empty">This round has no changes.</p>
1122
+ <p className="empty">{t("This round has no changes.")}</p>
880
1123
  )}
881
1124
  </>
882
1125
  ) : error ? (
@@ -889,9 +1132,9 @@ export function ReviewApp({
889
1132
  <p>
890
1133
  {state?.state === "building"
891
1134
  ? jobText(state.job)
892
- : "Waiting for the agent to publish its changes."}
1135
+ : t("Waiting for the agent to publish its changes.")}
893
1136
  </p>
894
- <p className="muted">This page updates by itself.</p>
1137
+ <p className="muted">{t("This page updates by itself.")}</p>
895
1138
  </div>
896
1139
  )}
897
1140
  </div>
@@ -899,6 +1142,7 @@ export function ReviewApp({
899
1142
  <ChangeMap
900
1143
  scrollRoot={paper}
901
1144
  stops={stops}
1145
+ showReviewed={history}
902
1146
  locations={locations}
903
1147
  decisions={decisions}
904
1148
  current={current}
@@ -915,7 +1159,7 @@ export function ReviewApp({
915
1159
  }}
916
1160
  >
917
1161
  <label htmlFor="composer-text">
918
- Comment on change {composer.stop + 1}
1162
+ {t("Comment on change {n}", { n: composer.stop + 1 })}
919
1163
  </label>
920
1164
  <textarea
921
1165
  id="composer-text"
@@ -927,20 +1171,20 @@ export function ReviewApp({
927
1171
  setDraft(e.target.value);
928
1172
  store(composer.key, e.target.value);
929
1173
  }}
930
- placeholder="What should the agent change here?"
1174
+ placeholder={t("What should the agent change here?")}
931
1175
  />
932
1176
  <div className="composer-row">
933
- <span className="muted">⌘↵ send · Esc cancel</span>
1177
+ <span className="muted">{t("⌘↵ send · Esc cancel")}</span>
934
1178
  <button type="button" onClick={() => closeComposer()}>
935
- Cancel
1179
+ {t("Cancel")}
936
1180
  </button>
937
1181
  <button
938
1182
  type="submit"
939
- title={state?.commentLock || undefined}
1183
+ title={state?.commentLock ? t(state.commentLock) : undefined}
940
1184
  aria-describedby={state?.commentLock ? "review-lock" : undefined}
941
1185
  disabled={busy || !draft.trim() || Boolean(state?.commentLock)}
942
1186
  >
943
- Send
1187
+ {t("Send")}
944
1188
  </button>
945
1189
  </div>
946
1190
  </form>
@@ -957,7 +1201,7 @@ export function ReviewApp({
957
1201
  aria-labelledby="help-title"
958
1202
  onClick={(e) => e.stopPropagation()}
959
1203
  >
960
- <h2 id="help-title">Keyboard shortcuts</h2>
1204
+ <h2 id="help-title">{t("Keyboard shortcuts")}</h2>
961
1205
  <dl>
962
1206
  {[
963
1207
  ["A", "Accept"],
@@ -971,18 +1215,18 @@ export function ReviewApp({
971
1215
  ].map(([key, label]) => (
972
1216
  <Fragment key={key}>
973
1217
  <dt>
974
- <kbd>{key}</kbd>
1218
+ <kbd>{t(key)}</kbd>
975
1219
  </dt>
976
- <dd>{label}</dd>
1220
+ <dd>{t(label)}</dd>
977
1221
  </Fragment>
978
1222
  ))}
979
1223
  </dl>
980
1224
  <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.
1225
+ {t(
1226
+ "Select text directly in the PDF preview. Copy reference includes the selection and its file, line numbers and page. Review shortcuts pause while text is selected.",
1227
+ )}
984
1228
  </p>
985
- <button onClick={() => setHelp(false)}>Close</button>
1229
+ <button onClick={() => setHelp(false)}>{t("Close")}</button>
986
1230
  </dialog>
987
1231
  </div>
988
1232
  )}