papilot 0.7.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 (63) hide show
  1. package/README.md +52 -30
  2. package/README.zh-CN.md +54 -30
  3. package/bin/doctor.mjs +53 -6
  4. package/bin/installation.d.mts +2 -0
  5. package/bin/installation.mjs +7 -1
  6. package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-D7ro6ecN.js} +54 -3
  7. package/dist/assets/index-Cc-awdNn.css +1 -0
  8. package/dist/assets/index-D_VX51no.js +265 -0
  9. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  10. package/dist/assets/{math-BsEl3u6P.js → math-V9uUaUzA.js} +1 -1
  11. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  12. package/dist/index.html +2 -2
  13. package/examples/papilot-paper/main.tex +1 -1
  14. package/examples/papilot-paper/sections/02-using.tex +3 -1
  15. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  16. package/package.json +10 -6
  17. package/skills/ai-review.md +18 -0
  18. package/skills/latex-warnings.md +1 -1
  19. package/src/conversation.ts +6 -0
  20. package/src/server/acp.ts +354 -0
  21. package/src/server/agents.ts +220 -43
  22. package/src/server/ai-review-routes.ts +72 -0
  23. package/src/server/ai-review.ts +38 -0
  24. package/src/server/app.ts +53 -11
  25. package/src/server/build-problems.ts +329 -0
  26. package/src/server/changes.ts +25 -2
  27. package/src/server/codex.ts +2 -1
  28. package/src/server/cursor.ts +153 -0
  29. package/src/server/latex-installation.ts +43 -0
  30. package/src/server/manuscript.ts +165 -23
  31. package/src/server/paths.ts +1 -1
  32. package/src/server/references.ts +70 -0
  33. package/src/server/review.ts +3 -3
  34. package/src/server/skills.ts +5 -8
  35. package/src/server/store.ts +20 -2
  36. package/src/server/update-install.ts +53 -2
  37. package/src/server/update-worker.ts +6 -6
  38. package/src/server/updates.ts +43 -14
  39. package/src/server/workspace.ts +183 -29
  40. package/src/shared.ts +78 -7
  41. package/src/skill-icons.ts +18 -0
  42. package/src/skill-metadata.ts +45 -0
  43. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  44. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  45. package/vendor/latex-review/dist/web/index.html +2 -2
  46. package/vendor/latex-review/src/core/bib.ts +692 -0
  47. package/vendor/latex-review/src/core/changes.ts +39 -0
  48. package/vendor/latex-review/src/core/review.ts +1 -0
  49. package/vendor/latex-review/src/core/verify.ts +784 -0
  50. package/vendor/latex-review/src/core/view.ts +204 -0
  51. package/vendor/latex-review/src/render/round.ts +11 -0
  52. package/vendor/latex-review/src/server.ts +65 -0
  53. package/vendor/latex-review/src/types.ts +125 -0
  54. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  55. package/vendor/latex-review/src/web/references.tsx +499 -0
  56. package/vendor/latex-review/src/web/review.tsx +259 -131
  57. package/vendor/latex-review/src/web/style.css +285 -0
  58. package/vendor/latex-review/src/web/zh-CN.ts +75 -0
  59. package/dist/assets/index-CIJEYlP7.js +0 -252
  60. package/dist/assets/index-hOJEruFc.css +0 -1
  61. package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
  62. package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
  63. package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
