@jini-ai/ui 0.4.4 → 0.4.5

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.
Files changed (165) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +20 -0
  3. package/dist/core/theme/default.d.ts +4 -0
  4. package/dist/core/theme/default.js +11 -0
  5. package/dist/core/theme/stylesheet.d.ts +8 -0
  6. package/dist/core/theme/stylesheet.js +14 -0
  7. package/dist/core/theme/types.d.ts +39 -0
  8. package/dist/core/theme/types.js +2 -0
  9. package/dist/core/theme/validation.d.ts +15 -0
  10. package/dist/core/theme/validation.js +55 -0
  11. package/dist/features/admin-widgets/components/Select/Select.d.ts +11 -3
  12. package/dist/features/admin-widgets/components/Select/Select.hooks.d.ts +8 -13
  13. package/dist/features/admin-widgets/components/Select/Select.hooks.js +37 -215
  14. package/dist/features/admin-widgets/components/Select/Select.js +4 -3
  15. package/dist/features/appearance/constants.js +1 -1
  16. package/dist/features/connectors/rules.d.ts +1 -6
  17. package/dist/features/connectors/types.d.ts +1 -6
  18. package/dist/features/connectors/types.js +1 -6
  19. package/dist/features/execution/http-adapter.d.ts +111 -0
  20. package/dist/features/execution/http-adapter.js +160 -0
  21. package/dist/features/execution/index.d.ts +4 -0
  22. package/dist/features/execution/index.js +2 -0
  23. package/dist/features/execution/react/hooks/use-execution-credential.hooks.d.ts +232 -0
  24. package/dist/features/execution/react/hooks/use-execution-credential.hooks.js +217 -0
  25. package/dist/features/html-editor/canvas-style.d.ts +1 -1
  26. package/dist/features/html-editor/canvas-style.js +2 -2
  27. package/dist/features/i18n/dictionaries/settings-dialog.ar.js +2 -0
  28. package/dist/features/i18n/dictionaries/settings-dialog.bn.js +2 -0
  29. package/dist/features/i18n/dictionaries/settings-dialog.de.js +2 -0
  30. package/dist/features/i18n/dictionaries/settings-dialog.en.js +2 -0
  31. package/dist/features/i18n/dictionaries/settings-dialog.es.js +2 -0
  32. package/dist/features/i18n/dictionaries/settings-dialog.fa.js +2 -0
  33. package/dist/features/i18n/dictionaries/settings-dialog.fr.js +2 -0
  34. package/dist/features/i18n/dictionaries/settings-dialog.hi.js +2 -0
  35. package/dist/features/i18n/dictionaries/settings-dialog.hu.js +2 -0
  36. package/dist/features/i18n/dictionaries/settings-dialog.id.js +2 -0
  37. package/dist/features/i18n/dictionaries/settings-dialog.it.js +2 -0
  38. package/dist/features/i18n/dictionaries/settings-dialog.ja.js +2 -0
  39. package/dist/features/i18n/dictionaries/settings-dialog.ko.js +2 -0
  40. package/dist/features/i18n/dictionaries/settings-dialog.pl.js +2 -0
  41. package/dist/features/i18n/dictionaries/settings-dialog.pt-BR.js +2 -0
  42. package/dist/features/i18n/dictionaries/settings-dialog.ru.js +2 -0
  43. package/dist/features/i18n/dictionaries/settings-dialog.th.js +2 -0
  44. package/dist/features/i18n/dictionaries/settings-dialog.tr.js +2 -0
  45. package/dist/features/i18n/dictionaries/settings-dialog.uk.js +2 -0
  46. package/dist/features/i18n/dictionaries/settings-dialog.ur.js +2 -0
  47. package/dist/features/i18n/dictionaries/settings-dialog.zh-CN.js +2 -0
  48. package/dist/features/i18n/dictionaries/settings-dialog.zh-TW.js +2 -0
  49. package/dist/features/i18n/dictionary-translator.d.ts +5 -2
  50. package/dist/features/i18n/dictionary-translator.js +1 -1
  51. package/dist/features/i18n/index.d.ts +1 -1
  52. package/dist/features/i18n/index.js +1 -1
  53. package/dist/features/i18n/template-i18n.d.ts +11 -0
  54. package/dist/features/i18n/template-i18n.js +12 -0
  55. package/dist/features/interactive-ui/providers/recharts/chart-tokens.d.ts +1 -1
  56. package/dist/features/interactive-ui/providers/recharts/chart-tokens.js +1 -1
  57. package/dist/features/lexical-rich-text-editor/deserialize.d.ts +0 -5
  58. package/dist/features/lexical-rich-text-editor/deserialize.js +0 -5
  59. package/dist/features/lexical-rich-text-editor/mention-node.d.ts +5 -17
  60. package/dist/features/lexical-rich-text-editor/mention-node.js +5 -17
  61. package/dist/features/lexical-rich-text-editor/mention-parser.d.ts +2 -4
  62. package/dist/features/lexical-rich-text-editor/serialize.d.ts +0 -4
  63. package/dist/features/lexical-rich-text-editor/serialize.js +0 -4
  64. package/dist/features/lexical-rich-text-editor/types.d.ts +3 -13
  65. package/dist/features/lexical-rich-text-editor/types.js +3 -13
  66. package/dist/features/mcp-ui/early-message-buffer.d.ts +1 -2
  67. package/dist/features/mcp-ui/early-message-buffer.js +1 -2
  68. package/dist/features/mcp-ui/escape.d.ts +4 -0
  69. package/dist/features/mcp-ui/escape.js +2 -9
  70. package/dist/features/mcp-ui/index.d.ts +1 -0
  71. package/dist/features/mcp-ui/index.js +1 -0
  72. package/dist/features/mcp-ui/protocol.d.ts +2 -5
  73. package/dist/features/mcp-ui/protocol.js +2 -5
  74. package/dist/features/mcp-ui/resource.d.ts +47 -7
  75. package/dist/features/mcp-ui/resource.js +5 -8
  76. package/dist/features/mcp-ui/secret-card.d.ts +115 -0
  77. package/dist/features/mcp-ui/secret-card.js +137 -0
  78. package/dist/features/mcp-ui/surfaces/confirmation.js +5 -0
  79. package/dist/features/mcp-ui/surfaces/fields.d.ts +5 -2
  80. package/dist/features/mcp-ui/surfaces/fields.js +3 -1
  81. package/dist/features/mcp-ui/surfaces/form.d.ts +4 -1
  82. package/dist/features/mcp-ui/surfaces/form.js +65 -3
  83. package/dist/features/mcp-ui/surfaces/outcome.d.ts +2 -0
  84. package/dist/features/mcp-ui/surfaces/text-input.d.ts +14 -6
  85. package/dist/features/mcp-ui/surfaces/text-input.js +16 -2
  86. package/dist/features/mcp-ui/surfaces/tokens.js +1 -1
  87. package/dist/features/memory/react/components/MemoryConnectedPanel.d.ts +1 -1
  88. package/dist/features/memory/react/components/MemoryConnectedPanel.js +0 -1
  89. package/dist/features/panel-kit/helpers/format-timestamp.d.ts +5 -3
  90. package/dist/features/panel-kit/helpers/format-timestamp.js +13 -4
  91. package/dist/features/panel-kit/helpers/permissions.js +2 -0
  92. package/dist/features/panel-kit/helpers/resolve-active-tab-id.js +2 -0
  93. package/dist/features/panel-kit/helpers/standing-draft-local-backup.d.ts +51 -0
  94. package/dist/features/panel-kit/helpers/standing-draft-local-backup.js +77 -0
  95. package/dist/features/panel-kit/helpers/standing-draft-recovery-message.d.ts +8 -0
  96. package/dist/features/panel-kit/helpers/standing-draft-recovery-message.js +10 -0
  97. package/dist/features/panel-kit/hooks/use-async-action.hooks.d.ts +1 -0
  98. package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.d.ts +1 -0
  99. package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.js +1 -0
  100. package/dist/features/panel-kit/hooks/use-serial-writes.hooks.d.ts +8 -0
  101. package/dist/features/panel-kit/hooks/use-serial-writes.hooks.js +3 -0
  102. package/dist/features/panel-kit/hooks/use-settings-slice.hooks.d.ts +211 -0
  103. package/dist/features/panel-kit/hooks/use-settings-slice.hooks.js +382 -0
  104. package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.d.ts +179 -0
  105. package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.js +300 -0
  106. package/dist/features/panel-kit/index.d.ts +7 -1
  107. package/dist/features/panel-kit/index.js +5 -1
  108. package/dist/features/resource-dashboard/types.d.ts +8 -41
  109. package/dist/features/resource-dashboard/types.js +3 -26
  110. package/dist/features/tab-strip/constants.d.ts +3 -4
  111. package/dist/features/tab-strip/constants.js +3 -4
  112. package/dist/features/tab-strip/dependencies.d.ts +1 -6
  113. package/dist/features/tab-strip/dependencies.js +1 -6
  114. package/dist/features/tab-strip/react/components/TabBar.d.ts +33 -1
  115. package/dist/features/tab-strip/react/components/TabBar.hooks.d.ts +57 -5
  116. package/dist/features/tab-strip/react/components/TabBar.hooks.js +63 -5
  117. package/dist/features/tab-strip/react/components/TabBar.js +12 -1
  118. package/dist/features/tab-strip/rules.d.ts +4 -17
  119. package/dist/features/tab-strip/rules.js +4 -17
  120. package/dist/features/tab-strip/types.d.ts +2 -13
  121. package/dist/icon-name.d.ts +1 -1
  122. package/dist/icon-name.js +1 -0
  123. package/dist/index.d.ts +42 -417
  124. package/dist/index.js +42 -430
  125. package/dist/react/components/CustomSelect.d.ts +6 -2
  126. package/dist/react/components/CustomSelect.js +20 -91
  127. package/dist/react/components/Icon.d.ts +6 -0
  128. package/dist/react/components/Icon.js +12 -0
  129. package/dist/react/components/select-popover.d.ts +106 -0
  130. package/dist/react/components/select-popover.js +210 -0
  131. package/dist/renderers/sandbox-scripts.d.ts +29 -0
  132. package/dist/renderers/sandbox-scripts.js +127 -0
  133. package/dist/renderers/sandboxed-document.js +3 -86
  134. package/dist/renderers/srcdoc/build.d.ts +10 -17
  135. package/dist/renderers/srcdoc/build.js +31 -234
  136. package/dist/styles/variables.css +2 -2
  137. package/dist/theme/apply.d.ts +1 -1
  138. package/dist/theme/apply.js +2 -2
  139. package/dist/theme/color-scheme.d.ts +1 -1
  140. package/dist/theme/default.d.ts +1 -3
  141. package/dist/theme/default.js +2 -10
  142. package/dist/theme/index.d.ts +3 -3
  143. package/dist/theme/index.js +2 -2
  144. package/dist/theme/stylesheet.d.ts +1 -7
  145. package/dist/theme/stylesheet.js +2 -13
  146. package/dist/theme/types.d.ts +1 -38
  147. package/dist/theme/types.js +2 -1
  148. package/dist/theme/validation.d.ts +1 -14
  149. package/dist/theme/validation.js +2 -54
  150. package/dist/utils/endpoint-policy.d.ts +21 -39
  151. package/dist/utils/endpoint-policy.js +19 -107
  152. package/dist/utils/file-system-errors.d.ts +1 -2
  153. package/dist/utils/file-system-errors.js +1 -2
  154. package/dist/utils/ime-composing.d.ts +0 -3
  155. package/dist/utils/ime-composing.js +0 -3
  156. package/dist/utils/platform.d.ts +1 -4
  157. package/dist/utils/platform.js +1 -4
  158. package/dist/utils/smooth-scroll-to-top.d.ts +1 -3
  159. package/dist/utils/smooth-scroll-to-top.js +3 -13
  160. package/dist/utils/timezone.js +1 -5
  161. package/dist/utils/uuid.d.ts +0 -3
  162. package/dist/utils/uuid.js +0 -3
  163. package/dist/utils/visual-stability.d.ts +1 -3
  164. package/dist/utils/visual-stability.js +1 -3
  165. 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