@khanglvm/relay 0.13.8 → 0.13.9

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/README.md CHANGED
@@ -89,7 +89,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
89
89
  | | |
90
90
  |---|---|
91
91
  | `rly help` | every command at a glance |
92
- | `rly git pick` / `rly git cherry-pick` | choose commit actions and rank the order directly on a board |
92
+ | `rly git pick` / `rly git cherry-pick` | choose commit actions and rank the order directly on a board; add `--code` to cherry-pick with split code review and per-hunk Apply/Skip/Hold |
93
93
  | `rly git conflict [files…]` | auto-detect unmerged conflict files, or open specific local paths, and return resolved content in `result.blockEdits` |
94
94
  | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
95
95
  | `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
package/docs/AGENT.md CHANGED
@@ -166,6 +166,7 @@ Pick/cherry-pick commits or resolve conflicts on a board:
166
166
  ```sh
167
167
  rly git pick --limit 20 # checklist: pick/cherry-pick/drop + rank order
168
168
  rly git cherry-pick main..feature # checklist: cherry-pick/skip/hold + rank order
169
+ rly git cherry-pick --code main..feature # adds split code review with Apply/Skip/Hold per hunk
169
170
  rly git conflict # auto-detect unmerged files with conflict markers
170
171
  rly git conflict src/app.js # resolve a specific conflicted file
171
172
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.8",
3
+ "version": "0.13.9",
4
4
  "description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -46,7 +46,7 @@ at — show it in a relay board instead of printing it.**
46
46
  | Gather requirements / plan approval / feedback round | **rly** |
47
47
  | Architecture or flow that benefits from a diagram | **rly** (mermaid block) |
48
48
  | "Show me the diff" / git diff / code changes / before-after | **rly** (`diff` block — run `git diff`, render it; never dump it in the terminal) |
49
- | Pick/cherry-pick commits or resolve conflict files | **rly git** (`rly git pick`, `rly git cherry-pick`, `rly git conflict`) |
49
+ | Pick/cherry-pick commits or resolve conflict files | **rly git** (`rly git pick`, `rly git cherry-pick --code`, `rly git conflict`) |
50
50
  | A demo, screen recording or walkthrough | **rly** (`video` block) |
51
51
  | Point the user at a file to open (log, capture, report) | **rly** (a clickable local file-link in markdown) |
52
52
  | Let the user read a markdown file (README, plan, report) | **rly view file.md** (or a `markdown` block with `mdFile`) — never dump the file into the terminal |
package/src/cli.js CHANGED
@@ -313,6 +313,19 @@ function gitCommitRows(range, limit) {
313
313
  .filter((r) => r.sha && r.short);
314
314
  }
315
315
 
