@buoy-gg/shared-ui 7.0.35 → 7.0.36
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/lib/commonjs/hooks/safe-area-impl.js +1 -1
- package/lib/commonjs/icons/index.js +7 -0
- package/lib/commonjs/index.js +48 -0
- package/lib/commonjs/sync/keyedArrays.js +209 -0
- package/lib/commonjs/sync/toolAttention.test.js +51 -0
- package/lib/commonjs/sync/toolUiBridge.js +58 -0
- package/lib/commonjs/sync/typedEdit.js +228 -0
- package/lib/commonjs/sync/valuePath.js +337 -0
- package/lib/commonjs/sync/valueShape.js +161 -0
- package/lib/commonjs/ui/components/ExpandablePopover.js +38 -6
- package/lib/module/hooks/safe-area-impl.js +1 -1
- package/lib/module/icons/index.js +3 -1
- package/lib/module/index.js +4 -0
- package/lib/module/sync/keyedArrays.js +204 -0
- package/lib/module/sync/toolAttention.test.js +50 -0
- package/lib/module/sync/toolUiBridge.js +55 -0
- package/lib/module/sync/typedEdit.js +220 -0
- package/lib/module/sync/valuePath.js +330 -0
- package/lib/module/sync/valueShape.js +153 -0
- package/lib/module/ui/components/ExpandablePopover.js +38 -6
- package/lib/typescript/commonjs/hooks/safe-area-impl.d.ts +1 -1
- package/lib/typescript/commonjs/icons/index.d.ts +1 -1
- package/lib/typescript/commonjs/icons/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/sync/keyedArrays.d.ts +35 -0
- package/lib/typescript/commonjs/sync/keyedArrays.d.ts.map +1 -0
- package/lib/typescript/commonjs/sync/toolAttention.test.d.ts +2 -0
- package/lib/typescript/commonjs/sync/toolAttention.test.d.ts.map +1 -0
- package/lib/typescript/commonjs/sync/toolUiBridge.d.ts +25 -0
- package/lib/typescript/commonjs/sync/toolUiBridge.d.ts.map +1 -1
- package/lib/typescript/commonjs/sync/typedEdit.d.ts +70 -0
- package/lib/typescript/commonjs/sync/typedEdit.d.ts.map +1 -0
- package/lib/typescript/commonjs/sync/valuePath.d.ts +52 -0
- package/lib/typescript/commonjs/sync/valuePath.d.ts.map +1 -0
- package/lib/typescript/commonjs/sync/valueShape.d.ts +77 -0
- package/lib/typescript/commonjs/sync/valueShape.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/ExpandablePopover.d.ts +14 -1
- package/lib/typescript/commonjs/ui/components/ExpandablePopover.d.ts.map +1 -1
- package/lib/typescript/module/hooks/safe-area-impl.d.ts +1 -1
- package/lib/typescript/module/icons/index.d.ts +1 -1
- package/lib/typescript/module/icons/index.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/sync/keyedArrays.d.ts +35 -0
- package/lib/typescript/module/sync/keyedArrays.d.ts.map +1 -0
- package/lib/typescript/module/sync/toolAttention.test.d.ts +2 -0
- package/lib/typescript/module/sync/toolAttention.test.d.ts.map +1 -0
- package/lib/typescript/module/sync/toolUiBridge.d.ts +25 -0
- package/lib/typescript/module/sync/toolUiBridge.d.ts.map +1 -1
- package/lib/typescript/module/sync/typedEdit.d.ts +70 -0
- package/lib/typescript/module/sync/typedEdit.d.ts.map +1 -0
- package/lib/typescript/module/sync/valuePath.d.ts +52 -0
- package/lib/typescript/module/sync/valuePath.d.ts.map +1 -0
- package/lib/typescript/module/sync/valueShape.d.ts +77 -0
- package/lib/typescript/module/sync/valueShape.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/ExpandablePopover.d.ts +14 -1
- package/lib/typescript/module/ui/components/ExpandablePopover.d.ts.map +1 -1
- package/package.json +4 -4
|
@@ -7,7 +7,7 @@ exports.useNativeSafeAreaInsets = exports.safeAreaType = exports.hasSafeAreaPack
|
|
|
7
7
|
/**
|
|
8
8
|
* Auto-generated safe area implementation
|
|
9
9
|
* Detected: none
|
|
10
|
-
* Generated at: 2026-09-
|
|
10
|
+
* Generated at: 2026-09-10T21:40:50.832Z
|
|
11
11
|
*
|
|
12
12
|
* DO NOT EDIT - This file is generated by scripts/detect-safe-area.js
|
|
13
13
|
*
|
|
@@ -72,6 +72,7 @@ var _exportNames = {
|
|
|
72
72
|
Search: true,
|
|
73
73
|
Server: true,
|
|
74
74
|
Settings: true,
|
|
75
|
+
SettingsSolid: true,
|
|
75
76
|
Shield: true,
|
|
76
77
|
Trash2: true,
|
|
77
78
|
Trash: true,
|
|
@@ -668,6 +669,12 @@ Object.defineProperty(exports, "Settings", {
|
|
|
668
669
|
return _floatingToolsCore.SettingsGlyph;
|
|
669
670
|
}
|
|
670
671
|
});
|
|
672
|
+
Object.defineProperty(exports, "SettingsSolid", {
|
|
673
|
+
enumerable: true,
|
|
674
|
+
get: function () {
|
|
675
|
+
return _floatingToolsCore.SettingsSolidIcon;
|
|
676
|
+
}
|
|
677
|
+
});
|
|
671
678
|
Object.defineProperty(exports, "Shield", {
|
|
672
679
|
enumerable: true,
|
|
673
680
|
get: function () {
|
package/lib/commonjs/index.js
CHANGED
|
@@ -813,6 +813,54 @@ Object.keys(_index2).forEach(function (key) {
|
|
|
813
813
|
}
|
|
814
814
|
});
|
|
815
815
|
});
|
|
816
|
+
var _keyedArrays = require("./sync/keyedArrays.js");
|
|
817
|
+
Object.keys(_keyedArrays).forEach(function (key) {
|
|
818
|
+
if (key === "default" || key === "__esModule") return;
|
|
819
|
+
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
|
|
820
|
+
if (key in exports && exports[key] === _keyedArrays[key]) return;
|
|
821
|
+
Object.defineProperty(exports, key, {
|
|
822
|
+
enumerable: true,
|
|
823
|
+
get: function () {
|
|
824
|
+
return _keyedArrays[key];
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
var _valueShape = require("./sync/valueShape.js");
|
|
829
|
+
Object.keys(_valueShape).forEach(function (key) {
|
|
830
|
+
if (key === "default" || key === "__esModule") return;
|
|
831
|
+
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
|
|
832
|
+
if (key in exports && exports[key] === _valueShape[key]) return;
|
|
833
|
+
Object.defineProperty(exports, key, {
|
|
834
|
+
enumerable: true,
|
|
835
|
+
get: function () {
|
|
836
|
+
return _valueShape[key];
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
var _valuePath = require("./sync/valuePath.js");
|
|
841
|
+
Object.keys(_valuePath).forEach(function (key) {
|
|
842
|
+
if (key === "default" || key === "__esModule") return;
|
|
843
|
+
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
|
|
844
|
+
if (key in exports && exports[key] === _valuePath[key]) return;
|
|
845
|
+
Object.defineProperty(exports, key, {
|
|
846
|
+
enumerable: true,
|
|
847
|
+
get: function () {
|
|
848
|
+
return _valuePath[key];
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
var _typedEdit = require("./sync/typedEdit.js");
|
|
853
|
+
Object.keys(_typedEdit).forEach(function (key) {
|
|
854
|
+
if (key === "default" || key === "__esModule") return;
|
|
855
|
+
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
|
|
856
|
+
if (key in exports && exports[key] === _typedEdit[key]) return;
|
|
857
|
+
Object.defineProperty(exports, key, {
|
|
858
|
+
enumerable: true,
|
|
859
|
+
get: function () {
|
|
860
|
+
return _typedEdit[key];
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
});
|
|
816
864
|
var _wireBudget = require("./sync/wireBudget.js");
|
|
817
865
|
Object.keys(_wireBudget).forEach(function (key) {
|
|
818
866
|
if (key === "default" || key === "__esModule") return;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.expandKeyedArrays = expandKeyedArrays;
|
|
7
|
+
exports.identityField = identityField;
|
|
8
|
+
/**
|
|
9
|
+
* Id-addressed list edits — one implementation, shared by every adapter that
|
|
10
|
+
* takes a merge patch.
|
|
11
|
+
*
|
|
12
|
+
* WHY. Resending a whole array is correct only when the caller has SEEN the
|
|
13
|
+
* whole array, and reads are capped. Measured with a 20-item list truncated to
|
|
14
|
+
* 5 (`pnpm ai:array --truncate=5`): the whole-array form scored 0/9 across three
|
|
15
|
+
* models — two refused outright, and one silently sent back only the 5 items it
|
|
16
|
+
* had been shown, destroying the other 15. Addressed edits scored 9/9.
|
|
17
|
+
*
|
|
18
|
+
* It also makes mass deletion impossible BY OMISSION: an addressed edit can only
|
|
19
|
+
* remove ids it names. Sending a real array still replaces the list, which is
|
|
20
|
+
* how a caller deliberately empties one.
|
|
21
|
+
*
|
|
22
|
+
* This lives here rather than in one adapter because react-query, zustand and
|
|
23
|
+
* network all take merge patches, and a second copy of a data-destroying safety
|
|
24
|
+
* rule is a copy that drifts.
|
|
25
|
+
*/
|
|
26
|
+
const isPlainObject = v => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
27
|
+
|
|
28
|
+
/** Fields we will treat as an item's stable identity, best first. */
|
|
29
|
+
const ID_FIELDS = ["id", "lineId", "key", "uuid", "_id", "slug", "name"];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The field that identifies items in this list, or undefined if there isn't one.
|
|
33
|
+
*
|
|
34
|
+
* Every item must carry it, and every value must be a distinct string/number —
|
|
35
|
+
* a duplicated id would make an addressed edit ambiguous, and silently picking
|
|
36
|
+
* the first match is exactly the kind of "helpful" guess that loses data.
|
|
37
|
+
*/
|
|
38
|
+
function identityField(items) {
|
|
39
|
+
if (!items.length || !items.every(isPlainObject)) return undefined;
|
|
40
|
+
const first = items[0];
|
|
41
|
+
for (const f of ID_FIELDS) {
|
|
42
|
+
if (!(f in first)) continue;
|
|
43
|
+
const values = items.map(it => it[f]);
|
|
44
|
+
const usable = values.every(v => typeof v === "string" || typeof v === "number");
|
|
45
|
+
if (!usable) continue;
|
|
46
|
+
if (new Set(values.map(String)).size !== values.length) continue;
|
|
47
|
+
return f;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The identity an ADDED item should carry.
|
|
54
|
+
*
|
|
55
|
+
* `Object.entries` hands back STRING keys, so `{"9901": {id: 9901, …}}` was
|
|
56
|
+
* written as `{id: "9901"}` — the caller's own numeric id replaced by text, and
|
|
57
|
+
* then refused by the typed-edit guard as `[2].id: number → string`. The caller
|
|
58
|
+
* recovers by resending the WHOLE array, which is exactly the truncation-unsafe
|
|
59
|
+
* move this module exists to prevent. Measured on a live QA run: one added
|
|
60
|
+
* offer cost a refusal, a full-list retransmission and 35 seconds.
|
|
61
|
+
*
|
|
62
|
+
* So the id the caller WROTE wins. With no id in the body, the key is coerced
|
|
63
|
+
* to the type the existing items use, and only when the text round-trips
|
|
64
|
+
* exactly: "007" is not 7, and inventing that identity is worse than a refusal
|
|
65
|
+
* the caller can read.
|
|
66
|
+
*/
|
|
67
|
+
function identityForAdd(current, idField, key, edit) {
|
|
68
|
+
const supplied = edit[idField];
|
|
69
|
+
if (typeof supplied === "string" || typeof supplied === "number") {
|
|
70
|
+
// An item whose id disagrees with the address it was sent under is
|
|
71
|
+
// ambiguous — either could be the one the caller meant. Say so rather than
|
|
72
|
+
// silently picking one and writing an item nobody asked for.
|
|
73
|
+
if (String(supplied) !== key) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: `The new item is addressed as \`${key}\` but carries \`${idField}\`: ${JSON.stringify(supplied)}. Use the same value for both — address it by the id it carries.`
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
value: supplied
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const numericList = current.every(it => typeof it[idField] === "number");
|
|
85
|
+
if (numericList && key.trim() !== "" && String(Number(key)) === key) {
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
value: Number(key)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
value: key
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Let a caller address list items by their own id instead of resending the list.
|
|
99
|
+
*
|
|
100
|
+
* { lines: { "seed-1": { qty: 5 } } } change one field of one item
|
|
101
|
+
* { lines: { "seed-2": null } } remove that item
|
|
102
|
+
* { lines: { "new-1": { …all fields… } } } append (id not already present)
|
|
103
|
+
*
|
|
104
|
+
* WHY THIS EXISTS. Resending the whole array is correct only when the caller has
|
|
105
|
+
* SEEN the whole array, and reads are capped — so on a long list the honest
|
|
106
|
+
* agents refuse and the careless ones send back only the items they were shown,
|
|
107
|
+
* silently destroying the rest. Measured: with a 20-item bag truncated to 5, the
|
|
108
|
+
* whole-array form scored 0/9 across three models and one of them deleted 15
|
|
109
|
+
* lines without a word. Addressed edits scored 9/9. (`pnpm ai:array`.)
|
|
110
|
+
*
|
|
111
|
+
* It also makes mass deletion impossible BY OMISSION: `lines: []` empties a bag,
|
|
112
|
+
* but a keyed edit can only remove ids it names.
|
|
113
|
+
*
|
|
114
|
+
* Returns the patch with keyed objects expanded into real arrays, so everything
|
|
115
|
+
* downstream — the typed-edit guard, the receipt, the store write — is unchanged
|
|
116
|
+
* and keeps all of its protections.
|
|
117
|
+
*/
|
|
118
|
+
function expandKeyedArrays(current, patch, path = "", notes = []) {
|
|
119
|
+
if (Array.isArray(current) && isPlainObject(patch)) {
|
|
120
|
+
const idField = identityField(current);
|
|
121
|
+
if (!idField) {
|
|
122
|
+
// No stable identity: leave it alone and let the normal guard report the
|
|
123
|
+
// object-vs-array type change, which says something truer than we could.
|
|
124
|
+
return {
|
|
125
|
+
patch,
|
|
126
|
+
notes
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const byId = new Map(current.map(it => [String(it[idField]), it]));
|
|
130
|
+
const next = current.slice();
|
|
131
|
+
let removed = 0;
|
|
132
|
+
let changed = 0;
|
|
133
|
+
let added = 0;
|
|
134
|
+
for (const [id, edit] of Object.entries(patch)) {
|
|
135
|
+
const i = next.findIndex(it => String(it[idField]) === id);
|
|
136
|
+
if (edit === null) {
|
|
137
|
+
if (i === -1) return {
|
|
138
|
+
patch,
|
|
139
|
+
notes
|
|
140
|
+
};
|
|
141
|
+
next.splice(i, 1);
|
|
142
|
+
removed++;
|
|
143
|
+
} else if (i === -1) {
|
|
144
|
+
if (!isPlainObject(edit)) return {
|
|
145
|
+
patch,
|
|
146
|
+
notes
|
|
147
|
+
};
|
|
148
|
+
const identity = identityForAdd(current, idField, id, edit);
|
|
149
|
+
if (!identity.ok) {
|
|
150
|
+
return {
|
|
151
|
+
patch,
|
|
152
|
+
notes,
|
|
153
|
+
error: identity.error
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
next.push({
|
|
157
|
+
...edit,
|
|
158
|
+
[idField]: identity.value
|
|
159
|
+
});
|
|
160
|
+
added++;
|
|
161
|
+
} else {
|
|
162
|
+
const merged = expandKeyedArrays(byId.get(id), edit, `${path}[${id}]`, notes);
|
|
163
|
+
if (merged.error) return {
|
|
164
|
+
patch,
|
|
165
|
+
notes,
|
|
166
|
+
error: merged.error
|
|
167
|
+
};
|
|
168
|
+
const base = next[i];
|
|
169
|
+
next[i] = isPlainObject(base) && isPlainObject(merged.patch) ? {
|
|
170
|
+
...base,
|
|
171
|
+
...merged.patch
|
|
172
|
+
} : merged.patch;
|
|
173
|
+
changed++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
notes.push(`${path || "(root)"}: addressed by \`${idField}\` — ${changed} changed, ${added} added, ${removed} removed, ${next.length} item(s) remain`);
|
|
177
|
+
return {
|
|
178
|
+
patch: next,
|
|
179
|
+
notes
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (isPlainObject(current) && isPlainObject(patch)) {
|
|
183
|
+
const outPatch = {};
|
|
184
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
185
|
+
const p = path ? `${path}.${k}` : k;
|
|
186
|
+
if (!(k in current)) {
|
|
187
|
+
outPatch[k] = v;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const inner = expandKeyedArrays(current[k], v, p, notes);
|
|
191
|
+
// A refusal deep in the tree is the whole patch's refusal: writing the
|
|
192
|
+
// rest of it would apply half of what the caller asked for.
|
|
193
|
+
if (inner.error) return {
|
|
194
|
+
patch,
|
|
195
|
+
notes,
|
|
196
|
+
error: inner.error
|
|
197
|
+
};
|
|
198
|
+
outPatch[k] = inner.patch;
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
patch: outPatch,
|
|
202
|
+
notes
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
patch,
|
|
207
|
+
notes
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _vitest = require("vitest");
|
|
4
|
+
var _toolUiBridge = require("./toolUiBridge.js");
|
|
5
|
+
/**
|
|
6
|
+
* The attention badge seam: a tool says "I need you" and the rail hears it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
(0, _vitest.describe)("toolAttention", () => {
|
|
10
|
+
(0, _vitest.it)("stores, replaces and clears a badge, notifying once per change", () => {
|
|
11
|
+
const fn = _vitest.vi.fn();
|
|
12
|
+
const off = (0, _toolUiBridge.subscribeToolAttention)(fn);
|
|
13
|
+
(0, _toolUiBridge.setToolAttention)("ask-buoy", {
|
|
14
|
+
kind: "needsInput",
|
|
15
|
+
label: "Needs your approval"
|
|
16
|
+
});
|
|
17
|
+
(0, _vitest.expect)((0, _toolUiBridge.getToolAttention)("ask-buoy")).toEqual({
|
|
18
|
+
kind: "needsInput",
|
|
19
|
+
label: "Needs your approval"
|
|
20
|
+
});
|
|
21
|
+
(0, _vitest.expect)(fn).toHaveBeenCalledTimes(1);
|
|
22
|
+
// Same value again is not a change.
|
|
23
|
+
(0, _toolUiBridge.setToolAttention)("ask-buoy", {
|
|
24
|
+
kind: "needsInput",
|
|
25
|
+
label: "Needs your approval"
|
|
26
|
+
});
|
|
27
|
+
(0, _vitest.expect)(fn).toHaveBeenCalledTimes(1);
|
|
28
|
+
(0, _toolUiBridge.setToolAttention)("ask-buoy", {
|
|
29
|
+
kind: "done",
|
|
30
|
+
label: "Finished"
|
|
31
|
+
});
|
|
32
|
+
(0, _vitest.expect)(fn).toHaveBeenCalledTimes(2);
|
|
33
|
+
(0, _toolUiBridge.setToolAttention)("ask-buoy", null);
|
|
34
|
+
(0, _vitest.expect)((0, _toolUiBridge.getToolAttention)("ask-buoy")).toBeNull();
|
|
35
|
+
(0, _vitest.expect)(fn).toHaveBeenCalledTimes(3);
|
|
36
|
+
// Clearing what is already clear is silent.
|
|
37
|
+
(0, _toolUiBridge.setToolAttention)("ask-buoy", null);
|
|
38
|
+
(0, _vitest.expect)(fn).toHaveBeenCalledTimes(3);
|
|
39
|
+
off();
|
|
40
|
+
});
|
|
41
|
+
(0, _vitest.it)("keeps tools apart", () => {
|
|
42
|
+
(0, _toolUiBridge.setToolAttention)("a", {
|
|
43
|
+
kind: "failed",
|
|
44
|
+
label: "x"
|
|
45
|
+
});
|
|
46
|
+
(0, _toolUiBridge.setToolAttention)("b", null);
|
|
47
|
+
(0, _vitest.expect)((0, _toolUiBridge.getToolAttention)("a")?.kind).toBe("failed");
|
|
48
|
+
(0, _vitest.expect)((0, _toolUiBridge.getToolAttention)("b")).toBeNull();
|
|
49
|
+
(0, _toolUiBridge.setToolAttention)("a", null);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -4,8 +4,11 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.canOpenBuoyTools = canOpenBuoyTools;
|
|
7
|
+
exports.getToolAttention = getToolAttention;
|
|
7
8
|
exports.openBuoyTool = openBuoyTool;
|
|
8
9
|
exports.registerToolOpener = registerToolOpener;
|
|
10
|
+
exports.setToolAttention = setToolAttention;
|
|
11
|
+
exports.subscribeToolAttention = subscribeToolAttention;
|
|
9
12
|
/**
|
|
10
13
|
* toolUiBridge — let a tool ask the host to bring another tool back on screen.
|
|
11
14
|
*
|
|
@@ -71,4 +74,59 @@ function openBuoyTool(toolId) {
|
|
|
71
74
|
/** True when some host is listening — for deciding whether to offer the button. */
|
|
72
75
|
function canOpenBuoyTools() {
|
|
73
76
|
return openers().size > 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Attention ───────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* "This tool needs you" — shown as a badge on its chip in the minimized rail.
|
|
83
|
+
*
|
|
84
|
+
* The case: Ask Buoy is minimized so a tester can watch the app, and the
|
|
85
|
+
* agent hits a step that needs a tap. Nobody can see the card. The sheet used
|
|
86
|
+
* to fail safe by DECLINING the change and ending the turn — which made
|
|
87
|
+
* minimize mean "stop", the opposite of what it is for. Now the approval
|
|
88
|
+
* holds, the chip gets a badge, and tapping the chip (the existing restore
|
|
89
|
+
* path) brings the card back. Same seam shape as the opener above: the rail
|
|
90
|
+
* lives inside the host, the tool cannot import the host, so it publishes
|
|
91
|
+
* through a global.
|
|
92
|
+
*
|
|
93
|
+
* `done` and `failed` cover the other thing a hidden tool cannot say — that
|
|
94
|
+
* the work you minimized it to wait for has finished.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
const ATTENTION_KEY = "__buoyToolAttention";
|
|
98
|
+
function attention() {
|
|
99
|
+
const g = globalThis;
|
|
100
|
+
if (!g[ATTENTION_KEY]) g[ATTENTION_KEY] = {
|
|
101
|
+
byTool: new Map(),
|
|
102
|
+
listeners: new Set()
|
|
103
|
+
};
|
|
104
|
+
return g[ATTENTION_KEY];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Set, replace or (with null) clear a tool's badge. No-op when nothing changed. */
|
|
108
|
+
function setToolAttention(toolId, next) {
|
|
109
|
+
const store = attention();
|
|
110
|
+
const prev = store.byTool.get(toolId) ?? null;
|
|
111
|
+
if (prev === next || prev && next && prev.kind === next.kind && prev.label === next.label) return;
|
|
112
|
+
if (next) store.byTool.set(toolId, next);else store.byTool.delete(toolId);
|
|
113
|
+
for (const fn of store.listeners) {
|
|
114
|
+
try {
|
|
115
|
+
fn();
|
|
116
|
+
} catch {
|
|
117
|
+
// A throwing subscriber must not stop the others from hearing.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function getToolAttention(toolId) {
|
|
122
|
+
return attention().byTool.get(toolId) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** For `useSyncExternalStore` in the rail. */
|
|
126
|
+
function subscribeToolAttention(fn) {
|
|
127
|
+
const store = attention();
|
|
128
|
+
store.listeners.add(fn);
|
|
129
|
+
return () => {
|
|
130
|
+
store.listeners.delete(fn);
|
|
131
|
+
};
|
|
74
132
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.findKeyPath = findKeyPath;
|
|
7
|
+
exports.refusalText = refusalText;
|
|
8
|
+
exports.shapeKind = shapeKind;
|
|
9
|
+
exports.suggestedPatch = suggestedPatch;
|
|
10
|
+
exports.typedEditViolations = typedEditViolations;
|
|
11
|
+
/**
|
|
12
|
+
* The typed-edit guard — ONE implementation, for every surface that takes a
|
|
13
|
+
* merge patch from a remote caller.
|
|
14
|
+
*
|
|
15
|
+
* The rule, which is the React Query devtools value-editor rule: you may change
|
|
16
|
+
* the VALUE of a field that already exists, to the same kind of thing. You may
|
|
17
|
+
* not add a field, change a field's type, or null a list or object the screen
|
|
18
|
+
* renders. A list may grow or shrink — carts are lists you add to — but every
|
|
19
|
+
* item in it must have the same fields as the items already there, because an
|
|
20
|
+
* item missing a field the others have is what crashes the list on render.
|
|
21
|
+
*
|
|
22
|
+
* WHY IT LIVES HERE. This existed twice, once in the zustand adapter and once in
|
|
23
|
+
* react-query, drifting slightly (one said "the ones already in the list", the
|
|
24
|
+
* other "the items"; only one had the did-you-mean hint). Jotai had NO copy at
|
|
25
|
+
* all, so `setAtom` accepted a string over a number or a one-item array over a
|
|
26
|
+
* fifty-item one with nothing to stop it. Three copies of a data-destroying
|
|
27
|
+
* safety rule is two more than anyone can keep in step — the same argument
|
|
28
|
+
* `keyedArrays.ts` makes about the expansion it owns.
|
|
29
|
+
*
|
|
30
|
+
* The one behavioural knob is `noun`: what to call the thing being edited in a
|
|
31
|
+
* refusal ("this store", "this data", "this atom"). Everything else is shared,
|
|
32
|
+
* deliberately — a rule that differs per surface is a rule nobody can state.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const isPlainObject = v => !!v && typeof v === "object" && !Array.isArray(v);
|
|
36
|
+
|
|
37
|
+
/** The shape category the screen depends on: array vs object vs the rest. */
|
|
38
|
+
function shapeKind(v) {
|
|
39
|
+
if (Array.isArray(v)) return "array";
|
|
40
|
+
if (v === null) return "null";
|
|
41
|
+
if (typeof v === "object") return "object";
|
|
42
|
+
return typeof v;
|
|
43
|
+
}
|
|
44
|
+
const SCALARS = ["string", "number", "boolean"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where a key actually lives in a nested value, as a dotted path — first match,
|
|
48
|
+
* breadth-first, capped so a huge payload cannot stall the walk.
|
|
49
|
+
*
|
|
50
|
+
* This is what turns "you put `name` at the top" into "`name` lives at
|
|
51
|
+
* `item.name`", which is the difference between a caller that gives up and one
|
|
52
|
+
* that fixes it. Ten of eleven measured `query` write failures were this exact
|
|
53
|
+
* mistake.
|
|
54
|
+
*/
|
|
55
|
+
function findKeyPath(obj, key, maxNodes = 2000) {
|
|
56
|
+
const queue = [{
|
|
57
|
+
node: obj,
|
|
58
|
+
path: ""
|
|
59
|
+
}];
|
|
60
|
+
let seen = 0;
|
|
61
|
+
while (queue.length && seen < maxNodes) {
|
|
62
|
+
const {
|
|
63
|
+
node,
|
|
64
|
+
path
|
|
65
|
+
} = queue.shift();
|
|
66
|
+
seen++;
|
|
67
|
+
if (Array.isArray(node)) {
|
|
68
|
+
node.slice(0, 20).forEach((v, i) => queue.push({
|
|
69
|
+
node: v,
|
|
70
|
+
path: `${path}[${i}]`
|
|
71
|
+
}));
|
|
72
|
+
} else if (isPlainObject(node)) {
|
|
73
|
+
for (const [k, v] of Object.entries(node)) {
|
|
74
|
+
const p = path ? `${path}.${k}` : k;
|
|
75
|
+
if (k === key && path) return p; // path set => nested, not the top level
|
|
76
|
+
if (isPlainObject(v) || Array.isArray(v)) queue.push({
|
|
77
|
+
node: v,
|
|
78
|
+
path: p
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every way this patch would break the value it is merged into. Empty means the
|
|
87
|
+
* edit is safe to apply.
|
|
88
|
+
*/
|
|
89
|
+
function typedEditViolations(current, patch, opts = {}, root = current, path = "", out = []) {
|
|
90
|
+
const noun = opts.noun ?? "data";
|
|
91
|
+
const here = path || "(root)";
|
|
92
|
+
if (Array.isArray(patch)) {
|
|
93
|
+
if (!Array.isArray(current)) {
|
|
94
|
+
out.push({
|
|
95
|
+
path: here,
|
|
96
|
+
kind: "type-change",
|
|
97
|
+
detail: `${shapeKind(current)} → array`
|
|
98
|
+
});
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
// Length may change — a cart is a list you add to and remove from — but each
|
|
102
|
+
// item must match the shape of the ones already there. The first item is the
|
|
103
|
+
// template; an added item missing a field the others have is what makes the
|
|
104
|
+
// list throw when it renders that field.
|
|
105
|
+
const template = current.length ? current[0] : undefined;
|
|
106
|
+
if (template !== undefined && (isPlainObject(template) || Array.isArray(template))) {
|
|
107
|
+
patch.forEach((el, i) => {
|
|
108
|
+
const ep = `${path}[${i}]`;
|
|
109
|
+
if (isPlainObject(template) && isPlainObject(el)) {
|
|
110
|
+
for (const k of Object.keys(template)) {
|
|
111
|
+
if (!(k in el)) {
|
|
112
|
+
out.push({
|
|
113
|
+
path: `${ep}.${k}`,
|
|
114
|
+
kind: "missing-key",
|
|
115
|
+
detail: `list items need \`${k}\` — the items already in the list have it`
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
typedEditViolations(template, el, opts, root, ep, out);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
if (isPlainObject(patch)) {
|
|
126
|
+
if (!isPlainObject(current)) {
|
|
127
|
+
out.push({
|
|
128
|
+
path: here,
|
|
129
|
+
kind: "type-change",
|
|
130
|
+
detail: `${shapeKind(current)} → object`
|
|
131
|
+
});
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
135
|
+
const p = path ? `${path}.${k}` : k;
|
|
136
|
+
if (!(k in current)) {
|
|
137
|
+
const elsewhere = findKeyPath(root, k);
|
|
138
|
+
out.push({
|
|
139
|
+
path: p,
|
|
140
|
+
kind: "add-key",
|
|
141
|
+
detail: elsewhere ? `no such field here — did you mean \`${elsewhere}\`?` : `no field named \`${k}\` exists in this ${noun}`,
|
|
142
|
+
...(elsewhere ? {
|
|
143
|
+
movedTo: elsewhere
|
|
144
|
+
} : {})
|
|
145
|
+
});
|
|
146
|
+
} else {
|
|
147
|
+
typedEditViolations(current[k], v, opts, root, p, out);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A leaf: the edit itself.
|
|
154
|
+
const ck = shapeKind(current);
|
|
155
|
+
const pk = shapeKind(patch);
|
|
156
|
+
// Filling an empty field and CLEARING one are the same kind of edit, and this
|
|
157
|
+
// guard used to allow only the first. That asymmetry had a cost: "take the
|
|
158
|
+
// promo off my bag" is `string → null`, the refusal named `force: true` as the
|
|
159
|
+
// only way through, and a model that anticipated it wrote the forced call
|
|
160
|
+
// pre-emptively — reaching for the one parameter that bypasses EVERY
|
|
161
|
+
// protection in order to do something completely ordinary. Measured on
|
|
162
|
+
// `cart/take-promo-off`: an S1 on an otherwise correct flow.
|
|
163
|
+
//
|
|
164
|
+
// Nulling a LIST or OBJECT the screen renders stays refused. That is the crash
|
|
165
|
+
// this guard exists for (`sizes.map` on null); a null scalar renders as empty.
|
|
166
|
+
const fillingNull = ck === "null" && SCALARS.includes(pk);
|
|
167
|
+
const clearingScalar = pk === "null" && SCALARS.includes(ck);
|
|
168
|
+
if (ck !== pk && !fillingNull && !clearingScalar) {
|
|
169
|
+
out.push({
|
|
170
|
+
path: here,
|
|
171
|
+
kind: "type-change",
|
|
172
|
+
detail: `${ck} → ${pk}`
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The patch the caller should have sent, when every problem was a field written
|
|
180
|
+
* at the wrong DEPTH.
|
|
181
|
+
*
|
|
182
|
+
* The guard already worked out where each field really lives in order to say
|
|
183
|
+
* "did you mean `item.name`?", so it can rebuild the patch instead of leaving
|
|
184
|
+
* the caller to re-derive it from prose. Returns undefined unless EVERY
|
|
185
|
+
* violation is a relocatable add-key: a partial suggestion that silently drops
|
|
186
|
+
* the parts we could not fix would be worse than none, because the caller would
|
|
187
|
+
* send it, be refused again, and learn nothing.
|
|
188
|
+
*/
|
|
189
|
+
function suggestedPatch(patch, violations) {
|
|
190
|
+
if (!violations.length || !violations.every(v => v.kind === "add-key" && v.movedTo)) return undefined;
|
|
191
|
+
const out = {};
|
|
192
|
+
for (const v of violations) {
|
|
193
|
+
const value = getAtDotted(patch, v.path);
|
|
194
|
+
if (value === undefined) return undefined;
|
|
195
|
+
// `movedTo` can name a list element (`results[0].name`); a merge patch
|
|
196
|
+
// cannot address one by index, and rewriting it to the id-addressed form
|
|
197
|
+
// needs the list itself. Leave those to the prose rather than suggest
|
|
198
|
+
// something that will not apply.
|
|
199
|
+
if (v.movedTo.includes("[")) return undefined;
|
|
200
|
+
let node = out;
|
|
201
|
+
const segs = v.movedTo.split(".");
|
|
202
|
+
segs.slice(0, -1).forEach(seg => {
|
|
203
|
+
node[seg] ??= {};
|
|
204
|
+
node = node[seg];
|
|
205
|
+
});
|
|
206
|
+
node[segs[segs.length - 1]] = value;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
function getAtDotted(obj, path) {
|
|
211
|
+
let cur = obj;
|
|
212
|
+
for (const seg of path.split(".")) {
|
|
213
|
+
if (!isPlainObject(cur)) return undefined;
|
|
214
|
+
cur = cur[seg];
|
|
215
|
+
}
|
|
216
|
+
return cur;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The sentence a refusal ends with. Shared so every surface refuses in the same
|
|
221
|
+
* words — a caller that learns the rule on one tool should recognise it on the
|
|
222
|
+
* next.
|
|
223
|
+
*/
|
|
224
|
+
function refusalText(violations, noun, suggestion) {
|
|
225
|
+
const listed = violations.slice(0, 6).map(v => `\`${v.path}\`: ${v.detail}`).join("; ");
|
|
226
|
+
const more = violations.length > 6 ? `; +${violations.length - 6} more` : "";
|
|
227
|
+
return `Refused: this isn't a same-type edit of existing fields (the rule the React Query devtools enforces). ${listed}${more}. ` + `You can only change the VALUE of a field that already exists, to the same type — you can't add fields, change a field's type, ` + `or null a list/object the screen renders. Clearing a scalar to null is fine. Send just the field(s) you're changing, nested to ` + `match the current shape of this ${noun}. Use force:true only to deliberately break that.` + (suggestion ? ` This is the same edit at the right depth — send it as the patch: ${JSON.stringify(suggestion)}` : "");
|
|
228
|
+
}
|