@kontourai/survey 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,9 +14,11 @@
14
14
  */
15
15
  import { type ServerReviewSessionRecord } from "../review-workbench/server-review-session.js";
16
16
  import { type ReviewQueueBinding } from "../review-workbench/queue-binding.js";
17
- import type { ReviewWorkbenchResult } from "../review-workbench/review-workbench.js";
17
+ import type { ReviewWorkbenchResult, ReviewQueuePresentation } from "../review-workbench/review-workbench.js";
18
18
  import type { ReviewSessionEvent } from "../review-resource.js";
19
19
  export interface ReviewConsoleServerOptions {
20
+ /** Producer-owned display hints; independent of queue binding and application authority. */
21
+ readonly presentation?: ReviewQueuePresentation;
20
22
  /** Absolute path to the session JSON file. */
21
23
  readonly sessionPath: string;
22
24
  /** TCP port. Defaults to 0 (OS-assigned). */
@@ -65,7 +65,12 @@ async function readSession(path) {
65
65
  // ---------------------------------------------------------------------------
66
66
  function createSseBroadcaster() {
67
67
  const clients = new Set();
68
+ let closed = false;
68
69
  function add(res) {
70
+ if (closed) {
71
+ res.end();
72
+ return;
73
+ }
69
74
  clients.add(res);
70
75
  res.on("close", () => clients.delete(res));
71
76
  }
@@ -80,7 +85,13 @@ function createSseBroadcaster() {
80
85
  }
81
86
  }
82
87
  }
83
- return { add, broadcast };
88
+ function close() {
89
+ closed = true;
90
+ for (const res of clients)
91
+ res.end();
92
+ clients.clear();
93
+ }
94
+ return { add, broadcast, close };
84
95
  }
85
96
  // ---------------------------------------------------------------------------
86
97
  // File watcher with debounce + polling fallback
@@ -275,6 +286,7 @@ body {
275
286
  color: var(--k-text, #eef3f8);
276
287
  font-size: 13px;
277
288
  }
289
+ .console-save-status[data-state="success"] { border-color: var(--k-positive); }
278
290
  .console-save-status[hidden] { display: none; }
279
291
  #review-workbench {
280
292
  flex: 1;
@@ -314,7 +326,7 @@ body {
314
326
  </button>
315
327
  </div>
316
328
  </header>
317
- ${canApply ? '<div style="display:flex;justify-content:flex-end;padding:8px 16px"><button type="button" id="apply-review" class="theme-toggle">Send review to workflow</button></div>' : ''}
329
+ ${canApply ? '<div style="display:flex;justify-content:flex-end;padding:8px 16px"><button type="button" id="apply-review" class="theme-toggle" disabled>Send review to workflow</button></div>' : ''}
318
330
  <div id="console-save-status" class="console-save-status" role="alert" data-testid="console-save-status" hidden></div>
319
331
  <main id="review-workbench" class="workbench survey-workbench-embed" data-testid="review-workbench"></main>
320
332
  <script type="module">
@@ -331,11 +343,13 @@ import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionNa
331
343
  // stored session, re-mounts from it and tells the reviewer once.
332
344
  let saveChain = Promise.resolve();
333
345
 
334
- function showSaveStatus(message) {
346
+ function showSaveStatus(message, state = "error") {
335
347
  const el = document.getElementById("console-save-status");
336
348
  if (!el) return;
337
349
  if (message) {
338
350
  el.textContent = message;
351
+ el.dataset.state = state;
352
+ el.setAttribute("role", state === "success" ? "status" : "alert");
339
353
  el.hidden = false;
340
354
  } else {
341
355
  el.textContent = "";
@@ -355,7 +369,9 @@ function createConsoleEventStore(baseRevision) {
355
369
 
356
370
  async function refused(message) {
357
371
  abandoned = true;
358
- showSaveStatus(message);
372
+ saveFailed = true;
373
+ updateApplyButton();
374
+ showSaveStatus(message + " Reload before sending the review, or retry the change.");
359
375
  await fetchAndMount();
360
376
  }
361
377
 
@@ -376,9 +392,11 @@ function createConsoleEventStore(baseRevision) {
376
392
  return;
377
393
  }
378
394
  if (res.ok) {
379
- const body = await res.json().catch(() => ({}));
380
- if (typeof body.revision === "string") { revision = body.revision; currentRevision = revision; }
395
+ const body = await res.json();
396
+ if (typeof body.revision !== "string") throw new Error("Missing saved revision");
397
+ revision = body.revision; currentRevision = revision;
381
398
  persistedCount = localEvents.length;
399
+ saveFailed = false;
382
400
  showSaveStatus("");
383
401
  return;
384
402
  }
@@ -393,9 +411,18 @@ function createConsoleEventStore(baseRevision) {
393
411
  return {
394
412
  // The mount state is already replayed from the stored log.
395
413
  load: () => undefined,
396
- save: (_session, events) => {
414
+ save: (session, events) => {
415
+ currentState = session;
416
+ stateGeneration++;
417
+ pendingSaveCount++;
418
+ updateApplyButton();
397
419
  const localEvents = [...events];
398
- saveChain = saveChain.then(() => persist(localEvents));
420
+ saveChain = saveChain.then(() => persist(localEvents)).catch(async () => {
421
+ await refused("Your last change was not saved. The console reloaded the stored session.");
422
+ }).finally(() => {
423
+ pendingSaveCount--;
424
+ updateApplyButton();
425
+ });
399
426
  pendingSave = saveChain;
400
427
  return saveChain;
401
428
  },
@@ -407,9 +434,19 @@ let currentRevision = null;
407
434
  let applying = false;
408
435
  let wasApplied = false;
409
436
  let pendingSave = Promise.resolve();
437
+ let pendingSaveCount = 0;
438
+ let saveFailed = false;
439
+ let currentState = null;
440
+ let stateGeneration = 0;
441
+ let fetchGeneration = 0;
442
+ function updateApplyButton() {
443
+ if (!applyButton) return;
444
+ const complete = currentState?.items?.length > 0 && currentState.items.every(item => currentState.decisionsByItemName[item.metadata.name] !== undefined);
445
+ applyButton.disabled = applying || wasApplied || pendingSaveCount > 0 || saveFailed || !currentRevision || !complete;
446
+ }
410
447
  const applyButton = document.getElementById("apply-review");
411
448
  applyButton?.addEventListener("click", async () => {
412
- if (applying || !currentRevision) return;
449
+ if (applyButton.disabled || applying || !currentRevision) return;
413
450
  applying = true;
414
451
  applyButton.disabled = true;
415
452
  try {
@@ -419,31 +456,41 @@ applyButton?.addEventListener("click", async () => {
419
456
  body: JSON.stringify({ baseRevision: currentRevision }),
420
457
  });
421
458
  const body = await response.json();
422
- showSaveStatus(response.ok ? body.application.summary : body.error);
459
+ showSaveStatus(response.ok ? body.application.summary : body.error, response.ok ? "success" : "error");
423
460
  await fetchAndMount();
424
- } catch { showSaveStatus("The review could not reach the workflow. Reload before trying again."); }
425
- finally { applying = false; applyButton.disabled = wasApplied || !currentRevision; }
461
+ } catch { saveFailed = true; showSaveStatus("The review could not reach the workflow. Reload before trying again."); }
462
+ finally { applying = false; updateApplyButton(); }
426
463
  });
427
464
 
428
465
  async function fetchAndMount() {
466
+ const fetchBasis = ++fetchGeneration;
467
+ const stateBasis = stateGeneration;
429
468
  try {
430
469
  const res = await fetch("/api/session");
470
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
431
471
  if (!res.ok) {
432
472
  const body = await res.json().catch(() => ({}));
473
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
474
+ currentRevision = null;
475
+ updateApplyButton();
433
476
  showSaveStatus(body.error || ("The session could not be loaded (HTTP " + res.status + ")."));
434
477
  const root = document.getElementById("review-workbench");
435
478
  if (root) root.innerHTML = "";
436
479
  return;
437
480
  }
438
481
  const data = await res.json();
439
- const { snapshot, events, revision, extractionImport } = data;
482
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
483
+ const { snapshot, events, revision, extractionImport, presentation } = data;
440
484
  currentRevision = revision;
441
485
  wasApplied = Boolean(data.producerApplication);
442
- if (applyButton) applyButton.disabled = applying || wasApplied;
486
+ if (data.producerApplication) showSaveStatus(data.producerApplication.summary, "success");
443
487
  const state = events && events.length > 0
444
488
  ? replayReviewSessionEvents(snapshot, events)
445
489
  : snapshot;
446
490
 
491
+ currentState = state;
492
+ updateApplyButton();
493
+
447
494
  // Preserve active item selection across reloads
448
495
  if (activeItemName && state.items && state.items.some(i => i.metadata.name === activeItemName)) {
449
496
  state.activeItemName = activeItemName;
@@ -452,8 +499,14 @@ async function fetchAndMount() {
452
499
  const root = document.getElementById("review-workbench");
453
500
  if (!root) return;
454
501
 
455
- mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision), ...(extractionImport ? { extractionImport } : {}) });
502
+ mountReviewWorkbench(root, state, { hideApply: Boolean(applyButton), queuePresentation: presentation, eventStore: createConsoleEventStore(revision), ...(extractionImport ? { extractionImport } : {}) });
503
+ if (wasApplied) root.querySelectorAll("button[data-item-name], input[data-item-name], textarea[data-item-name], select[data-item-name]").forEach(control => { control.disabled = true; });
456
504
  } catch (err) {
505
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
506
+ currentRevision = null;
507
+ currentState = null;
508
+ updateApplyButton();
509
+ showSaveStatus("The session could not be loaded. Reload before sending the review.");
457
510
  console.error("[console] Mount error:", err);
458
511
  }
459
512
  }
@@ -475,7 +528,7 @@ function connectSse() {
475
528
  if (label) label.textContent = "live";
476
529
  });
477
530
  sse.addEventListener("update", () => {
478
- fetchAndMount();
531
+ if (pendingSaveCount === 0 && !applying) fetchAndMount();
479
532
  });
480
533
  sse.addEventListener("error", () => {
481
534
  if (dot) dot.classList.add("disconnected");
@@ -653,6 +706,7 @@ export async function startReviewConsoleServer(options) {
653
706
  events: content.events,
654
707
  revision: reviewSessionRevision(content.events),
655
708
  state: currentSessionState(content.snapshot, content.events),
709
+ ...(options.presentation ? { presentation: options.presentation } : {}),
656
710
  queueAttestation: attestation.state,
657
711
  ...(content.producerApplication ? { producerApplication: content.producerApplication } : {}),
658
712
  ...(content.extractionImport !== undefined ? { extractionImport: content.extractionImport } : {}),
@@ -802,12 +856,14 @@ export async function startReviewConsoleServer(options) {
802
856
  listenPort = address.port;
803
857
  const normalizedHost = host === "::1" ? "[::1]" : host;
804
858
  const url = `http://${normalizedHost}:${address.port}/`;
859
+ let closing;
805
860
  return {
806
861
  url,
807
862
  port: address.port,
808
863
  host,
809
- close: () => new Promise((resolveClosed, reject) => {
864
+ close: () => closing ??= new Promise((resolveClosed, reject) => {
810
865
  stopWatcher();
866
+ broadcaster.close();
811
867
  server.close((err) => (err ? reject(err) : resolveClosed()));
812
868
  }),
813
869
  };
@@ -1781,6 +1781,17 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
1781
1781
  }
1782
1782
  }
1783
1783
 
1784
+ .survey-workbench-embed .review-context{
1785
+ margin: var(--k-space-4);
1786
+ padding: var(--k-space-4);
1787
+ border: var(--k-border-thin) solid var(--k-line-strong);
1788
+ border-radius: var(--k-radius-sm);
1789
+ background: var(--k-panel-raised);
1790
+ color: var(--k-text);
1791
+ white-space: pre-wrap;
1792
+ overflow-wrap: anywhere;
1793
+ }
1794
+
1784
1795
 
1785
1796
  .survey-workbench-embed {
1786
1797
  overflow: hidden;
@@ -1778,6 +1778,17 @@
1778
1778
  }
1779
1779
  }
1780
1780
 
1781
+ .survey-workbench-embed .review-context{
1782
+ margin: var(--k-space-4);
1783
+ padding: var(--k-space-4);
1784
+ border: var(--k-border-thin) solid var(--k-line-strong);
1785
+ border-radius: var(--k-radius-sm);
1786
+ background: var(--k-panel-raised);
1787
+ color: var(--k-text);
1788
+ white-space: pre-wrap;
1789
+ overflow-wrap: anywhere;
1790
+ }
1791
+
1781
1792
 
1782
1793
  .survey-workbench-embed {
1783
1794
  overflow: hidden;
@@ -100,7 +100,20 @@ export interface PersistentReviewSessionEventStoreOptions {
100
100
  readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
101
101
  readonly onStatusChange?: (state: ReviewSessionPersistenceState) => void;
102
102
  }
103
+ /** Serializable producer-owned display hints. Never alter canonical items, events or apply authority. */
104
+ export interface ReviewQueuePresentation {
105
+ readonly title?: string;
106
+ readonly context?: string;
107
+ readonly items?: Readonly<Record<string, {
108
+ readonly title?: string;
109
+ readonly kind?: "acknowledgment" | "choice";
110
+ readonly candidateLabels?: Readonly<Record<string, string>>;
111
+ }>>;
112
+ }
103
113
  export interface MountReviewWorkbenchOptions {
114
+ readonly queuePresentation?: ReviewQueuePresentation;
115
+ /** A host that owns submission can hide the local apply/export control. */
116
+ readonly hideApply?: boolean;
104
117
  readonly eventStore?: ReviewSessionEventStore;
105
118
  readonly presentationAdapter?: ReviewPresentationAdapter;
106
119
  /**
@@ -238,6 +251,8 @@ export declare function createPersistentReviewSessionEventStore(options: Persist
238
251
  export declare function renderReviewWorkbenchHtml(state: ReviewWorkbenchState | ReviewQueueSessionState, _events?: readonly ReviewSessionEvent[], options?: {
239
252
  readonly presentationAdapter?: ReviewPresentationAdapter;
240
253
  readonly queueView?: Partial<ReviewQueueView>;
254
+ readonly queuePresentation?: ReviewQueuePresentation;
255
+ readonly hideApply?: boolean;
241
256
  /** See {@link MountReviewWorkbenchOptions.extractionImport}. */
242
257
  readonly extractionImport?: ExtractionEnvelopeImport | ExtractionEnvelopeImportResult;
243
258
  /** See {@link MountReviewWorkbenchOptions.fieldStates}. */
@@ -135,10 +135,12 @@ function buildDecisionCardAuthorizing(state, adapter) {
135
135
  * Renders the decision card's prompt for a review item and decision.
136
136
  *
137
137
  * It is rebuilt from the item when the decision is built, not captured from
138
- * what a reviewer's screen showed: it records what the card states for this
139
- * item, with the same presentation adapter, target label and value text.
138
+ * what a reviewer's screen showed: it records a deterministic semantic basis
139
+ * from the canonical item, decision and presentation adapter. Queue display
140
+ * hints (title, context, action labels) are not a verbatim prompt capture and
141
+ * do not change this server-reconstructable authorizing basis.
140
142
  *
141
- * Format mirrors the review question shown in the workbench header:
143
+ * The current/proposed semantic form is:
142
144
  * "For {target}, decide whether {proposed} should replace {current}."
143
145
  * followed by the selected decision label so the block is self-contained.
144
146
  */
@@ -523,7 +525,7 @@ export function createPersistentReviewSessionEventStore(options) {
523
525
  }
524
526
  export function renderReviewWorkbenchHtml(state, _events, options = {}) {
525
527
  const session = queueSessionFromStartState(state);
526
- return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport), options.fieldStates);
528
+ return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport), options.fieldStates, options.queuePresentation, options.hideApply);
527
529
  }
528
530
  /**
529
531
  * The visual/decision state of a field card, derived from the reviewer's local
@@ -611,7 +613,7 @@ function isEmptyValue(value) {
611
613
  }
612
614
  const ARROW_SVG = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M5 12h14M13 6l6 6-6 6\"/></svg>";
613
615
  const WARNING_SVG = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z\"/></svg>";
614
- function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation, fieldStates) {
616
+ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation, fieldStates, queuePresentation, hideApply) {
615
617
  // A queue that does not match its stored extraction import is not reviewed:
616
618
  // its items could hide or alter what the source said.
617
619
  if (attestation.state === "diverges") {
@@ -641,7 +643,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
641
643
  <div class="top">
642
644
  <div class="subj">
643
645
  <p class="eyebrow">Proposed changes</p>
644
- <h1>${escapeHtml(headerTitle)}</h1>
646
+ <h1>${escapeHtml(queuePresentation?.title ?? headerTitle)}</h1>
645
647
  </div>
646
648
  </div>
647
649
  <div class="meta">
@@ -650,9 +652,10 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
650
652
  <div class="progress">
651
653
  <div class="bar"><i style="width:${progressPct}%"></i></div>
652
654
  <span class="ptext"><b data-testid="decided-count">${decidedCount}</b> of <b>${totalCount}</b> decided</span>
653
- <button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>
655
+ ${hideApply ? "" : `<button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>`}
654
656
  </div>
655
657
  </header>
658
+ ${queuePresentation?.context ? `<section class="review-context" data-testid="review-context" aria-label="Review context">${escapeHtml(queuePresentation.context)}</section>` : ""}
656
659
  ${fieldStates ? renderFieldStatesHtml(fieldStates, session) : ""}
657
660
  <nav class="queue-controls" aria-label="Review queue navigation">
658
661
  <label>
@@ -673,7 +676,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
673
676
  </div>` : ""}
674
677
  </nav>
675
678
  <div class="fields" data-testid="review-fields">
676
- ${window.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")
679
+ ${window.items.map((item) => renderFieldCard(item, session, presentationAdapter, queuePresentation?.items?.[item.metadata.name])).join("")
677
680
  || '<p class="queue-empty">No review fields match the current search and disposition.</p>'}
678
681
  </div>
679
682
  ${renderFooterTally(session)}
@@ -691,7 +694,7 @@ function queueDispositionOptions(selected) {
691
694
  ];
692
695
  return options.map(([value, label]) => `<option value="${value}"${selected === value ? " selected" : ""}>${label}</option>`).join("");
693
696
  }
694
- function renderFieldCard(item, session, presentationAdapter) {
697
+ function renderFieldCard(item, session, presentationAdapter, hints) {
695
698
  const decision = session.decisionsByItemName[item.metadata.name];
696
699
  const state = fieldCardState(item, decision);
697
700
  const decided = decision !== undefined;
@@ -703,6 +706,10 @@ function renderFieldCard(item, session, presentationAdapter) {
703
706
  const conflict = proposedCandidates.length > 1;
704
707
  const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
705
708
  const presentation = buildReviewItemPresentation(item, presentationAdapter);
709
+ const title = hints?.title ?? presentation.targetLabel;
710
+ const acknowledgment = hints?.kind === "acknowledgment" && proposed !== undefined;
711
+ const choice = hints?.kind === "choice";
712
+ const statusLabel = decision === "could-not-confirm" ? "Could not confirm" : choice ? (decided ? "Action recorded" : "Choose an action") : acknowledgment ? (decided ? "Reviewed" : "Needs review") : chipLabel(state, current !== undefined && !isEmptyValue(current.value), conflict ? proposedCandidates.length : undefined);
706
713
  const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
707
714
  const kind = hasCurrentValue ? "Update" : "New";
708
715
  const keepLabel = hasCurrentValue ? "Keep current" : conflict ? "Reject all values" : "Leave unset";
@@ -734,26 +741,28 @@ function renderFieldCard(item, session, presentationAdapter) {
734
741
  <div class="stripe"></div>
735
742
  <div class="fbody">
736
743
  <div class="frow1">
737
- <span class="fname">${escapeHtml(presentation.targetLabel)}</span>
744
+ <span class="fname">${escapeHtml(title)}</span>
738
745
  <span class="fkind">${kind}</span>
739
- <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
746
+ <span class="chip ${state} push" data-testid="field-chip">${statusLabel}</span>
740
747
  </div>
741
748
  ${proposed
742
749
  ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
743
750
  : proposedCandidates.length > 1
744
- ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name])
751
+ ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name], choice ? hints : undefined)
745
752
  : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
746
753
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter, isScoreBlind(session)) : ""}
747
754
  ${renderExtractionImportNotes(presentation)}
748
755
  ${isScoreBlind(session) ? "" : renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
756
+ ${choice ? renderReviewerNote(item, session) : ""}
749
757
  <div class="decide">
750
- ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
751
- ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
752
- <label class="wrong">
758
+ ${acknowledgment || keepDecision === undefined || (choice && !current) ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(choice && current ? hints?.candidateLabels?.[current.id] ?? keepLabel : keepLabel)}</button>`}
759
+ ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">${acknowledgment ? "Mark reviewed" : choice ? escapeHtml(hints?.candidateLabels?.[proposed.id] ?? proposedPresentationText) : "Use proposed"}</button>
760
+ ${acknowledgment || choice ? "" : `<label class="wrong">
753
761
  <input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
754
762
  Suggestion was wrong
755
763
  </label>
756
- <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
764
+ <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>`}
765
+ ${acknowledgment || choice ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}` : ""}
757
766
  ${conflict ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
758
767
  </div>
759
768
  <!--
@@ -765,10 +774,10 @@ function renderFieldCard(item, session, presentationAdapter) {
765
774
  -->
766
775
  <span class="derr" data-testid="decision-error" role="alert" hidden></span>
767
776
  <div class="decided">
768
- <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
777
+ <span class="chip ${state}" data-testid="decided-chip">${statusLabel}</span>
769
778
  <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
770
779
  </div>
771
- ${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
780
+ ${renderAuditDetails(item, current, proposed, session, presentationAdapter, choice)}
772
781
  </div>
773
782
  </section>
774
783
  `;
@@ -782,23 +791,24 @@ function renderFieldCard(item, session, presentationAdapter) {
782
791
  * one value. Proposals excluded at import are not candidates: they are listed
783
792
  * by {@link renderExtractionImportNotes} and cannot be chosen.
784
793
  */
785
- function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId) {
794
+ function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId, hints) {
786
795
  const chose = decision === "select-proposed";
787
796
  const values = candidates.map((candidate) => {
788
797
  const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
798
+ const actionLabel = hints?.candidateLabels?.[candidate.id] ?? text;
789
799
  const excerpt = candidate.locator?.excerpt;
790
800
  const chosen = chose && candidate.id === selectedCandidateId;
791
801
  const mark = chose
792
802
  ? ` <span class="choice-tag ${chosen ? "chosen" : "not-chosen"}" data-testid="choice-tag">${chosen ? "Chosen" : "Not chosen"}</span>`
793
803
  : "";
794
804
  const control = decision === undefined
795
- ? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}">Use this value</button>`
805
+ ? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="${hints ? escapeHtml(actionLabel) : `Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}`}">${hints ? escapeHtml(actionLabel) : "Use this value"}</button>`
796
806
  : "";
797
807
  return `<li data-testid="conflicting-value" data-candidate-id="${escapeHtml(candidate.id)}"${chose ? ` data-chosen="${chosen ? "true" : "false"}"` : ""}>
798
808
  <span class="conflict-text"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}${mark}</span>${control}
799
809
  </li>`;
800
810
  }).join("");
801
- const intro = decision === undefined
811
+ const intro = hints ? (decision === undefined ? "Choose an action. Add a reviewer note when a correction or explanation is needed." : "Action recorded.") : decision === undefined
802
812
  ? `${candidates.length} different values were proposed for this field. Use the value the source supports, reject them all, or mark the field Could not confirm with a reason.`
803
813
  : chose
804
814
  ? `${candidates.length} different values were proposed for this field. One was chosen; ${candidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
@@ -1066,7 +1076,10 @@ function renderConfidenceMeter(confidence) {
1066
1076
  * here is secondary detail tucked behind one `<details>` toggle, kept off the
1067
1077
  * primary review surface.
1068
1078
  */
1069
- function renderAuditDetails(item, current, proposed, session, presentationAdapter) {
1079
+ function renderReviewerNote(item, session) {
1080
+ return `<label class="note-field"><span class="field-label">Reviewer note</span><textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(session.notesByItemName[item.metadata.name] ?? "")}</textarea></label>`;
1081
+ }
1082
+ function renderAuditDetails(item, current, proposed, session, presentationAdapter, noteOnCard = false) {
1070
1083
  const decision = session.decisionsByItemName[item.metadata.name];
1071
1084
  const note = session.notesByItemName[item.metadata.name] ?? "";
1072
1085
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
@@ -1091,10 +1104,7 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
1091
1104
  <details class="audit-details" data-testid="audit-details">
1092
1105
  <summary>Audit details</summary>
1093
1106
  <div class="audit-body">
1094
- <label class="note-field">
1095
- <span class="field-label">Reviewer note</span>
1096
- <textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(note)}</textarea>
1097
- </label>
1107
+ ${noteOnCard ? "" : renderReviewerNote(item, session)}
1098
1108
  ${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
1099
1109
  ${renderProducerFeedbackTags(item)}
1100
1110
  <dl class="field-stack compact">
@@ -1459,7 +1469,7 @@ function createReviewWorkbenchController(root, startState, options) {
1459
1469
  currentSession: () => session,
1460
1470
  currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events, { presentationAdapter: options.presentationAdapter }),
1461
1471
  presentationAdapter: options.presentationAdapter,
1462
- renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation, options.fieldStates),
1472
+ renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation, options.fieldStates, options.queuePresentation, options.hideApply),
1463
1473
  setDecision,
1464
1474
  clearDecision,
1465
1475
  updateReviewerNote,
@@ -1479,8 +1489,8 @@ function reviewSessionStorageKey(session, keyPrefix) {
1479
1489
  const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
1480
1490
  return `${keyPrefix}:${itemNames}`;
1481
1491
  }
1482
- function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation, fieldStates) {
1483
- root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined), fieldStates);
1492
+ function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation, fieldStates, queuePresentation, hideApply) {
1493
+ root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined), fieldStates, queuePresentation, hideApply);
1484
1494
  bindFieldCardInteractions(root, controller);
1485
1495
  bindApplyButton(root, controller);
1486
1496
  bindClampToggles(root);
@@ -1242,3 +1242,14 @@ pre[data-testid="decision-payload"] {
1242
1242
  transition: none !important;
1243
1243
  }
1244
1244
  }
1245
+
1246
+ .review-context {
1247
+ margin: var(--k-space-4);
1248
+ padding: var(--k-space-4);
1249
+ border: var(--k-border-thin) solid var(--k-line-strong);
1250
+ border-radius: var(--k-radius-sm);
1251
+ background: var(--k-panel-raised);
1252
+ color: var(--k-text);
1253
+ white-space: pre-wrap;
1254
+ overflow-wrap: anywhere;
1255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -93,7 +93,7 @@
93
93
  "devDependencies": {
94
94
  "@kontourai/flow-agents": "5.10.0",
95
95
  "@kontourai/traverse": "^3.0.1",
96
- "@kontourai/ui": "^1.18.0",
96
+ "@kontourai/ui": "^1.20.0",
97
97
  "@playwright/test": "^1.62.0",
98
98
  "@types/node": "^26.1.2",
99
99
  "typescript": "^7.0.2"