papilot 0.6.0 → 0.8.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 (85) hide show
  1. package/README.md +58 -30
  2. package/README.zh-CN.md +58 -30
  3. package/bin/doctor.mjs +53 -6
  4. package/bin/installation.d.mts +8 -0
  5. package/bin/installation.mjs +54 -0
  6. package/bin/papilot.mjs +6 -2
  7. package/dist/assets/_virtual_papilot-licenses-D7ro6ecN.js +7597 -0
  8. package/dist/assets/index-Cc-awdNn.css +1 -0
  9. package/dist/assets/index-D_VX51no.js +265 -0
  10. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  11. package/dist/assets/{math-DY-H5SfX.js → math-V9uUaUzA.js} +1 -1
  12. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  13. package/dist/index.html +2 -2
  14. package/examples/papilot-paper/main.tex +1 -1
  15. package/examples/papilot-paper/sections/02-using.tex +3 -1
  16. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  17. package/package.json +15 -7
  18. package/scripts/service.ts +1 -0
  19. package/skills/ai-review.md +18 -0
  20. package/skills/latex-warnings.md +1 -1
  21. package/src/conversation.ts +6 -0
  22. package/src/note-groups.ts +30 -0
  23. package/src/notes.ts +34 -10
  24. package/src/server/acp.ts +354 -0
  25. package/src/server/agents.ts +237 -47
  26. package/src/server/ai-review-routes.ts +72 -0
  27. package/src/server/ai-review.ts +38 -0
  28. package/src/server/annotation-placement.ts +99 -0
  29. package/src/server/app.ts +216 -21
  30. package/src/server/attachments.ts +128 -0
  31. package/src/server/build-problems.ts +329 -0
  32. package/src/server/changes.ts +28 -2
  33. package/src/server/codex.ts +2 -1
  34. package/src/server/cursor.ts +153 -0
  35. package/src/server/diagnostics.ts +45 -0
  36. package/src/server/git-overview.ts +69 -0
  37. package/src/server/imports.ts +205 -0
  38. package/src/server/latex-installation.ts +43 -0
  39. package/src/server/main.ts +8 -42
  40. package/src/server/manuscript.ts +212 -12
  41. package/src/server/note-results.ts +65 -0
  42. package/src/server/paths.ts +1 -1
  43. package/src/server/references.ts +70 -0
  44. package/src/server/review.ts +6 -5
  45. package/src/server/skill-library.ts +113 -0
  46. package/src/server/skill-routes.ts +19 -0
  47. package/src/server/skills.ts +29 -19
  48. package/src/server/start.ts +51 -0
  49. package/src/server/store.ts +44 -4
  50. package/src/server/update-install.ts +124 -0
  51. package/src/server/update-worker.ts +78 -0
  52. package/src/server/updates.ts +152 -0
  53. package/src/server/workspace.ts +450 -60
  54. package/src/shared.ts +120 -10
  55. package/src/skill-icons.ts +18 -0
  56. package/src/skill-metadata.ts +45 -0
  57. package/src/task-caption.ts +20 -0
  58. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  59. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  60. package/vendor/latex-review/dist/web/index.html +2 -2
  61. package/vendor/latex-review/src/core/bib.ts +692 -0
  62. package/vendor/latex-review/src/core/build.ts +5 -1
  63. package/vendor/latex-review/src/core/changes.ts +39 -0
  64. package/vendor/latex-review/src/core/delivery.ts +109 -0
  65. package/vendor/latex-review/src/core/review.ts +12 -4
  66. package/vendor/latex-review/src/core/store.ts +3 -0
  67. package/vendor/latex-review/src/core/tasks.ts +5 -2
  68. package/vendor/latex-review/src/core/verify.ts +784 -0
  69. package/vendor/latex-review/src/core/view.ts +204 -0
  70. package/vendor/latex-review/src/render/round.ts +11 -0
  71. package/vendor/latex-review/src/server.ts +81 -1
  72. package/vendor/latex-review/src/types.ts +141 -0
  73. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  74. package/vendor/latex-review/src/web/i18n.ts +1 -1
  75. package/vendor/latex-review/src/web/overview.tsx +1 -5
  76. package/vendor/latex-review/src/web/references.tsx +499 -0
  77. package/vendor/latex-review/src/web/review.tsx +381 -230
  78. package/vendor/latex-review/src/web/shortcuts.ts +53 -0
  79. package/vendor/latex-review/src/web/style.css +295 -0
  80. package/vendor/latex-review/src/web/zh-CN.ts +95 -26
  81. package/dist/assets/index-BNGqA0_L.css +0 -1
  82. package/dist/assets/index-Bt9UXsGX.js +0 -222
  83. package/dist/assets/pdf-view-BGp3W-RG.js +0 -598
  84. package/vendor/latex-review/dist/web/assets/index-AFBj4gQ9.js +0 -19
  85. package/vendor/latex-review/dist/web/assets/index-DfC0GURl.css +0 -1
@@ -18,8 +18,17 @@ import type {
18
18
  } from "../types.ts";
19
19
  import { PdfStrip } from "./PdfStrip.tsx";
20
20
  import { ChangeMap } from "./ChangeMap.tsx";
21
+ import { EFFECTS_EVENT, EFFECTS_STORAGE, readExpandedEffects } from "./effect-preferences.ts";
21
22
  import { STATUS } from "./overview.tsx";