@@ -18,8 +18,16 @@ 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 { locale, 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";
23
31
  import { bindingLabel, readShortcuts, resolveShortcuts, shortcutAction, SHORTCUT_EVENT, SHORTCUT_STORAGE } from "./shortcuts.ts";
24
32
  import {
25
33
  STATE,
@@ -137,6 +145,13 @@ export function ReviewApp({
137
145
  Record<string, PlaceView[] | "loading" | { error: string }>
138
146
  >({}),
139
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),
140
155
  [draft, setDraft] = useState(""),
141
156
  [help, setHelp] = useState(false),
142
157
  // A note can reveal a decided change without opening the history controls.
@@ -152,6 +167,18 @@ export function ReviewApp({
152
167
  scrollUntil = useRef(0),
153
168
  snapshot = useRef({ state, detail, busy, composer, current });
154
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]);
155
182
  useEffect(() => {
156
183
  const changed = (event: Event) => setShortcuts(resolveShortcuts((event as CustomEvent).detail));
157
184
  const stored = (event: StorageEvent) => { if (event.key === SHORTCUT_STORAGE || event.key === null) setShortcuts(readShortcuts()); };
@@ -191,6 +218,13 @@ export function ReviewApp({
191
218
  undecided = Object.keys(decisions).filter(
192
219
  (id) => decisions[id] === "pending",
193
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)),
194
228
  latest = Boolean(
195
229
  detail && state && detail.round === state.latest && !state.closed,
196
230
  ),
@@ -198,6 +232,9 @@ export function ReviewApp({
198
232
  label = (kind: string) => t((changes ? CHANGE_ACTION : ACTION)[kind] ?? kind),
199
233
  stateLabel = (value: string) =>
200
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;
201
238
  // What each button acts on: undecided changes, or in changes mode the
202
239
  // staged ones for Unstage and every change still on disk for Revert.
203
240
  const targets = (units: string[], kind: Kind) =>
@@ -281,11 +318,20 @@ export function ReviewApp({
281
318
  const article = paper.current?.querySelector<HTMLElement>(
282
319
  `[data-stop="${index}"]`,
283
320
  );
284
- article?.scrollIntoView({ block: "start", behavior: "smooth" });
321
+ if (article) reveal(article, "smooth");
285
322
  article?.focus({ preventScroll: true });
286
323
  });
287
324
  }
288
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
+ }
289
335
  useEffect(() => {
290
336
  let cancelled = false,
291
337
  timer: ReturnType<typeof setTimeout>;
@@ -369,11 +415,10 @@ export function ReviewApp({
369
415
  }, [task, pinned]);
370
416
  useEffect(() => {
371
417
  if (detail && current >= 0)
372
- requestAnimationFrame(() =>
373
- paper.current
374
- ?.querySelector<HTMLElement>(`[data-stop="${current}"]`)
375
- ?.scrollIntoView({ block: "start" }),
376
- );
418
+ requestAnimationFrame(() => {
419
+ const article = paper.current?.querySelector<HTMLElement>(`[data-stop="${current}"]`);
420
+ if (article) reveal(article);
421
+ });
377
422
  }, [detail?.round]);
378
423
  useEffect(() => {
379
424
  if (state) document.title = `${t(state.title)} · LaTeX Review`;
@@ -408,6 +453,7 @@ export function ReviewApp({
408
453
  kind: Kind,
409
454
  chosen = targets(stops[current]?.units ?? [], kind),
410
455
  body = "",
456
+ confirmed = false,
411
457
  ) {
412
458
  if (
413
459
  !chosen.length ||
@@ -417,6 +463,13 @@ export function ReviewApp({
417
463
  blocked(kind)
418
464
  )
419
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
+ }
420
473
  epoch.current++;
421
474
  setBusy(true);
422
475
  snapshot.current.busy = true;
@@ -530,7 +583,8 @@ export function ReviewApp({
530
583
  typing =
531
584
  target.matches("input,textarea,select") || target.isContentEditable;
532
585
  if (event.key === "Escape") {
533
- if (help) setHelp(false);
586
+ if (confirm) setConfirm(null);
587
+ else if (help) setHelp(false);
534
588
  else if (composer) closeComposer();
535
589
  return;
536
590
  }
@@ -543,7 +597,7 @@ export function ReviewApp({
543
597
  void submitComposer();
544
598
  return;
545
599
  }
546
- if (typing || help || window.getSelection()?.toString()) return;
600
+ if (typing || help || confirm || window.getSelection()?.toString()) return;
547
601
  if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "z") {
548
602
  event.preventDefault();
549
603
  void undo();
@@ -568,7 +622,7 @@ export function ReviewApp({
568
622
  };
569
623
  window.addEventListener("keydown", listener);
570
624
  return () => window.removeEventListener("keydown", listener);
571
- }, [state, detail, current, busy, composer, draft, help, history, shortcuts]);
625
+ }, [state, detail, current, busy, composer, draft, help, confirm, history, shortcuts]);
572
626
  function onScroll() {
573
627
  if (
574
628
  !paper.current ||
@@ -698,7 +752,10 @@ export function ReviewApp({
698
752
  clean: boolean,
699
753
  ): ReactNode {
700
754
  const { total, shown } = unit.effects!,
701
- 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],
702
759
  places = [...shown, ...(Array.isArray(loaded) ? loaded : [])],
703
760
  strips = (items: Strip[], side: "before" | "after") =>
704
761
  items.length > 0 && (
@@ -720,47 +777,57 @@ export function ReviewApp({
720
777
  return (
721
778
  <section className="effects" key={unit.id}>
722
779
  <p className="effects-head">
723
- {tn(total, "Affects {n} place", "Affects {n} places")}
724
- </p>
725
- {places.map((place, i) => (
726
- <div className="place" key={i}>
727
- <p className="place-where">
728
- {t("p. {pages}", { pages: place.page })}
729
- {place.section ? ` · ${place.section}` : ""}
730
- {place.with
731
- ? ` · ${t("with {changes}", {
732
- changes: place.with
733
- .map((id) =>
734
- firstStop.has(id)
735
- ? t("Change {n}", { n: firstStop.get(id)! + 1 })
736
- : (units.get(id)?.file ?? id),
737
- )
738
- .join(", "),
739
- })}`
740
- : ""}
741
- </p>
742
- {strips(place.before, "before")}
743
- {strips(place.after, "after")}
744
- </div>
745
- ))}
746
- {total > places.length && (
747
- <button
748
- className="link more-places"
749
- disabled={loaded === "loading"}
750
- onClick={() => void loadPlaces(unit.id)}
751
- >
752
- {loaded === "loading"
753
- ? t("Loading…")
754
- : tn(
755
- total - places.length,
756
- "Show {n} more",
757
- "Show {n} more",
758
- )}
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")}
759
784
  </button>
760
- )}
761
- {loaded && typeof loaded === "object" && "error" in loaded && (
762
- <p className="caption">{loaded.error}</p>
763
- )}
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>
764
831
  </section>
765
832
  );
766
833
  }
@@ -770,6 +837,10 @@ export function ReviewApp({
770
837
  inline: boolean,
771
838
  ): ReactNode {
772
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)) ?? [],
773
844
  status = stopState(block.units, decisions),
774
845
  reviewed = !pendingUnits(stops[index], decisions).length,
775
846
  expanded = browsing || index === linkedChange,
@@ -785,15 +856,17 @@ export function ReviewApp({
785
856
  (status === "commented" ? "after" : undefined),
786
857
  source =
787
858
  openSource.has(index) ||
788
- block.identical ||
789
- (!block.before.length && !block.after.length),
859
+ (!bib.length &&
860
+ (block.identical || (!block.before.length && !block.after.length))),
790
861
  remarks = [
791
862
  ...new Set(
792
863
  block.units.map((id) => state?.comments[id]).filter(Boolean),
793
864
  ),
794
865
  ],
795
866
  location = describeLocation(known, t),
796
- kindLabel = block.reflow
867
+ kindLabel = bib.length
868
+ ? t("Bibliography")
869
+ : block.reflow
797
870
  ? t("Reflow")
798
871
  : [
799
872
  ...new Set(
@@ -905,7 +978,7 @@ export function ReviewApp({
905
978
  block.before.length > 0 &&
906
979
  images(block.before, "before")}
907
980
  {block.after.length > 0 && images(block.after, "after")}
908
- {block.identical && (
981
+ {block.identical && !bib.length && (
909
982
  <p className="caption">
910
983
  {t("No visible change in the PDF; see the LaTeX source.")}
911
984
  </p>
@@ -925,6 +998,25 @@ export function ReviewApp({
925
998
  ))}
926
999
  </div>
927
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
+ )}
928
1020
  {source && (
929
1021
  <div className="source">
930
1022
  {known.map((unit) => (
@@ -1036,7 +1128,7 @@ export function ReviewApp({
1036
1128
  {/* Batch decisions cover only undecided changes; accepted,
1037
1129
  rejected and commented changes keep their state. A single
1038
1130
  undecided change is decided from its own bar. */}
1039
- {count.pending > 1 && (
1131
+ {count.pending > 1 && batch.length > 0 && (
1040
1132
  <button
1041
1133
  className="ghost accept accept-all"
1042
1134
  disabled={
@@ -1047,30 +1139,41 @@ export function ReviewApp({
1047
1139
  }
1048
1140
  title={
1049
1141
  state?.decisionLock ||
1050
- (changes
1051
- ? tn(
1052
- count.pending,
1053
- "Stage the {n} unstaged change.",
1054
- "Stage the {n} unstaged changes.",
1055
- )
1056
- : tn(
1057
- count.pending,
1058
- "Accept {n} undecided change",
1059
- "Accept {n} undecided changes",
1060
- ))
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(" ")
1061
1164
  }
1062
1165
  aria-label={
1063
1166
  changes
1064
- ? t("Stage all ({n})", { n: count.pending })
1065
- : t("Accept all ({n})", { n: count.pending })
1167
+ ? t("Stage all ({n})", { n: batch.length })
1168
+ : t("Accept all ({n})", { n: batch.length })
1066
1169
  }
1067
1170
  aria-describedby={state?.decisionLock ? "review-lock" : undefined}
1068
- onClick={() => void decide("accept", undecided)}
1171
+ onClick={() => void decide("accept", batch)}
1069
1172
  >
1070
1173
  <span className="batch-label">
1071
1174
  {changes ? t("Stage all") : t("Accept all")}
1072
1175
  </span>
1073
- <span className="batch-count">{count.pending}</span>
1176
+ <span className="batch-count">{batch.length}</span>
1074
1177
  </button>
1075
1178
  )}
1076
1179
  {count.pending > 1 && !changes && (
@@ -1223,66 +1326,8 @@ export function ReviewApp({
1223
1326
  )}
1224
1327
  <div className="review-body">
1225
1328
  <main id="paper" ref={paper} tabIndex={-1} onScroll={onScroll}>
1226
- {(state?.commentLock || state?.decisionLock) && (
1227
- <div className="banner" id="review-lock" role="status">
1228
- {t((state.commentLock || state.decisionLock)!)}
1229
- </div>
1230
- )}
1231
- {state?.closed && !state.commentLock && (
1232
- <div className="banner">
1233
- {t(
1234
- "This review is finished. Accepted changes are in your manuscript.",
1235
- )}
1236
- </div>
1237
- )}
1238
- {state?.state === "build_failed" && (
1239
- <div className="banner">
1240
- <strong>
1241
- {changes
1242
- ? t("The comparison could not be built: ")
1243
- : t("The latest publish failed: ")}
1244
- </strong>
1245
- {state.job?.error}
1246
- {state.job?.details && (
1247
- <pre className="details">{state.job.details.join("\n")}</pre>
1248
- )}
1249
- </div>
1250
- )}
1251
- {state?.state === "building" && detail && !state.decisionLock && (
1252
- <div className="banner">
1253
- {changes
1254
- ? t(
1255
- "Updating the comparison with your latest edits. You can keep staging and reverting meanwhile.",
1256
- )
1257
- : t(
1258
- "Updating changes…",
1259
- )}
1260
- </div>
1261
- )}
1262
- {!changes && detail && state?.latest && detail.round !== state.latest && (
1263
- <div className="banner">
1264
- {t("Viewing earlier changes.")}{" "}
1265
- <a href={withLanguage(`?task=${encodeURIComponent(task)}`)}>
1266
- {t("Back to current changes →")}
1267
- </a>
1268
- </div>
1269
- )}
1270
- {detail?.warnings.length ? (
1271
- <div className="banner">
1272
- <details>
1273
- <summary>
1274
- {tn(
1275
- detail.warnings.length,
1276
- "{n} new LaTeX warning",
1277
- "{n} new LaTeX warnings",
1278
- )}
1279
- </summary>
1280
- <pre className="details">{detail.warnings.join("\n")}</pre>
1281
- </details>
1282
- </div>
1283
- ) : null}
1284
1329
  {changes && detail && !count.pending && (count.accepted > 0 || !stops.length) && (
1285
- <div className="review-complete" role="status">
1330
+ <div className="review-complete" role="status" tabIndex={-1}>
1286
1331
  <strong>
1287
1332
  {count.accepted
1288
1333
  ? t("All changes are staged")
@@ -1297,7 +1342,7 @@ export function ReviewApp({
1297
1342
  </div>
1298
1343
  )}
1299
1344
  {!changes && detail && !history && !count.pending && (
1300
- <div className="review-complete" role="status">
1345
+ <div className="review-complete" role="status" tabIndex={-1}>
1301
1346
  <strong>
1302
1347
  {t(
1303
1348
  state?.workspace?.status === "waiting_input"
@@ -1343,6 +1388,77 @@ export function ReviewApp({
1343
1388
  <div className="review-complete-actions" />
1344
1389
  </div>
1345
1390
  )}
1391
+ {(state?.commentLock || state?.decisionLock) && (
1392
+ <div className="banner" id="review-lock" role="status">
1393
+ {t((state.commentLock || state.decisionLock)!)}
1394
+ </div>
1395
+ )}
1396
+ {state?.closed && !state.commentLock && (
1397
+ <div className="banner">
1398
+ {t(
1399
+ "This review is finished. Accepted changes are in your manuscript.",
1400
+ )}
1401
+ </div>
1402
+ )}
1403
+ {state?.state === "build_failed" && (
1404
+ <div className="banner">
1405
+ <strong>
1406
+ {changes
1407
+ ? t("The comparison could not be built: ")
1408
+ : t("The latest publish failed: ")}
1409
+ </strong>
1410
+ {state.job?.error}
1411
+ {state.job?.details && (
1412
+ <pre className="details">{state.job.details.join("\n")}</pre>
1413
+ )}
1414
+ </div>
1415
+ )}
1416
+ {state?.state === "building" && detail && !state.decisionLock && (
1417
+ <div className="banner">
1418
+ {changes
1419
+ ? t(
1420
+ "Updating the comparison with your latest edits. You can keep staging and reverting meanwhile.",
1421
+ )
1422
+ : t(
1423
+ "Updating changes…",
1424
+ )}
1425
+ </div>
1426
+ )}
1427
+ {!changes && detail && state?.latest && detail.round !== state.latest && (
1428
+ <div className="banner">
1429
+ {t("Viewing earlier changes.")}{" "}
1430
+ <a href={withLanguage(`?task=${encodeURIComponent(task)}`)}>
1431
+ {t("Back to current changes →")}
1432
+ </a>
1433
+ </div>
1434
+ )}
1435
+ {detail?.warnings.length ? (
1436
+ <div className="banner">
1437
+ <details>
1438
+ <summary>
1439
+ {tn(
1440
+ detail.warnings.length,
1441
+ "{n} new LaTeX warning",
1442
+ "{n} new LaTeX warnings",
1443
+ )}
1444
+ </summary>
1445
+ <pre className="details">{detail.warnings.join("\n")}</pre>
1446
+ </details>
1447
+ </div>
1448
+ ) : null}
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
+ )}
1346
1462
  <div className="document">
1347
1463
  {detail ? (
1348
1464
  <>
@@ -1463,6 +1579,18 @@ export function ReviewApp({
1463
1579
  {toast}
1464
1580
  </div>
1465
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
+ )}
1466
1594
  {help && (
1467
1595
  <div className="help-backdrop" onClick={() => setHelp(false)}>
1468
1596
  <dialog