@lmzhen/dsh-evolution-settings-ui 0.17.0 → 0.18.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/README.md CHANGED
@@ -42,18 +42,70 @@ Its states are deliberately distinct: **reading** ("正在读取待批项…"),
42
42
  **failed** (the reason plus a retry). Merging the first two is what once made a slow answer read as lost data
43
43
  (E7), so the state machine lives in `pending-state.ts` and the three are asserted without a browser.
44
44
 
45
+ It reads BOTH windows the route serves — `?status=pending` and `?status=executing` — and orders them the way
46
+ the CLI does. A record stuck in `executing` (an approve that died between the claim and the decision, or
47
+ another process running it) is NOT waiting for a decision: its row carries no approve/reject pair, says the
48
+ write may already have landed, and the header counts it apart ("{n} 项执行中"). Reading only the pending window
49
+ is what made such a record invisible here while `/evolution pending` and doctor both listed it.
50
+
51
+ The card re-reads the window on demand: a **refresh** control in the body and a window `focus`. It reads once
52
+ per mount otherwise — deliberately no timer, since the family has no polling precedent — because an agent can
53
+ stage a write while the reader is looking at the card, and the empty sentence standing there afterwards is the
54
+ "empty vs. actually something" lie.
55
+
45
56
  A decision re-reads the window instead of editing the list locally (the host owns the state) and shows the
46
- host's own sentence when it refuses. **Preview** opens one row at a time: the facts line (`n 行新增` /
57
+ host's own sentence when it refuses. **Restore to default** (`unset`) is judged the same way as a save: the
58
+ reset's verdict is the next snapshot — the id must be GONE from the user layer, the opposite evidence a `set`
59
+ needs — so a host refusal prints "恢复默认值没有生效…" instead of leaving a chip that looks like a broken button,
60
+ the control reports its in-flight state, and a throwing settings service reaches the reader rather than
61
+ escaping as an unhandled rejection. `settle.ts` holds that one verdict function for both ops. **Preview** opens one row at a time: the facts line (`n 行新增` /
47
62
  `n 行删除` / truncated) and the source block with `-`/`+` lines, the same shape the history panel's diff
48
63
  uses; an unavailable preview prints the host's reason. A late answer for a row the reader already left is
49
64
  dropped rather than painted under another row's heading.
50
65
 
