@buoy-gg/redux 7.0.30 → 7.0.32

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 (44) hide show
  1. package/lib/commonjs/index.js +25 -0
  2. package/lib/commonjs/redux/components/ReduxActionButton.js +3 -1
  3. package/lib/commonjs/redux/components/ReduxActionDetailContent.js +60 -6
  4. package/lib/commonjs/redux/components/ReduxActionInfoView.js +61 -12
  5. package/lib/commonjs/redux/components/ReduxActionItem.js +20 -5
  6. package/lib/commonjs/redux/components/ReduxDetailViewToggle.js +7 -0
  7. package/lib/commonjs/redux/components/ReduxModal.js +169 -32
  8. package/lib/commonjs/redux/utils/activeStoreRegistry.js +31 -0
  9. package/lib/commonjs/redux/utils/autoInstrument.js +23 -40
  10. package/lib/commonjs/redux/utils/buoyReduxMiddleware.js +36 -9
  11. package/lib/commonjs/redux/utils/devtoolsHook.js +4 -0
  12. package/lib/commonjs/redux/utils/testIDs.js +75 -0
  13. package/lib/module/index.js +5 -0
  14. package/lib/module/redux/components/ReduxActionButton.js +3 -1
  15. package/lib/module/redux/components/ReduxActionDetailContent.js +60 -6
  16. package/lib/module/redux/components/ReduxActionInfoView.js +63 -14
  17. package/lib/module/redux/components/ReduxActionItem.js +20 -5
  18. package/lib/module/redux/components/ReduxDetailViewToggle.js +7 -0
  19. package/lib/module/redux/components/ReduxModal.js +172 -34
  20. package/lib/module/redux/utils/activeStoreRegistry.js +29 -0
  21. package/lib/module/redux/utils/autoInstrument.js +8 -40
  22. package/lib/module/redux/utils/buoyReduxMiddleware.js +32 -10
  23. package/lib/module/redux/utils/devtoolsHook.js +5 -1
  24. package/lib/module/redux/utils/testIDs.js +68 -0
  25. package/lib/typescript/index.d.ts +1 -0
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/lib/typescript/redux/components/ReduxActionButton.d.ts +2 -1
  28. package/lib/typescript/redux/components/ReduxActionButton.d.ts.map +1 -1
  29. package/lib/typescript/redux/components/ReduxActionDetailContent.d.ts.map +1 -1
  30. package/lib/typescript/redux/components/ReduxActionInfoView.d.ts.map +1 -1
  31. package/lib/typescript/redux/components/ReduxActionItem.d.ts +15 -0
  32. package/lib/typescript/redux/components/ReduxActionItem.d.ts.map +1 -1
  33. package/lib/typescript/redux/components/ReduxDetailViewToggle.d.ts.map +1 -1
  34. package/lib/typescript/redux/components/ReduxModal.d.ts.map +1 -1
  35. package/lib/typescript/redux/utils/activeStoreRegistry.d.ts +27 -0
  36. package/lib/typescript/redux/utils/activeStoreRegistry.d.ts.map +1 -1
  37. package/lib/typescript/redux/utils/autoInstrument.d.ts +7 -11
  38. package/lib/typescript/redux/utils/autoInstrument.d.ts.map +1 -1
  39. package/lib/typescript/redux/utils/buoyReduxMiddleware.d.ts +17 -3
  40. package/lib/typescript/redux/utils/buoyReduxMiddleware.d.ts.map +1 -1
  41. package/lib/typescript/redux/utils/devtoolsHook.d.ts.map +1 -1
  42. package/lib/typescript/redux/utils/testIDs.d.ts +51 -0
  43. package/lib/typescript/redux/utils/testIDs.d.ts.map +1 -0
  44. package/package.json +4 -4
@@ -9,34 +9,47 @@
9
9
 
10
10
  import { useState, useMemo, useRef, useCallback, useEffect } from "react";
11
11
  import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity } from "react-native";
12
- import { JsModal, ModalHeader, macOSColors, night, Search, Trash2, Power, X, Zap, CheckCircle, Copy, Lock, AlertTriangle, devToolsStorageKeys, copyToClipboard, ProUpgradeModal, PowerToggleButton, TickProvider, useLimit, TIER_LIMITS } from "@buoy-gg/shared-ui";
12
+ import { JsModal, ModalHeader, macOSColors, night, Search, Trash2, Power, X, Zap, CheckCircle, Copy, Lock, AlertTriangle, devToolsStorageKeys, copyToClipboard, ProUpgradeModal, PowerToggleButton, TickProvider, useLimit, TIER_LIMITS, windowDragProps } from "@buoy-gg/shared-ui";
13
13
  import { useIsPro } from "@buoy-gg/license";