316
+ function gitCommitDiff(commit) {
317
+ return runGit([
318
+ '--no-pager',
319
+ 'show',
320
+ '--format=',
321
+ '--patch',
322
+ '--find-renames',
323
+ '--find-copies',
324
+ '--no-ext-diff',
325
+ commit,
326
+ ], `git show ${commit} failed`).trimEnd();
327
+ }
328
+
316
329
  async function cmdGit(rest) {
317
330
  const args = parseArgs(rest);
318
331
  const modes = new Set(['pick', 'cherry-pick', 'cherrypick', 'conflict', 'conflicts', 'resolve']);
@@ -323,11 +336,13 @@ async function cmdGit(rest) {
323
336
 
324
337
  const blocks = [];
325
338
  const questions = [];
339
+ let reviewDiffCount = 0;
326
340
 
327
341
  if (mode === 'pick' || mode === 'cherry-pick') {
328
342
  const range = args.range || args._[0] || '';
329
343
  const commits = gitCommitRows(range, args.limit);
330
344
  if (!commits.length) throw new CliError(`git log${range ? ` ${range}` : ''} produced no commits to show.`);
345
+ const withCode = args.code === true || args.patch === true || args.diff === true;
331
346
  blocks.push({
332
347
  type: 'table',
333
348
  columns: ['short', 'date', 'author', 'subject'],
@@ -351,13 +366,32 @@ async function cmdGit(rest) {
351
366
  ? [{ value: 'cherry-pick', label: 'Cherry-pick', tone: 'ok' }, { value: 'skip', label: 'Skip', tone: 'muted' }, { value: 'hold', label: 'Hold', tone: 'warn' }]
352
367
  : [{ value: 'pick', label: 'Pick', tone: 'ok' }, { value: 'cherry-pick', label: 'Cherry-pick', tone: 'warn' }, { value: 'drop', label: 'Drop', tone: 'bad' }],
353
368
  });
354
- questions.push({
355
- id: 'commit_order',
356
- type: 'rank',
357
- label: mode === 'cherry-pick' ? 'Cherry-pick order' : 'Preferred commit order',
358
- description: 'Drag or move commits into the order the agent should apply them.',
359
- options,
360
- });
369
+ if (options.length > 1) {
370
+ questions.push({
371
+ id: 'commit_order',
372
+ type: 'rank',
373
+ label: mode === 'cherry-pick' ? 'Cherry-pick order' : 'Preferred commit order',
374
+ description: 'Drag or move commits into the order the agent should apply them.',
375
+ options,
376
+ });
377
+ }
378
+ if (withCode) {
379
+ for (const c of commits) {
380
+ const diff = gitCommitDiff(c.sha);
381
+ if (!diff.trim()) continue;
382
+ blocks.push({
383
+ type: 'diff',
384
+ title: `${c.short} ${c.subject}`,
385
+ filename: `${c.short} ${c.subject}`,
386
+ commit: c.sha,
387
+ reviewKind: mode,
388
+ review: true,
389
+ view: 'split',
390
+ diff,
391
+ });
392
+ reviewDiffCount++;
393
+ }
394
+ }
361
395
  }
362
396
 
363
397
  const paths = mode === 'conflict' ? gitConflictPaths(args._) : gitConflictPaths([]);
@@ -381,7 +415,9 @@ async function cmdGit(rest) {
381
415
  : mode === 'cherry-pick' ? 'Cherry-pick commits' : 'Pick commits');
382
416
  const intro = args.intro || (conflictCount
383
417
  ? 'Resolve each conflict on the board. The submitted result includes result.blockEdits with per-hunk choices and a full resolved file preview for every conflict block.'
384
- : 'Pick commit actions and order on the board. Submit returns the selected actions and rank order as JSON.');
418
+ : reviewDiffCount
419
+ ? 'Review commit code hunks on the board. Apply, skip, or hold each hunk; submit returns commit actions plus diff-review hunk choices as JSON.'
420
+ : 'Pick commit actions and order on the board. Submit returns the selected actions and rank order as JSON.');
385
421
  const spec = normalizeSpec({ title, intro, blocks, questions, submitLabel: 'Submit git choices' });
386
422
  await assertSpecReady(spec);
387
423
  const record = createBoard(spec);
@@ -1515,6 +1551,7 @@ USAGE
1515
1551
  --title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
1516
1552
  rly git pick [range] board for choosing pick/cherry-pick/drop actions plus commit order
1517
1553
  rly git cherry-pick [range] board for choosing commits and cherry-pick order
1554
+ add --code for split code view with per-hunk Apply/Skip/Hold
1518
1555
  rly git conflict [files…] board for resolving conflict-marker files; with no files, auto-detects
1519
1556
  unmerged git files and returns resolved content in result.blockEdits
1520
1557
  rly wait <id> [--timeout 3600] block until board finishes, print result JSON
package/src/server.js CHANGED
@@ -218,9 +218,40 @@ function sanitizeConflictResolution(value) {
218
218
  return Object.keys(out.resolutions).length ? out : null;
219
219
  }
220
220
 
221
+ function sanitizeDiffReview(value) {
222
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
223
+ if (value.type !== 'diff-review') return null;
224
+ const out = {
225
+ type: 'diff-review',
226
+ reviewKind: limitString(value.reviewKind, 80),
227
+ commit: limitString(value.commit, 120),
228
+ title: limitString(value.title, 500),
229
+ resolved: value.resolved === true,
230
+ hunks: {},
231
+ };
232
+ const raw = value.hunks && typeof value.hunks === 'object' && !Array.isArray(value.hunks)
233
+ ? value.hunks
234
+ : {};
235
+ let n = 0;
236
+ for (const id of Object.keys(raw)) {
237
+ if (n >= 500) break;
238
+ const h = raw[id];
239
+ if (!h || typeof h !== 'object' || Array.isArray(h)) continue;
240
+ const choice = limitString(h.choice, 30);
241
+ if (!['apply', 'skip', 'hold'].includes(choice)) continue;
242
+ out.hunks[limitString(id, 80)] = {
243
+ choice,
244
+ file: limitString(h.file, 1200),
245
+ header: limitString(h.header, 500),
246
+ };
247
+ n++;
248
+ }
249
+ return Object.keys(out.hunks).length ? out : null;
250
+ }
251
+
221
252
  // Validates + sanitizes an incoming blockEdits map (from draft/submit).
222
253
  // String values are editable Mermaid source. Structured git-conflict-resolution
223
- // values carry per-hunk choices plus resolved file content. Caps prevent a draft
254
+ // and diff-review values carry bounded per-hunk choices. Caps prevent a draft
224
255
  // from bloating board storage; invalid entries are dropped.
225
256
  function sanitizeBlockEdits(value) {
226
257
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
@@ -234,7 +265,7 @@ function sanitizeBlockEdits(value) {
234
265
  if (v.length > 20000) continue;
235
266
  out[key] = v;
236
267
  } else {
237
- const edit = sanitizeConflictResolution(v);
268
+ const edit = sanitizeConflictResolution(v) || sanitizeDiffReview(v);
238
269
  if (!edit) continue;
239
270
  out[key] = edit;
240
271
  }
package/src/spec.js CHANGED
@@ -492,6 +492,10 @@ function normalizeBlock(rawBlock, id, cwd, where) {
492
492
  const block = { id, type: 'diff', diff };
493
493
  if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
494
494
  if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
495
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
496
+ if (rawBlock.review === true) block.review = true;
497
+ if (rawBlock.reviewKind !== undefined) block.reviewKind = asStr(rawBlock.reviewKind);
498
+ if (rawBlock.commit !== undefined) block.commit = asStr(rawBlock.commit);
495
499
  const view = asStr(rawBlock.view).trim().toLowerCase();
496
500
  if (view === 'split' || view === 'unified') block.view = view;
497
501
  if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
@@ -1035,6 +1039,9 @@ const BLOCK_SCHEMA = {
1035
1039
  diff: { type: 'string', description: 'diff: a unified diff (git diff / diff -u output) — rendered as a colored, line-numbered comparison with +added / −removed / context rows and file/hunk headers. No git needed; just write/paste the diff text.' },
1036
1040
  diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
1037
1041
  view: { type: 'string', enum: ['unified', 'split'], description: 'diff: initial layout — "unified" (default, one column) or "split" (side-by-side old vs new). The viewer also has a live toggle either way.' },
1042
+ review: { type: 'boolean', description: 'diff: when true, render per-hunk Apply / Skip / Hold controls and return choices in result.blockEdits[blockId] as {type:"diff-review", hunks:{...}}. Used by `rly git cherry-pick --code`.' },
1043
+ reviewKind: { type: 'string', description: 'diff review: optional label for the review workflow, such as "cherry-pick" or "pick".' },
1044
+ commit: { type: 'string', description: 'diff review: optional commit SHA associated with this diff.' },
1038
1045
  content: { type: 'string', description: 'git-conflict: inline file content containing conflict markers (<<<<<<< / ======= / >>>>>>>). The board auto-detects each hunk and returns resolutions in result.blockEdits[blockId].' },
1039
1046
  text: { type: 'string', description: 'git-conflict: alias for inline "content".' },
1040
1047
  file: { type: 'string', description: 'git-conflict: local conflicted file path to load, parse, and resolve. Resolved against the CWD.' },
@@ -1179,7 +1186,7 @@ export const SPEC_SCHEMA = {
1179
1186
  type: 'object',
1180
1187
  readOnly: true,
1181
1188
  description:
1182
- 'Returned in the result (not part of the input spec). A map blockId→edited block payload. Editable mermaid blocks return edited source strings. git-conflict blocks return {type:"git-conflict-resolution", resolutions, content, resolved, filename, file}. null when the user made no edits.',
1189
+ 'Returned in the result (not part of the input spec). A map blockId→edited block payload. Editable mermaid blocks return edited source strings. git-conflict blocks return {type:"git-conflict-resolution", resolutions, content, resolved, filename, file}. diff review blocks return {type:"diff-review", hunks, resolved, commit}. null when the user made no edits.',
1183
1190
  },
1184
1191
  },
1185
1192
  anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
package/src/ui/blocks.css CHANGED
@@ -260,6 +260,68 @@
260
260
  }
261
261
  .diff-file-chip:hover { color: var(--accent); border-color: var(--accent); }
262
262
 
263
+ /* diff review: cherry-pick/apply hunk choices on top of the diff renderer */
264
+ .diff-review-head {
265
+ align-items: flex-start;
266
+ }
267
+ .diff-review-topactions {
268
+ display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px;
269
+ }
270
+ .diff-review-status {
271
+ flex: none; border: 1px solid rgba(185, 145, 63, 0.34);
272
+ background: rgba(185, 145, 63, 0.12); color: var(--warn);
273
+ border-radius: 999px; padding: 3px 8px; font-size: 0.72rem; font-weight: 760;
274
+ }
275
+ .diff-review-status.is-complete {
276
+ border-color: rgba(77, 138, 102, 0.42);
277
+ background: rgba(77, 138, 102, 0.14); color: var(--ok);
278
+ }
279
+ .diff-review-btn {
280
+ flex: none; cursor: pointer; font: inherit; font-size: 0.72rem; font-weight: 700;
281
+ color: var(--fg-2); background: var(--card);
282
+ border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
283
+ transition: color 150ms var(--ease), border-color 150ms var(--ease), background 150ms var(--ease);
284
+ }
285
+ .diff-review-btn:hover {
286
+ color: var(--accent); border-color: var(--accent);
287
+ }
288
+ .diff-review-choice.sel {
289
+ color: var(--accent); border-color: var(--accent); background: var(--accent-soft);
290
+ }
291
+ .diff-review-list {
292
+ border: 1px solid var(--border); border-radius: 0 0 10px 10px;
293
+ overflow: hidden; background: var(--bg-sunken);
294
+ }
295
+ .diff-review-hunk + .diff-review-hunk {
296
+ border-top: 1px solid var(--border);
297
+ }
298
+ .diff-review-hunkbar {
299
+ display: flex; justify-content: space-between; align-items: center; gap: 10px;
300
+ padding: 6px 8px 6px 12px; background: var(--card); border-bottom: 1px solid var(--border);
301
+ }
302
+ .diff-review-hunkmeta {
303
+ min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
304
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
305
+ }
306
+ .diff-review-file {
307
+ max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
308
+ color: var(--fg-2); font-size: 0.76rem; font-weight: 760;
309
+ }
310
+ .diff-review-header {
311
+ color: var(--muted); font-size: 0.72rem;
312
+ }
313
+ .diff-review-actions {
314
+ flex: none; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px;
315
+ }
316
+ .diff-review-hunk .blk-difftable {
317
+ min-width: 760px;
318
+ }
319
+ @media (max-width: 720px) {
320
+ .diff-review-head, .diff-review-hunkbar { flex-direction: column; align-items: flex-start; }
321
+ .diff-review-topactions, .diff-review-actions { justify-content: flex-start; }
322
+ .diff-review-file { max-width: 100%; }
323
+ }
324
+
263
325
  /* ---------- git conflict resolver ---------- */
264
326
  .gitconf {
265
327
  border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
package/src/ui/blocks.js CHANGED
@@ -582,8 +582,159 @@
582
582
  // Per-file header lines are redundant once we render a filename header bar.
583
583
  const DIFF_NOISE = /^(diff --git |index |--- |\+\+\+ )/;
584
584
 
585
+ function buildDiffReviewHunks(files, block) {
586
+ const hunks = [];
587
+ files.forEach((f) => {
588
+ const name = f.name || (files.length === 1 ? (block.filename || '') : '') || 'file';
589
+ let rows = parseDiff(f.text);
590
+ if (name) rows = rows.filter((r) => !(r.kind === 'meta' && DIFF_NOISE.test(r.text)));
591
+ let current = null;
592
+ const push = () => {
593
+ if (!current) return;
594
+ const hasChange = current.rows.some((r) => r.kind === 'add' || r.kind === 'del');
595
+ if (hasChange) hunks.push(current);
596
+ current = null;
597
+ };
598
+ rows.forEach((r) => {
599
+ if (r.kind === 'hunk') {
600
+ push();
601
+ current = { id: 'h' + (hunks.length + 1), file: name, header: r.text, rows: [r] };
602
+ } else if (current) {
603
+ current.rows.push(r);
604
+ } else if (r.kind === 'add' || r.kind === 'del' || r.kind === 'ctx') {
605
+ current = { id: 'h' + (hunks.length + 1), file: name, header: 'Change', rows: [r] };
606
+ }
607
+ });
608
+ push();
609
+ });
610
+ return hunks;
611
+ }
612
+
613
+ function renderDiffReview(block, ctx, blockId, files) {
614
+ const hunks = buildDiffReviewHunks(files, block);
615
+ let view = block.view === 'split' ? 'split' : 'unified';
616
+ const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
617
+ ? ctx.edits[blockId]
618
+ : {};
619
+ const choices = prior.hunks && typeof prior.hunks === 'object' ? { ...prior.hunks } : {};
620
+ const buttonsByHunk = new Map();
621
+ const wrap = el('div', { class: 'blk-codewrap blk-diffwrap diff-review' });
622
+ const status = el('span', { class: 'diff-review-status' }, '');
623
+ const list = el('div', { class: 'diff-review-list' });
624
+
625
+ const emit = () => {
626
+ const picked = {};
627
+ hunks.forEach((h) => {
628
+ const r = choices[h.id];
629
+ if (!r || !['apply', 'skip', 'hold'].includes(r.choice)) return;
630
+ picked[h.id] = { choice: r.choice, file: h.file, header: h.header };
631
+ });
632
+ const touched = Object.keys(picked).length;
633
+ const payload = touched
634
+ ? {
635
+ type: 'diff-review',
636
+ reviewKind: block.reviewKind || '',
637
+ commit: block.commit || '',
638
+ title: block.title || block.filename || '',
639
+ resolved: touched === hunks.length,
640
+ hunks: picked,
641
+ }
642
+ : null;
643
+ if (ctx && typeof ctx.onBlockEdit === 'function') ctx.onBlockEdit(blockId, payload);
644
+ wrap.dispatchEvent(new CustomEvent('relay:block-edit', {
645
+ bubbles: true,
646
+ detail: { blockId, value: payload },
647
+ }));
648
+ status.textContent = `${touched}/${hunks.length} reviewed`;
649
+ status.classList.toggle('is-complete', hunks.length > 0 && touched === hunks.length);
650
+ for (const h of hunks) {
651
+ const set = buttonsByHunk.get(h.id) || [];
652
+ const chosen = choices[h.id] && choices[h.id].choice;
653
+ set.forEach((b) => b.el.classList.toggle('sel', b.choice === chosen));
654
+ }
655
+ };
656
+
657
+ const setChoice = (h, choice) => {
658
+ choices[h.id] = { choice, file: h.file, header: h.header };
659
+ emit();
660
+ };
661
+
662
+ const setAll = (choice) => {
663
+ hunks.forEach((h) => {
664
+ choices[h.id] = { choice, file: h.file, header: h.header };
665
+ });
666
+ emit();
667
+ };
668
+
669
+ const makeReviewButton = (label, onClick, extraClass = '') => {
670
+ const b = el('button', { type: 'button', class: 'diff-review-btn' + (extraClass ? ' ' + extraClass : '') }, label);
671
+ b.addEventListener('mousedown', (e) => e.stopPropagation());
672
+ b.addEventListener('click', (e) => {
673
+ e.preventDefault();
674
+ e.stopPropagation();
675
+ onClick();
676
+ });
677
+ return b;
678
+ };
679
+
680
+ const repaint = () => {
681
+ list.replaceChildren();
682
+ hunks.forEach((h) => {
683
+ const actionbar = el('div', { class: 'diff-review-hunkbar' },
684
+ el('div', { class: 'diff-review-hunkmeta' },
685
+ el('span', { class: 'diff-review-file' }, h.file),
686
+ el('span', { class: 'diff-review-header' }, h.header)
687
+ )
688
+ );
689
+ const rowButtons = [
690
+ { choice: 'apply', label: 'Apply hunk' },
691
+ { choice: 'skip', label: 'Skip hunk' },
692
+ { choice: 'hold', label: 'Hold' },
693
+ ].map((item) => ({
694
+ choice: item.choice,
695
+ el: makeReviewButton(item.label, () => setChoice(h, item.choice), 'diff-review-choice'),
696
+ }));
697
+ buttonsByHunk.set(h.id, rowButtons);
698
+ actionbar.append(el('div', { class: 'diff-review-actions' }, rowButtons.map((b) => b.el)));
699
+ list.append(el('section', { class: 'diff-review-hunk' },
700
+ actionbar,
701
+ view === 'split' ? buildSplitDiff(h.rows, block.lang) : buildUnifiedDiff(h.rows, block.lang)
702
+ ));
703
+ });
704
+ emit();
705
+ };
706
+
707
+ const toggle = makeReviewButton('', () => {
708
+ view = view === 'split' ? 'unified' : 'split';
709
+ wrap.classList.toggle('is-split', view === 'split');
710
+ toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
711
+ repaint();
712
+ }, 'blk-difftoggle');
713
+ toggle.title = 'Toggle side-by-side view';
714
+ toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
715
+
716
+ const title = block.title || block.filename || (files.length > 1 ? `${files.length} files changed` : 'Diff review');
717
+ wrap.append(el('div', { class: 'blk-codehead diff-review-head' },
718
+ el('span', { class: 'blk-codename' }, title),
719
+ el('div', { class: 'diff-review-topactions' },
720
+ status,
721
+ makeReviewButton('Apply all', () => setAll('apply')),
722
+ makeReviewButton('Skip all', () => setAll('skip')),
723
+ makeReviewButton('Hold all', () => setAll('hold')),
724
+ toggle
725
+ )
726
+ ));
727
+ wrap.classList.toggle('is-split', view === 'split');
728
+ wrap.append(list);
729
+ repaint();
730
+ ctx && ctx.annotate && ctx.annotate.enableTextSelection(list, { blockId, questionId: ctx.questionId });
731
+ attachViewer(wrap, { zoomEl: null, label: 'diff review', comment: wholeBlockComment(ctx, blockId, 'diff review') });
732
+ return wrap;
733
+ }
734
+
585
735
  function renderDiff(block, ctx, blockId) {
586
736
  const files = splitDiffFiles(block.diff);
737
+ if (block.review === true) return renderDiffReview(block, ctx, blockId, files);
587
738
  const named = files.filter((f) => f.name);
588
739
  const multi = named.length > 1;
589
740
  let view = block.view === 'split' ? 'split' : 'unified';