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