14
14
  import { useReduxActions } from "../hooks/useReduxActions";
15
15
  import { reduxActionStore } from "../utils/reduxActionStore";
16
16
  import { useAutoInstrumentRedux } from "../hooks/useAutoInstrumentRedux";
17
17
  import { ReduxActionItem } from "./ReduxActionItem";
18
18
  import { ReduxActionDetailContent, ReduxActionDetailFooter } from "./ReduxActionDetailContent";
19
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
+ import { REDUX_TEST_ID, buildActionRowTestIDs } from "../utils/testIDs";
20
+
20
21
  /**
21
22
  * @deprecated Read the live limit with `useLimit("redux.actions")` — this
22
23
  * constant only ever reports the FREE tier's value and is blind to the
23
24
  * anonymous tier. Kept because it is part of the package's public exports.
24
25
  */
26
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
25
27
  export const FREE_TIER_ACTION_LIMIT = TIER_LIMITS["redux.actions"].free;
28
+ /**
29
+ * Why this takes the query as well as the enabled flag: "nothing was captured"
30
+ * and "nothing matched what you typed" are different problems, and an empty
31
+ * list under a live search reads as a dead tool. Naming the query back is what
32
+ * makes the difference legible without clearing the field to find out.
33
+ */
26
34
  function EmptyState({
27
- isEnabled
35
+ isEnabled,
36
+ query
28
37
  }) {
38
+ const isFiltered = query.length > 0;
29
39
  return /*#__PURE__*/_jsxs(View, {
30
40
  style: styles.emptyState,
41
+ testID: `${REDUX_TEST_ID}-empty`,
31
42
  children: [/*#__PURE__*/_jsx(Zap, {
32
43
  size: 32,
33
44
  color: night.color.textTertiary
34
45
  }), /*#__PURE__*/_jsx(Text, {
35
46
  style: styles.emptyTitle,
36
- children: "No Redux actions"
47
+ testID: `${REDUX_TEST_ID}-empty-title`,
48
+ children: isFiltered ? "No Matches" : "No Redux actions"
37
49
  }), /*#__PURE__*/_jsx(Text, {
38
50
  style: styles.emptyText,
39
- children: isEnabled ? "Actions will appear here as they are dispatched" : "Enable capture to start recording actions"
51
+ testID: `${REDUX_TEST_ID}-empty-text`,
52
+ children: isFiltered ? `No action type contains "${query}"` : isEnabled ? "Actions will appear here as they are dispatched" : "Enable capture to start recording actions"
40
53
  })]
41
54
  });
42
55
  }
@@ -79,14 +92,27 @@ export function ReduxModal({
79
92
  const lockedActionCount = useMemo(() => Math.max(0, filteredActions.length - actionLimit), [filteredActions.length, actionLimit]);
80
93
  const hasLockedActions = lockedActionCount > 0;
81
94
 
82
- // Selection state - track index for navigation
83
- const [selectedActionIndex, setSelectedActionIndex] = useState(null);
84
- const actionsRef = useRef([]);
85
-
86
- // Keep actions ref in sync (using visible actions for navigation)
87
- actionsRef.current = visibleActions;
88
-
89
- // Get selected action from index
95
+ // Selection state, held as an action ID rather than as a list position.
96
+ //
97
+ // This list grows at the HEAD: every dispatch prepends a row and pushes
98
+ // everything below it down one. An index therefore names a different action
99
+ // a moment after it is taken — so a detail page opened while the app was
100
+ // still dispatching silently became a detail page for some OTHER action,
101
+ // with no transition and nothing to say it had happened. A devtool that
102
+ // quietly swaps what it is showing you is worse than one that shows nothing.
103
+ //
104
+ // The index is still what the stepper and the diff's "previous action" are
105
+ // expressed in — both are genuinely about position — so it is derived here
106
+ // and re-derived whenever the list moves.
107
+ const [selectedActionId, setSelectedActionId] = useState(null);
108
+ const selectedActionIndex = useMemo(() => {
109
+ if (selectedActionId === null) return null;
110
+ const index = visibleActions.findIndex(a => a.id === selectedActionId);
111
+ // Below zero means the action is gone — cleared, or aged out past the tier
112
+ // cap. Falling back to the list is the honest outcome; the alternative is
113
+ // showing whatever moved into its place.
114
+ return index >= 0 ? index : null;
115
+ }, [selectedActionId, visibleActions]);
90
116
  const selectedAction = selectedActionIndex !== null ? visibleActions[selectedActionIndex] : null;
91
117
 
92
118
  // Notify the host on selection (keyed by action id so re-renders with the
@@ -110,7 +136,7 @@ export function ReduxModal({
110
136
  const index = visibleActions.findIndex(a => a.id === initialSelectedActionId);
111
137
  if (index < 0) return;
112
138
  appliedInitialSelectionRef.current = initialSelectedActionId;
113
- setSelectedActionIndex(index);
139
+ setSelectedActionId(initialSelectedActionId);
114
140
  }, [initialSelectedActionId, visibleActions]);
115
141
  const [searchText, setSearchText] = useState("");
116
142
  const [isSearchActive, setIsSearchActive] = useState(false);
@@ -126,19 +152,33 @@ export function ReduxModal({
126
152
  searchText: text
127
153
  }));
128
154
  };