22
- import { t, tn, withLanguage } from "./i18n.ts";
23
+ import { language, locale, t, tn, withLanguage } from "./i18n.ts";
24
+ import {
25
+ ConfirmReferences,
26
+ ReferenceBanner,
27
+ ReferenceEntries,
28
+ risks,
29
+ useReferenceChecks,
30
+ } from "./references.tsx";
31
+ import { bindingLabel, readShortcuts, resolveShortcuts, shortcutAction, SHORTCUT_EVENT, SHORTCUT_STORAGE } from "./shortcuts.ts";
23
32
  import {
24
33
  STATE,
25
34
  buildStops,
@@ -91,12 +100,29 @@ function jobText(job: PageState["job"]) {
91
100
  ? job.ahead
92
101
  ? tn(
93
102
  job.ahead,
94
- "Queued behind {n} other round…",
95
- "Queued behind {n} other rounds…",
103
+ "Queued behind {n} update…",
104
+ "Queued behind {n} updates…",
96
105
  )
97
106
  : t("Queued…")
98
107
  : `${t(PHASE[job?.phase ?? ""] || "Starting")}…`;
99
108
  }
109
+
110
+ function ReviewProgress({ value, total, label }: { value: number; total: number; label: string }) {
111
+ if (total === 0) return null;
112
+ return (
113
+ <div
114
+ className="meter review-progress"
115
+ role="progressbar"
116
+ aria-label={label}
117
+ aria-valuemin={0}
118
+ aria-valuemax={total}
119
+ aria-valuenow={value}
120
+ title={label}
121
+ >
122
+ <span aria-hidden="true"><span className="review-progress-value">{value}</span> / {total}</span>
123
+ </div>
124
+ );
125
+ }
100
126
  export function ReviewApp({
101
127
  task,
102
128
  pinned,
@@ -119,15 +145,50 @@ export function ReviewApp({
119
145
  Record<string, PlaceView[] | "loading" | { error: string }>
120
146
  >({}),
121
147
  [composer, setComposer] = useState<Composer | null>(null),
148
+ // Accepting bibliography entries that are not verified waits for this.
149
+ [confirm, setConfirm] = useState<{
150
+ kind: Kind;
151
+ chosen: string[];
152
+ body: string;
153
+ items: { label: string; reasons: string[] }[];
154
+ } | null>(null),
122
155
  [draft, setDraft] = useState(""),
123
156
  [help, setHelp] = useState(false),
157
+ // A note can reveal a decided change without opening the history controls.
158
+ [linkedChange] = useState(() => {
159
+ const value = Number(new URLSearchParams(location.search).get("change") ?? -1);
160
+ return Number.isSafeInteger(value) && value >= 0 ? value : -1;
161
+ }),
124
162
  [history, setHistory] = useState(
125
- Boolean(pinned) || new URLSearchParams(location.search).has("history"),
163
+ (Boolean(pinned) && linkedChange < 0) || new URLSearchParams(location.search).has("history"),
126
164
  );
127
165
  const paper = useRef<HTMLElement>(null),
128
166
  epoch = useRef(0),
129
167
  scrollUntil = useRef(0),
130
168
  snapshot = useRef({ state, detail, busy, composer, current });
169
+ const [shortcuts, setShortcuts] = useState(readShortcuts);
170
+ const [expandedEffects, setExpandedEffects] = useState(readExpandedEffects);
171
+ // Per-edit disclosure is temporary and scoped to its round. Changing the
172
+ // global preference resets those overrides without reloading the review.
173
+ const [effectOverrides, setEffectOverrides] = useState<Record<string, boolean>>({});
174
+ useEffect(() => {
175
+ const changed = (event: Event) => setExpandedEffects((event as CustomEvent).detail === true);
176
+ const stored = (event: StorageEvent) => { if (event.key === EFFECTS_STORAGE || event.key === null) setExpandedEffects(readExpandedEffects()); };
177
+ window.addEventListener(EFFECTS_EVENT, changed);
178
+ window.addEventListener('storage', stored);
179
+ return () => { window.removeEventListener(EFFECTS_EVENT, changed); window.removeEventListener('storage', stored); };
180
+ }, []);
181
+ useEffect(() => { setEffectOverrides({}); }, [expandedEffects]);
182
+ useEffect(() => {
183
+ const changed = (event: Event) => setShortcuts(resolveShortcuts((event as CustomEvent).detail));
184
+ const stored = (event: StorageEvent) => { if (event.key === SHORTCUT_STORAGE || event.key === null) setShortcuts(readShortcuts()); };
185
+ window.addEventListener(SHORTCUT_EVENT, changed);
186
+ window.addEventListener('storage', stored);
187
+ return () => { window.removeEventListener(SHORTCUT_EVENT, changed); window.removeEventListener('storage', stored); };
188
+ }, []);
189
+ function showHelp() {
190
+ if (window.dispatchEvent(new Event('latex-review:help', { cancelable: true }))) setHelp(true);
191
+ }
131
192
  snapshot.current = { state, detail, busy, composer, current };
132
193
  const units = useMemo(
133
194
  () => new Map(detail?.units.map((u) => [u.id, u]) ?? []),
@@ -157,6 +218,13 @@ export function ReviewApp({
157
218
  undecided = Object.keys(decisions).filter(
158
219
  (id) => decisions[id] === "pending",
159
220
  ),
221
+ references = useReferenceChecks(detail, api),
222
+ hazards = useMemo(
223
+ () => risks(detail?.references, references.checks, references.pending, references.confirmed),
224
+ [detail, references.checks, references.pending, references.confirmed],
225
+ ),
226
+ // Batch acceptance leaves the changes whose entries need a look.
227
+ batch = undecided.filter((id) => !hazards.has(id)),
160
228
  latest = Boolean(
161
229
  detail && state && detail.round === state.latest && !state.closed,
162
230
  ),
@@ -164,6 +232,9 @@ export function ReviewApp({
164
232
  label = (kind: string) => t((changes ? CHANGE_ACTION : ACTION)[kind] ?? kind),
165
233
  stateLabel = (value: string) =>
166
234
  t((changes ? CHANGE_STATE : STATE)[value] ?? value);
235
+ // Keyboard handlers read the newest checks, not those of their render.
236
+ const hazardsNow = useRef(hazards);
237
+ hazardsNow.current = hazards;
167
238
  // What each button acts on: undecided changes, or in changes mode the
168
239
  // staged ones for Unstage and every change still on disk for Revert.
169
240
  const targets = (units: string[], kind: Kind) =>
@@ -184,7 +255,7 @@ export function ReviewApp({
184
255
  : [];
185
256
  const navigation = stops
186
257
  .map((stop, index) => ({ stop, index }))
187
- .filter(({ stop }) => browsing || pendingUnits(stop, decisions).length)
258
+ .filter(({ stop, index }) => browsing || index === linkedChange || pendingUnits(stop, decisions).length)
188
259
  .map(({ index }) => index);
189
260
  const adjacent = (index: number, direction: number) =>
190
261
  navigation[navigation.indexOf(index) + direction] ?? -1;
@@ -247,11 +318,20 @@ export function ReviewApp({
247
318
  const article = paper.current?.querySelector<HTMLElement>(
248
319
  `[data-stop="${index}"]`,
249
320
  );
250
- article?.scrollIntoView({ block: "start", behavior: "smooth" });
321
+ if (article) reveal(article, "smooth");
251
322
  article?.focus({ preventScroll: true });
252
323
  });
253
324
  }
254
325
  }
326
+ function reveal(article: HTMLElement, behavior: ScrollBehavior = "instant") {
327
+ const root = paper.current;
328
+ if (!root) return;
329
+ // scrollIntoView also scrolls ancestors across the embedding iframe.
330
+ // Navigation belongs to the paper, independently of the host's Chat.
331
+ const padding = parseFloat(getComputedStyle(root).scrollPaddingTop) || 0;
332
+ const margin = parseFloat(getComputedStyle(article).scrollMarginTop) || 0;
333
+ root.scrollTo({ top: root.scrollTop + article.getBoundingClientRect().top - root.getBoundingClientRect().top - padding - margin, behavior });
334
+ }
255
335
  useEffect(() => {
256
336
  let cancelled = false,
257
337
  timer: ReturnType<typeof setTimeout>;
@@ -302,6 +382,7 @@ export function ReviewApp({
302
382
  !pendingUnits(nextStops[index], loadedState.decisions).length
303
383
  )
304
384
  index = nextPending(nextStops, loadedState.decisions, index);
385
+ if (linkedChange >= 0 && nextStops[linkedChange]) index = linkedChange;
305
386
  if (index < 0) index = pinned && nextStops.length ? 0 : -1;
306
387
  if (
307
388
  previous.detail &&
@@ -314,7 +395,7 @@ export function ReviewApp({
314
395
  setCurrent(index);
315
396
  setOpenSource(new Set());
316
397
  if (previous.detail)
317
- setToast(t("Round {round} is ready", { round: target }));
398
+ setToast(t("Changes are ready"));
318
399
  } else setState(next);
319
400
  } catch (e) {
320
401
  if (!cancelled) {
@@ -334,11 +415,10 @@ export function ReviewApp({
334
415
  }, [task, pinned]);
335
416
  useEffect(() => {
336
417
  if (detail && current >= 0)
337
- requestAnimationFrame(() =>
338
- paper.current
339
- ?.querySelector<HTMLElement>(`[data-stop="${current}"]`)
340
- ?.scrollIntoView({ block: "start" }),
341
- );
418
+ requestAnimationFrame(() => {
419
+ const article = paper.current?.querySelector<HTMLElement>(`[data-stop="${current}"]`);
420
+ if (article) reveal(article);
421
+ });
342
422
  }, [detail?.round]);
343
423
  useEffect(() => {
344
424
  if (state) document.title = `${t(state.title)} · LaTeX Review`;
@@ -373,6 +453,7 @@ export function ReviewApp({
373
453
  kind: Kind,
374
454
  chosen = targets(stops[current]?.units ?? [], kind),
375
455
  body = "",
456
+ confirmed = false,
376
457
  ) {
377
458
  if (
378
459
  !chosen.length ||
@@ -382,6 +463,13 @@ export function ReviewApp({
382
463
  blocked(kind)
383
464
  )
384
465
  return;
466
+ if (kind === "accept" && !confirmed) {
467
+ const items = chosen.flatMap((id) => hazardsNow.current.get(id) ?? []);
468
+ if (items.length) {
469
+ setConfirm({ kind, chosen, body, items });
470
+ return;
471
+ }
472
+ }
385
473
  epoch.current++;
386
474
  setBusy(true);
387
475
  snapshot.current.busy = true;
@@ -490,11 +578,13 @@ export function ReviewApp({
490
578
  }
491
579
  useEffect(() => {
492
580
  const listener = (event: KeyboardEvent) => {
581
+ if (event.defaultPrevented || event.isComposing) return;
493
582
  const target = event.target as HTMLElement,
494
583
  typing =
495
584
  target.matches("input,textarea,select") || target.isContentEditable;
496
585
  if (event.key === "Escape") {
497
- if (help) setHelp(false);
586
+ if (confirm) setConfirm(null);
587
+ else if (help) setHelp(false);
498
588
  else if (composer) closeComposer();
499
589
  return;
500
590
  }
@@ -507,53 +597,32 @@ export function ReviewApp({
507
597
  void submitComposer();
508
598
  return;
509
599
  }
510
- if (typing || help) return;
511
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
600
+ if (typing || help || confirm || window.getSelection()?.toString()) return;
601
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "z") {
512
602
  event.preventDefault();
513
603
  void undo();
514
604
  return;
515
605
  }
516
- if (
517
- event.metaKey ||
518
- event.ctrlKey ||
519
- event.altKey ||
520
- composer ||
521
- window.getSelection()?.toString()
522
- )
523
- return;
524
- const key = event.key.toLowerCase();
525
- if (
526
- [
527
- "a",
528
- "r",
529
- "c",
530
- "j",
531
- "k",
532
- "arrowright",
533
- "arrowleft",
534
- "n",
535
- "s",
536
- "u",
537
- "?",
538
- ].includes(key)
539
- )
540
- event.preventDefault();
541
- if (key === "a")
606
+ if (composer) return;
607
+ const action = shortcutAction(event, shortcuts);
608
+ if (!action || (changes && action === 'comment')) return;
609
+ event.preventDefault();
610
+ if (action === 'accept')
542
611
  void decide(kinds(stops[current]?.units ?? [])[0] ?? "accept");
543
- else if (key === "r") void decide("reject");
544
- else if (key === "c" && !changes) beginComposer();
545
- else if (key === "j" || key === "arrowright")
612
+ else if (action === 'reject') void decide("reject");
613
+ else if (action === 'comment') beginComposer();
614
+ else if (action === 'next')
546
615
  select(adjacent(current, 1));
547
- else if (key === "k" || key === "arrowleft")
616
+ else if (action === 'previous')
548
617
  select(adjacent(current, -1));
549
- else if (key === "n") select(nextPending(stops, decisions, current));
550
- else if (key === "s") toggleSource(current);
551
- else if (key === "u") void undo();
552
- else if (key === "?") setHelp(true);
618
+ else if (action === 'pending') select(nextPending(stops, decisions, current));
619
+ else if (action === 'source') toggleSource(current);
620
+ else if (action === 'undo') void undo();
621
+ else if (action === 'help') showHelp();
553
622
  };
554
623
  window.addEventListener("keydown", listener);
555
624
  return () => window.removeEventListener("keydown", listener);
556
- }, [state, detail, current, busy, composer, draft, help, history]);
625
+ }, [state, detail, current, busy, composer, draft, help, confirm, history, shortcuts]);
557
626
  function onScroll() {
558
627
  if (
559
628
  !paper.current ||
@@ -589,8 +658,8 @@ export function ReviewApp({
589
658
  <button
590
659
  className="nav"
591
660
  aria-label={t("Previous change")}
592
- aria-keyshortcuts="k ArrowLeft"
593
- title={t("Previous change (K or ←)")}
661
+ aria-keyshortcuts={shortcuts.previous.join(' ')}
662
+ title={[t('Previous change'), shortcuts.previous.map(bindingLabel).join(' / ')].filter(Boolean).join(' · ')}
594
663
  disabled={adjacent(index, -1) < 0}
595
664
  onClick={() => select(adjacent(index, -1))}
596
665
  >
@@ -604,16 +673,16 @@ export function ReviewApp({
604
673
  {available.length ? (
605
674
  <>
606
675
  {available.map((kind) => {
607
- const key = kind === "unstage" ? "a" : kind[0],
676
+ const keys = shortcuts[kind === 'unstage' ? 'accept' : kind],
608
677
  count = targets(units, kind).length;
609
678
  return (
610
679
  <button
611
680
  className={kind}
612
681
  key={kind}
613
- aria-keyshortcuts={key}
682
+ aria-keyshortcuts={keys.join(' ')}
614
683
  title={
615
684
  actionLock(kind) ||
616
- `${label(kind)} (${key.toUpperCase()})`
685
+ [label(kind), keys.map(bindingLabel).join(' / ')].filter(Boolean).join(' · ')
617
686
  }
618
687
  aria-describedby={
619
688
  actionLock(kind) ? "review-lock" : undefined
@@ -632,7 +701,7 @@ export function ReviewApp({
632
701
  ) : remaining >= 0 ? (
633
702
  <button
634
703
  className="skip"
635
- aria-keyshortcuts="n"
704
+ aria-keyshortcuts={shortcuts.pending.join(' ')}
636
705
  onClick={() => select(nextPending(stops, decisions, current))}
637
706
  >
638
707
  {changes ? t("Next unstaged") : t("Next undecided")}
@@ -643,15 +712,15 @@ export function ReviewApp({
643
712
  ? stateLabel(stopState(units, decisions))
644
713
  : latest
645
714
  ? t("Review complete")
646
- : t("Earlier round")}
715
+ : t("Earlier changes")}
647
716
  </span>
648
717
  )}
649
718
  {navigation.length > 1 && (
650
719
  <button
651
720
  className="nav"
652
721
  aria-label={t("Next change")}
653
- aria-keyshortcuts="j ArrowRight"
654
- title={t("Next change (J or →)")}
722
+ aria-keyshortcuts={shortcuts.next.join(' ')}
723
+ title={[t('Next change'), shortcuts.next.map(bindingLabel).join(' / ')].filter(Boolean).join(' · ')}
655
724
  disabled={adjacent(index, 1) < 0}
656
725
  onClick={() => select(adjacent(index, 1))}
657
726
  >
@@ -683,7 +752,10 @@ export function ReviewApp({
683
752
  clean: boolean,
684
753
  ): ReactNode {
685
754
  const { total, shown } = unit.effects!,
686
- loaded = more[`${detail!.round}:${unit.id}`],
755
+ key = `${detail!.round}:${unit.id}`,
756
+ open = effectOverrides[key] ?? (expandedEffects || unit.visual === "unmapped" || unit.visual === "identical"),
757
+ bodyId = `effects-${detail!.round}-${unit.id}`,
758
+ loaded = more[key],
687
759
  places = [...shown, ...(Array.isArray(loaded) ? loaded : [])],
688
760
  strips = (items: Strip[], side: "before" | "after") =>
689
761
  items.length > 0 && (
@@ -705,47 +777,57 @@ export function ReviewApp({
705
777
  return (
706
778
  <section className="effects" key={unit.id}>
707
779
  <p className="effects-head">
708
- {tn(total, "Affects {n} place", "Affects {n} places")}
709
- </p>
710
- {places.map((place, i) => (
711
- <div className="place" key={i}>
712
- <p className="place-where">
713
- {t("p. {pages}", { pages: place.page })}
714
- {place.section ? ` · ${place.section}` : ""}
715
- {place.with
716
- ? ` · ${t("with {changes}", {
717
- changes: place.with
718
- .map((id) =>
719
- firstStop.has(id)
720
- ? t("Change {n}", { n: firstStop.get(id)! + 1 })
721
- : (units.get(id)?.file ?? id),
722
- )
723
- .join(", "),
724
- })}`
725
- : ""}
726
- </p>
727
- {strips(place.before, "before")}
728
- {strips(place.after, "after")}
729
- </div>
730
- ))}
731
- {total > places.length && (
732
- <button
733
- className="link more-places"
734
- disabled={loaded === "loading"}
735
- onClick={() => void loadPlaces(unit.id)}
736
- >
737
- {loaded === "loading"
738
- ? t("Loading…")
739
- : tn(
740
- total - places.length,
741
- "Show {n} more",
742
- "Show {n} more",
743
- )}
780
+ <button className="link effects-toggle" aria-expanded={open} aria-controls={bodyId}
781
+ onClick={() => setEffectOverrides((values) => ({ ...values, [key]: !open }))}>
782
+ <span aria-hidden="true">{open ? "▾" : "▸"}</span>{" "}
783
+ {tn(total, "Affects {n} place", "Affects {n} places")}
744
784
  </button>
745
- )}
746
- {loaded && typeof loaded === "object" && "error" in loaded && (
747
- <p className="caption">{loaded.error}</p>
748
- )}
785
+ </p>
786
+ <div id={bodyId} hidden={!open}>
787
+ {open && (
788
+ <>
789
+ {places.map((place, i) => (
790
+ <div className="place" key={i}>
791
+ <p className="place-where">
792
+ {t("p. {pages}", { pages: place.page })}
793
+ {place.section ? ` · ${place.section}` : ""}
794
+ {place.with
795
+ ? ` · ${t("with {changes}", {
796
+ changes: place.with
797
+ .map((id) =>
798
+ firstStop.has(id)
799
+ ? t("Change {n}", { n: firstStop.get(id)! + 1 })
800
+ : (units.get(id)?.file ?? id),
801
+ )
802
+ .join(", "),
803
+ })}`
804
+ : ""}
805
+ </p>
806
+ {strips(place.before, "before")}
807
+ {strips(place.after, "after")}
808
+ </div>
809
+ ))}
810
+ {total > places.length && (
811
+ <button
812
+ className="link more-places"
813
+ disabled={loaded === "loading"}
814
+ onClick={() => void loadPlaces(unit.id)}
815
+ >
816
+ {loaded === "loading"
817
+ ? t("Loading…")
818
+ : tn(
819
+ total - places.length,
820
+ "Show {n} more",
821
+ "Show {n} more",
822
+ )}
823
+ </button>
824
+ )}
825
+ {loaded && typeof loaded === "object" && "error" in loaded && (
826
+ <p className="caption">{loaded.error}</p>
827
+ )}
828
+ </>
829
+ )}
830
+ </div>
749
831
  </section>
750
832
  );
751
833
  }
@@ -755,11 +837,16 @@ export function ReviewApp({
755
837
  inline: boolean,
756
838
  ): ReactNode {
757
839
  const known = block.units.map((id) => units.get(id)!).filter(Boolean),
840
+ // A .bib edit is shown entry by entry, every field, rather than as
841
+ // the bibliography's typeset lines alone.
842
+ bib = detail!.references?.entries.filter((e) => (e.units ?? [e.unit]).some((id) => block.units.includes(id))) ?? [],
843
+ uncited = detail!.references?.missing.filter((m) => m.unit && block.units.includes(m.unit)) ?? [],
758
844
  status = stopState(block.units, decisions),
759
845
  reviewed = !pendingUnits(stops[index], decisions).length,
846
+ expanded = browsing || index === linkedChange,
760
847
  // A reverted change shows the text that is back on disk; staged and
761
848
  // unstaged ones keep their before and after.
762
- resolved = history
849
+ resolved = history || index === linkedChange
763
850
  ? undefined
764
851
  : changes
765
852
  ? status === "rejected"
@@ -769,15 +856,17 @@ export function ReviewApp({
769
856
  (status === "commented" ? "after" : undefined),
770
857
  source =
771
858
  openSource.has(index) ||
772
- block.identical ||
773
- (!block.before.length && !block.after.length),
859
+ (!bib.length &&
860
+ (block.identical || (!block.before.length && !block.after.length))),
774
861
  remarks = [
775
862
  ...new Set(
776
863
  block.units.map((id) => state?.comments[id]).filter(Boolean),
777
864
  ),
778
865
  ],
779
866
  location = describeLocation(known, t),
780
- kindLabel = block.reflow
867
+ kindLabel = bib.length
868
+ ? t("Bibliography")
869
+ : block.reflow
781
870
  ? t("Reflow")
782
871
  : [
783
872
  ...new Set(
@@ -808,17 +897,17 @@ export function ReviewApp({
808
897
  return (
809
898
  <article
810
899
  key={index}
811
- className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}${reviewed && !browsing ? " reviewed-change" : ""}${reviewed && !browsing && !resolved ? " mixed-reviewed" : ""}`}
900
+ className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}${reviewed && !expanded ? " reviewed-change" : ""}${reviewed && !expanded && !resolved ? " mixed-reviewed" : ""}`}
812
901
  data-stop={index}
813
902
  data-state={status}
814
903
  data-resolved={resolved}
815
904
  tabIndex={-1}
816
905
  onClick={() => {
817
- if (index !== current && !composer && (browsing || !reviewed))
906
+ if (index !== current && !composer && (expanded || !reviewed))
818
907
  select(index, false);
819
908
  }}
820
909
  >
821
- {reviewed && !browsing && !resolved && (
910
+ {reviewed && !expanded && !resolved && (
822
911
  <p className="reviewed-placeholder">
823
912
  <button
824
913
  className="link"
@@ -861,7 +950,8 @@ export function ReviewApp({
861
950
  </button>
862
951
  <button
863
952
  className={`link ${source ? "active" : ""}`}
864
- title={t("Show the LaTeX source (S)")}
953
+ title={[t('Show LaTeX source'), shortcuts.source.map(bindingLabel).join(' / ')].filter(Boolean).join(' · ')}
954
+ aria-keyshortcuts={shortcuts.source.join(' ')}
865
955
  onClick={() => toggleSource(index)}
866
956
  >
867
957
  {block.units.length > 1
@@ -876,9 +966,7 @@ export function ReviewApp({
876
966
  .map((h, i) => (
877
967
  <p key={i}>
878
968
  <strong>
879
- {t("Revised after your comment in round {round}: ", {
880
- round: h.round,
881
- })}
969
+ {t("Revised after your comment: ")}
882
970
  </strong>
883
971
  “{h.comment}”
884
972
  </p>
@@ -890,7 +978,7 @@ export function ReviewApp({
890
978
  block.before.length > 0 &&
891
979
  images(block.before, "before")}
892
980
  {block.after.length > 0 && images(block.after, "after")}
893
- {block.identical && (
981
+ {block.identical && !bib.length && (
894
982
  <p className="caption">
895
983
  {t("No visible change in the PDF; see the LaTeX source.")}
896
984
  </p>
@@ -910,6 +998,25 @@ export function ReviewApp({
910
998
  ))}
911
999
  </div>
912
1000
  )}
1001
+ {uncited.length > 0 && (
1002
+ <p className="reference-problem reference-uncited">
1003
+ {uncited
1004
+ .map((m) => t("Cites {key}, which no bibliography entry defines.", { key: m.key }))
1005
+ .join(language() === "zh-CN" ? "" : " ")}
1006
+ </p>
1007
+ )}
1008
+ {bib.length > 0 && (
1009
+ <ReferenceEntries
1010
+ entries={bib}
1011
+ checks={references.checks}
1012
+ pending={references.pending > 0}
1013
+ onRetry={references.retry}
1014
+ confirmed={references.confirmed}
1015
+ onConfirm={(id, on) =>
1016
+ void references.confirm(id, on).catch((e) => setToast((e as Error).message))
1017
+ }
1018
+ />
1019
+ )}
913
1020
  {source && (
914
1021
  <div className="source">
915
1022
  {known.map((unit) => (
@@ -1000,31 +1107,28 @@ export function ReviewApp({
1000
1107
  {changes
1001
1108
  ? t("Since the last commit")
1002
1109
  : detail
1003
- ? t("Round {round}", { round: detail.round }) +
1004
- (detail.round !== state?.latest ? t(" (earlier round)") : "")
1110
+ ? (detail.round === state?.latest ? t("Current changes") : t("Earlier changes"))
1005
1111
  : ""}
1006
1112
  </span>
1007
1113
  </div>
1008
- {count.total > 0 && (
1009
- <div className="meter">
1010
- <span>
1011
- {changes
1012
- ? t("{staged} of {total} staged", {
1013
- staged: count.accepted,
1014
- total: count.accepted + count.pending,
1015
- })
1016
- : t("{decided} of {total} decided", {
1017
- decided: count.decided,
1018
- total: count.total,
1019
- })}
1020
- </span>
1021
- </div>
1022
- )}
1114
+ <ReviewProgress
1115
+ value={changes ? count.accepted : count.decided}
1116
+ total={changes ? count.accepted + count.pending : count.total}
1117
+ label={changes
1118
+ ? t("{staged} of {total} staged", {
1119
+ staged: count.accepted,
1120
+ total: count.accepted + count.pending,
1121
+ })
1122
+ : t("{decided} of {total} decided", {
1123
+ decided: count.decided,
1124
+ total: count.total,
1125
+ })}
1126
+ />
1023
1127
  <div className="bar-end">
1024
1128
  {/* Batch decisions cover only undecided changes; accepted,
1025
1129
  rejected and commented changes keep their state. A single
1026
1130
  undecided change is decided from its own bar. */}
1027
- {count.pending > 1 && (
1131
+ {count.pending > 1 && batch.length > 0 && (
1028
1132
  <button
1029
1133
  className="ghost accept accept-all"
1030
1134
  disabled={
@@ -1035,30 +1139,41 @@ export function ReviewApp({
1035
1139
  }
1036
1140
  title={
1037
1141
  state?.decisionLock ||
1038
- (changes
1039
- ? tn(
1040
- count.pending,
1041
- "Stage the {n} unstaged change.",
1042
- "Stage the {n} unstaged changes.",
1043
- )
1044
- : tn(
1045
- count.pending,
1046
- "Accept the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
1047
- "Accept the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
1048
- ))
1142
+ [
1143
+ changes
1144
+ ? tn(
1145
+ batch.length,
1146
+ "Stage the {n} unstaged change.",
1147
+ "Stage the {n} unstaged changes.",
1148
+ )
1149
+ : tn(
1150
+ batch.length,
1151
+ "Accept {n} undecided change",
1152
+ "Accept {n} undecided changes",
1153
+ ),
1154
+ undecided.length > batch.length
1155
+ ? tn(
1156
+ undecided.length - batch.length,
1157
+ "Leaves {n} change with bibliography entries to check.",
1158
+ "Leaves {n} changes with bibliography entries to check.",
1159
+ )
1160
+ : "",
1161
+ ]
1162
+ .filter(Boolean)
1163
+ .join(" ")
1049
1164
  }
1050
1165
  aria-label={
1051
1166
  changes
1052
- ? t("Stage all ({n})", { n: count.pending })
1053
- : t("Accept all ({n})", { n: count.pending })
1167
+ ? t("Stage all ({n})", { n: batch.length })
1168
+ : t("Accept all ({n})", { n: batch.length })
1054
1169
  }
1055
1170
  aria-describedby={state?.decisionLock ? "review-lock" : undefined}
1056
- onClick={() => void decide("accept", undecided)}
1171
+ onClick={() => void decide("accept", batch)}
1057
1172
  >
1058
1173
  <span className="batch-label">
1059
1174
  {changes ? t("Stage all") : t("Accept all")}
1060
1175
  </span>
1061
- <span className="batch-count">{count.pending}</span>
1176
+ <span className="batch-count">{batch.length}</span>
1062
1177
  </button>
1063
1178
  )}
1064
1179
  {count.pending > 1 && !changes && (
@@ -1074,8 +1189,8 @@ export function ReviewApp({
1074
1189
  state?.decisionLock ||
1075
1190
  tn(
1076
1191
  count.pending,
1077
- "Reject the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
1078
- "Reject the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
1192
+ "Reject {n} undecided change",
1193
+ "Reject {n} undecided changes",
1079
1194
  )
1080
1195
  }
1081
1196
  aria-label={t("Reject all ({n})", { n: count.pending })}
@@ -1111,7 +1226,8 @@ export function ReviewApp({
1111
1226
  <button
1112
1227
  className="ghost undo"
1113
1228
  disabled={!latest || busy || Boolean(state.decisionLock)}
1114
- title={state.decisionLock || t("Put back what the last revert removed (U)")}
1229
+ title={state.decisionLock || [t('Undo revert'), shortcuts.undo.map(bindingLabel).join(' / ')].filter(Boolean).join(' · ')}
1230
+ aria-keyshortcuts={shortcuts.undo.join(' ')}
1115
1231
  onClick={() => void undo()}
1116
1232
  >
1117
1233
  {t("Undo revert")}
@@ -1122,7 +1238,7 @@ export function ReviewApp({
1122
1238
  {detail && !history && !changes && (
1123
1239
  <button
1124
1240
  className="ghost history"
1125
- title={t("Earlier decisions and rounds")}
1241
+ title={t("Review history")}
1126
1242
  onClick={() => {
1127
1243
  setHistory(true);
1128
1244
  if (current < 0 && stops.length) setCurrent(0);
@@ -1134,7 +1250,7 @@ export function ReviewApp({
1134
1250
  <button
1135
1251
  className="ghost round"
1136
1252
  aria-label={t("Keyboard shortcuts")}
1137
- onClick={() => setHelp(true)}
1253
+ onClick={showHelp}
1138
1254
  >
1139
1255
  ?
1140
1256
  </button>
@@ -1145,7 +1261,7 @@ export function ReviewApp({
1145
1261
  <label>
1146
1262
  {t("History")}{" "}
1147
1263
  <select
1148
- aria-label={t("Review round")}
1264
+ aria-label={t("Review history")}
1149
1265
  value={detail.round}
1150
1266
  onChange={(e) => {
1151
1267
  location.href = withLanguage(
@@ -1160,8 +1276,10 @@ export function ReviewApp({
1160
1276
  .reverse()
1161
1277
  .map((round) => (
1162
1278
  <option key={round} value={round}>
1163
- {t("Round {round}", { round })}
1164
- {round === state?.latest ? t(" (latest)") : ""}
1279
+ {round === state?.latest ? t("Current changes") : (() => {
1280
+ const published = state?.history?.find(entry => entry.number === round)?.created;
1281
+ return published ? new Intl.DateTimeFormat(locale(), { dateStyle: "medium", timeStyle: "medium" }).format(new Date(published * 1000)) : t("Earlier changes");
1282
+ })()}
1165
1283
  </option>
1166
1284
  ))}
1167
1285
  </select>
@@ -1208,6 +1326,68 @@ export function ReviewApp({
1208
1326
  )}
1209
1327
  <div className="review-body">
1210
1328
  <main id="paper" ref={paper} tabIndex={-1} onScroll={onScroll}>
1329
+ {changes && detail && !count.pending && (count.accepted > 0 || !stops.length) && (
1330
+ <div className="review-complete" role="status" tabIndex={-1}>
1331
+ <strong>
1332
+ {count.accepted
1333
+ ? t("All changes are staged")
1334
+ : t("No uncommitted changes")}
1335
+ </strong>
1336
+ <span>
1337
+ {count.accepted
1338
+ ? t("Commit them when you are ready.")
1339
+ : t("The PDF matches the last commit.")}
1340
+ </span>
1341
+ <div className="review-complete-actions" />
1342
+ </div>
1343
+ )}
1344
+ {!changes && detail && !history && !count.pending && (
1345
+ <div className="review-complete" role="status" tabIndex={-1}>
1346
+ <strong>
1347
+ {t(
1348
+ state?.workspace?.status === "waiting_input"
1349
+ ? "Agent needs your input"
1350
+ : state?.workspace?.status === "queued"
1351
+ ? "Feedback queued"
1352
+ : state?.workspace?.status === "running"
1353
+ ? "Feedback sent · Agent is working"
1354
+ : state?.workspace?.status === "building" ||
1355
+ state?.state === "building"
1356
+ ? "Preparing the next review…"
1357
+ : "All changes reviewed",
1358
+ )}
1359
+ </strong>
1360
+ <span>
1361
+ {t(
1362
+ state?.workspace?.status === "waiting_input"
1363
+ ? "Open Chat to answer the agent's question."
1364
+ : ["running", "queued", "building"].includes(
1365
+ state?.workspace?.status || "",
1366
+ ) || state?.state === "building"
1367
+ ? "New changes will appear here when ready."
1368
+ : state?.workspace?.comments
1369
+ ? "Your comments are saved. Send feedback so the agent can revise."
1370
+ : state?.workspace?.deferred
1371
+ ? state.workspace.accepted
1372
+ ? "Accepted changes are saved in this task. Choose where to save them below."
1373
+ : state.workspace.destination === "branch"
1374
+ ? "Accepted changes are committed to the task branch."
1375
+ : state.workspace.destination === "workspace"
1376
+ ? "Accepted changes are in your manuscript."
1377
+ : "There are no accepted changes to save."
1378
+ : state?.workspace?.accepted
1379
+ ? "Accepted changes are in your manuscript."
1380
+ : state?.workspace?.unsent && !count.accepted
1381
+ ? "Your decisions are saved. Send them to the agent to continue."
1382
+ : count.commented && !state?.workspace
1383
+ ? "Your comments are saved for the agent."
1384
+ : "There are no new changes to review. Previous decisions are available in History.",
1385
+ )}
1386
+ </span>
1387
+ {/* A host such as Papilot puts its next step here, for example a commit. */}
1388
+ <div className="review-complete-actions" />
1389
+ </div>
1390
+ )}
1211
1391
  {(state?.commentLock || state?.decisionLock) && (
1212
1392
  <div className="banner" id="review-lock" role="status">
1213
1393
  {t((state.commentLock || state.decisionLock)!)}
@@ -1240,15 +1420,15 @@ export function ReviewApp({
1240
1420
  "Updating the comparison with your latest edits. You can keep staging and reverting meanwhile.",
1241
1421
  )
1242
1422
  : t(
1243
- "The agent is publishing a new round. You can keep reviewing; the new round will include your decisions.",
1423
+ "Updating changes…",
1244
1424
  )}
1245
1425
  </div>
1246
1426
  )}
1247
1427
  {!changes && detail && state?.latest && detail.round !== state.latest && (
1248
1428
  <div className="banner">
1249
- {t("You are viewing round {round}.", { round: detail.round })}{" "}
1429
+ {t("Viewing earlier changes.")}{" "}
1250
1430
  <a href={withLanguage(`?task=${encodeURIComponent(task)}`)}>
1251
- {t("Go to round {round} →", { round: state.latest })}
1431
+ {t("Back to current changes →")}
1252
1432
  </a>
1253
1433
  </div>
1254
1434
  )}
@@ -1266,60 +1446,19 @@ export function ReviewApp({
1266
1446
  </details>
1267
1447
  </div>
1268
1448
  ) : null}
1269
- {changes && detail && !count.pending && (count.accepted > 0 || !stops.length) && (
1270
- <div className="review-complete" role="status">
1271
- <strong>
1272
- {count.accepted
1273
- ? t("All changes are staged")
1274
- : t("No uncommitted changes")}
1275
- </strong>
1276
- <span>
1277
- {count.accepted
1278
- ? t("Commit them when you are ready.")
1279
- : t("The PDF matches the last commit.")}
1280
- </span>
1281
- <div className="review-complete-actions" />
1282
- </div>
1283
- )}
1284
- {!changes && detail && !history && !count.pending && (
1285
- <div className="review-complete" role="status">
1286
- <strong>
1287
- {t(
1288
- state?.workspace?.status === "waiting_input"
1289
- ? "Agent needs your input"
1290
- : state?.workspace?.status === "queued"
1291
- ? "Feedback queued"
1292
- : state?.workspace?.status === "running"
1293
- ? "Feedback sent · Agent is working"
1294
- : state?.workspace?.status === "building" ||
1295
- state?.state === "building"
1296
- ? "Preparing the next review…"
1297
- : "All changes reviewed",
1298
- )}
1299
- </strong>
1300
- <span>
1301
- {t(
1302
- state?.workspace?.status === "waiting_input"
1303
- ? "Open Chat to answer the agent's question."
1304
- : ["running", "queued", "building"].includes(
1305
- state?.workspace?.status || "",
1306
- ) || state?.state === "building"
1307
- ? "New changes will appear here when ready."
1308
- : state?.workspace?.comments
1309
- ? "Your comments are saved. Send feedback so the agent can revise."
1310
- : state?.workspace?.accepted
1311
- ? "Accepted changes are in your manuscript."
1312
- : state?.workspace?.unsent && !count.accepted
1313
- ? "Your decisions are saved. Send them to the agent to continue."
1314
- : count.commented && !state?.workspace
1315
- ? "Your comments are saved for the agent."
1316
- : "There are no new changes to review. Previous decisions are available in History.",
1317
- )}
1318
- </span>
1319
- {/* A host such as Papilot puts its next step here, for example a commit. */}
1320
- <div className="review-complete-actions" />
1321
- </div>
1322
- )}
1449
+ {detail?.references &&
1450
+ (detail.references.entries.length > 0 ||
1451
+ detail.references.missing.length > 0) && (
1452
+ <ReferenceBanner
1453
+ references={detail.references}
1454
+ checks={references.checks}
1455
+ pending={references.pending}
1456
+ confirmed={references.confirmed}
1457
+ changeOf={(unit) => (unit ? (firstStop.get(unit) ?? -1) : -1)}
1458
+ onSelect={(index) => select(index)}
1459
+ onRetry={references.retry}
1460
+ />
1461
+ )}
1323
1462
  <div className="document">
1324
1463
  {detail ? (
1325
1464
  <>
@@ -1356,16 +1495,14 @@ export function ReviewApp({
1356
1495
  ))}
1357
1496
  {detail.loose.length > 0 && (
1358
1497
  <section className="part">
1359
- <h2 className="part-title">
1360
- {detail.sections.length
1361
- ? t("Other changes")
1362
- : t("Changes")}
1363
- </h2>
1498
+ {detail.sections.length > 0 && (
1499
+ <h2 className="part-title">{t("Other changes")}</h2>
1500
+ )}
1364
1501
  {detail.loose.map((block) => change(block, index++, false))}
1365
1502
  </section>
1366
1503
  )}
1367
1504
  {!stops.length && !changes && (
1368
- <p className="empty">{t("This round has no changes.")}</p>
1505
+ <p className="empty">{t("No changes to review.")}</p>
1369
1506
  )}
1370
1507
  </>
1371
1508
  ) : error ? (
@@ -1442,6 +1579,18 @@ export function ReviewApp({
1442
1579
  {toast}
1443
1580
  </div>
1444
1581
  )}
1582
+ {confirm && (
1583
+ <ConfirmReferences
1584
+ items={confirm.items}
1585
+ staging={changes}
1586
+ onCancel={() => setConfirm(null)}
1587
+ onConfirm={() => {
1588
+ const chosen = confirm;
1589
+ setConfirm(null);
1590
+ void decide(chosen.kind, chosen.chosen, chosen.body, true);
1591
+ }}
1592
+ />
1593
+ )}
1445
1594
  {help && (
1446
1595
  <div className="help-backdrop" onClick={() => setHelp(false)}>
1447
1596
  <dialog
@@ -1453,26 +1602,28 @@ export function ReviewApp({
1453
1602
  <dl>
1454
1603
  {(changes
1455
1604
  ? [
1456
- ["A", "Stage or unstage"],
1457
- ["R", "Revert"],
1458
- ["J / K or → / ←", "Next / previous change"],
1459
- ["N", "Next unstaged"],
1460
- ["S", "Show LaTeX source"],
1461
- ["⌘Z / U", "Undo revert"],
1605
+ [shortcuts.accept.map(bindingLabel).join(' / '), "Stage or unstage"],
1606
+ [shortcuts.reject.map(bindingLabel).join(' / '), "Revert"],
1607
+ [shortcuts.next.map(bindingLabel).join(' / '), "Next change"],
1608
+ [shortcuts.previous.map(bindingLabel).join(' / '), "Previous change"],
1609
+ [shortcuts.pending.map(bindingLabel).join(' / '), "Next unstaged"],
1610
+ [shortcuts.source.map(bindingLabel).join(' / '), "Show LaTeX source"],
1611
+ [["⌘Z / Ctrl+Z", ...shortcuts.undo.map(bindingLabel)].join(' / '), "Undo revert"],
1462
1612
  ["⌘C / Ctrl+C", "Copy selected PDF text"],
1463
1613
  ]
1464
1614
  : [
1465
- ["A", "Accept"],
1466
- ["R", "Reject"],
1467
- ["C", "Comment"],
1468
- ["J / K or → / ←", "Next / previous change"],
1469
- ["N", "Next undecided"],
1470
- ["S", "Show LaTeX source"],
1471
- ["⌘Z / U", "Undo"],
1615
+ [shortcuts.accept.map(bindingLabel).join(' / '), "Accept"],
1616
+ [shortcuts.reject.map(bindingLabel).join(' / '), "Reject"],
1617
+ [shortcuts.comment.map(bindingLabel).join(' / '), "Comment"],
1618
+ [shortcuts.next.map(bindingLabel).join(' / '), "Next change"],
1619
+ [shortcuts.previous.map(bindingLabel).join(' / '), "Previous change"],
1620
+ [shortcuts.pending.map(bindingLabel).join(' / '), "Next undecided"],
1621
+ [shortcuts.source.map(bindingLabel).join(' / '), "Show LaTeX source"],
1622
+ [["⌘Z / Ctrl+Z", ...shortcuts.undo.map(bindingLabel)].join(' / '), "Undo"],
1472
1623
  ["⌘C / Ctrl+C", "Copy selected PDF text"],
1473
1624
  ]
1474
1625
  ).map(([key, label]) => (
1475
- <Fragment key={key}>
1626
+ <Fragment key={label}>
1476
1627
  <dt>
1477
1628
  <kbd>{t(key)}</kbd>
1478
1629
  </dt>