@ai-matrx/content-ir-react 0.18.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 — 2026-10-02
4
+
5
+ - **A record's old value is the value it holds NOW.** `useRecordValues` read a
6
+ record ONCE per `noun:id`, so after Update A applied, Update B naming the same
7
+ record — its row, and a host confirm whose body stayed mounted between
8
+ questions — still showed A's old value as "old" (G7: "G6A renamed target"
9
+ while the database held "G6A second rename"). Every apply now announces the
10
+ records it touched (`noteRecordChanged`, for update/delete targets and the
11
+ written records, even when the apply fails) and every `useRecordValues`
12
+ naming one reads it again. New exports: `noteRecordChanged`,
13
+ `recordRevision`, `useRecordRevision`. Guard:
14
+ `__tests__/directive-record-values-g7.test.tsx` (2 red on 0.18.1).
15
+
16
+ Consumer action: a host whose own writers change a record calls
17
+ `noteRecordChanged(id)`; a host `readRecord` that shares in-flight reads keys
18
+ them by `recordRevision(id)`, so a read started before a write is never handed
19
+ to a reader asking after it.
20
+
21
+ ## 0.18.1 — 2026-10-02
22
+
23
+ - **An apply belongs to the block, not to the card that asked.** A note re-renders
24
+ its blocks while the confirm dialog is open; the card that asked was unmounted,
25
+ the apply still ran, and the fresh card — which had read the ledger before the
26
+ write landed — offered "Apply" for an update that had already run. From the yes
27
+ on, the apply's progress and outcome are held per apply authority (the host's
28
+ `confirm`) and block identity, and every card drawing that block shows them.
29
+ Guard: `__tests__/directive-card-g6a.test.tsx` ("a card re-mounted while its
30
+ confirm was open…", red on 0.18.0).
31
+
32
+ No consumer action.
33
+
3
34
  ## 0.18.0 — 2026-10-02
4
35
 
5
36
  The G6A review of the action cards (nightly clone). Guard:
@@ -38,13 +38,16 @@ __export(directives_exports, {
38
38
  getDirectiveRenderer: () => getDirectiveRenderer,
39
39
  itemChanges: () => itemChanges,
40
40
  itemRecordId: () => itemRecordId,
41
+ noteRecordChanged: () => noteRecordChanged,
41
42
  provisionalDirectiveSlug: () => provisionalDirectiveSlug,
42
43
  readsApplyLedger: () => readsApplyLedger,
43
44
  recordFallbackName: () => recordFallbackName,
45
+ recordRevision: () => recordRevision,
44
46
  registerDirectiveRenderer: () => registerDirectiveRenderer,
45
47
  resetDirectiveRenderers: () => resetDirectiveRenderers,
46
48
  useDirectiveHost: () => useDirectiveHost,
47
49
  useDirectiveScope: () => useDirectiveScope,
50
+ useRecordRevision: () => useRecordRevision,
48
51
  useRecordValues: () => useRecordValues
49
52
  });
50
53
  module.exports = __toCommonJS(directives_exports);
@@ -91,7 +94,7 @@ function resetDirectiveRenderers() {
91
94
  }
92
95
 
93
96
  // directives/ApplyDirectiveButton.tsx
94
- var import_react2 = require("react");
97
+ var import_react3 = require("react");
95
98
  var import_content_ir2 = require("@ai-matrx/content-ir");
96
99
 
97
100
  // directives/item-changes.ts
@@ -170,6 +173,64 @@ function itemChanges(item, current, options = {}) {
170
173
  return out;
171
174
  }
172
175
 
176
+ // directives/record-values.ts
177
+ var import_react2 = require("react");
178
+ var revisions = /* @__PURE__ */ new Map();
179
+ var listeners = /* @__PURE__ */ new Set();
180
+ function recordRevision(id) {
181
+ return revisions.get(id) ?? 0;
182
+ }
183
+ function noteRecordChanged(id) {
184
+ if (!id) return;
185
+ revisions.set(id, recordRevision(id) + 1);
186
+ for (const listener of listeners) listener();
187
+ }
188
+ function subscribeRevisions(listener) {
189
+ listeners.add(listener);
190
+ return () => {
191
+ listeners.delete(listener);
192
+ };
193
+ }
194
+ function useRecordRevision(id) {
195
+ return (0, import_react2.useSyncExternalStore)(
196
+ subscribeRevisions,
197
+ () => recordRevision(id),
198
+ () => 0
199
+ );
200
+ }
201
+ function useRecordValues(read, ref) {
202
+ const revision = useRecordRevision(ref?.id ?? "");
203
+ const key = ref && read ? `${ref.noun}:${ref.id}#${revision}` : "";
204
+ const [result, setResult] = (0, import_react2.useState)({
205
+ key: "",
206
+ values: null,
207
+ status: "idle"
208
+ });
209
+ (0, import_react2.useEffect)(() => {
210
+ if (!key || !read) return;
211
+ const split = key.indexOf(":");
212
+ const target = {
213
+ noun: key.slice(0, split),
214
+ id: key.slice(split + 1, key.lastIndexOf("#"))
215
+ };
216
+ let live = true;
217
+ setResult({ key, values: null, status: "loading" });
218
+ read(target).then((values) => {
219
+ if (!live) return;
220
+ setResult({ key, values, status: values ? "ready" : "unavailable" });
221
+ }).catch(() => {
222
+ if (!live) return;
223
+ setResult({ key, values: null, status: "unavailable" });
224
+ });
225
+ return () => {
226
+ live = false;
227
+ };
228
+ }, [key, read]);
229
+ if (!key) return { values: null, status: "idle" };
230
+ if (result.key !== key) return { values: null, status: "loading" };
231
+ return { values: result.values, status: result.status };
232
+ }
233
+
173
234
  // directives/failure-words.ts
174
235
  var PAST = {
175
236
  create: "created",
@@ -289,6 +350,41 @@ function classIcon(directiveClass, noun) {
289
350
 
290
351
  // directives/ApplyDirectiveButton.tsx
291
352
  var import_jsx_runtime3 = require("react/jsx-runtime");
353
+ function announceChangedRecords(directive, items) {
354
+ if (!TARGETING_CLASSES.has(directive.directiveClass)) return;
355
+ for (const item of items) {
356
+ const id = itemRecordId(item);
357
+ if (id) noteRecordChanged(id);
358
+ }
359
+ }
360
+ var applyOutcomes = /* @__PURE__ */ new WeakMap();
361
+ var EMPTY_AUTHORITY = {};
362
+ var outcomeListeners = /* @__PURE__ */ new Set();
363
+ function outcomesOf(host) {
364
+ let held = applyOutcomes.get(host);
365
+ if (!held) {
366
+ held = /* @__PURE__ */ new Map();
367
+ applyOutcomes.set(host, held);
368
+ }
369
+ return held;
370
+ }
371
+ function publishOutcome(host, identity, outcome) {
372
+ outcomesOf(host).set(identity, outcome);
373
+ for (const listener of outcomeListeners) listener();
374
+ }
375
+ function subscribeOutcomes(listener) {
376
+ outcomeListeners.add(listener);
377
+ return () => {
378
+ outcomeListeners.delete(listener);
379
+ };
380
+ }
381
+ function useApplyOutcome(host, identity) {
382
+ return (0, import_react3.useSyncExternalStore)(
383
+ subscribeOutcomes,
384
+ () => applyOutcomes.get(host)?.get(identity) ?? null,
385
+ () => null
386
+ );
387
+ }
292
388
  var FINISH_POLL_MS = 2e3;
293
389
  var FINISH_POLLS_MAX = 30;
294
390
  function readsApplyLedger(host, directive) {
@@ -308,27 +404,29 @@ function ApplyDirectiveButton({
308
404
  const items = directive.items;
309
405
  const readsLedger = readsApplyLedger(host, directive);
310
406
  const initial = readsLedger ? { status: "checking" } : { status: "idle" };
311
- const [state, setState] = (0, import_react2.useState)(initial);
312
- const [elapsed, setElapsed] = (0, import_react2.useState)(0);
313
- const [askingAgain, setAskingAgain] = (0, import_react2.useState)(false);
314
- const startedAt = (0, import_react2.useRef)(null);
407
+ const [state, setState] = (0, import_react3.useState)(initial);
408
+ const [elapsed, setElapsed] = (0, import_react3.useState)(0);
409
+ const [askingAgain, setAskingAgain] = (0, import_react3.useState)(false);
410
+ const startedAt = (0, import_react3.useRef)(null);
315
411
  const identity = JSON.stringify([directive.slug, items, conversationId ?? null]);
316
- const [stateFor, setStateFor] = (0, import_react2.useState)(identity);
412
+ const [stateFor, setStateFor] = (0, import_react3.useState)(identity);
317
413
  if (stateFor !== identity) {
318
414
  setStateFor(identity);
319
415
  setState(initial);
320
416
  setAskingAgain(false);
321
417
  setElapsed(0);
322
418
  }
323
- const currentIdentity = (0, import_react2.useRef)(identity);
324
- (0, import_react2.useEffect)(() => {
419
+ const shared = useApplyOutcome(host.confirm ?? EMPTY_AUTHORITY, identity);
420
+ const view = state.status === "asking" || !shared ? state : shared;
421
+ const currentIdentity = (0, import_react3.useRef)(identity);
422
+ (0, import_react3.useEffect)(() => {
325
423
  currentIdentity.current = identity;
326
424
  }, [identity]);
327
425
  const nounText = (0, import_content_ir2.nounLabel)(directive.noun, host.nouns);
328
426
  const shellKey = readsLedger ? JSON.stringify([directive.slug, items, conversationId ?? null]) : "";
329
427
  const read = host.applyState;
330
428
  const report = host.reportError;
331
- (0, import_react2.useEffect)(() => {
429
+ (0, import_react3.useEffect)(() => {
332
430
  if (!shellKey || !read) return;
333
431
  const [slug, shellItems, inConversation] = JSON.parse(shellKey);
334
432
  const shell = { __kind: slug, items: shellItems };
@@ -373,33 +471,34 @@ function ApplyDirectiveButton({
373
471
  if (timer) clearTimeout(timer);
374
472
  };
375
473
  }, [shellKey, read, report]);
376
- const appliedClean = state.status === "applied" && state.failed === 0 ? state : null;
474
+ const appliedClean = view.status === "applied" && view.failed === 0 ? view : null;
377
475
  const appliedBefore = appliedClean ? appliedClean.before : null;
378
- const phaseKey = state.status === "checking" || state.status === "finishing" ? "pending" : appliedClean ? `applied:${JSON.stringify(appliedBefore)}` : "open";
379
- (0, import_react2.useEffect)(() => {
476
+ const phaseKey = view.status === "checking" || view.status === "finishing" ? "pending" : appliedClean ? `applied:${JSON.stringify(appliedBefore)}` : "open";
477
+ (0, import_react3.useEffect)(() => {
380
478
  if (!onPhase) return;
381
479
  if (phaseKey === "pending") onPhase({ phase: "pending" });
382
480
  else if (phaseKey === "open") onPhase({ phase: "open" });
383
481
  else onPhase({ phase: "applied", before: appliedBefore });
384
482
  }, [phaseKey, onPhase, appliedBefore]);
385
- (0, import_react2.useEffect)(() => {
386
- if (state.status !== "applying") return;
387
- startedAt.current = Date.now();
483
+ const applyingSince = view.status === "applying" ? view.startedAt : null;
484
+ (0, import_react3.useEffect)(() => {
485
+ if (applyingSince === null) return;
486
+ startedAt.current = applyingSince;
388
487
  const id = setInterval(
389
488
  () => setElapsed(Math.round((Date.now() - (startedAt.current ?? 0)) / 1e3)),
390
489
  1e3
391
490
  );
392
491
  return () => clearInterval(id);
393
- }, [state.status]);
492
+ }, [applyingSince]);
394
493
  if (!directive.parsed.executes) return null;
395
494
  const confirm = host.confirm;
396
495
  if (!confirm) return null;
397
496
  async function handleApply(again = false) {
398
497
  if (!confirm) return;
399
- const prior = state;
498
+ const prior = view;
400
499
  const askedFor = identity;
401
500
  const stillHere = () => currentIdentity.current === askedFor;
402
- const priorCopies = state.status === "applied" ? state.copies : 0;
501
+ const priorCopies = view.status === "applied" ? view.copies : 0;
403
502
  if (host.ask) {
404
503
  if (again) setAskingAgain(true);
405
504
  else setState({ status: "asking" });
@@ -428,14 +527,20 @@ function ApplyDirectiveButton({
428
527
  }
429
528
  }
430
529
  setElapsed(0);
431
- setState({ status: "applying" });
530
+ setState((prev) => prev.status === "asking" ? { status: "idle" } : prev);
531
+ publishOutcome(confirm, askedFor, { status: "applying", startedAt: Date.now() });
432
532
  try {
433
533
  const shell = { __kind: directive.slug, items };
434
534
  if (conversationId) shell.conversationId = conversationId;
435
535
  if (again) shell.force = true;
436
- const result = await confirm(shell);
437
- if (!stillHere()) return;
438
- setState({
536
+ let result;
537
+ try {
538
+ result = await confirm(shell);
539
+ } finally {
540
+ announceChangedRecords(directive, items);
541
+ }
542
+ for (const record of result.records ?? []) noteRecordChanged(record.id);
543
+ publishOutcome(confirm, askedFor, {
439
544
  status: "applied",
440
545
  applied: result.applied,
441
546
  failed: result.failed,
@@ -447,20 +552,19 @@ function ApplyDirectiveButton({
447
552
  // apply replaced is still the change this block made.
448
553
  before: again && prior.status === "applied" && prior.before ? prior.before : result.before ?? null
449
554
  });
450
- onApplied?.();
555
+ if (stillHere()) onApplied?.();
451
556
  } catch (error) {
452
- if (!stillHere()) return;
453
- setState({
557
+ publishOutcome(confirm, askedFor, {
454
558
  status: "error",
455
559
  message: error instanceof Error && error.message ? error.message : "That couldn't be applied just now. Please try again."
456
560
  });
457
561
  }
458
562
  }
459
- if (state.status === "applied") {
460
- const bad = state.failed > 0;
563
+ if (view.status === "applied") {
564
+ const bad = view.failed > 0;
461
565
  const trashed = directive.directiveClass === "delete";
462
566
  const label2 = nounText;
463
- const records = host.renderRecord ? state.records : [];
567
+ const records = host.renderRecord ? view.records : [];
464
568
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
465
569
  "span",
466
570
  {
@@ -474,26 +578,26 @@ function ApplyDirectiveButton({
474
578
  {
475
579
  words: failureWords(
476
580
  {
477
- raw: state.message ?? `${state.failed} failed`,
581
+ raw: view.message ?? `${view.failed} failed`,
478
582
  directiveClass: directive.directiveClass,
479
583
  noun: directive.noun,
480
584
  nounLabel: nounText,
481
- failed: state.failed,
482
- total: state.applied + state.failed
585
+ failed: view.failed,
586
+ total: view.applied + view.failed
483
587
  },
484
588
  host.explainFailure
485
589
  ),
486
- raw: state.message
590
+ raw: view.message
487
591
  }
488
- ) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "min-w-0", children: state.message ?? `Applied ${state.applied}` })
592
+ ) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "min-w-0", children: view.message ?? `Applied ${view.applied}` })
489
593
  ] }),
490
- state.copies > 1 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
594
+ view.copies > 1 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
491
595
  "span",
492
596
  {
493
597
  className: "shrink-0 text-muted-foreground",
494
- "data-apply-copies": state.copies,
598
+ "data-apply-copies": view.copies,
495
599
  title: records.length > 0 ? directive.directiveClass === "create" ? "Links to the latest copy." : "Links to the latest run." : void 0,
496
- children: directive.directiveClass === "create" ? `${state.copies} copies` : `Ran ${state.copies} times`
600
+ children: directive.directiveClass === "create" ? `${view.copies} copies` : `Ran ${view.copies} times`
497
601
  }
498
602
  ) : null,
499
603
  records.map((record) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "inline-flex min-w-0 items-center", children: host.renderRecord?.({
@@ -520,7 +624,7 @@ function ApplyDirectiveButton({
520
624
  }
521
625
  );
522
626
  }
523
- if (state.status === "checking") {
627
+ if (view.status === "checking") {
524
628
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
525
629
  "span",
526
630
  {
@@ -532,7 +636,7 @@ function ApplyDirectiveButton({
532
636
  }
533
637
  );
534
638
  }
535
- if (state.status === "finishing") {
639
+ if (view.status === "finishing") {
536
640
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
537
641
  "span",
538
642
  {
@@ -547,7 +651,7 @@ function ApplyDirectiveButton({
547
651
  );
548
652
  }
549
653
  const destructive = directive.directiveClass === "delete";
550
- const muted = nothingToChange && state.status === "idle";
654
+ const muted = nothingToChange && view.status === "idle";
551
655
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "inline-flex items-center gap-2", children: [
552
656
  muted ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "text-xs text-muted-foreground", "data-apply-nothing": "", children: "Nothing to change" }) : null,
553
657
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
@@ -555,17 +659,17 @@ function ApplyDirectiveButton({
555
659
  {
556
660
  type: "button",
557
661
  onClick: () => void handleApply(),
558
- disabled: state.status === "applying" || state.status === "asking" || items.length === 0,
662
+ disabled: view.status === "applying" || view.status === "asking" || items.length === 0,
559
663
  "data-apply-tone": muted ? "muted" : destructive ? "destructive" : "default",
560
664
  title: muted ? "It sets no fields, so applying it changes nothing." : void 0,
561
665
  className: `inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium disabled:opacity-50 ${muted ? "border-border/60 bg-background text-muted-foreground hover:bg-accent" : destructive ? "border-destructive/50 bg-background text-destructive hover:bg-destructive/10" : "border-border bg-background hover:bg-accent"}`,
562
666
  children: [
563
- state.status === "applying" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LoaderIcon, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PlayIcon, { className: "h-3.5 w-3.5" }),
564
- state.status === "applying" ? `Applying${itemCount ? ` ${itemCount} ${itemCount === 1 ? "item" : "items"}` : ""}\u2026${elapsed ? ` ${elapsed}s` : ""}` : label
667
+ view.status === "applying" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LoaderIcon, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PlayIcon, { className: "h-3.5 w-3.5" }),
668
+ view.status === "applying" ? `Applying${itemCount ? ` ${itemCount} ${itemCount === 1 ? "item" : "items"}` : ""}\u2026${elapsed ? ` ${elapsed}s` : ""}` : label
565
669
  ]
566
670
  }
567
671
  ),
568
- state.status === "error" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
672
+ view.status === "error" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
569
673
  "span",
570
674
  {
571
675
  className: "inline-flex max-w-sm items-start gap-1 text-xs text-destructive",
@@ -577,14 +681,14 @@ function ApplyDirectiveButton({
577
681
  {
578
682
  words: failureWords(
579
683
  {
580
- raw: state.message,
684
+ raw: view.message,
581
685
  directiveClass: directive.directiveClass,
582
686
  noun: directive.noun,
583
687
  nounLabel: nounText
584
688
  },
585
689
  host.explainFailure
586
690
  ),
587
- raw: state.message
691
+ raw: view.message
588
692
  }
589
693
  )
590
694
  ]
@@ -596,7 +700,7 @@ function FailureNotice({
596
700
  words,
597
701
  raw
598
702
  }) {
599
- const [open, setOpen] = (0, import_react2.useState)(false);
703
+ const [open, setOpen] = (0, import_react3.useState)(false);
600
704
  const detail = raw?.trim() ?? "";
601
705
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "flex min-w-0 flex-col gap-0.5", "data-apply-failure": true, children: [
602
706
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "min-w-0", children: [
@@ -629,39 +733,6 @@ function FailureNotice({
629
733
  // directives/SideEffectDirectiveCard.tsx
630
734
  var import_react4 = require("react");
631
735
  var import_content_ir3 = require("@ai-matrx/content-ir");
632
-
633
- // directives/record-values.ts
634
- var import_react3 = require("react");
635
- function useRecordValues(read, ref) {
636
- const key = ref && read ? `${ref.noun}:${ref.id}` : "";
637
- const [result, setResult] = (0, import_react3.useState)({
638
- key: "",
639
- values: null,
640
- status: "idle"
641
- });
642
- (0, import_react3.useEffect)(() => {
643
- if (!key || !read) return;
644
- const split = key.indexOf(":");
645
- const target = { noun: key.slice(0, split), id: key.slice(split + 1) };
646
- let live = true;
647
- setResult({ key, values: null, status: "loading" });
648
- read(target).then((values) => {
649
- if (!live) return;
650
- setResult({ key, values, status: values ? "ready" : "unavailable" });
651
- }).catch(() => {
652
- if (!live) return;
653
- setResult({ key, values: null, status: "unavailable" });
654
- });
655
- return () => {
656
- live = false;
657
- };
658
- }, [key, read]);
659
- if (!key) return { values: null, status: "idle" };
660
- if (result.key !== key) return { values: null, status: "loading" };
661
- return { values: result.values, status: result.status };
662
- }
663
-
664
- // directives/SideEffectDirectiveCard.tsx
665
736
  var import_jsx_runtime4 = require("react/jsx-runtime");
666
737
  var ROWS_BEFORE_FOLD = 3;
667
738
  function ItemRow({