@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 +54 -2
- package/lib/client.js +271 -52
- package/lib/types/client/ParamCard.d.ts +8 -3
- package/lib/types/client/api.d.ts +10 -2
- package/lib/types/client/generated-params.d.ts +7 -1
- package/lib/types/client/messages.d.ts +6 -0
- package/lib/types/client/seam.d.ts +11 -1
- package/lib/types/client/settle.d.ts +19 -9
- package/lib/types/client/source.d.ts +58 -7
- package/package.json +1 -1
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. **
|
|
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
|
|
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
|
|
660
|
-
|
|
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-
|
|
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
|
|
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")),
|
|
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 (
|
|
924
|
-
|
|
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
|
|
928
|
-
* @param pending - the
|
|
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
|
|
997
|
+
* @returns true when each staged id now holds the requested value, or is gone after an `unset`.
|
|
931
998
|
*/
|
|
932
|
-
function
|
|
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.
|
|
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,
|
|
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
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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: () =>
|
|
1203
|
-
|
|
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
|
|
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(
|
|
1213
|
-
const form = () =>
|
|
1400
|
+
function writeFaceFor(probe, namespace) {
|
|
1401
|
+
const form = () => probe()?.get(namespace);
|
|
1214
1402
|
return {
|
|
1215
|
-
|
|
1216
|
-
await form()?.
|
|
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
|
-
|
|
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-
|
|
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
|
|
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(
|
|
1482
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
20
|
-
* @param pending - the
|
|
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
|
|
32
|
+
* @returns true when each staged id now holds the requested value, or is gone after an `unset`.
|
|
23
33
|
*/
|
|
24
|
-
export declare function
|
|
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
|
|
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
|
|
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(
|
|
65
|
+
export declare function sourceFor(probe: SeatProbe, namespace: string): ParamSectionSource;
|
|
31
66
|
/**
|
|
32
67
|
* The write pair for one row.
|
|
33
|
-
* @param
|
|
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(
|
|
38
|
-
|
|
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