155
+
156
+ /** Drop the query AND put the header back — the ✕ means "stop filtering". */
157
+ const closeSearch = useCallback(() => {
158
+ setSearchText("");
159
+ setFilter(prev => ({
160
+ ...prev,
161
+ searchText: ""
162
+ }));
163
+ setIsSearchActive(false);
164
+ }, [setFilter]);
129
165
  const handleActionPress = useCallback(action => {
130
- const index = actionsRef.current.findIndex(a => a.id === action.id);
131
- setSelectedActionIndex(index >= 0 ? index : 0);
166
+ setSelectedActionId(action.id);
132
167
  }, []);
133
168
  const handleUpgradePress = useCallback(() => {
134
169
  setShowProModal(true);
135
170
  }, []);
136
171
  const handleBack = useCallback(() => {
137
- setSelectedActionIndex(null);
172
+ setSelectedActionId(null);
138
173
  }, []);
174
+
175
+ // The stepper and the async timeline both navigate by POSITION; the
176
+ // selection is kept by identity, so the position is resolved to an id here,
177
+ // once, at the moment of the tap.
139
178
  const handleIndexChange = useCallback(newIndex => {
140
- setSelectedActionIndex(newIndex);
141
- }, []);
179
+ const next = visibleActions[newIndex];
180
+ if (next) setSelectedActionId(next.id);
181
+ }, [visibleActions]);
142
182
  const handleCopyAll = async () => {
143
183
  if (!isPro) {
144
184
  setShowProModal(true);
@@ -160,14 +200,49 @@ export function ReduxModal({
160
200
 
161
201
  // FlatList optimization
162
202
  const keyExtractor = useCallback(item => item.id, []);
203
+
204
+ // An action row's handle carries an ordinal among rows of the SAME type, and
205
+ // an ordinal is a fact about the list rather than about the row — see
206
+ // ../utils/testIDs.ts for why a type alone cannot address a redux row.
207
+ const rowTestIDs = useMemo(() => buildActionRowTestIDs(visibleActions), [visibleActions]);
208
+
209
+ // How many captured actions share each requestId, i.e. how far along each
210
+ // async operation is.
211
+ //
212
+ // Computed HERE rather than in the row, and passed down, because a row's
213
+ // async badge is a fact about OTHER rows. `pending` is captured and drawn
214
+ // while it is still alone in the world; `fulfilled` arrives a moment later
215
+ // and makes the earlier row's badge wrong. `ReduxActionItem` is memoized on
216
+ // its props and the action object it holds never changes identity, so
217
+ // nothing about that second arrival reached the first row — the pending half
218
+ // of every settled thunk permanently showed no flow badge, which is exactly
219
+ // backwards from what the badge is for.
220
+ //
221
+ // One pass over the store instead of a per-row scan: the previous shape was
222
+ // O(rows x actions) at 200 actions, on a list that re-renders on every
223
+ // dispatch.
224
+ const linkedCounts = useMemo(() => {
225
+ const counts = new Map();
226
+ for (const action of reduxActionStore.getActions()) {
227
+ const requestId = action.meta?.requestId;
228
+ if (!requestId) continue;
229
+ counts.set(requestId, (counts.get(requestId) ?? 0) + 1);
230
+ }
231
+ return counts;
232
+ // `filteredActions` takes a new identity whenever the store notifies, which
233
+ // is the only moment these counts can change.
234
+ }, [filteredActions]);
163
235
  const renderItem = useCallback(({
164
236
  item
165
237
  }) => {
238
+ const requestId = item.meta?.requestId;
166
239
  return /*#__PURE__*/_jsx(ReduxActionItem, {
167
240
  action: item,
168
- onPress: handleActionPress
241
+ onPress: handleActionPress,
242
+ linkedCount: requestId ? linkedCounts.get(requestId) ?? 0 : 0,
243
+ testID: rowTestIDs.get(item.id)
169
244
  });
170
- }, [handleActionPress]);
245
+ }, [handleActionPress, rowTestIDs, linkedCounts]);
171
246
  const renderHeaderContent = () => {
172
247
  // Detail view header - show action type
173
248
  if (selectedAction) {
@@ -176,7 +251,8 @@ export function ReduxModal({
176
251
  onBack: handleBack
177
252
  }), /*#__PURE__*/_jsx(ModalHeader.Content, {
178
253
  title: selectedAction.type,
179
- centered: true
254
+ centered: true,
255
+ testID: `${REDUX_TEST_ID}-detail-title`
180
256
  })]
181
257
  });
182
258
  }
