@jini-ai/ui 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +20 -0
- package/dist/core/theme/default.d.ts +4 -0
- package/dist/core/theme/default.js +11 -0
- package/dist/core/theme/stylesheet.d.ts +8 -0
- package/dist/core/theme/stylesheet.js +14 -0
- package/dist/core/theme/types.d.ts +39 -0
- package/dist/core/theme/types.js +2 -0
- package/dist/core/theme/validation.d.ts +15 -0
- package/dist/core/theme/validation.js +55 -0
- package/dist/features/admin-widgets/components/Select/Select.d.ts +11 -3
- package/dist/features/admin-widgets/components/Select/Select.hooks.d.ts +8 -13
- package/dist/features/admin-widgets/components/Select/Select.hooks.js +37 -215
- package/dist/features/admin-widgets/components/Select/Select.js +4 -3
- package/dist/features/appearance/constants.js +1 -1
- package/dist/features/connectors/rules.d.ts +1 -6
- package/dist/features/connectors/types.d.ts +1 -6
- package/dist/features/connectors/types.js +1 -6
- package/dist/features/execution/http-adapter.d.ts +111 -0
- package/dist/features/execution/http-adapter.js +160 -0
- package/dist/features/execution/index.d.ts +4 -0
- package/dist/features/execution/index.js +2 -0
- package/dist/features/execution/react/hooks/use-execution-credential.hooks.d.ts +232 -0
- package/dist/features/execution/react/hooks/use-execution-credential.hooks.js +217 -0
- package/dist/features/html-editor/canvas-style.d.ts +1 -1
- package/dist/features/html-editor/canvas-style.js +2 -2
- package/dist/features/i18n/dictionaries/settings-dialog.ar.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.bn.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.de.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.en.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.es.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fa.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fr.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hi.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hu.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.id.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.it.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ja.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ko.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pl.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pt-BR.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ru.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.th.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.tr.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.uk.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ur.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-CN.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-TW.js +2 -0
- package/dist/features/i18n/dictionary-translator.d.ts +5 -2
- package/dist/features/i18n/dictionary-translator.js +1 -1
- package/dist/features/i18n/index.d.ts +1 -1
- package/dist/features/i18n/index.js +1 -1
- package/dist/features/i18n/template-i18n.d.ts +11 -0
- package/dist/features/i18n/template-i18n.js +12 -0
- package/dist/features/interactive-ui/providers/recharts/chart-tokens.d.ts +1 -1
- package/dist/features/interactive-ui/providers/recharts/chart-tokens.js +1 -1
- package/dist/features/lexical-rich-text-editor/deserialize.d.ts +0 -5
- package/dist/features/lexical-rich-text-editor/deserialize.js +0 -5
- package/dist/features/lexical-rich-text-editor/mention-node.d.ts +5 -17
- package/dist/features/lexical-rich-text-editor/mention-node.js +5 -17
- package/dist/features/lexical-rich-text-editor/mention-parser.d.ts +2 -4
- package/dist/features/lexical-rich-text-editor/serialize.d.ts +0 -4
- package/dist/features/lexical-rich-text-editor/serialize.js +0 -4
- package/dist/features/lexical-rich-text-editor/types.d.ts +3 -13
- package/dist/features/lexical-rich-text-editor/types.js +3 -13
- package/dist/features/mcp-ui/early-message-buffer.d.ts +1 -2
- package/dist/features/mcp-ui/early-message-buffer.js +1 -2
- package/dist/features/mcp-ui/escape.d.ts +4 -0
- package/dist/features/mcp-ui/escape.js +2 -9
- package/dist/features/mcp-ui/index.d.ts +1 -0
- package/dist/features/mcp-ui/index.js +1 -0
- package/dist/features/mcp-ui/protocol.d.ts +2 -5
- package/dist/features/mcp-ui/protocol.js +2 -5
- package/dist/features/mcp-ui/resource.d.ts +47 -7
- package/dist/features/mcp-ui/resource.js +5 -8
- package/dist/features/mcp-ui/secret-card.d.ts +115 -0
- package/dist/features/mcp-ui/secret-card.js +137 -0
- package/dist/features/mcp-ui/surfaces/confirmation.js +5 -0
- package/dist/features/mcp-ui/surfaces/fields.d.ts +5 -2
- package/dist/features/mcp-ui/surfaces/fields.js +3 -1
- package/dist/features/mcp-ui/surfaces/form.d.ts +4 -1
- package/dist/features/mcp-ui/surfaces/form.js +65 -3
- package/dist/features/mcp-ui/surfaces/outcome.d.ts +2 -0
- package/dist/features/mcp-ui/surfaces/text-input.d.ts +14 -6
- package/dist/features/mcp-ui/surfaces/text-input.js +16 -2
- package/dist/features/mcp-ui/surfaces/tokens.js +1 -1
- package/dist/features/memory/react/components/MemoryConnectedPanel.d.ts +1 -1
- package/dist/features/memory/react/components/MemoryConnectedPanel.js +0 -1
- package/dist/features/panel-kit/helpers/format-timestamp.d.ts +5 -3
- package/dist/features/panel-kit/helpers/format-timestamp.js +13 -4
- package/dist/features/panel-kit/helpers/permissions.js +2 -0
- package/dist/features/panel-kit/helpers/resolve-active-tab-id.js +2 -0
- package/dist/features/panel-kit/helpers/standing-draft-local-backup.d.ts +51 -0
- package/dist/features/panel-kit/helpers/standing-draft-local-backup.js +77 -0
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.d.ts +8 -0
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.js +10 -0
- package/dist/features/panel-kit/hooks/use-async-action.hooks.d.ts +1 -0
- package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.d.ts +1 -0
- package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.js +1 -0
- package/dist/features/panel-kit/hooks/use-serial-writes.hooks.d.ts +8 -0
- package/dist/features/panel-kit/hooks/use-serial-writes.hooks.js +3 -0
- package/dist/features/panel-kit/hooks/use-settings-slice.hooks.d.ts +211 -0
- package/dist/features/panel-kit/hooks/use-settings-slice.hooks.js +382 -0
- package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.d.ts +179 -0
- package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.js +300 -0
- package/dist/features/panel-kit/index.d.ts +7 -1
- package/dist/features/panel-kit/index.js +5 -1
- package/dist/features/resource-dashboard/types.d.ts +8 -41
- package/dist/features/resource-dashboard/types.js +3 -26
- package/dist/features/tab-strip/constants.d.ts +3 -4
- package/dist/features/tab-strip/constants.js +3 -4
- package/dist/features/tab-strip/dependencies.d.ts +1 -6
- package/dist/features/tab-strip/dependencies.js +1 -6
- package/dist/features/tab-strip/react/components/TabBar.d.ts +33 -1
- package/dist/features/tab-strip/react/components/TabBar.hooks.d.ts +57 -5
- package/dist/features/tab-strip/react/components/TabBar.hooks.js +63 -5
- package/dist/features/tab-strip/react/components/TabBar.js +12 -1
- package/dist/features/tab-strip/rules.d.ts +4 -17
- package/dist/features/tab-strip/rules.js +4 -17
- package/dist/features/tab-strip/types.d.ts +2 -13
- package/dist/icon-name.d.ts +1 -1
- package/dist/icon-name.js +1 -0
- package/dist/index.d.ts +42 -417
- package/dist/index.js +42 -430
- package/dist/react/components/CustomSelect.d.ts +6 -2
- package/dist/react/components/CustomSelect.js +20 -91
- package/dist/react/components/Icon.d.ts +6 -0
- package/dist/react/components/Icon.js +12 -0
- package/dist/react/components/select-popover.d.ts +106 -0
- package/dist/react/components/select-popover.js +210 -0
- package/dist/renderers/sandbox-scripts.d.ts +29 -0
- package/dist/renderers/sandbox-scripts.js +127 -0
- package/dist/renderers/sandboxed-document.js +3 -86
- package/dist/renderers/srcdoc/build.d.ts +10 -17
- package/dist/renderers/srcdoc/build.js +31 -234
- package/dist/styles/variables.css +2 -2
- package/dist/theme/apply.d.ts +1 -1
- package/dist/theme/apply.js +2 -2
- package/dist/theme/color-scheme.d.ts +1 -1
- package/dist/theme/default.d.ts +1 -3
- package/dist/theme/default.js +2 -10
- package/dist/theme/index.d.ts +3 -3
- package/dist/theme/index.js +2 -2
- package/dist/theme/stylesheet.d.ts +1 -7
- package/dist/theme/stylesheet.js +2 -13
- package/dist/theme/types.d.ts +1 -38
- package/dist/theme/types.js +2 -1
- package/dist/theme/validation.d.ts +1 -14
- package/dist/theme/validation.js +2 -54
- package/dist/utils/endpoint-policy.d.ts +21 -39
- package/dist/utils/endpoint-policy.js +19 -107
- package/dist/utils/file-system-errors.d.ts +1 -2
- package/dist/utils/file-system-errors.js +1 -2
- package/dist/utils/ime-composing.d.ts +0 -3
- package/dist/utils/ime-composing.js +0 -3
- package/dist/utils/platform.d.ts +1 -4
- package/dist/utils/platform.js +1 -4
- package/dist/utils/smooth-scroll-to-top.d.ts +1 -3
- package/dist/utils/smooth-scroll-to-top.js +3 -13
- package/dist/utils/timezone.js +1 -5
- package/dist/utils/uuid.d.ts +0 -3
- package/dist/utils/uuid.js +0 -3
- package/dist/utils/visual-stability.d.ts +1 -3
- package/dist/utils/visual-stability.js +1 -3
- package/package.json +74 -15
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export type SettingsRefreshScope = readonly string[] | null;
|
|
2
|
+
/**
|
|
3
|
+
* @file One settings-dialog tab's load/edit/debounced-save lifecycle,
|
|
4
|
+
* generic over the tab's own config type.
|
|
5
|
+
*
|
|
6
|
+
* This was `SettingsUi.tsx`'s inline machinery when exactly one tab (Execution
|
|
7
|
+
* mode) was mounted. It is extracted rather than copied because the logic is
|
|
8
|
+
* subtle out of proportion to its size — successive audits have found FOUR
|
|
9
|
+
* distinct data-loss bugs in it (see `saveTicket`, `hasUnsavedEdits`, `runSave`,
|
|
10
|
+
* and `commits` below), and every one of those hazards is identical for every
|
|
11
|
+
* tab. Duplicating it per tab would mean re-introducing all four bugs three
|
|
12
|
+
* more times, and fixing them in three more places.
|
|
13
|
+
*
|
|
14
|
+
* The last two share one root cause worth stating plainly, because it is the
|
|
15
|
+
* shape every future bug here will take: **a value read at schedule time is a
|
|
16
|
+
* claim about the store that may already be false by the time the code using it
|
|
17
|
+
* runs.** `runSave` fixes it for writes by reading the diff base at run time;
|
|
18
|
+
* `commits` fixes it for reads by discarding a reload whose response predates a
|
|
19
|
+
* write that landed while it was in flight. Anything added here that captures
|
|
20
|
+
* `persisted` or `latest` across an `await` needs the same treatment.
|
|
21
|
+
*
|
|
22
|
+
* Each mounted tab owns one instance. They are independent by construction:
|
|
23
|
+
* separate debounce timers, separate save chains, separate diff bases. The
|
|
24
|
+
* only shared surface is the merged status the page chrome renders, which
|
|
25
|
+
* `SettingsUi.tsx` derives.
|
|
26
|
+
*/
|
|
27
|
+
export type SaveState = {
|
|
28
|
+
status: "idle";
|
|
29
|
+
} | {
|
|
30
|
+
status: "saving";
|
|
31
|
+
} | {
|
|
32
|
+
status: "saved";
|
|
33
|
+
} | {
|
|
34
|
+
status: "error";
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
/** Debounce for text fields: tabs are controlled and fire on every keystroke,
|
|
38
|
+
* and each ledger write is an append-only revision. Without this, typing into
|
|
39
|
+
* a textarea would write one revision per character. */
|
|
40
|
+
export declare const SAVE_DEBOUNCE_MS = 600;
|
|
41
|
+
/** Namespace-only notifications preserve the authorized re-read boundary; never carry values. */
|
|
42
|
+
export interface SettingsSliceRefreshPort {
|
|
43
|
+
publish(required: {
|
|
44
|
+
namespaces: readonly string[];
|
|
45
|
+
}, optional?: Record<string, never>): void;
|
|
46
|
+
subscribe(required: {
|
|
47
|
+
listener: (scope: SettingsRefreshScope) => void;
|
|
48
|
+
}, optional?: Record<string, never>): () => void;
|
|
49
|
+
}
|
|
50
|
+
export interface SettingsSliceOptions<T> {
|
|
51
|
+
/** Reads the persisted value. A rejection surfaces as `loadError` and the
|
|
52
|
+
* slice falls back to `defaultValue` — an unreadable setting shows defaults
|
|
53
|
+
* with a visible warning rather than blocking the whole dialog. */
|
|
54
|
+
load: (required: Record<string, never>, optional?: Record<string, never>) => Promise<T>;
|
|
55
|
+
/** Writes whatever changed between `next` and `previous`, returning the keys
|
|
56
|
+
* actually written (an empty array means "nothing to do", which the status
|
|
57
|
+
* indicator renders as idle rather than a misleading "Saved"). */
|
|
58
|
+
save: (required: {
|
|
59
|
+
next: T;
|
|
60
|
+
previous: T;
|
|
61
|
+
}, optional?: Record<string, never>) => Promise<readonly string[]>;
|
|
62
|
+
defaultValue: T;
|
|
63
|
+
/**
|
|
64
|
+
* Ledger namespaces this slice reads, so a refresh naming other namespaces
|
|
65
|
+
* is ignored instead of costing a pointless reload.
|
|
66
|
+
*
|
|
67
|
+
* Omit to reload on EVERY refresh. That is the safe default in both
|
|
68
|
+
* directions: a slice that forgets to declare its namespaces stays correct
|
|
69
|
+
* (it just refetches more than it needs), whereas defaulting to "ignore
|
|
70
|
+
* everything" would make a forgotten declaration silently stop updating —
|
|
71
|
+
* the exact bug this feature exists to remove.
|
|
72
|
+
*/
|
|
73
|
+
namespaces?: readonly string[];
|
|
74
|
+
/** Use one port for publication and subscription, so successful writes refresh their own surface.
|
|
75
|
+
* The host supplies a namespace-only bus; ports own their subscription cleanup. */
|
|
76
|
+
refreshPort: SettingsSliceRefreshPort;
|
|
77
|
+
/**
|
|
78
|
+
* Reconciles a freshly loaded value against the operator's current in-memory value before
|
|
79
|
+
* {@link SettingsSlice.refresh} lets it replace `value`/`persisted`/`latest`. Defaults to "the
|
|
80
|
+
* loaded value wins outright" — correct for every field this slice actually round-trips through
|
|
81
|
+
* `save`/`load`.
|
|
82
|
+
*
|
|
83
|
+
* Provide this when `T` carries a field `save`/`load` deliberately never touch (see
|
|
84
|
+
* `execution-settings.ts`'s `reconcileExecutionConfigRefresh` for `byok.apiKey`, which `save`
|
|
85
|
+
* never writes and `load` always returns empty). Without it, an external refresh — a same-tab
|
|
86
|
+
* echo of this slice's OWN write arriving over the settings-changed SSE feed, another tab,
|
|
87
|
+
* another operator — carries that field back at its "nothing here" default and silently
|
|
88
|
+
* overwrites an operator's typed-but-unsaved edit to it. `runSave` cannot leak this on its own:
|
|
89
|
+
* it never calls `setValue`. This path can, because replacing `value` with server truth is its
|
|
90
|
+
* entire job.
|
|
91
|
+
*
|
|
92
|
+
* @param current - `latest.current` at the moment the reload settled — the newest value the
|
|
93
|
+
* operator has produced, saved or not.
|
|
94
|
+
* @param loaded - What `load()` just returned — true server state for every field it manages.
|
|
95
|
+
*/
|
|
96
|
+
reconcileRefresh?: (required: {
|
|
97
|
+
current: T;
|
|
98
|
+
loaded: T;
|
|
99
|
+
}, optional?: Record<string, never>) => T;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Runs one save-chain link: calls `runSave`, then paints the ticket-owning outcome onto
|
|
103
|
+
* `saveState` — unless a newer save has since taken the ticket (`currentTicket()` no longer matches
|
|
104
|
+
* `ticket`), or the operator has queued another edit behind this one (`hasUnsavedEdits()`). Pulled
|
|
105
|
+
* out of `onChange`'s debounce-timer closure (2026-08-06, complexity pass) — it was the deepest
|
|
106
|
+
* nesting in this file, three closures in (`onChange` > the `setTimeout` callback > the
|
|
107
|
+
* `saveChain.current.then(async () => …)` link) — so the ticket/staleness handling is directly
|
|
108
|
+
* assertable against a fake `runSave`, without a real timer or save chain.
|
|
109
|
+
*
|
|
110
|
+
* The ref reads are passed as thunks rather than plain booleans specifically so this keeps the
|
|
111
|
+
* original "read at run time, not schedule time" contract this whole file's header calls out:
|
|
112
|
+
* `deps.currentTicket()`/`deps.hasUnsavedEdits()` are called from inside this function, at the same
|
|
113
|
+
* points the inline version read `saveTicket.current`/`hasUnsavedEdits.current` directly, so a
|
|
114
|
+
* ticket or edit that changes while `runSave` is in flight is still seen correctly.
|
|
115
|
+
*
|
|
116
|
+
* @param ticket - This link's own ticket, taken by the caller when the debounce timer fired.
|
|
117
|
+
*/
|
|
118
|
+
export declare function commitQueuedSave({ ticket, deps }: {
|
|
119
|
+
ticket: number;
|
|
120
|
+
deps: {
|
|
121
|
+
runSave: () => Promise<readonly string[]>;
|
|
122
|
+
currentTicket: () => number;
|
|
123
|
+
hasUnsavedEdits: () => boolean;
|
|
124
|
+
setSaveState: (state: SaveState) => void;
|
|
125
|
+
};
|
|
126
|
+
}, _options?: Record<string, never>): Promise<void>;
|
|
127
|
+
/**
|
|
128
|
+
* Whether a background `refresh()` reload may still land, given what is true about the slice's
|
|
129
|
+
* uncommitted-work state at the moment of the check.
|
|
130
|
+
*
|
|
131
|
+
* Pulled out of `refresh` (2026-08-06, complexity pass, third pass): the same four-flag shape is
|
|
132
|
+
* checked TWICE inside `refresh` — once before the `load()` await (where `mounted`/`commitsUnchanged`
|
|
133
|
+
* are trivially true, since nothing could have unmounted or committed a save before the function has
|
|
134
|
+
* even started awaiting), once after (where all four are live) — and collapsing the check into one
|
|
135
|
+
* named function removes the duplicated branch count rather than moving it. This does not change
|
|
136
|
+
* WHEN any ref is read: every call site below still reads `timer.current`/`hasUnsavedEdits.current`/
|
|
137
|
+
* `mounted.current`/`commits.current` at exactly the point in `refresh`'s own body the inline version
|
|
138
|
+
* did, in the same tick — this function only names the boolean combination those reads produce. See
|
|
139
|
+
* `refresh`'s own doc for why a re-check after the await is necessary at all.
|
|
140
|
+
*/
|
|
141
|
+
export declare function canAcceptRefresh(state: {
|
|
142
|
+
timerPending: boolean;
|
|
143
|
+
hasUnsavedEdits: boolean;
|
|
144
|
+
mounted: boolean;
|
|
145
|
+
commitsUnchanged: boolean;
|
|
146
|
+
}, _options?: Record<string, never>): boolean;
|
|
147
|
+
export interface SettingsSlice<T> {
|
|
148
|
+
/** `null` until the initial load settles — the caller renders a loading state. */
|
|
149
|
+
value: T | null;
|
|
150
|
+
loadError: string | null;
|
|
151
|
+
saveState: SaveState;
|
|
152
|
+
onChange: (next: T) => void;
|
|
153
|
+
/**
|
|
154
|
+
* Re-reads the persisted value, discarding nothing.
|
|
155
|
+
*
|
|
156
|
+
* Wired to {@link subscribeToSettingsRefresh} automatically; exposed mainly so
|
|
157
|
+
* a caller can force one. Declines while the operator has uncommitted work —
|
|
158
|
+
* see the implementation for why that is a refusal rather than a merge.
|
|
159
|
+
*/
|
|
160
|
+
refresh: () => Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* @complexity O(1) per edit — one debounce timer reset plus one link appended
|
|
164
|
+
* to the save chain.
|
|
165
|
+
*
|
|
166
|
+
* @complexityExemption (2026-08-06, complexity pass, third pass; bar raised to ≤9/≤9 same day,
|
|
167
|
+
* exemption reconfirmed against the new bar) **Score: this hook's own lexical scope is 1
|
|
168
|
+
* cyclomatic / ~0 cognitive under ESLint — every closure inside it is independently ≤9/≤9 too
|
|
169
|
+
* (`onChange`'s debounce callback is 2/1, the unmount-flush cleanup is 3/2, `refresh` is 5/4 as of
|
|
170
|
+
* this pass — see below). What is exempted here is a DIFFERENT, unmeasurable-by-me number: the
|
|
171
|
+
* dispatch brief's owner-tool score of 17/23, which rolls every nested closure's branches into the
|
|
172
|
+
* hook's total. Bar: ≤9/≤9 on whichever view is scored — ESLint's view already clears it; the
|
|
173
|
+
* owner-tool aggregate does not, and I have no local tool that reproduces that aggregate to
|
|
174
|
+
* re-measure against the new bar.**
|
|
175
|
+
*
|
|
176
|
+
* Same shape as `use-assistant-chats.hooks.ts`'s `useAssistantChats` exemption — see that one for
|
|
177
|
+
* the full reasoning. `commitQueuedSave` (first pass) and `canAcceptRefresh` (this pass, pulled out
|
|
178
|
+
* of `refresh`'s two duplicated guard checks — see its own doc) are the two pieces of this hook's
|
|
179
|
+
* logic that genuinely fit a top-level pure-function shape (0–4 plain params, no ref reads);
|
|
180
|
+
* `refresh` itself dropped from 9/5 to 5/4 as a result, its own per-function score real progress,
|
|
181
|
+
* not exempted. What remains (`runSave`/`onChange`/the unmount cleanup/the rest of `refresh`) each
|
|
182
|
+
* reads or writes 3–6 of this hook's own refs (`io`, `persisted`, `latest`, `timer`,
|
|
183
|
+
* `saveTicket`, `hasUnsavedEdits`, `commits`, `mounted`) plus the shared `writes` lane, and several of
|
|
184
|
+
* them call each other
|
|
185
|
+
* (`onChange` schedules a link that calls `runSave`; the unmount cleanup calls `runSave` directly),
|
|
186
|
+
* so none of them can become a top-level function without threading that whole ref set through as a
|
|
187
|
+
* parameter object. This file's own header states the reason that is dangerous here specifically:
|
|
188
|
+
* "a value read at schedule time is a claim about the store that may already be false by the time
|
|
189
|
+
* the code using it runs," and every ref above exists because an earlier version of exactly this
|
|
190
|
+
* logic got that ordering wrong (four distinct data-loss bugs, per the header). A previous pass
|
|
191
|
+
* already tried this kind of extraction on this file and the total was unchanged, 25 → 25 — the
|
|
192
|
+
* honest reading of that result is that the remaining closures do not have a top-level-shaped cut
|
|
193
|
+
* available at the risk level a time-boxed pass allows, not that the attempt was done wrong.
|
|
194
|
+
* Declined for the remainder; flagged to the coordinator. See this session's report for the full
|
|
195
|
+
* reasoning.
|
|
196
|
+
*/
|
|
197
|
+
export declare function useSettingsSlice<T>(options: SettingsSliceOptions<T>, _optional?: Record<string, never>): SettingsSlice<T>;
|
|
198
|
+
/**
|
|
199
|
+
* Collapses several slices' save states into the single one the page chrome
|
|
200
|
+
* renders.
|
|
201
|
+
*
|
|
202
|
+
* Precedence is error > saving > saved > idle, and it is deliberate: an error
|
|
203
|
+
* is the only state the operator must act on, so it must not be hidden by
|
|
204
|
+
* another tab's cheerful "Saved". "Saving" outranks "saved" for the same
|
|
205
|
+
* reason in miniature — while anything is still in flight, claiming everything
|
|
206
|
+
* is saved is false.
|
|
207
|
+
*/
|
|
208
|
+
export declare function mergeSaveStates({ states }: {
|
|
209
|
+
states: readonly SaveState[];
|
|
210
|
+
}, _options?: Record<string, never>): SaveState;
|
|
211
|
+
//# sourceMappingURL=use-settings-slice.hooks.d.ts.map
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useSerialWrites } from "./use-serial-writes.hooks.js";
|
|
3
|
+
import { useSettlementGeneration } from "./use-settlement-generation.hooks.js";
|
|
4
|
+
/** Debounce for text fields: tabs are controlled and fire on every keystroke,
|
|
5
|
+
* and each ledger write is an append-only revision. Without this, typing into
|
|
6
|
+
* a textarea would write one revision per character. */
|
|
7
|
+
export const SAVE_DEBOUNCE_MS = 600;
|
|
8
|
+
/**
|
|
9
|
+
* Runs one save-chain link: calls `runSave`, then paints the ticket-owning outcome onto
|
|
10
|
+
* `saveState` — unless a newer save has since taken the ticket (`currentTicket()` no longer matches
|
|
11
|
+
* `ticket`), or the operator has queued another edit behind this one (`hasUnsavedEdits()`). Pulled
|
|
12
|
+
* out of `onChange`'s debounce-timer closure (2026-08-06, complexity pass) — it was the deepest
|
|
13
|
+
* nesting in this file, three closures in (`onChange` > the `setTimeout` callback > the
|
|
14
|
+
* `saveChain.current.then(async () => …)` link) — so the ticket/staleness handling is directly
|
|
15
|
+
* assertable against a fake `runSave`, without a real timer or save chain.
|
|
16
|
+
*
|
|
17
|
+
* The ref reads are passed as thunks rather than plain booleans specifically so this keeps the
|
|
18
|
+
* original "read at run time, not schedule time" contract this whole file's header calls out:
|
|
19
|
+
* `deps.currentTicket()`/`deps.hasUnsavedEdits()` are called from inside this function, at the same
|
|
20
|
+
* points the inline version read `saveTicket.current`/`hasUnsavedEdits.current` directly, so a
|
|
21
|
+
* ticket or edit that changes while `runSave` is in flight is still seen correctly.
|
|
22
|
+
*
|
|
23
|
+
* @param ticket - This link's own ticket, taken by the caller when the debounce timer fired.
|
|
24
|
+
*/
|
|
25
|
+
export async function commitQueuedSave({ ticket, deps }, _options = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const written = await deps.runSave();
|
|
28
|
+
if (deps.currentTicket() !== ticket)
|
|
29
|
+
return;
|
|
30
|
+
// A newer edit is already queued behind this one, so "Saved" would be a claim about state
|
|
31
|
+
// that is not saved. Stay in "saving".
|
|
32
|
+
if (deps.hasUnsavedEdits())
|
|
33
|
+
return;
|
|
34
|
+
deps.setSaveState(written.length > 0 ? { status: "saved" } : { status: "idle" });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (deps.currentTicket() !== ticket)
|
|
38
|
+
return;
|
|
39
|
+
deps.setSaveState({
|
|
40
|
+
status: "error",
|
|
41
|
+
message: error instanceof Error ? error.message : String(error),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a background `refresh()` reload may still land, given what is true about the slice's
|
|
47
|
+
* uncommitted-work state at the moment of the check.
|
|
48
|
+
*
|
|
49
|
+
* Pulled out of `refresh` (2026-08-06, complexity pass, third pass): the same four-flag shape is
|
|
50
|
+
* checked TWICE inside `refresh` — once before the `load()` await (where `mounted`/`commitsUnchanged`
|
|
51
|
+
* are trivially true, since nothing could have unmounted or committed a save before the function has
|
|
52
|
+
* even started awaiting), once after (where all four are live) — and collapsing the check into one
|
|
53
|
+
* named function removes the duplicated branch count rather than moving it. This does not change
|
|
54
|
+
* WHEN any ref is read: every call site below still reads `timer.current`/`hasUnsavedEdits.current`/
|
|
55
|
+
* `mounted.current`/`commits.current` at exactly the point in `refresh`'s own body the inline version
|
|
56
|
+
* did, in the same tick — this function only names the boolean combination those reads produce. See
|
|
57
|
+
* `refresh`'s own doc for why a re-check after the await is necessary at all.
|
|
58
|
+
*/
|
|
59
|
+
export function canAcceptRefresh(state, _options = {}) {
|
|
60
|
+
return !state.timerPending && !state.hasUnsavedEdits && state.mounted && state.commitsUnchanged;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* @complexity O(1) per edit — one debounce timer reset plus one link appended
|
|
64
|
+
* to the save chain.
|
|
65
|
+
*
|
|
66
|
+
* @complexityExemption (2026-08-06, complexity pass, third pass; bar raised to ≤9/≤9 same day,
|
|
67
|
+
* exemption reconfirmed against the new bar) **Score: this hook's own lexical scope is 1
|
|
68
|
+
* cyclomatic / ~0 cognitive under ESLint — every closure inside it is independently ≤9/≤9 too
|
|
69
|
+
* (`onChange`'s debounce callback is 2/1, the unmount-flush cleanup is 3/2, `refresh` is 5/4 as of
|
|
70
|
+
* this pass — see below). What is exempted here is a DIFFERENT, unmeasurable-by-me number: the
|
|
71
|
+
* dispatch brief's owner-tool score of 17/23, which rolls every nested closure's branches into the
|
|
72
|
+
* hook's total. Bar: ≤9/≤9 on whichever view is scored — ESLint's view already clears it; the
|
|
73
|
+
* owner-tool aggregate does not, and I have no local tool that reproduces that aggregate to
|
|
74
|
+
* re-measure against the new bar.**
|
|
75
|
+
*
|
|
76
|
+
* Same shape as `use-assistant-chats.hooks.ts`'s `useAssistantChats` exemption — see that one for
|
|
77
|
+
* the full reasoning. `commitQueuedSave` (first pass) and `canAcceptRefresh` (this pass, pulled out
|
|
78
|
+
* of `refresh`'s two duplicated guard checks — see its own doc) are the two pieces of this hook's
|
|
79
|
+
* logic that genuinely fit a top-level pure-function shape (0–4 plain params, no ref reads);
|
|
80
|
+
* `refresh` itself dropped from 9/5 to 5/4 as a result, its own per-function score real progress,
|
|
81
|
+
* not exempted. What remains (`runSave`/`onChange`/the unmount cleanup/the rest of `refresh`) each
|
|
82
|
+
* reads or writes 3–6 of this hook's own refs (`io`, `persisted`, `latest`, `timer`,
|
|
83
|
+
* `saveTicket`, `hasUnsavedEdits`, `commits`, `mounted`) plus the shared `writes` lane, and several of
|
|
84
|
+
* them call each other
|
|
85
|
+
* (`onChange` schedules a link that calls `runSave`; the unmount cleanup calls `runSave` directly),
|
|
86
|
+
* so none of them can become a top-level function without threading that whole ref set through as a
|
|
87
|
+
* parameter object. This file's own header states the reason that is dangerous here specifically:
|
|
88
|
+
* "a value read at schedule time is a claim about the store that may already be false by the time
|
|
89
|
+
* the code using it runs," and every ref above exists because an earlier version of exactly this
|
|
90
|
+
* logic got that ordering wrong (four distinct data-loss bugs, per the header). A previous pass
|
|
91
|
+
* already tried this kind of extraction on this file and the total was unchanged, 25 → 25 — the
|
|
92
|
+
* honest reading of that result is that the remaining closures do not have a top-level-shaped cut
|
|
93
|
+
* available at the risk level a time-boxed pass allows, not that the attempt was done wrong.
|
|
94
|
+
* Declined for the remainder; flagged to the coordinator. See this session's report for the full
|
|
95
|
+
* reasoning.
|
|
96
|
+
*/
|
|
97
|
+
export function useSettingsSlice(options, _optional = {}) {
|
|
98
|
+
const { defaultValue } = options;
|
|
99
|
+
const [value, setValue] = useState(null);
|
|
100
|
+
const [loadError, setLoadError] = useState(null);
|
|
101
|
+
const [saveState, setSaveState] = useState({ status: "idle" });
|
|
102
|
+
/** Guards `refresh`'s own `setValue`/`persisted`/`latest` write against a second background
|
|
103
|
+
* refresh started after it: `canAcceptRefresh`'s `commitsUnchanged` check cannot tell two
|
|
104
|
+
* refreshes apart when no save happens between them (both sample the same `commits.current`),
|
|
105
|
+
* so without this an older refresh call that happens to SETTLE last could still land over a
|
|
106
|
+
* newer one's result (S2, plan-components.md 2026-09-20; two rapid SSE frames from another
|
|
107
|
+
* tab's writes is the trigger). Same guard `use-admin-execution-credential.hooks.ts`'s `refresh`
|
|
108
|
+
* and `use-admin-locale.hooks.ts`'s `fetchLocale` already use for the identical shape. */
|
|
109
|
+
const refreshSettlement = useSettlementGeneration();
|
|
110
|
+
/** `load`/`save` are usually inline arrows at the call site, so a new
|
|
111
|
+
* identity arrives on every render. Holding them in a ref keeps the effect
|
|
112
|
+
* and callback below from re-running (and re-loading, and dropping pending
|
|
113
|
+
* timers) once per render. */
|
|
114
|
+
const io = useRef(options);
|
|
115
|
+
io.current = options;
|
|
116
|
+
/** Last value known to be persisted — the diff base handed to `save`. */
|
|
117
|
+
const persisted = useRef(defaultValue);
|
|
118
|
+
/** The newest value the operator has produced, saved or not. A queued save
|
|
119
|
+
* writes THIS rather than whatever was current when it was scheduled, so a
|
|
120
|
+
* save that waited behind another still persists the latest state instead
|
|
121
|
+
* of resurrecting an intermediate one. */
|
|
122
|
+
const latest = useRef(defaultValue);
|
|
123
|
+
const timer = useRef(null);
|
|
124
|
+
/**
|
|
125
|
+
* Saves run strictly one at a time, queued through this lane.
|
|
126
|
+
*
|
|
127
|
+
* The debounce alone does NOT prevent overlap: it only cancels a save that
|
|
128
|
+
* has not STARTED. Once one is in flight, the next edit schedules a fresh
|
|
129
|
+
* timer that can fire while the first is still running, and then two saves
|
|
130
|
+
* race — their per-key writes interleave (so the ledger can end on the older
|
|
131
|
+
* value), and both diff against the same `persisted` base, which the slower
|
|
132
|
+
* one then overwrites on completion. That leaves the diff base claiming a
|
|
133
|
+
* value was persisted that never was, so the next edit skips writing the
|
|
134
|
+
* fields it thinks are already saved.
|
|
135
|
+
*/
|
|
136
|
+
const writes = useSerialWrites();
|
|
137
|
+
/** Monotonic ticket, so only the newest save may write the status
|
|
138
|
+
* indicator. Without it a slow earlier save resolving last would paint
|
|
139
|
+
* "Saved" over a newer save's error, or vice versa. */
|
|
140
|
+
const saveTicket = useRef(0);
|
|
141
|
+
/**
|
|
142
|
+
* True whenever `latest` has moved ahead of `persisted` — i.e. the operator
|
|
143
|
+
* has edited something no save has committed yet.
|
|
144
|
+
*
|
|
145
|
+
* The ticket alone does NOT cover this. A ticket is only taken when a
|
|
146
|
+
* DEBOUNCE TIMER FIRES, so an edit made while an earlier save is still in
|
|
147
|
+
* flight has no ticket yet: the in-flight save still owns the newest one,
|
|
148
|
+
* completes, and paints "Saved" over changes that are not saved at all. If
|
|
149
|
+
* the operator then navigates away, unmount clears the pending timer and
|
|
150
|
+
* those edits are gone — with the UI's last word having been "Saved".
|
|
151
|
+
*/
|
|
152
|
+
const hasUnsavedEdits = useRef(false);
|
|
153
|
+
/**
|
|
154
|
+
* Bumped every time a save commits and advances `persisted`.
|
|
155
|
+
*
|
|
156
|
+
* `refresh` samples it before its `load()` round trip and discards the
|
|
157
|
+
* response if it moved. The two flag checks cannot cover this on their own:
|
|
158
|
+
* `timer` and `hasUnsavedEdits` describe work that is still OUTSTANDING, and
|
|
159
|
+
* a save that both started and finished inside the reload's await window
|
|
160
|
+
* clears them, so a response that predates that write passes every check.
|
|
161
|
+
* Letting it through does not merely repaint a stale value — it installs it
|
|
162
|
+
* as the diff base, so the next edit diffs against a value the store never
|
|
163
|
+
* held and skips writing the fields that differ.
|
|
164
|
+
*/
|
|
165
|
+
const commits = useRef(0);
|
|
166
|
+
/** Guards every post-await `setState` in {@link refresh}, which — unlike the
|
|
167
|
+
* mount load — can be invoked at any time by an external publisher. */
|
|
168
|
+
const mounted = useRef(true);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
mounted.current = true;
|
|
171
|
+
return () => {
|
|
172
|
+
mounted.current = false;
|
|
173
|
+
};
|
|
174
|
+
}, []);
|
|
175
|
+
// `defaultValue` is a module-level constant at every call site; re-running
|
|
176
|
+
// this on a new identity would re-issue the load and clobber live edits.
|
|
177
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `defaultValue` is a module-level constant; re-running on a new identity would re-issue the load and clobber live edits.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
let alive = true;
|
|
180
|
+
io.current
|
|
181
|
+
.load({}, {})
|
|
182
|
+
.then((loaded) => {
|
|
183
|
+
if (!alive)
|
|
184
|
+
return;
|
|
185
|
+
// `persisted` always takes the raw load, same as `refresh()` — it is the diff base the next
|
|
186
|
+
// save diffs against, and the server's real state is a strictly better base than the
|
|
187
|
+
// never-actually-persisted `defaultValue` this ref started at, whether or not the operator
|
|
188
|
+
// has since edited.
|
|
189
|
+
persisted.current = loaded;
|
|
190
|
+
// Guards the same hazard `refresh()` guards via `canAcceptRefresh`'s `hasUnsavedEdits` check,
|
|
191
|
+
// on the OTHER load path: `onChange` has no documented precondition requiring `value` to be
|
|
192
|
+
// non-null first, so nothing stops an edit from landing before this initial `load()` settles.
|
|
193
|
+
// Without this check, that edit is silently overwritten the instant the response arrives —
|
|
194
|
+
// the same class of data loss this file's four prior audited bugs all belong to.
|
|
195
|
+
if (hasUnsavedEdits.current)
|
|
196
|
+
return;
|
|
197
|
+
latest.current = loaded;
|
|
198
|
+
setValue(loaded);
|
|
199
|
+
})
|
|
200
|
+
.catch((error) => {
|
|
201
|
+
if (!alive)
|
|
202
|
+
return;
|
|
203
|
+
setLoadError(error instanceof Error ? error.message : String(error));
|
|
204
|
+
if (hasUnsavedEdits.current)
|
|
205
|
+
return;
|
|
206
|
+
setValue(defaultValue);
|
|
207
|
+
});
|
|
208
|
+
return () => {
|
|
209
|
+
alive = false;
|
|
210
|
+
};
|
|
211
|
+
}, []);
|
|
212
|
+
/**
|
|
213
|
+
* The only place a save is ever issued.
|
|
214
|
+
*
|
|
215
|
+
* Both the target and the diff base are read at RUN time, never at schedule
|
|
216
|
+
* time. By the time a chained link runs, an earlier save may already have
|
|
217
|
+
* advanced `persisted` and the operator may have edited further, so anything
|
|
218
|
+
* captured when the link was queued is potentially a lie about the store.
|
|
219
|
+
*
|
|
220
|
+
* This exists as one function rather than two call sites because the second
|
|
221
|
+
* call site is exactly where that invariant was lost: the unmount flush used
|
|
222
|
+
* to snapshot both values as it queued itself behind an in-flight save, so
|
|
223
|
+
* edit A→B, revert B→A, unmount produced `save(A, A)` — an empty diff — and
|
|
224
|
+
* the revert was dropped while the store kept B. Sharing the runner makes the
|
|
225
|
+
* rule structural instead of something each caller has to remember.
|
|
226
|
+
*/
|
|
227
|
+
const runSave = useCallback(async () => {
|
|
228
|
+
const target = latest.current;
|
|
229
|
+
const written = await io.current.save({ next: target, previous: persisted.current }, {});
|
|
230
|
+
// `persisted` is deliberately advanced only here, after a resolved save, so
|
|
231
|
+
// a rejection leaves the base alone and the next save re-attempts the
|
|
232
|
+
// fields this one could not write.
|
|
233
|
+
persisted.current = target;
|
|
234
|
+
commits.current += 1;
|
|
235
|
+
// Only clear the flag if nothing was edited WHILE this save ran.
|
|
236
|
+
if (latest.current === target)
|
|
237
|
+
hasUnsavedEdits.current = false;
|
|
238
|
+
// Notify same-tab siblings this slice has no direct connection to — same rationale
|
|
239
|
+
// `settings-refresh-bus.ts`'s own doc comment gives for `AssistantDock`: this slice and
|
|
240
|
+
// whatever else reads this namespace (e.g. `useAdminLocale()`, read by ~30 screens outside the
|
|
241
|
+
// Settings dialog for `core.language`) are unrelated subtrees under `App.tsx` with no shared
|
|
242
|
+
// state. Without this, a language switch only reached OTHER tabs via SSE — the tab that made
|
|
243
|
+
// the change never re-read its own write, so it looked "stuck" until a hard reload. Scoped to
|
|
244
|
+
// this slice's own declared `namespaces`, so an unrelated slice's save doesn't trigger a
|
|
245
|
+
// pointless refetch elsewhere; a slice that never declared `namespaces` publishes nothing,
|
|
246
|
+
// matching that it never subscribed to anything either.
|
|
247
|
+
if (io.current.namespaces && io.current.namespaces.length > 0) {
|
|
248
|
+
io.current.refreshPort.publish({ namespaces: io.current.namespaces }, {});
|
|
249
|
+
}
|
|
250
|
+
return written;
|
|
251
|
+
}, []);
|
|
252
|
+
// On unmount, don't just drop a debounced edit — run it. Cancelling the
|
|
253
|
+
// timer silently discards whatever the operator typed in the last 600ms.
|
|
254
|
+
useEffect(() => () => {
|
|
255
|
+
if (!timer.current)
|
|
256
|
+
return;
|
|
257
|
+
clearTimeout(timer.current);
|
|
258
|
+
if (!hasUnsavedEdits.current)
|
|
259
|
+
return;
|
|
260
|
+
// Fire-and-forget: the component is going away, so there is no status
|
|
261
|
+
// left to paint. The write itself still has to happen — through the same
|
|
262
|
+
// lane as the debounce path, so it diffs against the base that is
|
|
263
|
+
// current when it RUNS, not the one that was current when it was queued.
|
|
264
|
+
void writes.run({ task: () => runSave().then(() => { }, () => { }) });
|
|
265
|
+
}, [runSave, writes]);
|
|
266
|
+
const onChange = useCallback((next) => {
|
|
267
|
+
setValue(next);
|
|
268
|
+
latest.current = next;
|
|
269
|
+
hasUnsavedEdits.current = true;
|
|
270
|
+
if (timer.current)
|
|
271
|
+
clearTimeout(timer.current);
|
|
272
|
+
timer.current = setTimeout(() => {
|
|
273
|
+
// Cleared as the timer FIRES, so `timer.current` means "a debounce is still pending" rather
|
|
274
|
+
// than "one was ever scheduled". The unmount flush below already pairs it with
|
|
275
|
+
// `hasUnsavedEdits`, so it tolerated a stale handle; `refresh` reads it alone as the
|
|
276
|
+
// "operator has work in flight" signal, and a handle that never cleared would refuse every
|
|
277
|
+
// external refresh for the rest of the component's life after the first keystroke.
|
|
278
|
+
timer.current = null;
|
|
279
|
+
const ticket = ++saveTicket.current;
|
|
280
|
+
setSaveState({ status: "saving" });
|
|
281
|
+
void writes.run({ task: () => commitQueuedSave({ ticket, deps: {
|
|
282
|
+
runSave,
|
|
283
|
+
currentTicket: () => saveTicket.current,
|
|
284
|
+
hasUnsavedEdits: () => hasUnsavedEdits.current,
|
|
285
|
+
setSaveState,
|
|
286
|
+
} }, {}) });
|
|
287
|
+
}, SAVE_DEBOUNCE_MS);
|
|
288
|
+
}, [runSave, writes]);
|
|
289
|
+
/**
|
|
290
|
+
* Re-reads the persisted value on an external notification.
|
|
291
|
+
*
|
|
292
|
+
* **Refuses whenever the operator has uncommitted work**, rather than merging. Three states count
|
|
293
|
+
* as uncommitted: a pending debounce timer, `hasUnsavedEdits`, and a non-idle save. Overwriting
|
|
294
|
+
* any of them replaces what the operator typed with a value from somewhere else, mid-keystroke —
|
|
295
|
+
* data loss, and the same class of bug the audit already found twice in this file. Refusing costs
|
|
296
|
+
* only a missed update, and the operator's own save re-establishes agreement a moment later.
|
|
297
|
+
*
|
|
298
|
+
* The check runs AGAIN after the await: `load()` is a network round trip, and an edit begun while
|
|
299
|
+
* it was in flight would otherwise be clobbered by a response that predates it.
|
|
300
|
+
*
|
|
301
|
+
* Re-checking the flags is necessary but NOT sufficient, which is why {@link commits} is sampled
|
|
302
|
+
* too. Both flags describe work that is still outstanding, so a save that started and finished
|
|
303
|
+
* entirely within the await window leaves them clear and the stale response sails through — see
|
|
304
|
+
* the counter's own doc for why that is worse than a wrong repaint.
|
|
305
|
+
*
|
|
306
|
+
* A failed reload is swallowed on purpose. This is a background refresh the operator never asked
|
|
307
|
+
* for; surfacing `loadError` here would replace a working panel with an error state because of a
|
|
308
|
+
* transient blip. The mount load still reports its failures.
|
|
309
|
+
*/
|
|
310
|
+
const refresh = useCallback(async () => {
|
|
311
|
+
if (!canAcceptRefresh({
|
|
312
|
+
timerPending: timer.current !== null,
|
|
313
|
+
hasUnsavedEdits: hasUnsavedEdits.current,
|
|
314
|
+
mounted: true,
|
|
315
|
+
commitsUnchanged: true,
|
|
316
|
+
})) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const seenCommits = commits.current;
|
|
320
|
+
const generation = refreshSettlement.next();
|
|
321
|
+
let loaded;
|
|
322
|
+
try {
|
|
323
|
+
loaded = await io.current.load({}, {});
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!refreshSettlement.isCurrent({ generation }) ||
|
|
329
|
+
!canAcceptRefresh({
|
|
330
|
+
timerPending: timer.current !== null,
|
|
331
|
+
hasUnsavedEdits: hasUnsavedEdits.current,
|
|
332
|
+
mounted: mounted.current,
|
|
333
|
+
commitsUnchanged: commits.current === seenCommits,
|
|
334
|
+
})) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// `persisted` tracks what `load`/`save` actually manage, so it takes the RAW reload — a
|
|
338
|
+
// reconciled field like a never-persisted API key has no business in the diff base `save` will
|
|
339
|
+
// next diff against. `latest`/`value` are what the operator sees and edits next, so THEY take
|
|
340
|
+
// the reconciled result.
|
|
341
|
+
const reconciled = io.current.reconcileRefresh ? io.current.reconcileRefresh({ current: latest.current, loaded }, {}) : loaded;
|
|
342
|
+
persisted.current = loaded;
|
|
343
|
+
latest.current = reconciled;
|
|
344
|
+
setValue(reconciled);
|
|
345
|
+
}, []);
|
|
346
|
+
/**
|
|
347
|
+
* Subscribes to out-of-band changes — an assistant run that wrote a setting, or an SSE frame from
|
|
348
|
+
* a write in another tab. `namespaces` narrows which notifications matter; see the option's doc
|
|
349
|
+
* for why omitting it means "refresh on everything" rather than "never".
|
|
350
|
+
*/
|
|
351
|
+
const refreshPort = options.refreshPort;
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
return refreshPort.subscribe({ listener: (scope) => {
|
|
354
|
+
const mine = io.current.namespaces;
|
|
355
|
+
if (scope && mine && !mine.some((ns) => scope.includes(ns)))
|
|
356
|
+
return;
|
|
357
|
+
void refresh();
|
|
358
|
+
} }, {});
|
|
359
|
+
}, [refresh, refreshPort]);
|
|
360
|
+
return { value, loadError, saveState, onChange, refresh };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Collapses several slices' save states into the single one the page chrome
|
|
364
|
+
* renders.
|
|
365
|
+
*
|
|
366
|
+
* Precedence is error > saving > saved > idle, and it is deliberate: an error
|
|
367
|
+
* is the only state the operator must act on, so it must not be hidden by
|
|
368
|
+
* another tab's cheerful "Saved". "Saving" outranks "saved" for the same
|
|
369
|
+
* reason in miniature — while anything is still in flight, claiming everything
|
|
370
|
+
* is saved is false.
|
|
371
|
+
*/
|
|
372
|
+
export function mergeSaveStates({ states }, _options = {}) {
|
|
373
|
+
const failed = states.find((state) => state.status === "error");
|
|
374
|
+
if (failed)
|
|
375
|
+
return failed;
|
|
376
|
+
if (states.some((state) => state.status === "saving"))
|
|
377
|
+
return { status: "saving" };
|
|
378
|
+
if (states.some((state) => state.status === "saved"))
|
|
379
|
+
return { status: "saved" };
|
|
380
|
+
return { status: "idle" };
|
|
381
|
+
}
|
|
382
|
+
//# sourceMappingURL=use-settings-slice.hooks.js.map
|