@ai-matrx/content-ir-react 0.17.1 → 0.18.1

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/dist/react.cjs CHANGED
@@ -329,19 +329,52 @@ function classIcon(directiveClass, noun) {
329
329
 
330
330
  // directives/ApplyDirectiveButton.tsx
331
331
  var import_jsx_runtime4 = require("react/jsx-runtime");
332
+ var applyOutcomes = /* @__PURE__ */ new WeakMap();
333
+ var EMPTY_AUTHORITY = {};
334
+ var outcomeListeners = /* @__PURE__ */ new Set();
335
+ function outcomesOf(host) {
336
+ let held = applyOutcomes.get(host);
337
+ if (!held) {
338
+ held = /* @__PURE__ */ new Map();
339
+ applyOutcomes.set(host, held);
340
+ }
341
+ return held;
342
+ }
343
+ function publishOutcome(host, identity, outcome) {
344
+ outcomesOf(host).set(identity, outcome);
345
+ for (const listener of outcomeListeners) listener();
346
+ }
347
+ function subscribeOutcomes(listener) {
348
+ outcomeListeners.add(listener);
349
+ return () => {
350
+ outcomeListeners.delete(listener);
351
+ };
352
+ }
353
+ function useApplyOutcome(host, identity) {
354
+ return (0, import_react4.useSyncExternalStore)(
355
+ subscribeOutcomes,
356
+ () => applyOutcomes.get(host)?.get(identity) ?? null,
357
+ () => null
358
+ );
359
+ }
332
360
  var FINISH_POLL_MS = 2e3;
333
361
  var FINISH_POLLS_MAX = 30;
362
+ function readsApplyLedger(host, directive) {
363
+ return !!host.applyState && !!host.confirm && directive.parsed.executes && directive.items.length > 0;
364
+ }
334
365
  function ApplyDirectiveButton({
335
366
  directive,
336
367
  label = "Apply",
337
368
  itemCount,
338
- onApplied
369
+ onApplied,
370
+ onPhase,
371
+ nothingToChange = false
339
372
  }) {
340
373
  const host = useDirectiveHost();
341
374
  const scope = useDirectiveScope();
342
375
  const conversationId = scope.conversationId || void 0;
343
376
  const items = directive.items;
344
- const readsLedger = !!host.applyState && !!host.confirm && directive.parsed.executes && items.length > 0;
377
+ const readsLedger = readsApplyLedger(host, directive);
345
378
  const initial = readsLedger ? { status: "checking" } : { status: "idle" };
346
379
  const [state, setState] = (0, import_react4.useState)(initial);
347
380
  const [elapsed, setElapsed] = (0, import_react4.useState)(0);
@@ -355,6 +388,8 @@ function ApplyDirectiveButton({
355
388
  setAskingAgain(false);
356
389
  setElapsed(0);
357
390
  }
391
+ const shared = useApplyOutcome(host.confirm ?? EMPTY_AUTHORITY, identity);
392
+ const view = state.status === "asking" || !shared ? state : shared;
358
393
  const currentIdentity = (0, import_react4.useRef)(identity);
359
394
  (0, import_react4.useEffect)(() => {
360
395
  currentIdentity.current = identity;
@@ -382,7 +417,8 @@ function ApplyDirectiveButton({
382
417
  failed: 0,
383
418
  message: answer.message?.trim() ? answer.message.trim() : null,
384
419
  records: answer.records ?? [],
385
- copies: Math.max(1, answer.copies ?? 1)
420
+ copies: Math.max(1, answer.copies ?? 1),
421
+ before: answer.before ?? null
386
422
  } : prev
387
423
  );
388
424
  } else if (answer.state === "in_flight" && polls < FINISH_POLLS_MAX) {
@@ -407,24 +443,34 @@ function ApplyDirectiveButton({
407
443
  if (timer) clearTimeout(timer);
408
444
  };
409
445
  }, [shellKey, read, report]);
446
+ const appliedClean = view.status === "applied" && view.failed === 0 ? view : null;
447
+ const appliedBefore = appliedClean ? appliedClean.before : null;
448
+ const phaseKey = view.status === "checking" || view.status === "finishing" ? "pending" : appliedClean ? `applied:${JSON.stringify(appliedBefore)}` : "open";
449
+ (0, import_react4.useEffect)(() => {
450
+ if (!onPhase) return;
451
+ if (phaseKey === "pending") onPhase({ phase: "pending" });
452
+ else if (phaseKey === "open") onPhase({ phase: "open" });
453
+ else onPhase({ phase: "applied", before: appliedBefore });
454
+ }, [phaseKey, onPhase, appliedBefore]);
455
+ const applyingSince = view.status === "applying" ? view.startedAt : null;
410
456
  (0, import_react4.useEffect)(() => {
411
- if (state.status !== "applying") return;
412
- startedAt.current = Date.now();
457
+ if (applyingSince === null) return;
458
+ startedAt.current = applyingSince;
413
459
  const id = setInterval(
414
460
  () => setElapsed(Math.round((Date.now() - (startedAt.current ?? 0)) / 1e3)),
415
461
  1e3
416
462
  );
417
463
  return () => clearInterval(id);
418
- }, [state.status]);
464
+ }, [applyingSince]);
419
465
  if (!directive.parsed.executes) return null;
420
466
  const confirm = host.confirm;
421
467
  if (!confirm) return null;
422
468
  async function handleApply(again = false) {
423
469
  if (!confirm) return;
424
- const before = state;
470
+ const prior = view;
425
471
  const askedFor = identity;
426
472
  const stillHere = () => currentIdentity.current === askedFor;
427
- const priorCopies = state.status === "applied" ? state.copies : 0;
473
+ const priorCopies = view.status === "applied" ? view.copies : 0;
428
474
  if (host.ask) {
429
475
  if (again) setAskingAgain(true);
430
476
  else setState({ status: "asking" });
@@ -448,41 +494,43 @@ function ApplyDirectiveButton({
448
494
  if (!stillHere()) return;
449
495
  setAskingAgain(false);
450
496
  if (!ok) {
451
- setState(again ? before : { status: "idle" });
497
+ setState(again ? prior : { status: "idle" });
452
498
  return;
453
499
  }
454
500
  }
455
501
  setElapsed(0);
456
- setState({ status: "applying" });
502
+ setState((prev) => prev.status === "asking" ? { status: "idle" } : prev);
503
+ publishOutcome(confirm, askedFor, { status: "applying", startedAt: Date.now() });
457
504
  try {
458
505
  const shell = { __kind: directive.slug, items };
459
506
  if (conversationId) shell.conversationId = conversationId;
460
507
  if (again) shell.force = true;
461
508
  const result = await confirm(shell);
462
- if (!stillHere()) return;
463
- setState({
509
+ publishOutcome(confirm, askedFor, {
464
510
  status: "applied",
465
511
  applied: result.applied,
466
512
  failed: result.failed,
467
513
  message: result.message?.trim() ? result.message.trim() : null,
468
514
  records: result.records ?? [],
469
515
  // "Run again" made one more; a first apply (or a replay of one) is one.
470
- copies: again ? Math.max(1, priorCopies) + 1 : Math.max(1, priorCopies)
516
+ copies: again ? Math.max(1, priorCopies) + 1 : Math.max(1, priorCopies),
517
+ // "Run again" wrote the same values over themselves; what the FIRST
518
+ // apply replaced is still the change this block made.
519
+ before: again && prior.status === "applied" && prior.before ? prior.before : result.before ?? null
471
520
  });
472
- onApplied?.();
521
+ if (stillHere()) onApplied?.();
473
522
  } catch (error) {
474
- if (!stillHere()) return;
475
- setState({
523
+ publishOutcome(confirm, askedFor, {
476
524
  status: "error",
477
525
  message: error instanceof Error && error.message ? error.message : "That couldn't be applied just now. Please try again."
478
526
  });
479
527
  }
480
528
  }
481
- if (state.status === "applied") {
482
- const bad = state.failed > 0;
529
+ if (view.status === "applied") {
530
+ const bad = view.failed > 0;
483
531
  const trashed = directive.directiveClass === "delete";
484
532
  const label2 = nounText;
485
- const records = host.renderRecord ? state.records : [];
533
+ const records = host.renderRecord ? view.records : [];
486
534
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
487
535
  "span",
488
536
  {
@@ -496,26 +544,26 @@ function ApplyDirectiveButton({
496
544
  {
497
545
  words: failureWords(
498
546
  {
499
- raw: state.message ?? `${state.failed} failed`,
547
+ raw: view.message ?? `${view.failed} failed`,
500
548
  directiveClass: directive.directiveClass,
501
549
  noun: directive.noun,
502
550
  nounLabel: nounText,
503
- failed: state.failed,
504
- total: state.applied + state.failed
551
+ failed: view.failed,
552
+ total: view.applied + view.failed
505
553
  },
506
554
  host.explainFailure
507
555
  ),
508
- raw: state.message
556
+ raw: view.message
509
557
  }
510
- ) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "min-w-0", children: state.message ?? `Applied ${state.applied}` })
558
+ ) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "min-w-0", children: view.message ?? `Applied ${view.applied}` })
511
559
  ] }),
512
- state.copies > 1 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
560
+ view.copies > 1 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
513
561
  "span",
514
562
  {
515
563
  className: "shrink-0 text-muted-foreground",
516
- "data-apply-copies": state.copies,
564
+ "data-apply-copies": view.copies,
517
565
  title: records.length > 0 ? directive.directiveClass === "create" ? "Links to the latest copy." : "Links to the latest run." : void 0,
518
- children: directive.directiveClass === "create" ? `${state.copies} copies` : `Ran ${state.copies} times`
566
+ children: directive.directiveClass === "create" ? `${view.copies} copies` : `Ran ${view.copies} times`
519
567
  }
520
568
  ) : null,
521
569
  records.map((record) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "inline-flex min-w-0 items-center", children: host.renderRecord?.({
@@ -542,7 +590,7 @@ function ApplyDirectiveButton({
542
590
  }
543
591
  );
544
592
  }
545
- if (state.status === "checking") {
593
+ if (view.status === "checking") {
546
594
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
547
595
  "span",
548
596
  {
@@ -554,7 +602,7 @@ function ApplyDirectiveButton({
554
602
  }
555
603
  );
556
604
  }
557
- if (state.status === "finishing") {
605
+ if (view.status === "finishing") {
558
606
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
559
607
  "span",
560
608
  {
@@ -569,22 +617,25 @@ function ApplyDirectiveButton({
569
617
  );
570
618
  }
571
619
  const destructive = directive.directiveClass === "delete";
620
+ const muted = nothingToChange && view.status === "idle";
572
621
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "inline-flex items-center gap-2", children: [
622
+ muted ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "text-xs text-muted-foreground", "data-apply-nothing": "", children: "Nothing to change" }) : null,
573
623
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
574
624
  "button",
575
625
  {
576
626
  type: "button",
577
627
  onClick: () => void handleApply(),
578
- disabled: state.status === "applying" || state.status === "asking" || items.length === 0,
579
- "data-apply-tone": destructive ? "destructive" : "default",
580
- className: `inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium disabled:opacity-50 ${destructive ? "border-destructive/50 bg-background text-destructive hover:bg-destructive/10" : "border-border bg-background hover:bg-accent"}`,
628
+ disabled: view.status === "applying" || view.status === "asking" || items.length === 0,
629
+ "data-apply-tone": muted ? "muted" : destructive ? "destructive" : "default",
630
+ title: muted ? "It sets no fields, so applying it changes nothing." : void 0,
631
+ 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"}`,
581
632
  children: [
582
- state.status === "applying" ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(LoaderIcon, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PlayIcon, { className: "h-3.5 w-3.5" }),
583
- state.status === "applying" ? `Applying${itemCount ? ` ${itemCount} ${itemCount === 1 ? "item" : "items"}` : ""}\u2026${elapsed ? ` ${elapsed}s` : ""}` : label
633
+ view.status === "applying" ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(LoaderIcon, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PlayIcon, { className: "h-3.5 w-3.5" }),
634
+ view.status === "applying" ? `Applying${itemCount ? ` ${itemCount} ${itemCount === 1 ? "item" : "items"}` : ""}\u2026${elapsed ? ` ${elapsed}s` : ""}` : label
584
635
  ]
585
636
  }
586
637
  ),
587
- state.status === "error" ? /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
638
+ view.status === "error" ? /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
588
639
  "span",
589
640
  {
590
641
  className: "inline-flex max-w-sm items-start gap-1 text-xs text-destructive",
@@ -596,14 +647,14 @@ function ApplyDirectiveButton({
596
647
  {
597
648
  words: failureWords(
598
649
  {
599
- raw: state.message,
650
+ raw: view.message,
600
651
  directiveClass: directive.directiveClass,
601
652
  noun: directive.noun,
602
653
  nounLabel: nounText
603
654
  },
604
655
  host.explainFailure
605
656
  ),
606
- raw: state.message
657
+ raw: view.message
607
658
  }
608
659
  )
609
660
  ]
@@ -765,12 +816,13 @@ function ItemRow({
765
816
  directive,
766
817
  item,
767
818
  index,
768
- total
819
+ total,
820
+ phase
769
821
  }) {
770
822
  const host = useDirectiveHost();
771
823
  const recordId = TARGETING_CLASSES.has(directive.directiveClass) ? itemRecordId(item) : null;
772
824
  if (recordId) {
773
- return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(TargetRow, { directive, item, recordId });
825
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(TargetRow, { directive, item, recordId, index, phase });
774
826
  }
775
827
  const titleColumn = (0, import_content_ir5.nounTitleColumn)(directive.noun, host.nouns);
776
828
  const title = (0, import_content_ir5.itemTitle)(item, titleColumn, index, total);
@@ -857,16 +909,20 @@ function DirectiveChangeList({
857
909
  function TargetRow({
858
910
  directive,
859
911
  item,
860
- recordId
912
+ recordId,
913
+ index,
914
+ phase
861
915
  }) {
862
916
  const host = useDirectiveHost();
863
917
  const fallback = recordFallbackName((0, import_content_ir5.nounLabel)(directive.noun, host.nouns), recordId);
864
918
  const isUpdate = directive.directiveClass === "update";
865
- const current = useRecordValues(
866
- isUpdate ? host.readRecord : void 0,
867
- isUpdate ? { noun: directive.noun, id: recordId } : null
919
+ const readsLive = isUpdate && phase.phase === "open";
920
+ const live = useRecordValues(
921
+ readsLive ? host.readRecord : void 0,
922
+ readsLive ? { noun: directive.noun, id: recordId } : null
868
923
  );
869
- const changes = isUpdate ? itemChanges(item, current.values, { titleColumn: (0, import_content_ir5.nounTitleColumn)(directive.noun, host.nouns) }) : [];
924
+ const compareTo = phase.phase === "applied" ? phase.before?.[index] ?? null : readsLive ? live.values : null;
925
+ const changes = isUpdate ? itemChanges(item, compareTo, { titleColumn: (0, import_content_ir5.nounTitleColumn)(directive.noun, host.nouns) }) : [];
870
926
  const name = host.renderRecord ? host.renderRecord({ noun: directive.noun, id: recordId, fallback, context: "row" }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "truncate font-medium text-foreground", children: fallback });
871
927
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "py-1 text-xs", "data-directive-target": recordId, children: [
872
928
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex min-w-0 items-center gap-2", children: [
@@ -883,6 +939,10 @@ function SideEffectDirectiveCard({ directive }) {
883
939
  const total = items.length;
884
940
  const display = (0, import_content_ir5.directiveDisplay)(directive.directiveClass, directive.noun, host.nouns);
885
941
  const Icon = classIcon(directive.directiveClass, directive.noun);
942
+ const [phase, setPhase] = (0, import_react6.useState)(
943
+ () => readsApplyLedger(host, directive) ? { phase: "pending" } : { phase: "open" }
944
+ );
945
+ const nothingToChange = directive.directiveClass === "update" && total > 0 && items.every((item) => itemChanges(item).length === 0);
886
946
  const shown = expanded ? items : items.slice(0, ROWS_BEFORE_FOLD);
887
947
  const hidden = total - shown.length;
888
948
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "my-3 rounded-md border border-border bg-card px-2.5 py-1.5", "data-directive": directive.slug, children: [
@@ -897,13 +957,21 @@ function SideEffectDirectiveCard({ directive }) {
897
957
  ] }),
898
958
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "ml-auto flex items-center gap-1", children: [
899
959
  host.renderCopy?.({ label: display.title, value: directive.shell, size: "icon" }),
900
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ApplyDirectiveButton, { directive, itemCount: total })
960
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
961
+ ApplyDirectiveButton,
962
+ {
963
+ directive,
964
+ itemCount: total,
965
+ onPhase: setPhase,
966
+ nothingToChange
967
+ }
968
+ )
901
969
  ] })
902
970
  ] }),
903
971
  total === 0 ? (
904
972
  // Stated, never silent: an empty batch is a real (and suspicious) state.
905
973
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "mt-1 text-xs text-muted-foreground", children: "No items \u2014 nothing would be written." })
906
- ) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "mt-0.5 divide-y divide-border/60", children: shown.map((item, i) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ItemRow, { directive, item, index: i, total }, i)) }),
974
+ ) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "mt-0.5 divide-y divide-border/60", children: shown.map((item, i) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ItemRow, { directive, item, index: i, total, phase }, i)) }),
907
975
  hidden > 0 || expanded ? /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
908
976
  "button",
909
977
  {