@@ -189,27 +265,42 @@ export function ReduxModal({
189
265
  title: "",
190
266
  children: isSearchActive ? /*#__PURE__*/_jsxs(View, {
191
267
  style: styles.headerSearchContainer,
268
+ testID: `${REDUX_TEST_ID}-search`,
192
269
  children: [/*#__PURE__*/_jsx(Search, {
193
270
  size: 14,
194
271
  color: night.color.textSecondary
195
272
  }), /*#__PURE__*/_jsx(TextInput, {
196
273
  ref: searchInputRef,
274
+ testID: `${REDUX_TEST_ID}-search-field`,
197
275
  style: styles.headerSearchInput,
198
276
  placeholder: "Search action types...",
199
277
  placeholderTextColor: night.color.textTertiary,
200
278
  value: searchText,
201
279
  onChangeText: handleSearch,
202
- onSubmitEditing: () => setIsSearchActive(false),
203
- onBlur: () => setIsSearchActive(false),
280
+ onSubmitEditing: () => searchInputRef.current?.blur()
281
+ // Stay open while the query still filters the list.
282
+ //
283
+ // Collapsing it on blur hid the reason rows were missing: the
284
+ // header went back to its chips — which count the WHOLE
285
+ // capture, not the filtered list — while the list stayed
286
+ // narrowed, so the tool showed "4 actions" above a single row
287
+ // and offered nothing to explain the gap. The only way back
288
+ // was to reopen a field the user had no reason to think was
289
+ // still armed.
290
+ ,
291
+ onBlur: () => {
292
+ if (searchText.length === 0) setIsSearchActive(false);
293
+ },
294
+ accessibilityLabel: "Search action types",
204
295
  autoCapitalize: "none",
205
296
  autoCorrect: false,
206
297
  returnKeyType: "search"
207
- }), searchText.length > 0 && /*#__PURE__*/_jsx(TouchableOpacity, {
208
- onPress: () => {
209
- handleSearch("");
210
- setIsSearchActive(false);
211
- },
298
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
299
+ onPress: closeSearch,
212
300
  style: styles.headerSearchClear,
301
+ testID: `${REDUX_TEST_ID}-search-clear`,
302
+ accessibilityRole: "button",
303
+ accessibilityLabel: "Close search",
213
304
  children: /*#__PURE__*/_jsx(X, {
214
305
  size: 14,
215
306
  color: night.color.textSecondary
@@ -223,6 +314,12 @@ export function ReduxModal({
223
314
  ...prev,
224
315
  onlyWithChanges: false
225
316
  })),
317
+ testID: `${REDUX_TEST_ID}-chip-all`,
318
+ accessibilityRole: "button",
319
+ accessibilityLabel: `Show all actions, ${stats.totalActions} captured`,
320
+ accessibilityState: {
321
+ selected: !filter.onlyWithChanges
322
+ },
226
323
  children: [/*#__PURE__*/_jsx(Text, {
227
324
  style: styles.headerChipLabel,
228
325
  children: "ALL"
@@ -230,6 +327,7 @@ export function ReduxModal({
230
327
  style: [styles.headerChipValue, {
231
328
  color: night.color.text
232
329
  }],
330
+ testID: `${REDUX_TEST_ID}-chip-all-value`,
233
331
  children: stats.totalActions
234
332
  })]
235
333
  }), /*#__PURE__*/_jsxs(TouchableOpacity, {
@@ -238,6 +336,12 @@ export function ReduxModal({
238
336
  ...prev,
239
337
  onlyWithChanges: true
240
338
  })),
339
+ testID: `${REDUX_TEST_ID}-chip-changed`,
340
+ accessibilityRole: "button",
341
+ accessibilityLabel: `Show only actions that changed state, ${stats.actionsWithChanges} of them`,
342
+ accessibilityState: {
343
+ selected: !!filter.onlyWithChanges
344
+ },
241
345
  children: [/*#__PURE__*/_jsx(Zap, {
242
346
  size: 12,
243
347
  color: macOSColors.semantic.warning
@@ -245,10 +349,13 @@ export function ReduxModal({
245
349
  style: [styles.headerChipValue, {
246
350
  color: macOSColors.semantic.warning
247
351
  }],
352
+ testID: `${REDUX_TEST_ID}-chip-changed-value`,
248
353
  children: stats.actionsWithChanges
249
354
  })]
250
355
  }), /*#__PURE__*/_jsxs(View, {
251
356
  style: styles.headerChip,
357
+ testID: `${REDUX_TEST_ID}-chip-types`,
358
+ accessibilityLabel: `${stats.uniqueActionTypes} distinct action types`,
252
359
  children: [/*#__PURE__*/_jsx(CheckCircle, {
253
360
  size: 12,
254
361
  color: macOSColors.semantic.success
@@ -256,22 +363,32 @@ export function ReduxModal({
256
363
  style: [styles.headerChipValue, {
257
364
  color: macOSColors.semantic.success
258
365
  }],
366
+ testID: `${REDUX_TEST_ID}-chip-types-value`,
259
367
  children: stats.uniqueActionTypes
260
368
  })]
261
369
  })]
262
370
  })
263
371
  }), /*#__PURE__*/_jsxs(ModalHeader.Actions, {
264
- children: [/*#__PURE__*/_jsx(TouchableOpacity, {
372
+ children: [!isSearchActive && /*#__PURE__*/_jsx(TouchableOpacity, {
265
373
  onPress: () => setIsSearchActive(true),
266
- style: styles.headerActionButton,
374
+ style: [styles.headerActionButton, searchText.length > 0 && styles.headerActionButtonActive],
375
+ testID: `${REDUX_TEST_ID}-search-open`,
376
+ accessibilityRole: "button",
377
+ accessibilityLabel: "Search action types",
267
378
  children: /*#__PURE__*/_jsx(Search, {
268
379
  size: 14,
269
- color: night.color.textSecondary
380
+ color: searchText.length > 0 ? night.color.accent : night.color.textSecondary
270
381
  })
271
382
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
272
383
  onPress: handleCopyAll,
273
384
  style: [styles.headerActionButton, filteredActions.length === 0 && styles.headerActionButtonDisabled],
274
385
  disabled: filteredActions.length === 0,
386
+ testID: `${REDUX_TEST_ID}-copy`,
387
+ accessibilityRole: "button",
388
+ accessibilityLabel: "Copy all actions",
389
+ accessibilityState: {
390
+ disabled: filteredActions.length === 0
391
+ },
275
392
  children: /*#__PURE__*/_jsx(Copy, {
276
393
  size: 14,
277
394
  color: filteredActions.length > 0 ? night.color.textSecondary : night.color.textTertiary
@@ -279,11 +396,18 @@ export function ReduxModal({
279
396
  }), /*#__PURE__*/_jsx(PowerToggleButton, {
280
397
  isEnabled: isEnabled,
281
398
  onToggle: toggleCapture,
282
- accessibilityLabel: "Toggle Redux action capture"
399
+ accessibilityLabel: "Toggle Redux action capture",
400
+ testID: `${REDUX_TEST_ID}-power`
283
401
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
284
402
  onPress: clearActions,
285
403
  style: [styles.headerActionButton, filteredActions.length === 0 && styles.headerActionButtonDisabled],
286
404
  disabled: filteredActions.length === 0,
405
+ testID: `${REDUX_TEST_ID}-clear`,
406
+ accessibilityRole: "button",
407
+ accessibilityLabel: "Clear actions",
408
+ accessibilityState: {
409
+ disabled: filteredActions.length === 0
410
+ },
287
411
  children: /*#__PURE__*/_jsx(Trash2, {
288
412
  size: 14,
289
413
  color: filteredActions.length > 0 ? night.color.textTertiary : night.color.textTertiary
@@ -319,6 +443,7 @@ export function ReduxModal({
319
443
  }) : /*#__PURE__*/_jsxs(_Fragment, {
320
444
  children: [instrumentError && !isInstrumented && !reduxActionStore.isCaptureSuppressed() && /*#__PURE__*/_jsxs(View, {
321
445
  style: styles.errorBanner,
446
+ testID: `${REDUX_TEST_ID}-banner-error`,
322
447
  children: [/*#__PURE__*/_jsx(AlertTriangle, {
323
448
  size: 14,
324
449
  color: night.color.danger
@@ -328,6 +453,7 @@ export function ReduxModal({
328
453
  })]
329
454
  }), !isEnabled && isInstrumented && /*#__PURE__*/_jsxs(View, {
330
455
  style: styles.disabledBanner,
456
+ testID: `${REDUX_TEST_ID}-banner-disabled`,
331
457
  children: [/*#__PURE__*/_jsx(Power, {
332
458
  size: 14,
333
459
  color: night.color.warning
@@ -337,6 +463,7 @@ export function ReduxModal({
337
463
  })]
338
464
  }), captureMechanism === "patch" && !reduxActionStore.isCaptureSuppressed() && /*#__PURE__*/_jsxs(View, {
339
465
  style: styles.disabledBanner,
466
+ testID: `${REDUX_TEST_ID}-banner-patch`,
340
467
  children: [/*#__PURE__*/_jsx(AlertTriangle, {
341
468
  size: 14,
342
469
  color: night.color.warning
@@ -348,11 +475,15 @@ export function ReduxModal({
348
475
  style: styles.lockedBanner,
349
476
  onPress: handleUpgradePress,
350
477
  activeOpacity: 0.8,
478
+ testID: `${REDUX_TEST_ID}-locked`,
479
+ accessibilityRole: "button",
480
+ accessibilityLabel: `${lockedActionCount} older ${lockedActionCount === 1 ? "action" : "actions"} locked. Upgrade to see them.`,
351
481
  children: [/*#__PURE__*/_jsx(Lock, {
352
482
  size: 14,
353
483
  color: night.color.accent
354
484
  }), /*#__PURE__*/_jsxs(Text, {
355
485
  style: styles.lockedText,
486
+ testID: `${REDUX_TEST_ID}-locked-text`,
356
487
  children: [lockedActionCount, " older ", lockedActionCount === 1 ? "action" : "actions", " locked"]
357
488
  }), /*#__PURE__*/_jsx(View, {
358
489
  style: styles.upgradeBadge,
@@ -363,6 +494,7 @@ export function ReduxModal({
363
494
  })]
364
495
  }), visibleActions.length > 0 ? /*#__PURE__*/_jsx(FlatList, {
365
496
  ref: flatListRef,
497
+ testID: `${REDUX_TEST_ID}-list`,
366
498
  data: visibleActions,
367
499
  renderItem: renderItem,
368
500
  keyExtractor: keyExtractor,
@@ -374,7 +506,8 @@ export function ReduxModal({
374
506
  windowSize: 5,
375
507
  updateCellsBatchingPeriod: 50
376
508
  }) : /*#__PURE__*/_jsx(EmptyState, {
377
- isEnabled: isEnabled
509
+ isEnabled: isEnabled,
510
+ query: searchText
378
511
  })]
379
512
  })
380
513
  });
@@ -392,6 +525,7 @@ export function ReduxModal({
392
525
  style: styles.inlineRoot,
393
526
  children: [/*#__PURE__*/_jsx(View, {
394
527
  style: styles.inlineHeader,
528
+ ...windowDragProps,
395
529
  children: renderHeaderContent()
396
530
  }), body, footerNode]
397
531
  }), upgradeModal]
@@ -520,6 +654,10 @@ const styles = StyleSheet.create({
520
654
  headerActionButtonDisabled: {
521
655
  opacity: 0.55
522
656
  },
657
+ headerActionButtonActive: {
658
+ backgroundColor: night.color.accentSoft,
659
+ borderColor: night.color.accentBorderStrong
660
+ },
523
661
  startButton: {
524
662
  backgroundColor: macOSColors.semantic.successBackground,
525
663
  borderColor: macOSColors.semantic.success + "40"
@@ -28,6 +28,7 @@ export const ENHANCER_SYMBOL = Symbol.for("@@buoy/enhancerInstrumented");
28
28
  let activeStore = null;
29
29
  let mechanism = null;
30
30
  let diagnostic = null;
31
+ let timeTravelReducer = false;
31
32
  export function registerActiveStore(store, mech) {
32
33
  activeStore = store;
33
34
  mechanism = mech;
@@ -46,6 +47,33 @@ export function getCaptureMechanism() {
46
47
  return mechanism;
47
48
  }
48
49
 
50
+ /**
51
+ * Record that a reducer able to serve `BUOY_JUMP_TO_STATE` is in the store.
52
+ *
53
+ * Called by the devtools-hook enhancer and by `withBuoyDevTools` — the only
54
+ * two things that wrap the root reducer. Capture and time travel are separate
55
+ * capabilities and they are wired separately: a store can be fully captured
56
+ * through `buoyReduxMiddleware` and still have no way to restore a state,
57
+ * because middleware sits ABOVE the reducer and cannot replace what it
58
+ * returns.
59
+ */
60
+ export function markTimeTravelReducer() {
61
+ timeTravelReducer = true;
62
+ }
63
+
64
+ /**
65
+ * Whether pressing JUMP can actually do anything.
66
+ *
67
+ * Worth a first-class answer rather than a try-and-see, because the failure is
68
+ * invisible: `jumpToState` dispatches into a store that simply does not
69
+ * recognise the action, the reducer returns the state it already had, and the
70
+ * screen does not move. From the outside that is indistinguishable from a
71
+ * devtool that is broken.
72
+ */
73
+ export function isTimeTravelAvailable() {
74
+ return timeTravelReducer && activeStore !== null;
75
+ }
76
+
49
77
  /**
50
78
  * Recorded by the auto-instrument hook so non-React surfaces (sync adapter,
51
79
  * MCP) can report WHY there's no store instead of a generic failure.
@@ -58,6 +86,7 @@ export function getInstrumentationStatus() {
58
86
  mechanism,
59
87
  capture: mechanism === "enhancer" || mechanism === "middleware" ? "full" : mechanism === "patch" ? "top-level-only" : "none",
60
88
  hasStore: activeStore !== null,
89
+ timeTravel: isTimeTravelAvailable(),
61
90
  diagnostic
62
91
  };
63
92
  }
@@ -15,8 +15,8 @@
15
15
  * capture as "top-level-only" — see activeStoreRegistry.getInstrumentationStatus.
16
16
  */
17
17
 
18
- import { reduxActionStore, isEvictedState } from "./reduxActionStore";
19
- import { BUOY_JUMP_TO_STATE, isMiddlewareActive } from "./buoyReduxMiddleware";
18
+ import { reduxActionStore } from "./reduxActionStore";
19
+ import { isMiddlewareActive } from "./buoyReduxMiddleware";
20
20
  import { registerActiveStore, unregisterActiveStore, getActiveStore as getRegisteredStore, ENHANCER_SYMBOL } from "./activeStoreRegistry";
21
21
 
22
22
  // Re-exported for non-React callers (sync adapter, app code).
@@ -172,47 +172,15 @@ export function registerReduxStore(store, options = {}) {
172
172
  // Time-Travel / Introspection
173
173
  // ============================================
174
174
 
175
- let warnedJumpUnsupported = false;
176
-
177
175
  /**
178
- * Jump to a specific state (time-travel).
176
+ * Time travel lives in buoyReduxMiddleware.ts and is re-exported here.
179
177
  *
180
- * Real on the enhancer path (reducer is wrapped from creation) and on the
181
- * middleware path when the app used `withBuoyDevTools`. On the bare patch
182
- * path there's no reducer wrapper, so the jump action is ignored — we detect
183
- * that and warn once instead of pretending it worked.
184
- */
185
- export function jumpToState(state) {
186
- if (isEvictedState(state)) {
187
- console.warn("[BuoyRedux] Cannot jump - this action's state was evicted (raw state is retained for recent actions only)");
188
- return;
189
- }
190
- const store = getRegisteredStore();
191
- if (!store) {
192
- console.warn("[BuoyRedux] Cannot jump to state - no store instrumented");
193
- return;
194
- }
195
- store.dispatch({
196
- type: BUOY_JUMP_TO_STATE,
197
- payload: state
198
- });
199
- if (typeof __DEV__ !== "undefined" && __DEV__ && !warnedJumpUnsupported && store.getState() !== state) {
200
- warnedJumpUnsupported = true;
201
- console.warn("[BuoyRedux] Time-travel jump was ignored by the store. Wrap your root " + "reducer with withBuoyDevTools(), or ensure @buoy-gg/redux is imported " + "before your store is created (enhancer path) for real time travel.");
202
- }
203
- }
204
-
205
- /**
206
- * Replay an action (dispatch it again)
178
+ * It used to be reimplemented in this file, and the two copies drifted: this
179
+ * one learned to notice that a jump had been ignored, the one the modal's JUMP
180
+ * button actually calls never did. So the detection existed and no user could
181
+ * ever have seen it. One implementation, one behaviour.
207
182
  */
208
- export function replayAction(action) {
209
- const store = getRegisteredStore();
210
- if (store) {
211
- store.dispatch(action);
212
- } else {
213
- console.warn("[BuoyRedux] Cannot replay action - no store instrumented");
214
- }
215
- }
183
+ export { jumpToState, replayAction, isTimeTravelAvailable } from "./buoyReduxMiddleware";
216
184
 
217
185
  /**
218
186
  * Get the currently active (instrumented) store.
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { reduxActionStore, isEvictedState } from "./reduxActionStore";
20
- import { registerActiveStore, getActiveStore } from "./activeStoreRegistry";
20
+ import { registerActiveStore, getActiveStore, markTimeTravelReducer, isTimeTravelAvailable } from "./activeStoreRegistry";
21
21
 
22
22
  // ============================================
23
23
  // Middleware Active Tracking
@@ -43,9 +43,18 @@ export const BUOY_REPLAY_ACTION = "@@buoy/REPLAY_ACTION";
43
43
  /**
44
44
  * Jump to a specific state (time-travel).
45
45
  *
46
- * Registry-backed, so it works on EVERY capture path (enhancer, middleware,
47
- * patch) — previously this only worked when the middleware was installed,
48
- * which silently broke the modal's Jump button on the zero-config path.
46
+ * Registry-backed, so it reaches the store on every capture path — but
47
+ * REACHING the store is not the same as being able to jump, and conflating the
48
+ * two is what made this fail invisibly. A jump is an action like any other: it
49
+ * only does something if a reducer handles it, and the only two things that
50
+ * install such a reducer are the devtools-hook enhancer and `withBuoyDevTools`.
51
+ * An app that captures through `buoyReduxMiddleware` alone dispatches the jump
52
+ * into a store that has never heard of it, gets its own state back, and shows
53
+ * a screen that does not move — with no error, because nothing went wrong.
54
+ *
55
+ * So the capability is checked up front and refused out loud. The UI asks the
56
+ * same question (`isTimeTravelAvailable`) and disables the button, which is the
57
+ * half a warning cannot do.
49
58
  */
50
59
  export function jumpToState(state) {
51
60
  if (isEvictedState(state)) {
@@ -53,16 +62,26 @@ export function jumpToState(state) {
53
62
  return;
54
63
  }
55
64
  const store = getActiveStore();
56
- if (store) {
57
- store.dispatch({
58
- type: BUOY_JUMP_TO_STATE,
59
- payload: state
60
- });
61
- } else {
65
+ if (!store) {
62
66
  console.warn("[BuoyRedux] Cannot jump to state - no store instrumented");
67
+ return;
63
68
  }
69
+ if (!isTimeTravelAvailable()) {
70
+ console.warn("[BuoyRedux] Cannot jump to state - the root reducer cannot serve a " + "state jump. Capture and time travel are wired separately: either put " + "`import '@buoy-gg/redux';` first in your app entry (so Buoy is the " + "store enhancer), or wrap your root reducer with withBuoyDevTools().");
71
+ return;
72
+ }
73
+ store.dispatch({
74
+ type: BUOY_JUMP_TO_STATE,
75
+ payload: state
76
+ });
64
77
  }
65
78
 
79
+ /**
80
+ * Whether the JUMP affordance can do anything in this app. Re-exported from
81
+ * the registry so hosts have one import for the whole time-travel surface.
82
+ */
83
+ export { isTimeTravelAvailable } from "./activeStoreRegistry";
84
+
66
85
  /**
67
86
  * Replay an action (dispatch it again)
68
87
  */
@@ -87,6 +106,9 @@ export function replayAction(action) {
87
106
  * ```
88
107
  */
89
108
  export function withBuoyDevTools(reducer) {
109
+ // The other half of the time-travel wiring (see jumpToState): this is one of
110
+ // exactly two places that can answer BUOY_JUMP_TO_STATE.
111
+ markTimeTravelReducer();
90
112
  return (state, action) => {
91
113
  // Handle jump to state action
92
114
  if (action.type === BUOY_JUMP_TO_STATE) {
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { reduxActionStore } from "./reduxActionStore";
29
29
  import { BUOY_JUMP_TO_STATE, isMiddlewareActive } from "./buoyReduxMiddleware";
30
- import { registerActiveStore, ENHANCER_SYMBOL } from "./activeStoreRegistry";
30
+ import { registerActiveStore, markTimeTravelReducer, ENHANCER_SYMBOL } from "./activeStoreRegistry";
31
31
  /** Standard redux compose. */
32
32
  function composeFns(...funcs) {
33
33
  if (funcs.length === 0) return arg => arg;
@@ -37,6 +37,10 @@ function composeFns(...funcs) {
37
37
 
38
38
  /** Wrap a reducer so BUOY_JUMP_TO_STATE performs a real state jump. */
39
39
  function wrapReducer(reducer) {
40
+ // Announce the capability, not just perform it: with nothing wrapped, a jump
41
+ // is dispatched, ignored, and silent — so the UI needs to know in advance
42
+ // whether the button it is drawing can keep its promise.
43
+ markTimeTravelReducer();
40
44
  return (state, action) => {
41
45
  if (action && action.type === BUOY_JUMP_TO_STATE) {
42
46
  return action.payload;