66
+ ## A seat that arrives late revives the card
67
+
68
+ The seat is probed lazily (never captured at apply time), but probing alone only answers the NEXT read —
69
+ a card that already painted 「本部署没有这个座位」 never asks again, and its row subscription was never
70
+ attached at all. `apply()` therefore subscribes to the platform's own arrival channel
71
+ (`ctx.inject(['configForms'], () => sources.revive())`): `revive` force-re-binds the form (a REPLACED
72
+ seat is picked up too), attaches the subscription and wakes every listener, so the card turns into a form
73
+ without a re-render and without a timer. The mechanism sentence this replaced — "the platform has no
74
+ service arrival signal" — was wrong: `ctx.inject` is exactly that signal (v46 S5.2 / T5-11).
75
+
76
+ ## One save is one write, on both faces
77
+
78
+ A save hands the seat ONE op list (`mutateOnce(ops)`) instead of one call per field. The Plugins page
79
+ fences that list with the revision the render read, which is the platform's own form model ("apply
80
+ ordered field edits in one revision-fenced write"); the section path passes no fence, exactly as before.
81
+ Per-field calls with one shared render-time revision were the T5-01 bug: the host accepted the first
82
+ field, the revision moved, and every later field of the SAME save was refused with a conflict — the
83
+ change silently did not land while the card showed one generic refusal.
84
+
85
+ The stylesheet carries two structural invariants, both pinned by `tests/styles.client.spec.ts`: one
86
+ selector never carries two rules (the row chip and the preview's diff block were both
87
+ `.evolution-param-source`, so the diff rule's padding/border/overflow landed on a 20px chip and the
88
+ chip's `inline-flex` laid every diff line out in one row), and the family scale is declared on the
89
+ CARD root as well as the section root (the Plugins page renders the same card outside
90
+ `.evolution-params`, where a section-only declaration left every `--evo-*` undefined — no border, zero
91
+ padding, square corners).
92
+
51
93
  ## The `hooks` compartment never reaches the component
52
94
 
53
95
  A card's inject face carries `hooks: { paramSection: source }` — that is the SHELL's seat, not a prop. The renderer binds each entry to a `use<Name>` seat and hands the component the face with `hooks` REMOVED (the slot contract is `PropsHooks<face['hooks']>` plus `Omit<face, 'hooks'>`). Reading `props.hooks` therefore reads a prop that never exists, and the failure only shows up at runtime, inside the save path.
54
96
 
55
97
  The props type is spelled the way the renderer derives it — `Omit<ParamCardFace, 'hooks'> & { useParamSection }` — so re-introducing that mistake fails `tsc` instead of failing the operator's save. 0.7.0 shipped the mistake; 0.7.1 fixed it and derived the type this way.
56
98
 
99
+ ## The seat contract: lazy probe, stable snapshot, one source object
100
+
101
+ `src/client/source.ts` is the one place that turns `configForms.get(namespace)` into the three things a card needs — a snapshot, a subscription and the two write calls — and it holds three obligations the platform's renderer imposes:
102
+
103
+ - **The seat is probed lazily.** `apply()` may run before the settings shell that declares this section and provides `configForms`, so a seat captured at apply time would pin the row to the empty state for the life of its fiber. The source reads a `SeatProbe` on every snapshot read; the write face re-probes per call.
104
+ - **The projected snapshot keeps ONE reference until the row's raw snapshot moves.** The renderer binds the `hooks` compartment through `useSyncExternalStoreWithSelector`, which compares by reference: a projection that builds an object per call never compares equal, so React aborts the card (minified invariant #185) and the slot error boundary replaces it with an empty placeholder — that is 0.17.0's "all the cards disappeared". The projection caches per raw snapshot reference, and a projection failure returns the frozen `PROJECTION_FAILED` snapshot instead of throwing: a throw from `getSnapshot()` happens inside the renderer's render pass, where this bundle cannot catch it and where a `try`/`catch` around a hook call would not be legal React.
105
+ - **One source object per namespace.** The renderer caches a hook binding per SOURCE object, so a source built per render re-binds the hook and remounts the card. `createSourceCache(probe)` owns the per-namespace cache and goes away with the fiber.
106
+
107
+ A card therefore tells three unavailable reasons apart — `seat-missing` (no settings surface composed), `projection-failed` (the seat served the row and reading it failed; the error is logged once) and `not-served` (the row itself is unavailable) — instead of painting one blank state. `N29`/`N30` in the family guard hold the shape of this seam, and `tests/section.client.spec.ts` pins the stable reference, the re-projection on change, the frozen failure value, the per-namespace identity and the late seat.
108
+
57
109
  ## Styling
58
110
 
59
111
  The card draws from the FAMILY's scale, not from magic numbers: `src/client/tokens.ts` is generated from `packages/scripts/client-tokens.json` (identical bytes in the history panel, which shares it), and its type steps are relative to the surface that hosts the card — `calc(1em - 2px)` for a hint, `calc(1em + 2px)` for the title — so the card no longer resizes itself with the `--dsh-content-font-size*` setting, which is documented as affecting conversation content only and which the platform's own surfaces never read. Fields take the family's single focus ring (`outline: var(--evo-focus-ring)`), the same one the history panel draws, instead of a recoloured border. The design system's CSS is not exported to packages outside the platform repository, so this bundle carries its own stylesheet and injects it once behind a `<style data-plugin-css="…">` tag — the mechanism the platform's own client bundles use. Every rule reads a `--dsw-alias-*` design token, so light and dark follow the theme without a colour of our own, and the field metrics (12px padding, 6px gap, 13px label, 12px hint) copy the platform's settings fields.
@@ -72,4 +124,4 @@ The browser half ships as the client module system's lazy CJS factory artifact (
72
124
  - The section renders plain controls styled with the design tokens rather than the platform's `@deepseek-ai/dsh-client-ui-primitives` kit: that module IS available to out-of-repo bundles (the market plugin requires it), but its prop shapes are not published, and guessing them would break the live GUI.
73
125
  - An unsaved draft lives in the card's own component state and the settings shell renders only the active section, so switching to another section drops a draft that was not saved yet. Values already written are unaffected; moving the draft into an apply-time store is the 0.7.x follow-up.
74
126
  - A deployment that overrides a field through the `evolution-policy` row does not show on the card: the card reports the deployment value the settings scope serves, while the policy snapshot can differ. That divergence stays a doctor / `/evolution params` matter.
75
- - The parameter card has no RENDERED spec: the family specs are Node-level and the family carries no React runtime, so it is covered by `tsc`, the bundle build, the gate steps and a live pass on the installed artifact (0.7.0's save defect was found exactly there). The verdict is pinned instead by `tests/settle.spec.ts` (the pure `landedWrites` decision, including the already-overridden refusal case); rendering the card with the props the renderer actually builds — no `hooks`, the bound seat stubbed — remains the follow-up that would catch the wiring in CI.
127
+ - The parameter card's spec covers the **wiring**, not the **assembly**. `tests/section.client.spec.ts` renders the real card component with the props the renderer actually builds — no `hooks`, the settings seat stubbed — so a wrong prop name or a missing `useParamSection` binding fails in CI, on top of `tests/settle.spec.ts` (the pure `landedWrites` decision, including the already-overridden refusal case). What no Node-level spec can see is the assembly the loader performs: the framework's real kit, the style scoping, the module identity handed to the bundle. Defects of that layer — two same-named classes overwriting each other, styles scoped to the wrong root — still surface only in a live pass on the installed artifact (0.7.0's save defect was found exactly there).
package/lib/client.js CHANGED
@@ -68,7 +68,7 @@ window.__ModuleLoader__.load({
68
68
  body: JSON.stringify({ id })
69
69
  });
70
70
  return {
71
- pending: async () => await read(doFetch, APPROVAL_CLIENT_ROUTES.pending),
71
+ pending: async (status) => await read(doFetch, status === void 0 ? APPROVAL_CLIENT_ROUTES.pending : APPROVAL_CLIENT_ROUTES.pending + "?status=" + status),
72
72
  approve: async (id) => await post(APPROVAL_CLIENT_ROUTES.approve, id),
73
73
  reject: async (id) => await post(APPROVAL_CLIENT_ROUTES.reject, id),
74
74
  preview: async (id) => await read(doFetch, APPROVAL_CLIENT_ROUTES.preview + "?id=" + encodeURIComponent(id))
@@ -79,6 +79,7 @@ window.__ModuleLoader__.load({
79
79
  const CLIENT_PARAM_SECTIONS = [
80
80
  {
81
81
  namespace: "evolution-curator",
82
+ seatKey: "evolution-curator",
82
83
  fields: [
83
84
  {
84
85
  id: "curatorIntervalHours",
@@ -216,6 +217,7 @@ window.__ModuleLoader__.load({
216
217
  },
217
218
  {
218
219
  namespace: "evolution-review",
220
+ seatKey: "evolution-review",
219
221
  fields: [
220
222
  {
221
223
  id: "reviewSkillInterval",
@@ -306,6 +308,7 @@ window.__ModuleLoader__.load({
306
308
  },
307
309
  {
308
310
  namespace: "memory-files",
311
+ seatKey: "memory-files",
309
312
  fields: [
310
313
  {
311
314
  id: "memoryChars",
@@ -355,6 +358,7 @@ window.__ModuleLoader__.load({
355
358
  },
356
359
  {
357
360
  namespace: "tool-memory",
361
+ seatKey: "tool-memory",
358
362
  fields: [{
359
363
  id: "entryPreviewChars",
360
364
  group: "memory",
@@ -369,6 +373,7 @@ window.__ModuleLoader__.load({
369
373
  },
370
374
  {
371
375
  namespace: "tool-skill-manage",
376
+ seatKey: "tool-skill-manage",
372
377
  fields: [
373
378
  {
374
379
  id: "skillContentChars",
@@ -654,13 +659,37 @@ window.__ModuleLoader__.load({
654
659
  latest.current = value;
655
660
  setState(value);
656
661
  };
662
+ /**
663
+ * Read the window the card is about: the writes WAITING and the writes RUNNING.
664
+ *
665
+ * T4-05/A47: the route has always taken `?status=` — the CLI asks for both lists — but this card
666
+ * asked for neither, so a record whose approve died between the claim and the decision showed as
667
+ * "nothing is waiting for a decision" while `/evolution pending` and doctor listed it. The two
668
+ * answers are ordered the way the CLI orders them (waiting first); either one failing fails the
669
+ * read, because a half-list would read as a complete window.
670
+ */
657
671
  const load = async () => {
658
672
  apply(loading);
659
- const answer = await api.pending();
660
- apply((current) => answer.ok ? loaded(current, answer.data) : failed(current, answer.message));
673
+ const [waiting, running] = await Promise.all([api.pending(), api.pending("executing")]);
674
+ if (!waiting.ok) {
675
+ apply((current) => failed(current, waiting.message));
676
+ return;
677
+ }
678
+ if (!running.ok) {
679
+ apply((current) => failed(current, running.message));
680
+ return;
681
+ }
682
+ apply((current) => loaded(current, [...waiting.data, ...running.data]));
661
683
  };
662
684
  (0, react.useEffect)(() => {
663
685
  load();
686
+ const onFocus = () => {
687
+ load();
688
+ };
689
+ window.addEventListener("focus", onFocus);
690
+ return () => {
691
+ window.removeEventListener("focus", onFocus);
692
+ };
664
693
  }, []);
665
694
  const decide = (id, action) => {
666
695
  apply((current) => beginAction(current, id));
@@ -738,9 +767,13 @@ window.__ModuleLoader__.load({
738
767
  }, "+ " + line));
739
768
  }
740
769
  }
741
- return (0, react.createElement)("div", null, (0, react.createElement)("p", { className: "evolution-param-facts" }, ...facts), (0, react.createElement)("pre", { className: "evolution-param-source" }, ...lines.length === 0 ? ["±"] : lines));
770
+ return (0, react.createElement)("div", null, (0, react.createElement)("p", { className: "evolution-param-facts" }, ...facts), (0, react.createElement)("pre", { className: "evolution-param-diff" }, ...lines.length === 0 ? ["±"] : lines));
742
771
  };
743
772
  const row = (entry) => {
773
+ if (entry.status === "executing") return (0, react.createElement)("div", {
774
+ className: "evolution-param-field",
775
+ key: entry.id
776
+ }, (0, react.createElement)("div", { className: "evolution-param-label" }, (0, react.createElement)("span", { className: "evolution-param-source" }, t(KIND_KEYS[entry.kind])), (0, react.createElement)("span", { className: "evolution-param-value" }, entry.summary)), (0, react.createElement)("p", { className: "evolution-param-hint" }, age(entry)), (0, react.createElement)("p", { className: "evolution-param-note" }, t("approvalExecuting")), entry.claimedBy === void 0 ? null : (0, react.createElement)("p", { className: "evolution-param-hint" }, entry.claimedBy));
744
777
  const busy = state.busy.includes(entry.id);
745
778
  const button = (label, action) => (0, react.createElement)("button", {
746
779
  className: "evolution-param-button",
@@ -773,7 +806,9 @@ window.__ModuleLoader__.load({
773
806
  }, t("approvalRetry")));
774
807
  return (0, react.createElement)("div", null, ...state.view.rows.map(row), state.notice === null ? null : (0, react.createElement)("p", { className: "evolution-param-error" }, state.notice));
775
808
  };
776
- const count = state.view.kind === "ready" ? state.view.rows.length : 0;
809
+ const rows = state.view.kind === "ready" ? state.view.rows : [];
810
+ const waiting = rows.filter((entry) => entry.status !== "executing").length;
811
+ const running = rows.length - waiting;
777
812
  return (0, react.createElement)("section", { className: "evolution-param-card" }, (0, react.createElement)("button", {
778
813
  className: "evolution-param-head",
779
814
  type: "button",
@@ -781,7 +816,13 @@ window.__ModuleLoader__.load({
781
816
  setOpen(!open);
782
817
  },
783
818
  "aria-expanded": open
784
- }, (0, react.createElement)("span", { className: "evolution-param-card-title" }, t("approvalTitle")), count === 0 ? null : (0, react.createElement)("span", { className: "evolution-param-count" }, t("approvalCount").replace("{n}", String(count))), (0, react.createElement)("span", { className: "evolution-param-chevron" }, open ? "▲" : "▼")), open ? (0, react.createElement)("div", { className: "evolution-param-body" }, body()) : null);
819
+ }, (0, react.createElement)("span", { className: "evolution-param-card-title" }, t("approvalTitle")), waiting === 0 ? null : (0, react.createElement)("span", { className: "evolution-param-count" }, t("approvalCount").replace("{n}", String(waiting))), running === 0 ? null : (0, react.createElement)("span", { className: "evolution-param-count" }, t("approvalRunningCount").replace("{n}", String(running))), (0, react.createElement)("span", { className: "evolution-param-chevron" }, open ? "▲" : "▼")), open ? (0, react.createElement)("div", { className: "evolution-param-body" }, (0, react.createElement)("div", { className: "evolution-param-actions" }, (0, react.createElement)("button", {
820
+ className: "evolution-param-button",
821
+ type: "button",
822
+ onClick: () => {
823
+ load();
824
+ }
825
+ }, t("approvalRefresh"))), body()) : null);
785
826
  }
786
827
  //#endregion
787
828
  //#region src/client/messages.ts
@@ -806,6 +847,7 @@ window.__ModuleLoader__.load({
806
847
  loading: "正在读取设置…",
807
848
  unavailable: "这个功能没有启动(对应的插件没有安装)",
808
849
  seatMissing: "这个界面没有提供设置服务,参数只能看不能改",
850
+ projectionFailed: "这一块读设置时出错,已记入日志;其它参数不受影响",
809
851
  readonly: "本次会话不允许写入设置(临时运行模式)",
810
852
  apply: "保存",
811
853
  reset: "恢复默认值",
@@ -813,7 +855,9 @@ window.__ModuleLoader__.load({
813
855
  changed: "{n} 项已改",
814
856
  discard: "放弃修改",
815
857
  saving: "保存中…",
858
+ resetting: "恢复中…",
816
859
  refused: "这个值没有被接受(界面上保持原样)。想看原因:运行 /evolution params 或 /evolution doctor。",
860
+ resetRefused: "恢复默认值没有生效(这一行还是你的值)。想看原因:运行 /evolution params 或 /evolution doctor。",
817
861
  cardReview: "会话回顾",
818
862
  cardMemory: "长期记忆",
819
863
  cardToolMemory: "记忆写入",
@@ -827,6 +871,9 @@ window.__ModuleLoader__.load({
827
871
  approvalApprove: "批准",
828
872
  approvalReject: "拒绝",
829
873
  approvalCount: "{n} 项待批",
874
+ approvalRunningCount: "{n} 项执行中",
875
+ approvalRefresh: "刷新",
876
+ approvalExecuting: "执行中:这条的写效果可能已经落地,不要重复批准;要清掉它请用拒绝。",
830
877
  approvalKindMemory: "记忆",
831
878
  approvalKindSkill: "技能",
832
879
  approvalKindCapability: "能力",
@@ -852,6 +899,7 @@ window.__ModuleLoader__.load({
852
899
  loading: "reading settings…",
853
900
  unavailable: "this feature is not running (its plugin is not installed)",
854
901
  seatMissing: "this shell serves no settings surface, so the parameters cannot be edited here",
902
+ projectionFailed: "reading this row failed and the error is in the log; the other parameters are unaffected",
855
903
  readonly: "settings cannot be written in this session (temporary mode)",
856
904
  apply: "Save",
857
905
  reset: "Reset to default",
@@ -859,7 +907,9 @@ window.__ModuleLoader__.load({
859
907
  changed: "{n} changed",
860
908
  discard: "Discard",
861
909
  saving: "Saving…",
910
+ resetting: "Resetting…",
862
911
  refused: "The value was not accepted (the page keeps what you see). For the reason run /evolution params or /evolution doctor.",
912
+ resetRefused: "The reset did not take (this row still holds your value). For the reason run /evolution params or /evolution doctor.",
863
913
  cardReview: "Session review",
864
914
  cardMemory: "Long-term memory",
865
915
  cardToolMemory: "Writing memory",
@@ -873,6 +923,9 @@ window.__ModuleLoader__.load({
873
923
  approvalApprove: "Approve",
874
924
  approvalReject: "Reject",
875
925
  approvalCount: "{n} waiting",
926
+ approvalRunningCount: "{n} running",
927
+ approvalRefresh: "Refresh",
928
+ approvalExecuting: "Running: this write may already have landed — do not approve it twice; reject it to clear it.",
876
929
  approvalKindMemory: "memory",
877
930
  approvalKindSkill: "skill",
878
931
  approvalKindCapability: "capability",
@@ -914,24 +967,38 @@ window.__ModuleLoader__.load({
914
967
  //#region src/client/settle.ts
915
968
  /**
916
969
  * Structural equality for the values a parameter can hold.
970
+ *
971
+ * T5-10: the comparison walks the value instead of comparing serializations, because key ORDER is
972
+ * not part of an object's value — `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` are the same value, but
973
+ * their `JSON.stringify` output differs, and the card then read a save that HAD landed as refused
974
+ * (draft kept, error line shown). Array order IS part of an array's value, so arrays compare
975
+ * element-wise. `NaN` equals itself through the `Object.is` fast path; other primitives use `===`,
976
+ * which keeps `0` and `-0` equal exactly as the serialization comparison did.
917
977
  * @param a - one side of the comparison.
918
978
  * @param b - the other side.
919
979
  * @returns true when the two are the same value.
920
980
  */
921
981
  function sameValue(a, b) {
922
982
  if (Object.is(a, b)) return true;
923
- if (typeof a !== typeof b || a === null || b === null) return false;
924
- return JSON.stringify(a) === JSON.stringify(b);
983
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return a === b;
984
+ if (Array.isArray(a) || Array.isArray(b)) {
985
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
986
+ return a.every((item, index) => sameValue(item, b[index]));
987
+ }
988
+ const left = a;
989
+ const right = b;
990
+ const keys = Object.keys(left);
991
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameValue(left[key], right[key]));
925
992
  }
926
993
  /**
927
- * Whether every staged write is visible in the user layer.
928
- * @param pending - the writes awaiting a verdict.
994
+ * Whether every staged operation is visible in the user layer.
995
+ * @param pending - the operations awaiting a verdict.
929
996
  * @param user - the user layer of the post-write snapshot; a non-section reads as empty.
930
- * @returns true when each staged id now holds the value that was requested.
997
+ * @returns true when each staged id now holds the requested value, or is gone after an `unset`.
931
998
  */
932
- function landedWrites(pending, user) {
999
+ function landedSettlements(pending, user) {
933
1000
  const section = typeof user === "object" && user !== null && !Array.isArray(user) ? user : {};
934
- return pending.every((entry) => sameValue(section[entry.id], entry.want));
1001
+ return pending.every((entry) => entry.op === "unset" ? !Object.hasOwn(section, entry.id) : sameValue(section[entry.id], entry.want));
935
1002
  }
936
1003
  //#endregion
937
1004
  //#region src/client/ParamCard.ts
@@ -1030,11 +1097,11 @@ window.__ModuleLoader__.load({
1030
1097
  }, props.overridden ? t("overridden") : t("deployment"))), FieldControl(props), (0, react.createElement)("p", { className: "evolution-param-hint" }, field.hint === "" ? field.doc : field.hint), props.overridden ? (0, react.createElement)("div", { className: "evolution-param-actions" }, (0, react.createElement)("button", {
1031
1098
  type: "button",
1032
1099
  className: "evolution-param-button",
1033
- disabled: props.disabled,
1100
+ disabled: props.disabled || props.clearing,
1034
1101
  onClick: () => {
1035
- props.clear(field.id);
1102
+ props.onClear(field.id);
1036
1103
  }
1037
- }, t("reset"))) : null);
1104
+ }, props.clearing ? t("resetting") : t("reset"))) : null);
1038
1105
  }
1039
1106
  /**
1040
1107
  * Render one row's card body from a supplied snapshot.
@@ -1042,18 +1109,19 @@ window.__ModuleLoader__.load({
1042
1109
  * @returns the card.
1043
1110
  */
1044
1111
  function ParamCardView(props) {
1045
- const { t, fields, write, clear, namespace, snapshot } = props;
1112
+ const { t, fields, mutateOnce, clear, namespace, snapshot } = props;
1046
1113
  const [open, setOpen] = (0, react.useState)(false);
1047
1114
  const [draft, setDraft] = (0, react.useState)({});
1048
1115
  const [busy, setBusy] = (0, react.useState)(false);
1049
1116
  const [error, setError] = (0, react.useState)("");
1050
1117
  const [pending, setPending] = (0, react.useState)(null);
1118
+ const [clearing, setClearing] = (0, react.useState)(null);
1051
1119
  if (snapshot.status === "loading") return (0, react.createElement)("p", { className: "evolution-param-note" }, t("loading"));
1052
- if (snapshot.status === "unavailable") return (0, react.createElement)("p", { className: "evolution-param-note" }, t(snapshot.reason === "seat-missing" ? "seatMissing" : "unavailable"));
1120
+ if (snapshot.status === "unavailable") return (0, react.createElement)("p", { className: "evolution-param-note" }, t(snapshot.reason === "seat-missing" ? "seatMissing" : snapshot.reason === "projection-failed" ? "projectionFailed" : "unavailable"));
1053
1121
  const user = isSection$1(snapshot.user) ? snapshot.user : {};
1054
1122
  const value = isSection$1(snapshot.value) ? snapshot.value : {};
1055
1123
  const disabled = !snapshot.writable;
1056
- const locked = disabled || busy;
1124
+ const locked = disabled || busy || clearing !== null;
1057
1125
  const titleKey = NAMESPACE_TITLES[namespace];
1058
1126
  const title = titleKey === void 0 ? namespace : t(titleKey);
1059
1127
  const changed = fields.filter((field) => Object.hasOwn(user, field.id)).length;
@@ -1082,20 +1150,54 @@ window.__ModuleLoader__.load({
1082
1150
  if (pending === null) return;
1083
1151
  setPending(null);
1084
1152
  setBusy(false);
1085
- if (landedWrites(pending, snapshot.user)) setDraft({});
1086
- else setError(t("refused"));
1153
+ setClearing(null);
1154
+ if (!landedSettlements(pending, snapshot.user)) {
1155
+ setError(t(pending.every((entry) => entry.op === "unset") ? "resetRefused" : "refused"));
1156
+ return;
1157
+ }
1158
+ if (pending.some((entry) => entry.op === "set")) setDraft({});
1087
1159
  }, [
1088
1160
  pending,
1089
1161
  snapshot.user,
1090
1162
  t
1091
1163
  ]);
1164
+ /**
1165
+ * Restore one field's deployment default (T5-05/A60).
1166
+ *
1167
+ * The scope's own boolean is NOT the verdict — same reason the save path judges the settled
1168
+ * snapshot instead: a host refusal leaves the user layer untouched, which is precisely what a
1169
+ * structural check sees and a dropped return value does not. A throw (the settings service
1170
+ * rejecting a non-volatile path) used to escape as an unhandled rejection.
1171
+ */
1172
+ const reset = (field) => {
1173
+ setClearing(field);
1174
+ setError("");
1175
+ (async () => {
1176
+ try {
1177
+ await clear(field);
1178
+ } catch (caught) {
1179
+ setClearing(null);
1180
+ setError(caught instanceof Error && caught.message !== "" ? caught.message : t("resetRefused"));
1181
+ return;
1182
+ }
1183
+ setPending([{
1184
+ op: "unset",
1185
+ id: field
1186
+ }]);
1187
+ })();
1188
+ };
1092
1189
  const save = () => {
1093
1190
  setBusy(true);
1094
1191
  setError("");
1095
1192
  (async () => {
1096
1193
  try {
1097
- for (const field of dirty) await write(field.id, parseFor(field.control, textOf(field)));
1194
+ await mutateOnce(dirty.map((field) => ({
1195
+ op: "set",
1196
+ path: [field.id],
1197
+ value: parseFor(field.control, textOf(field))
1198
+ })));
1098
1199
  setPending(dirty.map((field) => ({
1200
+ op: "set",
1099
1201
  id: field.id,
1100
1202
  want: parseFor(field.control, textOf(field))
1101
1203
  })));
@@ -1118,9 +1220,10 @@ window.__ModuleLoader__.load({
1118
1220
  text: textOf(field),
1119
1221
  overridden: Object.hasOwn(user, field.id),
1120
1222
  disabled: locked,
1223
+ clearing: clearing === field.id,
1121
1224
  t,
1122
1225
  onChange: change,
1123
- clear
1226
+ onClear: reset
1124
1227
  })), error === "" ? null : (0, react.createElement)("p", { className: "evolution-param-error" }, error), fields.length === 0 ? null : (0, react.createElement)("div", { className: "evolution-param-footer" }, (0, react.createElement)("button", {
1125
1228
  type: "button",
1126
1229
  className: "evolution-param-button",
@@ -1133,7 +1236,7 @@ window.__ModuleLoader__.load({
1133
1236
  type: "button",
1134
1237
  className: "evolution-param-button",
1135
1238
  "data-primary": "true",
1136
- disabled: disabled || busy || dirty.length === 0,
1239
+ disabled: locked || dirty.length === 0,
1137
1240
  onClick: save
1138
1241
  }, busy ? t("saving") : t("apply")))) : null);
1139
1242
  }
@@ -1149,7 +1252,7 @@ window.__ModuleLoader__.load({
1149
1252
  namespace: props.namespace,
1150
1253
  fields: props.fields,
1151
1254
  t: props.t,
1152
- write: props.write,
1255
+ mutateOnce: props.mutateOnce,
1153
1256
  clear: props.clear,
1154
1257
  snapshot: props.useParamSection((state) => state)
1155
1258
  });
@@ -1164,6 +1267,16 @@ window.__ModuleLoader__.load({
1164
1267
  writable: false,
1165
1268
  reason: "seat-missing"
1166
1269
  });
1270
+ /** What a card shows when the seat served the row but projecting it failed. A frozen constant
1271
+ * so a failing row keeps ONE reference: a fresh object per call would reintroduce the
1272
+ * render loop this module exists to prevent. */
1273
+ const PROJECTION_FAILED = Object.freeze({
1274
+ status: "unavailable",
1275
+ value: void 0,
1276
+ user: void 0,
1277
+ writable: false,
1278
+ reason: "projection-failed"
1279
+ });
1167
1280
  /**
1168
1281
  * Whether a value is a plain section object.
1169
1282
  * @param value - the value to test.
@@ -1187,39 +1300,141 @@ window.__ModuleLoader__.load({
1187
1300
  };
1188
1301
  }
1189
1302
  /**
1303
+ * Bind one row's form to a projection that is stable per raw snapshot and never throws.
1304
+ * @param form - the row's client form, as the settings seat hands it over.
1305
+ * @returns the projection the card's hook reads.
1306
+ */
1307
+ function stableProjection(form) {
1308
+ let lastRaw;
1309
+ let lastOut;
1310
+ let reported = false;
1311
+ const failed = (error) => {
1312
+ if (!reported) {
1313
+ reported = true;
1314
+ console.error("[evolution-settings] projecting one row failed:", error);
1315
+ }
1316
+ return PROJECTION_FAILED;
1317
+ };
1318
+ return () => {
1319
+ let raw;
1320
+ try {
1321
+ raw = form.getSnapshot();
1322
+ } catch (error) {
1323
+ return failed(error);
1324
+ }
1325
+ if (lastOut !== void 0 && raw === lastRaw) return lastOut;
1326
+ try {
1327
+ const next = sectionSnapshotOf(raw);
1328
+ lastRaw = raw;
1329
+ lastOut = next;
1330
+ return next;
1331
+ } catch (error) {
1332
+ return failed(error);
1333
+ }
1334
+ };
1335
+ }
1336
+ /**
1190
1337
  * The live source one row's card binds.
1191
- * @param seat - the probed settings seat, when the deployment composed it.
1338
+ * @param probe - the lazily probed settings seat.
1192
1339
  * @param namespace - the row's entry id.
1193
- * @returns the source; without a seat it reports the stated empty state and never notifies.
1340
+ * @returns the source; without a seat it reports the stated empty state, and it re-probes on
1341
+ * every read so a seat that appears later is picked up without a timer.
1194
1342
  */
1195
- function sourceFor(seat, namespace) {
1196
- if (seat === void 0) return {
1197
- getSnapshot: () => SEAT_MISSING,
1198
- subscribe: () => () => {}
1343
+ function sourceFor(probe, namespace) {
1344
+ const listeners = /* @__PURE__ */ new Set();
1345
+ let form;
1346
+ let projected;
1347
+ let offForm;
1348
+ /**
1349
+ * Bind the seat's form for this row.
1350
+ * @param force - re-read the seat even when one is already bound, so a REPLACED seat (the shell
1351
+ * re-composed, a different form object for the same row) is picked up instead of kept stale.
1352
+ */
1353
+ const bind = (force = false) => {
1354
+ if (form !== void 0 && !force) return;
1355
+ const seat = probe();
1356
+ if (seat === void 0) return;
1357
+ const next = seat.get(namespace);
1358
+ if (next === form) return;
1359
+ offForm?.();
1360
+ offForm = void 0;
1361
+ form = next;
1362
+ projected = stableProjection(form);
1363
+ };
1364
+ const attach = () => {
1365
+ if (form === void 0 || offForm !== void 0) return;
1366
+ offForm = form.subscribe(() => {
1367
+ for (const listener of listeners) listener();
1368
+ });
1199
1369
  };
1200
- const form = seat.get(namespace);
1201
1370
  return {
1202
- getSnapshot: () => sectionSnapshotOf(form.getSnapshot()),
1203
- subscribe: (listener) => form.subscribe(listener)
1371
+ getSnapshot: () => {
1372
+ bind();
1373
+ return projected === void 0 ? SEAT_MISSING : projected();
1374
+ },
1375
+ revive: () => {
1376
+ bind(true);
1377
+ attach();
1378
+ for (const listener of listeners) listener();
1379
+ },
1380
+ subscribe: (listener) => {
1381
+ listeners.add(listener);
1382
+ bind();
1383
+ attach();
1384
+ return () => {
1385
+ listeners.delete(listener);
1386
+ if (listeners.size === 0) {
1387
+ offForm?.();
1388
+ offForm = void 0;
1389
+ }
1390
+ };
1391
+ }
1204
1392
  };
1205
1393
  }
1206
1394
  /**
1207
1395
  * The write pair for one row.
1208
- * @param seat - the probed settings seat, when the deployment composed it.
1396
+ * @param probe - the lazily probed settings seat.
1209
1397
  * @param namespace - the row's entry id.
1210
1398
  * @returns the two calls a card's save/reset path uses; without a seat they resolve without writing.
1211
1399
  */
1212
- function writeFaceFor(seat, namespace) {
1213
- const form = () => seat?.get(namespace);
1400
+ function writeFaceFor(probe, namespace) {
1401
+ const form = () => probe()?.get(namespace);
1214
1402
  return {
1215
- write: async (field, value) => {
1216
- await form()?.set(field, value);
1403
+ mutateOnce: async (ops) => {
1404
+ await form()?.mutate(ops);
1217
1405
  },
1218
1406
  clear: async (field) => {
1219
1407
  await form()?.unset(field);
1220
1408
  }
1221
1409
  };
1222
1410
  }
1411
+ /**
1412
+ * One source object per namespace, for the life of the plugin fiber.
1413
+ *
1414
+ * The renderer caches a hook binding per SOURCE OBJECT, so building a source per render
1415
+ * would re-bind the hook and remount the card. The cache is owned by the caller's fiber and
1416
+ * cleared on dispose.
1417
+ * @param probe - the lazily probed settings seat.
1418
+ * @returns the per-namespace source cache and its disposer.
1419
+ */
1420
+ function createSourceCache(probe) {
1421
+ const sources = /* @__PURE__ */ new Map();
1422
+ return {
1423
+ sourceFor: (namespace) => {
1424
+ const existing = sources.get(namespace);
1425
+ if (existing !== void 0) return existing;
1426
+ const source = sourceFor(probe, namespace);
1427
+ sources.set(namespace, source);
1428
+ return source;
1429
+ },
1430
+ revive: () => {
1431
+ for (const source of sources.values()) source.revive();
1432
+ },
1433
+ dispose: () => {
1434
+ sources.clear();
1435
+ }
1436
+ };
1437
+ }
1223
1438
  //#endregion
1224
1439
  //#region src/client/RowConfigCard.ts
1225
1440
  /**
@@ -1257,12 +1472,8 @@ window.__ModuleLoader__.load({
1257
1472
  fields,
1258
1473
  t,
1259
1474
  snapshot: sectionSnapshotOf(form.state),
1260
- write: async (field, value) => {
1261
- await form.mutate([{
1262
- op: "set",
1263
- path: [field],
1264
- value
1265
- }], form.state.revision);
1475
+ mutateOnce: async (ops) => {
1476
+ await form.mutate(ops, form.state.revision);
1266
1477
  },
1267
1478
  clear: async (field) => {
1268
1479
  await form.mutate([{
@@ -1362,7 +1573,7 @@ window.__ModuleLoader__.load({
1362
1573
  const CSS_TAG_ID = "@lmzhen/dsh-evolution-settings-ui/settings.css";
1363
1574
  /** The stylesheet the section injects once. */
1364
1575
  const CSS = [
1365
- tokenVars(".evolution-params"),
1576
+ tokenVars(".evolution-params,.evolution-param-card"),
1366
1577
  ".evolution-params{display:flex;flex-direction:column}",
1367
1578
  ".evolution-params-title{margin:0 0 var(--evo-space-4);color:var(--dsw-alias-label-primary);font-size:var(--evo-type-lg);font-weight:600;line-height:var(--evo-leading-base)}",
1368
1579
  ".evolution-params-subtitle{margin:0 0 var(--evo-space-16);color:var(--dsw-alias-label-tertiary);font-size:var(--evo-type-xs);line-height:var(--evo-leading-base)}",
@@ -1400,7 +1611,7 @@ window.__ModuleLoader__.load({
1400
1611
  ".evolution-param-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:var(--evo-type-xs);line-height:var(--evo-leading-base)}",
1401
1612
  ".evolution-param-facts{display:flex;flex-wrap:wrap;align-items:baseline;margin:0;color:var(--dsw-alias-label-tertiary);font-size:var(--evo-type-xs);line-height:var(--evo-leading-base)}",
1402
1613
  ".evolution-param-fact+.evolution-param-fact::before{content:\"·\";margin:0 var(--evo-space-6);color:var(--dsw-alias-label-tertiary)}",
1403
- ".evolution-param-source{margin:0;padding:var(--evo-space-12) var(--evo-space-16);max-height:var(--evo-cap-block);overflow:auto;border:var(--evo-hairline-width) solid var(--dsw-alias-border-l1);border-radius:var(--evo-radius-surface);background:var(--dsw-alias-markdown-code-block);color:var(--dsw-alias-label-primary);font:var(--dsw-font-markdown-code-block-small);white-space:pre-wrap}",
1614
+ ".evolution-param-diff{margin:0;padding:var(--evo-space-12) var(--evo-space-16);max-height:var(--evo-cap-block);overflow:auto;border:var(--evo-hairline-width) solid var(--dsw-alias-border-l1);border-radius:var(--evo-radius-surface);background:var(--dsw-alias-markdown-code-block);color:var(--dsw-alias-label-primary);font:var(--dsw-font-markdown-code-block-small);white-space:pre-wrap}",
1404
1615
  ".evolution-param-line-add{color:var(--evo-tone-success)}",
1405
1616
  ".evolution-param-line-del{color:var(--evo-tone-danger)}"
1406
1617
  ].join("\n");
@@ -1448,7 +1659,8 @@ window.__ModuleLoader__.load({
1448
1659
  });
1449
1660
  registered = true;
1450
1661
  return dispose;
1451
- } catch {
1662
+ } catch (error) {
1663
+ ctx.logger.warn(`evolution-settings-ui: the locale seat refused the "${NS}" dictionary registration (${error instanceof Error ? error.message : String(error)}) — the cards fall back to the bundle's own copy`);
1452
1664
  return () => {};
1453
1665
  }
1454
1666
  }, "evolution-settings: dictionaries");
@@ -1462,7 +1674,14 @@ window.__ModuleLoader__.load({
1462
1674
  }
1463
1675
  return bound(key);
1464
1676
  };
1465
- const configForms = ctx.get("configForms");
1677
+ const probe = () => ctx.get("configForms");
1678
+ const sources = createSourceCache(probe);
1679
+ ctx.effect(() => () => {
1680
+ sources.dispose();
1681
+ }, "evolution-settings: sources");
1682
+ ctx.inject?.(["configForms"], () => {
1683
+ sources.revive();
1684
+ });
1466
1685
  seam.slots.inject("settings.section", function* () {
1467
1686
  yield seam.slots.register({
1468
1687
  name: "settings.section",
@@ -1478,13 +1697,13 @@ window.__ModuleLoader__.load({
1478
1697
  });
1479
1698
  seam.slots.inject(CARD_SLOT, function* () {
1480
1699
  for (const section of CLIENT_PARAM_SECTIONS) {
1481
- const source = sourceFor(configForms, section.namespace);
1482
- const { write, clear } = writeFaceFor(configForms, section.namespace);
1700
+ const source = sources.sourceFor(section.seatKey);
1701
+ const { mutateOnce, clear } = writeFaceFor(probe, section.namespace);
1483
1702
  const face = () => ({
1484
1703
  namespace: section.namespace,
1485
1704
  fields: section.fields,
1486
1705
  t,
1487
- write,
1706
+ mutateOnce,
1488
1707
  clear,
1489
1708
  hooks: { paramSection: source }
1490
1709
  });
@@ -16,7 +16,7 @@
16
16
  import { type ReactNode } from 'react';
17
17
  import type { ClientParamField } from './generated-params.ts';
18
18
  import { type MessageKey } from './messages.ts';
19
- import type { ParamSectionSnapshot, ParamSectionSource } from './seam.ts';
19
+ import type { ParamSectionSnapshot, ParamSectionSource, SettingsPathOpLike } from './seam.ts';
20
20
  /**
21
21
  * Injected face: plain data and callbacks, plus the hook seat.
22
22
  *
@@ -29,7 +29,8 @@ export interface ParamCardFace {
29
29
  namespace: string;
30
30
  fields: readonly ClientParamField[];
31
31
  t: (key: MessageKey) => string;
32
- write: (field: string, value: unknown) => Promise<void>;
32
+ /** ONE call per save, carrying every dirty field as one op list (T5-01/A56). */
33
+ mutateOnce: (ops: readonly SettingsParamOp[]) => Promise<void>;
33
34
  clear: (field: string) => Promise<void>;
34
35
  hooks: {
35
36
  paramSection: ParamSectionSource;
@@ -44,6 +45,8 @@ export type ParamCardProps = Omit<ParamCardFace, 'hooks'> & {
44
45
  /** Bound from \`hooks.paramSection\` by the renderer. */
45
46
  readonly useParamSection: <T>(selector: (state: ParamSectionSnapshot) => T) => T;
46
47
  };
48
+ /** The op list a save hands to the seat — the seam's own shape, never a second vocabulary. */
49
+ export type SettingsParamOp = SettingsPathOpLike;
47
50
  /**
48
51
  * Render one namespace's card.
49
52
  * @param props - the card data, its callbacks, and the bound section hook.
@@ -54,7 +57,9 @@ export interface ParamCardViewProps {
54
57
  namespace: string;
55
58
  fields: readonly ClientParamField[];
56
59
  t: (key: MessageKey) => string;
57
- write: (field: string, value: unknown) => Promise<void>;
60
+ /** ONE call per save, carrying every dirty field as one op list (T5-01/A56). */
61
+ mutateOnce: (ops: readonly SettingsParamOp[]) => Promise<void>;
62
+ /** Ask the scope to drop one override; resolves when the scope answered, not when it landed. */
58
63
  clear: (field: string) => Promise<void>;
59
64
  /** The current row state, from whichever source owns it (the bound hook or the page). */
60
65
  snapshot: ParamSectionSnapshot;
@@ -41,10 +41,18 @@ export type ApprovalAnswer<T> = {
41
41
  readonly ok: false;
42
42
  readonly message: string;
43
43
  };
44
+ /**
45
+ * Which window the pending route is asked for. The host route has always taken `?status=` (the CLI
46
+ * asks for both); this half asked for neither, so a record stuck in `executing` — an approve that
47
+ * died between the claim and the decision, or another process running it — was invisible in the GUI
48
+ * while `/evolution pending` and doctor both listed it (T4-05/A47). `PendingRow.status` even declared
49
+ * the member, so the type face expected data the data face could not reach.
50
+ */
51
+ export type PendingWindow = 'pending' | 'executing';
44
52
  /** How this bundle reaches the host. Injectable so a spec drives it without a server. */
45
53
  export interface ApprovalApi {
46
- /** The staged writes waiting for a decision. */
47
- pending: () => Promise<ApprovalAnswer<readonly PendingRow[]>>;
54
+ /** The staged writes in one window; `pending` when the caller does not say. */
55
+ pending: (status?: PendingWindow) => Promise<ApprovalAnswer<readonly PendingRow[]>>;
48
56
  /** Ask the host to replay one staged write. */
49
57
  approve: (id: string) => Promise<ApprovalAnswer<unknown>>;
50
58
  /** Close one staged write without replaying it. */
@@ -23,9 +23,15 @@ export interface ClientParamField {
23
23
  /** Display names for `values`, same order ([] shows the raw value). */
24
24
  valueLabels: readonly string[];
25
25
  }
26
- /** One card: the settings namespace plus the fields it exposes. */
26
+ /** One card: the settings namespace it writes through, the SEAT key its source is looked up by,
27
+ * and the fields it exposes. A73 (`ClientParamSection.namespace` 一名字两义): the two roles are
28
+ * spelled separately so a Host may key them differently — `namespace` is what a save writes into
29
+ * (`update(namespace, patch)`) and what the card title is looked up by; `seatKey` is what the
30
+ * shell's `sources.sourceFor(...)` probes. Both are the LOADER ROW ID today. */
27
31
  export interface ClientParamSection {
28
32
  namespace: string;
33
+ /** The settings-card seat key the shell probes for this section's source. */
34
+ seatKey: string;
29
35
  fields: readonly ClientParamField[];
30
36
  }
31
37
  export declare const CLIENT_PARAM_SECTIONS: readonly ClientParamSection[];
@@ -19,6 +19,7 @@ export declare const zh: {
19
19
  readonly loading: "正在读取设置…";
20
20
  readonly unavailable: "这个功能没有启动(对应的插件没有安装)";
21
21
  readonly seatMissing: "这个界面没有提供设置服务,参数只能看不能改";
22
+ readonly projectionFailed: "这一块读设置时出错,已记入日志;其它参数不受影响";
22
23
  readonly readonly: "本次会话不允许写入设置(临时运行模式)";
23
24
  readonly apply: "保存";
24
25
  readonly reset: "恢复默认值";
@@ -26,7 +27,9 @@ export declare const zh: {
26
27
  readonly changed: "{n} 项已改";
27
28
  readonly discard: "放弃修改";
28
29
  readonly saving: "保存中…";
30
+ readonly resetting: "恢复中…";
29
31
  readonly refused: "这个值没有被接受(界面上保持原样)。想看原因:运行 /evolution params 或 /evolution doctor。";
32
+ readonly resetRefused: "恢复默认值没有生效(这一行还是你的值)。想看原因:运行 /evolution params 或 /evolution doctor。";
30
33
  readonly cardReview: "会话回顾";
31
34
  readonly cardMemory: "长期记忆";
32
35
  readonly cardToolMemory: "记忆写入";
@@ -40,6 +43,9 @@ export declare const zh: {
40
43
  readonly approvalApprove: "批准";
41
44
  readonly approvalReject: "拒绝";
42
45
  readonly approvalCount: "{n} 项待批";
46
+ readonly approvalRunningCount: "{n} 项执行中";
47
+ readonly approvalRefresh: "刷新";
48
+ readonly approvalExecuting: "执行中:这条的写效果可能已经落地,不要重复批准;要清掉它请用拒绝。";
43
49
  readonly approvalKindMemory: "记忆";
44
50
  readonly approvalKindSkill: "技能";
45
51
  readonly approvalKindCapability: "能力";
@@ -47,12 +47,22 @@ export interface ParamSectionSnapshot {
47
47
  * all, or the seat is there and serves no such row. The two read differently to the
48
48
  * operator (one is the UI's own gap, the other is a row that is not composed).
49
49
  */
50
- reason?: 'seat-missing' | 'not-served';
50
+ reason?: 'seat-missing' | 'not-served' | 'projection-failed';
51
51
  }
52
52
  /** The reactive source the renderer binds to a `use<Name>` seat. */
53
53
  export interface ParamSectionSource {
54
54
  getSnapshot(): ParamSectionSnapshot;
55
55
  subscribe(listener: () => void): () => void;
56
+ /**
57
+ * T5-11/A66: the seat ARRIVED (or was replaced) — bind it and wake every listener.
58
+ *
59
+ * The source re-probes on every READ, which covers a card that has not rendered yet; it does not
60
+ * cover a card that already rendered its "no settings surface" state, because nothing tells React to
61
+ * read again. `ctx.inject` is the platform's own arrival channel (it runs the callback when the
62
+ * service appears and re-runs it when the service changes), so the bundle subscribes there instead of
63
+ * inventing a timer.
64
+ */
65
+ revive(): void;
56
66
  }
57
67
  /** One ordered field operation, as the client settings seat takes them. */
58
68
  export interface SettingsPathOpLike {
@@ -8,18 +8,28 @@
8
8
  * too — so the verdict compares what each staged id holds now against what was requested.
9
9
  * @module @lmzhen/dsh-evolution-settings-ui
10
10
  */
11
- /** One staged write awaiting its verdict. */
12
- export interface PendingWrite {
13
- /** Parameter id the write named. */
11
+ /**
12
+ * One staged operation awaiting its verdict, in the platform's own two op kinds.
13
+ *
14
+ * `unset` is here because "restore the default" had NO verdict at all (T5-05/A60): the reset path
15
+ * called the scope and dropped its answer, so a host refusal changed nothing on screen — the row kept
16
+ * its "edited by you" chip and the reader concluded the button was broken. The two ops need different
17
+ * evidence (a set landed when the id holds the value; an unset landed when the id is GONE), which is
18
+ * exactly why they are one union judged by one function instead of two look-alike helpers.
19
+ */
20
+ export type Settlement = {
21
+ readonly op: 'set';
14
22
  readonly id: string;
15
- /** The value handed to the scope's `set`. */
16
23
  readonly want: unknown;
17
- }
24
+ } | {
25
+ readonly op: 'unset';
26
+ readonly id: string;
27
+ };
18
28
  /**
19
- * Whether every staged write is visible in the user layer.
20
- * @param pending - the writes awaiting a verdict.
29
+ * Whether every staged operation is visible in the user layer.
30
+ * @param pending - the operations awaiting a verdict.
21
31
  * @param user - the user layer of the post-write snapshot; a non-section reads as empty.
22
- * @returns true when each staged id now holds the value that was requested.
32
+ * @returns true when each staged id now holds the requested value, or is gone after an `unset`.
23
33
  */
24
- export declare function landedWrites(pending: readonly PendingWrite[], user: unknown): boolean;
34
+ export declare function landedSettlements(pending: readonly Settlement[], user: unknown): boolean;
25
35
  //# sourceMappingURL=settle.d.ts.map
@@ -4,11 +4,31 @@
4
4
  * One place maps `configForms.get(entryId)` to the three things a card needs — a
5
5
  * snapshot, a subscription and the two write calls — so our own section's cards and the
6
6
  * Plugins page's per-row entry cannot drift apart in how they read or write a row.
7
+ *
8
+ * Two invariants live here, both required by the renderer's hook seam:
9
+ *
10
+ * - **Stable references.** The renderer binds this source through
11
+ * `useSyncExternalStoreWithSelector`, which compares the projected snapshot by reference.
12
+ * A `getSnapshot()` that builds a new object on every call never compares equal, so the
13
+ * card re-renders until React aborts it (minified invariant #185) and the slot error
14
+ * boundary replaces the card with an empty placeholder. The projection therefore caches
15
+ * per raw snapshot reference.
16
+ * - **Never throws.** A throw from `getSnapshot()` happens inside the renderer's render pass,
17
+ * where this bundle cannot catch it and a card-level `try`/`catch` around a hook call is
18
+ * not legal React. Projection failures return a frozen unavailable snapshot instead.
19
+ *
20
+ * The seat itself is probed LAZILY: `apply()` runs before the settings shell may be active
21
+ * (the shell declares `settings.section` and provides `configForms`), so reading the seat at
22
+ * apply time would capture `undefined` for the whole life of the fiber.
7
23
  * @module @lmzhen/dsh-evolution-settings-ui/client
8
24
  */
9
- import type { ConfigFormSnapshotLike, ConfigFormsSeat, ParamSectionSnapshot, ParamSectionSource } from './seam.ts';
25
+ import type { ConfigFormLike, ConfigFormSnapshotLike, ConfigFormsSeat, ParamSectionSnapshot, ParamSectionSource, SettingsPathOpLike } from './seam.ts';
10
26
  /** What a card shows when this UI found no settings seat at all. */
11
27
  export declare const SEAT_MISSING: ParamSectionSnapshot;
28
+ /** What a card shows when the seat served the row but projecting it failed. A frozen constant
29
+ * so a failing row keeps ONE reference: a fresh object per call would reintroduce the
30
+ * render loop this module exists to prevent. */
31
+ export declare const PROJECTION_FAILED: ParamSectionSnapshot;
12
32
  /**
13
33
  * Whether a value is a plain section object.
14
34
  * @param value - the value to test.
@@ -21,21 +41,52 @@ export declare function isSection(value: unknown): value is Record<string, unkno
21
41
  * @returns the card's snapshot, with the reason an unavailable one is unavailable.
22
42
  */
23
43
  export declare function sectionSnapshotOf(snapshot: ConfigFormSnapshotLike): ParamSectionSnapshot;
44
+ /**
45
+ * Bind one row's form to a projection that is stable per raw snapshot and never throws.
46
+ * @param form - the row's client form, as the settings seat hands it over.
47
+ * @returns the projection the card's hook reads.
48
+ */
49
+ export declare function stableProjection(form: ConfigFormLike): () => ParamSectionSnapshot;
50
+ /**
51
+ * The lazily probed client settings seat.
52
+ *
53
+ * Probed at read time rather than captured at apply time: this row may activate before the
54
+ * settings shell, and a captured `undefined` would pin every card to the empty state.
55
+ * @returns the seat, or undefined while no settings surface is composed.
56
+ */
57
+ export type SeatProbe = () => ConfigFormsSeat | undefined;
24
58
  /**
25
59
  * The live source one row's card binds.
26
- * @param seat - the probed settings seat, when the deployment composed it.
60
+ * @param probe - the lazily probed settings seat.
27
61
  * @param namespace - the row's entry id.
28
- * @returns the source; without a seat it reports the stated empty state and never notifies.
62
+ * @returns the source; without a seat it reports the stated empty state, and it re-probes on
63
+ * every read so a seat that appears later is picked up without a timer.
29
64
  */
30
- export declare function sourceFor(seat: ConfigFormsSeat | undefined, namespace: string): ParamSectionSource;
65
+ export declare function sourceFor(probe: SeatProbe, namespace: string): ParamSectionSource;
31
66
  /**
32
67
  * The write pair for one row.
33
- * @param seat - the probed settings seat, when the deployment composed it.
68
+ * @param probe - the lazily probed settings seat.
34
69
  * @param namespace - the row's entry id.
35
70
  * @returns the two calls a card's save/reset path uses; without a seat they resolve without writing.
36
71
  */
37
- export declare function writeFaceFor(seat: ConfigFormsSeat | undefined, namespace: string): {
38
- write: (field: string, value: unknown) => Promise<void>;
72
+ export declare function writeFaceFor(probe: SeatProbe, namespace: string): {
73
+ mutateOnce: (ops: readonly SettingsPathOpLike[]) => Promise<void>;
39
74
  clear: (field: string) => Promise<void>;
40
75
  };
76
+ /**
77
+ * One source object per namespace, for the life of the plugin fiber.
78
+ *
79
+ * The renderer caches a hook binding per SOURCE OBJECT, so building a source per render
80
+ * would re-bind the hook and remount the card. The cache is owned by the caller's fiber and
81
+ * cleared on dispose.
82
+ * @param probe - the lazily probed settings seat.
83
+ * @returns the per-namespace source cache and its disposer.
84
+ */
85
+ export declare function createSourceCache(probe: SeatProbe): {
86
+ sourceFor: (namespace: string) => ParamSectionSource;
87
+ /** T5-11/A66: the seat arrived — wake every source this cache built (the cached ones are exactly the
88
+ * ones whose cards are already mounted and waiting). */
89
+ revive: () => void;
90
+ dispose: () => void;
91
+ };
41
92
  //# sourceMappingURL=source.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-settings-ui",
3
3
  "description": "Web settings cards for the evolution family's parameter namespaces (community build)",
4
- "version": "0.17.0",
4
+ "version": "0.18.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },