@lmzhen/dsh-evolution-settings-ui 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/README.md CHANGED
@@ -42,12 +42,54 @@ 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.
@@ -82,4 +124,4 @@ The browser half ships as the client module system's lazy CJS factory artifact (
82
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.
83
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.
84
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.
85
- - 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
@@ -814,7 +855,9 @@ window.__ModuleLoader__.load({
814
855
  changed: "{n} 项已改",
815
856
  discard: "放弃修改",
816
857
  saving: "保存中…",
858
+ resetting: "恢复中…",
817
859
  refused: "这个值没有被接受(界面上保持原样)。想看原因:运行 /evolution params 或 /evolution doctor。",
860
+ resetRefused: "恢复默认值没有生效(这一行还是你的值)。想看原因:运行 /evolution params 或 /evolution doctor。",
818
861
  cardReview: "会话回顾",
819
862
  cardMemory: "长期记忆",
820
863
  cardToolMemory: "记忆写入",
@@ -828,6 +871,9 @@ window.__ModuleLoader__.load({
828
871
  approvalApprove: "批准",
829
872
  approvalReject: "拒绝",
830
873
  approvalCount: "{n} 项待批",
874
+ approvalRunningCount: "{n} 项执行中",
875
+ approvalRefresh: "刷新",
876
+ approvalExecuting: "执行中:这条的写效果可能已经落地,不要重复批准;要清掉它请用拒绝。",
831
877
  approvalKindMemory: "记忆",
832
878
  approvalKindSkill: "技能",
833
879
  approvalKindCapability: "能力",
@@ -861,7 +907,9 @@ window.__ModuleLoader__.load({
861
907
  changed: "{n} changed",
862
908
  discard: "Discard",
863
909
  saving: "Saving…",
910
+ resetting: "Resetting…",
864
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.",
865
913
  cardReview: "Session review",
866
914
  cardMemory: "Long-term memory",
867
915
  cardToolMemory: "Writing memory",
@@ -875,6 +923,9 @@ window.__ModuleLoader__.load({
875
923
  approvalApprove: "Approve",
876
924
  approvalReject: "Reject",
877
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.",
878
929
  approvalKindMemory: "memory",
879
930
  approvalKindSkill: "skill",
880
931
  approvalKindCapability: "capability",
@@ -916,24 +967,38 @@ window.__ModuleLoader__.load({
916
967
  //#region src/client/settle.ts
917
968
  /**
918
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.
919
977
  * @param a - one side of the comparison.
920
978
  * @param b - the other side.
921
979
  * @returns true when the two are the same value.
922
980
  */
923
981
  function sameValue(a, b) {
924
982
  if (Object.is(a, b)) return true;
925
- if (typeof a !== typeof b || a === null || b === null) return false;
926
- 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]));
927
992
  }
928
993
  /**
929
- * Whether every staged write is visible in the user layer.
930
- * @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.
931
996
  * @param user - the user layer of the post-write snapshot; a non-section reads as empty.
932
- * @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`.
933
998
  */
934
- function landedWrites(pending, user) {
999
+ function landedSettlements(pending, user) {
935
1000
  const section = typeof user === "object" && user !== null && !Array.isArray(user) ? user : {};
936
- 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));
937
1002
  }
938
1003
  //#endregion
939
1004
  //#region src/client/ParamCard.ts
@@ -1032,11 +1097,11 @@ window.__ModuleLoader__.load({
1032
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", {
1033
1098
  type: "button",
1034
1099
  className: "evolution-param-button",
1035
- disabled: props.disabled,
1100
+ disabled: props.disabled || props.clearing,
1036
1101
  onClick: () => {
1037
- props.clear(field.id);
1102
+ props.onClear(field.id);
1038
1103
  }
1039
- }, t("reset"))) : null);
1104
+ }, props.clearing ? t("resetting") : t("reset"))) : null);
1040
1105
  }
1041
1106
  /**
1042
1107
  * Render one row's card body from a supplied snapshot.
@@ -1044,18 +1109,19 @@ window.__ModuleLoader__.load({
1044
1109
  * @returns the card.
1045
1110
  */
1046
1111
  function ParamCardView(props) {
1047
- const { t, fields, write, clear, namespace, snapshot } = props;
1112
+ const { t, fields, mutateOnce, clear, namespace, snapshot } = props;
1048
1113
  const [open, setOpen] = (0, react.useState)(false);
1049
1114
  const [draft, setDraft] = (0, react.useState)({});
1050
1115
  const [busy, setBusy] = (0, react.useState)(false);
1051
1116
  const [error, setError] = (0, react.useState)("");
1052
1117
  const [pending, setPending] = (0, react.useState)(null);
1118
+ const [clearing, setClearing] = (0, react.useState)(null);
1053
1119
  if (snapshot.status === "loading") return (0, react.createElement)("p", { className: "evolution-param-note" }, t("loading"));
1054
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"));
1055
1121
  const user = isSection$1(snapshot.user) ? snapshot.user : {};
1056
1122
  const value = isSection$1(snapshot.value) ? snapshot.value : {};
1057
1123
  const disabled = !snapshot.writable;
1058
- const locked = disabled || busy;
1124
+ const locked = disabled || busy || clearing !== null;
1059
1125
  const titleKey = NAMESPACE_TITLES[namespace];
1060
1126
  const title = titleKey === void 0 ? namespace : t(titleKey);
1061
1127
  const changed = fields.filter((field) => Object.hasOwn(user, field.id)).length;
@@ -1084,20 +1150,54 @@ window.__ModuleLoader__.load({
1084
1150
  if (pending === null) return;
1085
1151
  setPending(null);
1086
1152
  setBusy(false);
1087
- if (landedWrites(pending, snapshot.user)) setDraft({});
1088
- 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({});
1089
1159
  }, [
1090
1160
  pending,
1091
1161
  snapshot.user,
1092
1162
  t
1093
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
+ };
1094
1189
  const save = () => {
1095
1190
  setBusy(true);
1096
1191
  setError("");
1097
1192
  (async () => {
1098
1193
  try {
1099
- 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
+ })));
1100
1199
  setPending(dirty.map((field) => ({
1200
+ op: "set",
1101
1201
  id: field.id,
1102
1202
  want: parseFor(field.control, textOf(field))
1103
1203
  })));
@@ -1120,9 +1220,10 @@ window.__ModuleLoader__.load({
1120
1220
  text: textOf(field),
1121
1221
  overridden: Object.hasOwn(user, field.id),
1122
1222
  disabled: locked,
1223
+ clearing: clearing === field.id,
1123
1224
  t,
1124
1225
  onChange: change,
1125
- clear
1226
+ onClear: reset
1126
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", {
1127
1228
  type: "button",
1128
1229
  className: "evolution-param-button",
@@ -1135,7 +1236,7 @@ window.__ModuleLoader__.load({
1135
1236
  type: "button",
1136
1237
  className: "evolution-param-button",
1137
1238
  "data-primary": "true",
1138
- disabled: disabled || busy || dirty.length === 0,
1239
+ disabled: locked || dirty.length === 0,
1139
1240
  onClick: save
1140
1241
  }, busy ? t("saving") : t("apply")))) : null);
1141
1242
  }
@@ -1151,7 +1252,7 @@ window.__ModuleLoader__.load({
1151
1252
  namespace: props.namespace,
1152
1253
  fields: props.fields,
1153
1254
  t: props.t,
1154
- write: props.write,
1255
+ mutateOnce: props.mutateOnce,
1155
1256
  clear: props.clear,
1156
1257
  snapshot: props.useParamSection((state) => state)
1157
1258
  });
@@ -1244,11 +1345,20 @@ window.__ModuleLoader__.load({
1244
1345
  let form;
1245
1346
  let projected;
1246
1347
  let offForm;
1247
- const bind = () => {
1248
- if (form !== void 0) return;
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;
1249
1355
  const seat = probe();
1250
1356
  if (seat === void 0) return;
1251
- form = seat.get(namespace);
1357
+ const next = seat.get(namespace);
1358
+ if (next === form) return;
1359
+ offForm?.();
1360
+ offForm = void 0;
1361
+ form = next;
1252
1362
  projected = stableProjection(form);
1253
1363
  };
1254
1364
  const attach = () => {
@@ -1262,6 +1372,11 @@ window.__ModuleLoader__.load({
1262
1372
  bind();
1263
1373
  return projected === void 0 ? SEAT_MISSING : projected();
1264
1374
  },
1375
+ revive: () => {
1376
+ bind(true);
1377
+ attach();
1378
+ for (const listener of listeners) listener();
1379
+ },
1265
1380
  subscribe: (listener) => {
1266
1381
  listeners.add(listener);
1267
1382
  bind();
@@ -1285,8 +1400,8 @@ window.__ModuleLoader__.load({
1285
1400
  function writeFaceFor(probe, namespace) {
1286
1401
  const form = () => probe()?.get(namespace);
1287
1402
  return {
1288
- write: async (field, value) => {
1289
- await form()?.set(field, value);
1403
+ mutateOnce: async (ops) => {
1404
+ await form()?.mutate(ops);
1290
1405
  },
1291
1406
  clear: async (field) => {
1292
1407
  await form()?.unset(field);
@@ -1312,6 +1427,9 @@ window.__ModuleLoader__.load({
1312
1427
  sources.set(namespace, source);
1313
1428
  return source;
1314
1429
  },
1430
+ revive: () => {
1431
+ for (const source of sources.values()) source.revive();
1432
+ },
1315
1433
  dispose: () => {
1316
1434
  sources.clear();
1317
1435
  }
@@ -1354,12 +1472,8 @@ window.__ModuleLoader__.load({
1354
1472
  fields,
1355
1473
  t,
1356
1474
  snapshot: sectionSnapshotOf(form.state),
1357
- write: async (field, value) => {
1358
- await form.mutate([{
1359
- op: "set",
1360
- path: [field],
1361
- value
1362
- }], form.state.revision);
1475
+ mutateOnce: async (ops) => {
1476
+ await form.mutate(ops, form.state.revision);
1363
1477
  },
1364
1478
  clear: async (field) => {
1365
1479
  await form.mutate([{
@@ -1459,7 +1573,7 @@ window.__ModuleLoader__.load({
1459
1573
  const CSS_TAG_ID = "@lmzhen/dsh-evolution-settings-ui/settings.css";
1460
1574
  /** The stylesheet the section injects once. */
1461
1575
  const CSS = [
1462
- tokenVars(".evolution-params"),
1576
+ tokenVars(".evolution-params,.evolution-param-card"),
1463
1577
  ".evolution-params{display:flex;flex-direction:column}",
1464
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)}",
1465
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)}",
@@ -1497,7 +1611,7 @@ window.__ModuleLoader__.load({
1497
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)}",
1498
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)}",
1499
1613
  ".evolution-param-fact+.evolution-param-fact::before{content:\"·\";margin:0 var(--evo-space-6);color:var(--dsw-alias-label-tertiary)}",
1500
- ".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}",
1501
1615
  ".evolution-param-line-add{color:var(--evo-tone-success)}",
1502
1616
  ".evolution-param-line-del{color:var(--evo-tone-danger)}"
1503
1617
  ].join("\n");
@@ -1545,7 +1659,8 @@ window.__ModuleLoader__.load({
1545
1659
  });
1546
1660
  registered = true;
1547
1661
  return dispose;
1548
- } 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`);
1549
1664
  return () => {};
1550
1665
  }
1551
1666
  }, "evolution-settings: dictionaries");
@@ -1564,6 +1679,9 @@ window.__ModuleLoader__.load({
1564
1679
  ctx.effect(() => () => {
1565
1680
  sources.dispose();
1566
1681
  }, "evolution-settings: sources");
1682
+ ctx.inject?.(["configForms"], () => {
1683
+ sources.revive();
1684
+ });
1567
1685
  seam.slots.inject("settings.section", function* () {
1568
1686
  yield seam.slots.register({
1569
1687
  name: "settings.section",
@@ -1579,13 +1697,13 @@ window.__ModuleLoader__.load({
1579
1697
  });
1580
1698
  seam.slots.inject(CARD_SLOT, function* () {
1581
1699
  for (const section of CLIENT_PARAM_SECTIONS) {
1582
- const source = sources.sourceFor(section.namespace);
1583
- const { write, clear } = writeFaceFor(probe, section.namespace);
1700
+ const source = sources.sourceFor(section.seatKey);
1701
+ const { mutateOnce, clear } = writeFaceFor(probe, section.namespace);
1584
1702
  const face = () => ({
1585
1703
  namespace: section.namespace,
1586
1704
  fields: section.fields,
1587
1705
  t,
1588
- write,
1706
+ mutateOnce,
1589
1707
  clear,
1590
1708
  hooks: { paramSection: source }
1591
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[];
@@ -27,7 +27,9 @@ export declare const zh: {
27
27
  readonly changed: "{n} 项已改";
28
28
  readonly discard: "放弃修改";
29
29
  readonly saving: "保存中…";
30
+ readonly resetting: "恢复中…";
30
31
  readonly refused: "这个值没有被接受(界面上保持原样)。想看原因:运行 /evolution params 或 /evolution doctor。";
32
+ readonly resetRefused: "恢复默认值没有生效(这一行还是你的值)。想看原因:运行 /evolution params 或 /evolution doctor。";
31
33
  readonly cardReview: "会话回顾";
32
34
  readonly cardMemory: "长期记忆";
33
35
  readonly cardToolMemory: "记忆写入";
@@ -41,6 +43,9 @@ export declare const zh: {
41
43
  readonly approvalApprove: "批准";
42
44
  readonly approvalReject: "拒绝";
43
45
  readonly approvalCount: "{n} 项待批";
46
+ readonly approvalRunningCount: "{n} 项执行中";
47
+ readonly approvalRefresh: "刷新";
48
+ readonly approvalExecuting: "执行中:这条的写效果可能已经落地,不要重复批准;要清掉它请用拒绝。";
44
49
  readonly approvalKindMemory: "记忆";
45
50
  readonly approvalKindSkill: "技能";
46
51
  readonly approvalKindCapability: "能力";
@@ -53,6 +53,16 @@ export interface ParamSectionSnapshot {
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
@@ -22,7 +22,7 @@
22
22
  * apply time would capture `undefined` for the whole life of the fiber.
23
23
  * @module @lmzhen/dsh-evolution-settings-ui/client
24
24
  */
25
- import type { ConfigFormLike, ConfigFormSnapshotLike, ConfigFormsSeat, ParamSectionSnapshot, ParamSectionSource } from './seam.ts';
25
+ import type { ConfigFormLike, ConfigFormSnapshotLike, ConfigFormsSeat, ParamSectionSnapshot, ParamSectionSource, SettingsPathOpLike } from './seam.ts';
26
26
  /** What a card shows when this UI found no settings seat at all. */
27
27
  export declare const SEAT_MISSING: ParamSectionSnapshot;
28
28
  /** What a card shows when the seat served the row but projecting it failed. A frozen constant
@@ -70,7 +70,7 @@ export declare function sourceFor(probe: SeatProbe, namespace: string): ParamSec
70
70
  * @returns the two calls a card's save/reset path uses; without a seat they resolve without writing.
71
71
  */
72
72
  export declare function writeFaceFor(probe: SeatProbe, namespace: string): {
73
- write: (field: string, value: unknown) => Promise<void>;
73
+ mutateOnce: (ops: readonly SettingsPathOpLike[]) => Promise<void>;
74
74
  clear: (field: string) => Promise<void>;
75
75
  };
76
76
  /**
@@ -84,6 +84,9 @@ export declare function writeFaceFor(probe: SeatProbe, namespace: string): {
84
84
  */
85
85
  export declare function createSourceCache(probe: SeatProbe): {
86
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;
87
90
  dispose: () => void;
88
91
  };
89
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.1",
4
+ "version": "0.18.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },