@kontourai/survey 1.13.0 → 1.14.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.
@@ -38,6 +38,12 @@ export interface CurrentProposedReviewItemInput {
38
38
  readonly current?: string;
39
39
  readonly proposed?: string;
40
40
  };
41
+ /**
42
+ * Whether the reviewer may edit the proposed value inline. Defaults to
43
+ * `true`. Pass `false` for keep/use/reject-only queues (see
44
+ * {@link ReviewItemSpec.editable}).
45
+ */
46
+ readonly editable?: boolean;
41
47
  }
42
48
  /**
43
49
  * Builds the two-candidate current/proposed {@link ReviewItem} envelope that
@@ -38,6 +38,7 @@ export function currentProposedReviewItem(input) {
38
38
  ...(selectedCandidateId ? { selectedCandidateId } : {}),
39
39
  ...(input.rationale ? { rationale: input.rationale } : {}),
40
40
  ...(input.producerPolicy ? { producerPolicy: input.producerPolicy } : {}),
41
+ ...(input.editable === false ? { editable: false } : {}),
41
42
  projection: { ...input.projection, candidateSetId },
42
43
  },
43
44
  status: {
@@ -142,6 +142,17 @@ export interface ReviewItemSpec {
142
142
  * Absent → a plain text editor with no validation (today's behavior).
143
143
  */
144
144
  valueDescriptor?: ReviewValueDescriptor;
145
+ /**
146
+ * Whether the reviewer may edit the proposed value inline before accepting
147
+ * it. Defaults to `true` (today's behavior). Set `false` for queues where an
148
+ * edited value is meaningless or must not be accepted — an approve/keep
149
+ * identity decision, a value the producer will re-derive, a review that only
150
+ * chooses between the two candidates as given. The workbench then renders no
151
+ * editor at all: the decision is keep-current / use-proposed / reject only,
152
+ * and `effectiveValue` is always the selected candidate's own value. This is
153
+ * enforcement (the affordance is absent), not a cosmetic hide.
154
+ */
155
+ editable?: boolean;
145
156
  }
146
157
  export interface ReviewItemStatus {
147
158
  observedCandidateCount?: number;
@@ -197,7 +197,15 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
197
197
  const candidate = candidateForDecision(item, decision);
198
198
  const definition = workbenchDecisionDefinitions[decision];
199
199
  const reviewDecisionName = `${item.metadata.name}-${decision}`;
200
- const data = { workbenchDecision: decision };
200
+ // Carry the reviewer's inline edit in the event itself (accept-proposed
201
+ // only — it is the sole decision where an edited value is meaningful), so
202
+ // that replaying snapshot + events reconstructs editedValuesByItemName and
203
+ // the server apply boundary derives effectiveValue from it. Without this
204
+ // the edit lives only in browser state and never survives replay.
205
+ const editedValue = decision === "accept-proposed" ? session.editedValuesByItemName?.[item.metadata.name] : undefined;
206
+ const data = editedValue !== undefined
207
+ ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
208
+ : { workbenchDecision: decision };
201
209
  events.push(buildReviewSessionEvent(session, {
202
210
  sessionName,
203
211
  sequence: events.length + 1,
@@ -252,24 +260,49 @@ export function replayReviewSessionEvents(startState, events) {
252
260
  }
253
261
  if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
254
262
  && event.spec.reviewItemName) {
263
+ const itemName = event.spec.reviewItemName;
255
264
  if (isClearedWorkbenchDecisionEvent(event)) {
256
- const { [event.spec.reviewItemName]: _removed, ...remainingDecisions } = session.decisionsByItemName;
257
- return { ...session, decisionsByItemName: remainingDecisions };
265
+ const { [itemName]: _removedDecision, ...remainingDecisions } = session.decisionsByItemName;
266
+ const { [itemName]: _removedEdit, ...remainingEdits } = session.editedValuesByItemName ?? {};
267
+ return { ...session, decisionsByItemName: remainingDecisions, editedValuesByItemName: remainingEdits };
258
268
  }
259
269
  const decision = workbenchDecisionFromEvent(event);
260
- return decision
261
- ? {
262
- ...session,
263
- decisionsByItemName: {
264
- ...session.decisionsByItemName,
265
- [event.spec.reviewItemName]: decision,
266
- },
267
- }
268
- : session;
270
+ if (!decision) {
271
+ return session;
272
+ }
273
+ // Restore the inline edit the event carried (accept-proposed only); any
274
+ // other decision, or an accept with no carried edit, clears a stale edit
275
+ // for this item so effectiveValue can't fall back to an edit the
276
+ // reviewer moved away from.
277
+ const editedValue = workbenchEditedValueFromEvent(event);
278
+ const editedValuesByItemName = { ...session.editedValuesByItemName };
279
+ if (decision === "accept-proposed" && editedValue !== undefined) {
280
+ editedValuesByItemName[itemName] = editedValue;
281
+ }
282
+ else {
283
+ delete editedValuesByItemName[itemName];
284
+ }
285
+ return {
286
+ ...session,
287
+ decisionsByItemName: {
288
+ ...session.decisionsByItemName,
289
+ [itemName]: decision,
290
+ },
291
+ editedValuesByItemName,
292
+ };
269
293
  }
270
294
  return session;
271
295
  }, startState);
272
296
  }
297
+ /**
298
+ * Extracts a decision event's carried inline edit, or `undefined` when the
299
+ * event carries none. The edit rides `data.workbenchEditedValue`.
300
+ */
301
+ function workbenchEditedValueFromEvent(event) {
302
+ return event.spec.data && "workbenchEditedValue" in event.spec.data
303
+ ? event.spec.data.workbenchEditedValue
304
+ : undefined;
305
+ }
273
306
  /**
274
307
  * Detects the explicit "clear this ReviewItem's decision" replay signal (emitted
275
308
  * by the workbench's "Change" / undo control): a decision event whose
@@ -567,7 +567,7 @@ function renderDiffRow(item, current, proposed, targetLabel, decided, effectiveP
567
567
  <div class="val proposed">
568
568
  <div class="vlbl">Proposed</div>
569
569
  <div class="vtext" data-value data-testid="proposed-value">${escapeHtml(effectiveProposedText)}</div>
570
- ${decided ? "" : renderProposedValueEditor(item, proposed, targetLabel)}
570
+ ${decided || item.spec.editable === false ? "" : renderProposedValueEditor(item, proposed, targetLabel)}
571
571
  </div>
572
572
  </div>
573
573
  `;
@@ -968,6 +968,16 @@ function createReviewWorkbenchController(root, startState, options) {
968
968
  const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
969
969
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
970
970
  const note = itemName ? session.notesByItemName[itemName] : undefined;
971
+ // Carry the reviewer's inline edit in the event (accept-proposed only), so
972
+ // it survives replay and the server apply boundary derives the edited
973
+ // effectiveValue from snapshot + events alone. setDecision updates the
974
+ // session before this runs, so the edit is already in state.
975
+ const editedValue = itemName && decision === "accept-proposed" ? session.editedValuesByItemName?.[itemName] : undefined;
976
+ const defaultData = decision
977
+ ? (editedValue !== undefined
978
+ ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
979
+ : { workbenchDecision: decision })
980
+ : undefined;
971
981
  events = [
972
982
  ...events,
973
983
  buildReviewSessionEvent(session, {
@@ -980,7 +990,7 @@ function createReviewWorkbenchController(root, startState, options) {
980
990
  candidateId: candidate?.id,
981
991
  status: definition?.status,
982
992
  rationale: note,
983
- data: dataOverride ?? (decision ? { workbenchDecision: decision } : undefined),
993
+ data: dataOverride ?? defaultData,
984
994
  }),
985
995
  ];
986
996
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "1.13.0",
3
+ "version": "1.14.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",