@buoy-gg/shared-ui 7.0.30 → 7.0.31

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 (129) hide show
  1. package/lib/commonjs/JsModal.js +18 -2
  2. package/lib/commonjs/JsModal.web.js +7 -4
  3. package/lib/commonjs/dataViewer/DataTreeActionDock.js +4 -2
  4. package/lib/commonjs/dataViewer/LiveExplorer.js +930 -0
  5. package/lib/commonjs/dataViewer/ValueEditModal.js +385 -0
  6. package/lib/commonjs/dataViewer/index.js +47 -0
  7. package/lib/commonjs/dataViewer/nestedPath.js +115 -0
  8. package/lib/commonjs/dataViewer/treeActions.js +3 -2
  9. package/lib/commonjs/dataViewer/useTreeDraft.js +125 -0
  10. package/lib/commonjs/dataViewer/useTreeEditor.js +105 -0
  11. package/lib/commonjs/env/EnvironmentIndicator.js +7 -1
  12. package/lib/commonjs/hooks/safe-area-impl.js +1 -1
  13. package/lib/commonjs/index.js +24 -0
  14. package/lib/commonjs/license/tierLimits.js +14 -0
  15. package/lib/commonjs/storage/devToolsStorageKeys.js +36 -0
  16. package/lib/commonjs/sync/toolActionRegistry.js +166 -0
  17. package/lib/commonjs/sync/toolUiBridge.js +74 -0
  18. package/lib/commonjs/ui/components/CompactRow.js +4 -0
  19. package/lib/commonjs/ui/components/EventHistoryViewer/DiffModeTabs.js +9 -1
  20. package/lib/commonjs/ui/components/ExpandedInfoRow.js +3 -1
  21. package/lib/commonjs/ui/components/ModalHeader.js +7 -4
  22. package/lib/commonjs/ui/components/SearchBar.js +5 -109
  23. package/lib/commonjs/ui/components/ToolButton.js +5 -2
  24. package/lib/module/JsModal.js +18 -2
  25. package/lib/module/JsModal.web.js +7 -4
  26. package/lib/module/dataViewer/DataTreeActionDock.js +4 -2
  27. package/lib/module/dataViewer/LiveExplorer.js +925 -0
  28. package/lib/module/dataViewer/ValueEditModal.js +381 -0
  29. package/lib/module/dataViewer/index.js +10 -1
  30. package/lib/module/dataViewer/nestedPath.js +109 -0
  31. package/lib/module/dataViewer/treeActions.js +3 -2
  32. package/lib/module/dataViewer/useTreeDraft.js +121 -0
  33. package/lib/module/dataViewer/useTreeEditor.js +101 -0
  34. package/lib/module/env/EnvironmentIndicator.js +7 -1
  35. package/lib/module/hooks/safe-area-impl.js +1 -1
  36. package/lib/module/index.js +2 -0
  37. package/lib/module/license/tierLimits.js +14 -0
  38. package/lib/module/storage/devToolsStorageKeys.js +36 -0
  39. package/lib/module/sync/toolActionRegistry.js +154 -0
  40. package/lib/module/sync/toolUiBridge.js +68 -0
  41. package/lib/module/ui/components/CompactRow.js +4 -0
  42. package/lib/module/ui/components/EventHistoryViewer/DiffModeTabs.js +9 -1
  43. package/lib/module/ui/components/ExpandedInfoRow.js +3 -1
  44. package/lib/module/ui/components/ModalHeader.js +7 -4
  45. package/lib/module/ui/components/SearchBar.js +8 -112
  46. package/lib/module/ui/components/ToolButton.js +5 -2
  47. package/lib/typescript/commonjs/JsModal.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/JsModal.web.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/dataViewer/DataTreeActionDock.d.ts +6 -1
  50. package/lib/typescript/commonjs/dataViewer/DataTreeActionDock.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/dataViewer/LiveExplorer.d.ts +73 -0
  52. package/lib/typescript/commonjs/dataViewer/LiveExplorer.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/dataViewer/ValueEditModal.d.ts +40 -0
  54. package/lib/typescript/commonjs/dataViewer/ValueEditModal.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/dataViewer/index.d.ts +5 -0
  56. package/lib/typescript/commonjs/dataViewer/index.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/dataViewer/nestedPath.d.ts +20 -0
  58. package/lib/typescript/commonjs/dataViewer/nestedPath.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/dataViewer/treeActions.d.ts +10 -0
  60. package/lib/typescript/commonjs/dataViewer/treeActions.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/dataViewer/useTreeDraft.d.ts +81 -0
  62. package/lib/typescript/commonjs/dataViewer/useTreeDraft.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/dataViewer/useTreeEditor.d.ts +52 -0
  64. package/lib/typescript/commonjs/dataViewer/useTreeEditor.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/env/EnvironmentIndicator.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/hooks/safe-area-impl.d.ts +1 -1
  67. package/lib/typescript/commonjs/index.d.ts +2 -0
  68. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/license/tierLimits.d.ts +10 -0
  70. package/lib/typescript/commonjs/license/tierLimits.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/storage/devToolsStorageKeys.d.ts +36 -0
  72. package/lib/typescript/commonjs/storage/devToolsStorageKeys.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/sync/toolActionRegistry.d.ts +85 -0
  74. package/lib/typescript/commonjs/sync/toolActionRegistry.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/sync/toolUiBridge.d.ts +39 -0
  76. package/lib/typescript/commonjs/sync/toolUiBridge.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/ui/components/CompactRow.d.ts +5 -1
  78. package/lib/typescript/commonjs/ui/components/CompactRow.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/components/EventHistoryViewer/DiffModeTabs.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/ui/components/EventHistoryViewer/types.d.ts +6 -0
  81. package/lib/typescript/commonjs/ui/components/EventHistoryViewer/types.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/components/ExpandedInfoRow.d.ts +7 -1
  83. package/lib/typescript/commonjs/ui/components/ExpandedInfoRow.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/ui/components/SearchBar.d.ts +1 -3
  85. package/lib/typescript/commonjs/ui/components/SearchBar.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/ui/components/ToolButton.d.ts +5 -1
  87. package/lib/typescript/commonjs/ui/components/ToolButton.d.ts.map +1 -1
  88. package/lib/typescript/module/JsModal.d.ts.map +1 -1
  89. package/lib/typescript/module/JsModal.web.d.ts.map +1 -1
  90. package/lib/typescript/module/dataViewer/DataTreeActionDock.d.ts +6 -1
  91. package/lib/typescript/module/dataViewer/DataTreeActionDock.d.ts.map +1 -1
  92. package/lib/typescript/module/dataViewer/LiveExplorer.d.ts +73 -0
  93. package/lib/typescript/module/dataViewer/LiveExplorer.d.ts.map +1 -0
  94. package/lib/typescript/module/dataViewer/ValueEditModal.d.ts +40 -0
  95. package/lib/typescript/module/dataViewer/ValueEditModal.d.ts.map +1 -0
  96. package/lib/typescript/module/dataViewer/index.d.ts +5 -0
  97. package/lib/typescript/module/dataViewer/index.d.ts.map +1 -1
  98. package/lib/typescript/module/dataViewer/nestedPath.d.ts +20 -0
  99. package/lib/typescript/module/dataViewer/nestedPath.d.ts.map +1 -0
  100. package/lib/typescript/module/dataViewer/treeActions.d.ts +10 -0
  101. package/lib/typescript/module/dataViewer/treeActions.d.ts.map +1 -1
  102. package/lib/typescript/module/dataViewer/useTreeDraft.d.ts +81 -0
  103. package/lib/typescript/module/dataViewer/useTreeDraft.d.ts.map +1 -0
  104. package/lib/typescript/module/dataViewer/useTreeEditor.d.ts +52 -0
  105. package/lib/typescript/module/dataViewer/useTreeEditor.d.ts.map +1 -0
  106. package/lib/typescript/module/env/EnvironmentIndicator.d.ts.map +1 -1
  107. package/lib/typescript/module/hooks/safe-area-impl.d.ts +1 -1
  108. package/lib/typescript/module/index.d.ts +2 -0
  109. package/lib/typescript/module/index.d.ts.map +1 -1
  110. package/lib/typescript/module/license/tierLimits.d.ts +10 -0
  111. package/lib/typescript/module/license/tierLimits.d.ts.map +1 -1
  112. package/lib/typescript/module/storage/devToolsStorageKeys.d.ts +36 -0
  113. package/lib/typescript/module/storage/devToolsStorageKeys.d.ts.map +1 -1
  114. package/lib/typescript/module/sync/toolActionRegistry.d.ts +85 -0
  115. package/lib/typescript/module/sync/toolActionRegistry.d.ts.map +1 -0
  116. package/lib/typescript/module/sync/toolUiBridge.d.ts +39 -0
  117. package/lib/typescript/module/sync/toolUiBridge.d.ts.map +1 -0
  118. package/lib/typescript/module/ui/components/CompactRow.d.ts +5 -1
  119. package/lib/typescript/module/ui/components/CompactRow.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/components/EventHistoryViewer/DiffModeTabs.d.ts.map +1 -1
  121. package/lib/typescript/module/ui/components/EventHistoryViewer/types.d.ts +6 -0
  122. package/lib/typescript/module/ui/components/EventHistoryViewer/types.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/components/ExpandedInfoRow.d.ts +7 -1
  124. package/lib/typescript/module/ui/components/ExpandedInfoRow.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/components/SearchBar.d.ts +1 -3
  126. package/lib/typescript/module/ui/components/SearchBar.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/components/ToolButton.d.ts +5 -1
  128. package/lib/typescript/module/ui/components/ToolButton.d.ts.map +1 -1
  129. package/package.json +4 -4
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Everything editing a tree document involves, minus how it looks.
5
+ *
6
+ * The phone and the dashboard run the same editor: one buffered draft, one
7
+ * write on save, a selection the structural actions operate on, a modal for
8
+ * typing a value, and a guard so closing can't silently drop unsaved work.
9
+ * What differs is presentation — thumb-sized tiles under a full screen versus
10
+ * chips docked in a panel — which is a rendering concern and nothing more.
11
+ *
12
+ * Keeping the rules here means the surfaces (and the tools — storage keys and
13
+ * zustand stores run this same hook) can't drift on the things that would
14
+ * actually hurt: whether a boolean opens an editor, whether a failed write
15
+ * closes the editor anyway, what "dirty" means.
16
+ */
17
+
18
+ import { useCallback, useEffect, useState } from "react";
19
+ import { getAtPath } from "./jsonOps.js";
20
+ import { useTreeDraft } from "./useTreeDraft.js";
21
+ export function useTreeEditor({
22
+ value,
23
+ save,
24
+ onClose,
25
+ confirmDiscard
26
+ }) {
27
+ const draft = useTreeDraft(value, save);
28
+ const [selection, setSelection] = useState(null);
29
+ const [valuePath, setValuePath] = useState(null);
30
+ const {
31
+ begin
32
+ } = draft;
33
+ useEffect(() => {
34
+ begin();
35
+ // Seed the draft ONCE, when the editor opens. `begin` closes over the live
36
+ // value, whose identity changes every time the list refreshes underneath
37
+ // us (a parsed JSON string is a new object each pass), so re-running would
38
+ // reset the draft mid-edit and discard the user's work.
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, []);
41
+
42
+ /**
43
+ * "Change this node". For a boolean that's a flip rather than a modal — the
44
+ * same rule `treeActionsFor` applies to the action controls, so the gesture
45
+ * and the button can't mean different things.
46
+ */
47
+ const openValueEditor = useCallback(path => {
48
+ const current = getAtPath(draft.value, path);
49
+ if (typeof current === "boolean") {
50
+ draft.apply({
51
+ kind: "replace",
52
+ path,
53
+ value: !current
54
+ });
55
+ return;
56
+ }
57
+ setValuePath(path);
58
+ }, [draft]);
59
+ const applyValue = useCallback(next => {
60
+ draft.apply({
61
+ kind: "replace",
62
+ path: valuePath ?? [],
63
+ value: next
64
+ });
65
+ setValuePath(null);
66
+ }, [draft, valuePath]);
67
+
68
+ // Only leave once the write actually landed — `commit` resolves false and
69
+ // keeps the draft on screen when the owner refuses it, and closing anyway
70
+ // would throw the work away while showing an error nobody ever sees.
71
+ const saveAndClose = useCallback(() => {
72
+ void draft.commit().then(saved => saved && onClose());
73
+ }, [draft, onClose]);
74
+ const requestClose = useCallback(() => {
75
+ if (!draft.isDirty) {
76
+ onClose();
77
+ return;
78
+ }
79
+ // The draft is the only copy of this work, so closing over it silently is
80
+ // the one way this editor can lose something the user can't get back.
81
+ confirmDiscard({
82
+ save: saveAndClose,
83
+ discard: () => {
84
+ draft.discard();
85
+ onClose();
86
+ }
87
+ });
88
+ }, [confirmDiscard, draft, onClose, saveAndClose]);
89
+ return {
90
+ draft,
91
+ selection,
92
+ setSelection,
93
+ valuePath,
94
+ openValueEditor,
95
+ closeValueEditor: () => setValuePath(null),
96
+ applyValue,
97
+ openRawEditor: () => setValuePath([]),
98
+ requestClose,
99
+ saveAndClose
100
+ };
101
+ }
@@ -89,7 +89,13 @@ export function EnvironmentIndicator({
89
89
  style: {
90
90
  fontSize: 11,
91
91
  fontWeight: "600",
92
- fontFamily: "Poppins-SemiBold",
92
+ // No fontFamily on purpose: the platform UI font (SF Pro / Roboto)
93
+ // is what this badge is meant to look like, and it is what the DOM
94
+ // twin in EnvironmentIndicator.web.tsx has always rendered. This
95
+ // used to name "Poppins-SemiBold" — a family no app, package or
96
+ // example in the repo has ever loaded, so it resolved nowhere:
97
+ // silently the system font on device, and the browser's default
98
+ // SERIF under react-native-web (the docs demo).
93
99
  color: gameUIColors.primaryLight,
94
100
  letterSpacing: 0.5
95
101
  },
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Auto-generated safe area implementation
5
5
  * Detected: none
6
- * Generated at: 2026-08-24T23:44:23.535Z
6
+ * Generated at: 2026-08-29T17:41:46.184Z
7
7
  *
8
8
  * DO NOT EDIT - This file is generated by scripts/detect-safe-area.js
9
9
  *
@@ -6,6 +6,8 @@ export * from "./ui/index.js";
6
6
  // Store base classes
7
7
  export * from "./stores/index.js";
8
8
  export * from "./sync/wireBudget.js";
9
+ export * from "./sync/toolActionRegistry.js";
10
+ export * from "./sync/toolUiBridge.js";
9
11
 
10
12
  // Utils exports - selectively export to avoid conflicts
11
13
  export {
@@ -100,6 +100,20 @@ const LIMITS = {
100
100
  anonymous: 1,
101
101
  free: 3,
102
102
  pro: Infinity
103
+ },
104
+ // ── scenarios ─────────────────────────────────────────────────────────────
105
+ // The SCENARIO is the unit, not the rules it creates: a free user's
106
+ // three-endpoint scenario either runs whole or refuses up front, and is never
107
+ // silently trimmed to one rule mid-run. Enforcement is atomic in pre-flight.
108
+ "scenarios.saved": {
109
+ anonymous: 1,
110
+ free: 3,
111
+ pro: Infinity
112
+ },
113
+ "scenarios.active": {
114
+ anonymous: 1,
115
+ free: 1,
116
+ pro: 3
103
117
  }
104
118
  };
105
119
  export const TIER_LIMITS = LIMITS;
@@ -128,6 +128,18 @@ export const devToolsStorageKeys = {
128
128
  root: () => `${devToolsStorageKeys.base}_time_machine`,
129
129
  diffViewerMode: () => `${devToolsStorageKeys.timeMachine.root()}_diff_viewer_mode` // 'split' | 'tree'
130
130
  },
131
+ /**
132
+ * Scenarios storage keys. The library (saved + draft scenarios) and the
133
+ * active-scenario record live in separate keys deliberately: the active
134
+ * record is written on every run and deactivation, while the library changes
135
+ * rarely, and a single blob would rewrite the whole library each run.
136
+ */
137
+ scenarios: {
138
+ root: () => `${devToolsStorageKeys.base}_scenarios`,
139
+ modal: () => `${devToolsStorageKeys.scenarios.root()}_modal`,
140
+ library: () => `${devToolsStorageKeys.scenarios.root()}_library`,
141
+ active: () => `${devToolsStorageKeys.scenarios.root()}_active`
142
+ },
131
143
  /**
132
144
  * React Query-related storage keys
133
145
  */
@@ -202,16 +214,40 @@ export const devToolsStorageKeys = {
202
214
  settings: () => `${devToolsStorageKeys.benchmark.root()}_settings`,
203
215
  selectedReports: () => `${devToolsStorageKeys.benchmark.root()}_selected_reports`
204
216
  },
217
+ /**
218
+ * Debug Borders overlay keys
219
+ */
220
+ debugBorders: {
221
+ root: () => `${devToolsStorageKeys.base}_debug_borders`,
222
+ /** JsModal `persistenceKey` for the overlay's info window. */
223
+ infoModal: () => `${devToolsStorageKeys.debugBorders.root()}_info_modal`
224
+ },
205
225
  /**
206
226
  * Unified events-related storage keys
207
227
  */
208
228
  events: {
209
229
  root: () => `${devToolsStorageKeys.base}_events`,
230
+ /**
231
+ * NOT what the Events tool uses. Its preset passes the literal
232
+ * `"buoy-events-modal"` to JsModal, and three things outside this repo's
233
+ * runtime now pin that string: the website's live tour, its QA script, and
234
+ * the e2e runner's reset. `isDevToolsStorageKey` treats the `buoy-` prefix
235
+ * as internal, so nothing leaks — this is a naming inconsistency, not a
236
+ * bug, and moving the key would cost a coordinated change across four
237
+ * surfaces to buy tidiness. Kept so the shape of this file stays uniform.
238
+ */
210
239
  modal: () => `${devToolsStorageKeys.events.root()}_modal`,
211
240
  /** Selected badge/source filters */
212
241
  enabledSources: () => `${devToolsStorageKeys.events.root()}_enabled_sources`,
213
242
  /** Whether event capturing is active */
214
243
  isCapturing: () => `${devToolsStorageKeys.events.root()}_is_capturing`,
244
+ /**
245
+ * When capture was last switched off (ms epoch). Persisted with the
246
+ * capturing flag so a remount/relaunch while paused keeps hiding events
247
+ * captured into the shared store after the ORIGINAL pause moment (a
248
+ * watching dashboard keeps sources alive through the device's pause).
249
+ */
250
+ pausedAt: () => `${devToolsStorageKeys.events.root()}_paused_at`,
215
251
  /** Copy/export settings */
216
252
  copySettings: () => `${devToolsStorageKeys.events.root()}_copy_settings`
217
253
  }
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * toolActionRegistry — the on-device twin of the broker's action dispatch.
5
+ *
6
+ * Buoy already has exactly one place where `{toolId, action, params}` becomes a
7
+ * real call into a tool: `handleAction` in external-sync, fed by the adapter
8
+ * map that `autoExternalSync` assembles. That map was private to the React
9
+ * tree, so anything running ON the device (the Scenarios runner) had no way to
10
+ * reach it and would have had to duplicate the map — two dispatch paths that
11
+ * drift the first time an adapter changes.
12
+ *
13
+ * This module publishes the SAME map through a global seam, so a scenario step,
14
+ * a desktop click and an MCP call are one code path with two entry points.
15
+ *
16
+ * WHY A GLOBAL, NOT AN IMPORT: the map is built inside a component (the
17
+ * react-query adapter is a hook, the env adapter is a factory closed over a
18
+ * prop, and custom tools arrive as props), so it cannot be a module constant.
19
+ * Meanwhile the consumer must not import `@buoy-gg/core` — that package imports
20
+ * the tools, and a tool importing it back is the require cycle that puts a
21
+ * LogBox on every reload. Same reasoning, and the same shape, as
22
+ * `crashFlush.ts` in external-sync.
23
+ *
24
+ * WE STORE A GETTER, NOT A SNAPSHOT, for the reason `attachDeviceSync` does:
25
+ * the adapter map is re-created on renders that change its inputs, and a stale
26
+ * captured copy would keep dispatching into adapters the tree has replaced.
27
+ *
28
+ * SAFETY: this registry is populated only while `AutoExternalSync` is mounted,
29
+ * which is itself gated (dev, or release + an explicit opt-in + a real Pro
30
+ * license). It is deliberately NOT a substitute for a caller's own production
31
+ * gate — anything that bends app state through it must check `__DEV__` itself,
32
+ * because the actions reachable here (network overrides, storage writes,
33
+ * impersonation) have no business running in a shipped app.
34
+ */
35
+
36
+ const REGISTRY_KEY = "__buoyToolActionRegistry";
37
+
38
+ /** The duck-typed adapter shape, matching external-sync's `ToolSyncAdapter`. */
39
+
40
+ /**
41
+ * A SET of getters, not one slot — React can briefly mount two providers, or
42
+ * re-run an effect's setup before the previous cleanup. A last-writer-wins slot
43
+ * would let the second registration silently blank the first.
44
+ */
45
+
46
+ function sources() {
47
+ const g = globalThis;
48
+ if (!g[REGISTRY_KEY]) g[REGISTRY_KEY] = new Set();
49
+ return g[REGISTRY_KEY];
50
+ }
51
+
52
+ /**
53
+ * Publish the live adapter map. Called by `autoExternalSync` with a getter that
54
+ * reads its current `useMemo` value. Returns the unregister function.
55
+ */
56
+ export function registerToolAdapters(getTools) {
57
+ const set = sources();
58
+ set.add(getTools);
59
+ return () => {
60
+ set.delete(getTools);
61
+ };
62
+ }
63
+
64
+ /** The merged adapter map across every live source. */
65
+ export function getToolAdapters() {
66
+ const merged = {};
67
+ for (const get of sources()) {
68
+ try {
69
+ Object.assign(merged, get());
70
+ } catch {
71
+ // A source that throws must not take the whole registry down.
72
+ }
73
+ }
74
+ return merged;
75
+ }
76
+
77
+ /** Tool ids currently dispatchable. */
78
+ export function listToolIds() {
79
+ return Object.keys(getToolAdapters());
80
+ }
81
+
82
+ /** `toolId -> action names`, for pre-flight validation and capability display. */
83
+ export function listToolActions() {
84
+ const out = {};
85
+ for (const [id, adapter] of Object.entries(getToolAdapters())) {
86
+ out[id] = Object.keys(adapter.actions ?? {});
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Tool ids whose adapter publishes a `getSnapshot`.
93
+ *
94
+ * Buoy's read surface is split in two: some tools answer through `actions`
95
+ * (`listQueries`, `async.getAllKeys`), and some answer only through the
96
+ * snapshot the sync layer polls — console entries, network events, the route
97
+ * sitemap, the env table. Anything driving tools ON the device sees only the
98
+ * first half unless it asks for the second, which is why the console and
99
+ * network panels look empty to an in-app agent that has an action for
100
+ * everything except reading them.
101
+ */
102
+ export function listSnapshotToolIds() {
103
+ return Object.entries(getToolAdapters()).filter(([, adapter]) => typeof adapter.getSnapshot === "function").map(([id]) => id);
104
+ }
105
+
106
+ /**
107
+ * Read one tool's snapshot — the same value `attachDeviceSync` puts on the wire
108
+ * for MCP's `get_snapshot`, without a socket.
109
+ *
110
+ * Async to match {@link dispatchToolAction}: every caller already awaits, and
111
+ * an adapter is free to make this a promise later without breaking them.
112
+ */
113
+ export async function readToolSnapshot(toolId) {
114
+ const tools = getToolAdapters();
115
+ const adapter = tools[toolId];
116
+ if (!adapter) throw new Error(`Unknown tool "${toolId}"`);
117
+ if (typeof adapter.getSnapshot !== "function") {
118
+ throw new Error(`Tool "${toolId}" has no snapshot to read`);
119
+ }
120
+ return await adapter.getSnapshot();
121
+ }
122
+ export function isToolRegistryReady() {
123
+ return sources().size > 0;
124
+ }
125
+
126
+ /**
127
+ * Dispatch one action, shaping failures exactly as `handleAction` does: an
128
+ * unknown tool and an unknown action are distinguishable, and adapter errors
129
+ * surface as plain messages rather than objects nobody can read on a phone.
130
+ */
131
+ export async function dispatchToolAction(toolId, action, params) {
132
+ const tools = getToolAdapters();
133
+ const tool = tools[toolId];
134
+ const fn = tool?.actions?.[action];
135
+ if (!fn) {
136
+ throw new Error(tool ? `Unknown action "${action}" on tool "${toolId}"` : `Unknown tool "${toolId}"`);
137
+ }
138
+ return await fn(params);
139
+ }
140
+
141
+ /**
142
+ * Wait for the registry to be populated, for the narrow window between first
143
+ * frame and `AutoExternalSync` mounting. Resolves false on timeout so the
144
+ * caller can say "Buoy tools are still starting up" instead of "unknown tool".
145
+ */
146
+ export async function waitForToolRegistry(timeoutMs = 2000) {
147
+ if (isToolRegistryReady()) return true;
148
+ const deadline = Date.now() + timeoutMs;
149
+ while (Date.now() < deadline) {
150
+ await new Promise(r => setTimeout(r, 50));
151
+ if (isToolRegistryReady()) return true;
152
+ }
153
+ return false;
154
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * toolUiBridge — let a tool ask the host to bring another tool back on screen.
5
+ *
6
+ * WHY THIS EXISTS. Buoy's tool overlays (the SIMULATED banner, the recording
7
+ * banner, the perf HUD) render as siblings of `AppHostProvider`, not inside it,
8
+ * so none of them can reach the host that knows how to open and restore tools.
9
+ * That is usually fine — an overlay's job is to be seen, not to navigate. It
10
+ * stops being fine the moment an overlay is the ONLY thing on screen and the
11
+ * next step lives inside a tool: the macro recorder minimizes Scenarios so a
12
+ * tester can drive the app, and when they stop, the review screen they need is
13
+ * behind a tool icon they have to know to hunt for.
14
+ *
15
+ * The alternative was moving every overlay inside the provider, which would
16
+ * re-order the stacking of six unrelated tools to fix one banner's button. This
17
+ * is the same global-seam shape `toolActionRegistry` already uses for the same
18
+ * reason: the capability lives inside a component tree, the caller must not
19
+ * import `@buoy-gg/core` (that package imports the tools, and a tool importing
20
+ * it back is a require cycle), so it is published through a global.
21
+ *
22
+ * A SET of openers, not one slot, for the reason the action registry keeps one:
23
+ * React can briefly mount two providers, or re-run an effect's setup before the
24
+ * previous cleanup, and a last-writer-wins slot lets the second registration
25
+ * silently blank the first.
26
+ */
27
+
28
+ const BRIDGE_KEY = "__buoyToolUiBridge";
29
+
30
+ /** Returns true when the tool was actually brought back on screen. */
31
+
32
+ function openers() {
33
+ const g = globalThis;
34
+ if (!g[BRIDGE_KEY]) g[BRIDGE_KEY] = new Set();
35
+ return g[BRIDGE_KEY];
36
+ }
37
+
38
+ /** Published by the host, inside the provider that owns open tools. */
39
+ export function registerToolOpener(opener) {
40
+ const set = openers();
41
+ set.add(opener);
42
+ return () => {
43
+ set.delete(opener);
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Ask the host to show a tool.
49
+ *
50
+ * Returns false when nothing could do it — no host mounted, or the tool isn't
51
+ * open for it to restore. Callers must treat that as a real answer and say so,
52
+ * rather than rendering a button that silently does nothing.
53
+ */
54
+ export function openBuoyTool(toolId) {
55
+ for (const opener of openers()) {
56
+ try {
57
+ if (opener(toolId)) return true;
58
+ } catch {
59
+ // A host that throws must not stop another from answering.
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ /** True when some host is listening — for deciding whether to offer the button. */
66
+ export function canOpenBuoyTools() {
67
+ return openers().size > 0;
68
+ }
@@ -25,6 +25,8 @@ export function CompactRow({
25
25
  testID,
26
26
  statusTestID,
27
27
  statusSublabelTestID,
28
+ primaryTestID,
29
+ secondaryTestID,
28
30
  accessibilityLabel
29
31
  }) {
30
32
  return /*#__PURE__*/_jsx(View, {
@@ -78,12 +80,14 @@ export function CompactRow({
78
80
  children: [/*#__PURE__*/_jsx(Text, {
79
81
  style: styles.queryHash,
80
82
  numberOfLines: isExpanded ? undefined : 2,
83
+ testID: primaryTestID,
81
84
  children: primaryText
82
85
  }), !isExpanded && (secondaryText || secondaryAccessory) ? /*#__PURE__*/_jsxs(View, {
83
86
  style: styles.secondaryRow,
84
87
  children: [secondaryText ? /*#__PURE__*/_jsx(Text, {
85
88
  style: styles.secondaryText,
86
89
  numberOfLines: 1,
90
+ testID: secondaryTestID,
87
91
  children: secondaryText
88
92
  }) : null, secondaryAccessory]
89
93
  }) : null]
@@ -19,7 +19,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
19
19
  export const DiffModeTabs = /*#__PURE__*/memo(function DiffModeTabs({
20
20
  tabs,
21
21
  activeTab,
22
- onTabChange
22
+ onTabChange,
23
+ testIDPrefix
23
24
  }) {
24
25
  return /*#__PURE__*/_jsx(View, {
25
26
  style: styles.container,
@@ -31,6 +32,13 @@ export const DiffModeTabs = /*#__PURE__*/memo(function DiffModeTabs({
31
32
  onPress: () => !isDisabled && onTabChange(tab.key),
32
33
  activeOpacity: isDisabled ? 1 : 0.7,
33
34
  disabled: isDisabled,
35
+ testID: testIDPrefix ? `${testIDPrefix}-${tab.key}` : undefined,
36
+ accessibilityRole: "button",
37
+ accessibilityLabel: tab.label,
38
+ accessibilityState: {
39
+ selected: isActive,
40
+ disabled: isDisabled
41
+ },
34
42
  children: /*#__PURE__*/_jsx(Text, {
35
43
  style: [styles.tabText, isActive && styles.tabTextActive, isDisabled && styles.tabTextDisabled],
36
44
  children: tab.label
@@ -40,7 +40,8 @@ export function PillBadge({
40
40
  color,
41
41
  children,
42
42
  icon,
43
- size = "md"
43
+ size = "md",
44
+ testID
44
45
  }) {
45
46
  const isSm = size === "sm";
46
47
  return /*#__PURE__*/_jsxs(View, {
@@ -55,6 +56,7 @@ export function PillBadge({
55
56
  style: [styles.pillText, isSm && styles.pillTextSm, {
56
57
  color
57
58
  }],
59
+ testID: testID,
58
60
  children: children
59
61
  })]
60
62
  });
@@ -182,10 +182,13 @@ const styles = StyleSheet.create({
182
182
  flex: 1,
183
183
  gap: 8,
184
184
  minHeight: 32,
185
- paddingLeft: 4,
186
- // Traffic-light window controls sit absolutely in this corner. Reserve
187
- // space so Copy / search / extra actions never paint under them.
188
- paddingRight: 56
185
+ paddingLeft: 4
186
+ // No right inset: the traffic-light window controls are cleared
187
+ // VERTICALLY — JsModal's drag-indicator strip owns their band (see
188
+ // WINDOW_CONTROLS_BAND) — so nothing here has to be pushed sideways to
189
+ // stay out from under them. `headerActions` already ends 4pt off the
190
+ // edge, matching the left, and hosts that render this header with no
191
+ // window controls at all (the desktop inline panels) get no phantom gap.
189
192
  },
190
193
  navigationButton: {
191
194
  padding: 4