pycascadeui 3.3.1__tar.gz → 3.3.3__tar.gz

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 (135) hide show
  1. {pycascadeui-3.3.1/pycascadeui.egg-info → pycascadeui-3.3.3}/PKG-INFO +2 -1
  2. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/README.md +1 -0
  3. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/__init__.py +27 -1
  4. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/devtools.py +143 -42
  5. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/manager.py +12 -25
  6. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/actions.py +13 -3
  7. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/reducers.py +2 -0
  8. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/store.py +52 -0
  9. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/types.py +1 -0
  10. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/_interaction.py +36 -5
  11. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/_navigation.py +98 -24
  12. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/base.py +125 -22
  13. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/layout.py +25 -6
  14. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/persistent.py +9 -3
  15. {pycascadeui-3.3.1 → pycascadeui-3.3.3/pycascadeui.egg-info}/PKG-INFO +2 -1
  16. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/pyproject.toml +1 -1
  17. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_auto_defer.py +91 -0
  18. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_devtools.py +140 -3
  19. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_layout_view.py +155 -0
  20. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_navigation.py +164 -0
  21. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_rebuild_callback.py +19 -18
  22. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_reducers.py +17 -0
  23. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_state_store.py +123 -0
  24. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_undo.py +12 -4
  25. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_view_init.py +42 -0
  26. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/LICENSE +0 -0
  27. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/__main__.py +0 -0
  28. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/__init__.py +0 -0
  29. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/base.py +0 -0
  30. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/buttons.py +0 -0
  31. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/inputs.py +0 -0
  32. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/patterns/__init__.py +0 -0
  33. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/patterns/v1.py +0 -0
  34. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/patterns/v2.py +0 -0
  35. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/selects.py +0 -0
  36. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/types.py +0 -0
  37. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/v1_composition.py +0 -0
  38. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/components/wrappers.py +0 -0
  39. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/exceptions.py +0 -0
  40. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/__init__.py +0 -0
  41. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/backends/__init__.py +0 -0
  42. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/backends/memory.py +0 -0
  43. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/backends/postgres.py +0 -0
  44. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/backends/sqlite.py +0 -0
  45. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/config.py +0 -0
  46. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/migrations.py +0 -0
  47. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/protocols.py +0 -0
  48. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/schema.py +0 -0
  49. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/persistence/schema_postgres.py +0 -0
  50. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/py.typed +0 -0
  51. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/setup.py +0 -0
  52. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/__init__.py +0 -0
  53. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/computed.py +0 -0
  54. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/middleware/__init__.py +0 -0
  55. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/middleware/logging.py +0 -0
  56. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/middleware/persistence.py +0 -0
  57. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/middleware/undo.py +0 -0
  58. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/singleton.py +0 -0
  59. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/state/slots.py +0 -0
  60. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/theming/__init__.py +0 -0
  61. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/theming/context.py +0 -0
  62. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/theming/core.py +0 -0
  63. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/theming/themes.py +0 -0
  64. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/tracing.py +0 -0
  65. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/__init__.py +0 -0
  66. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/coercion.py +0 -0
  67. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/decorators.py +0 -0
  68. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/errors.py +0 -0
  69. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/fetch.py +0 -0
  70. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/logging.py +0 -0
  71. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/strings.py +0 -0
  72. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/utils/tasks.py +0 -0
  73. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/validation.py +0 -0
  74. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/__init__.py +0 -0
  75. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/_placement.py +0 -0
  76. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/__init__.py +0 -0
  77. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/form.py +0 -0
  78. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/leaderboard.py +0 -0
  79. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/menu.py +0 -0
  80. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/paginated.py +0 -0
  81. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/roles.py +0 -0
  82. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/tabs.py +0 -0
  83. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/types.py +0 -0
  84. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/patterns/wizard.py +0 -0
  85. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/cascadeui/views/view.py +0 -0
  86. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/pycascadeui.egg-info/SOURCES.txt +0 -0
  87. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/pycascadeui.egg-info/dependency_links.txt +0 -0
  88. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/pycascadeui.egg-info/requires.txt +0 -0
  89. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/pycascadeui.egg-info/top_level.txt +0 -0
  90. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/setup.cfg +0 -0
  91. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_backends.py +0 -0
  92. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_backends_postgres.py +0 -0
  93. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_backends_raw_sql.py +0 -0
  94. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_batching.py +0 -0
  95. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_button_grid.py +0 -0
  96. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_components.py +0 -0
  97. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_computed.py +0 -0
  98. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_dynamic_persistent_button.py +0 -0
  99. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_emoji_grid.py +0 -0
  100. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_ephemeral_refresh.py +0 -0
  101. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_fetch.py +0 -0
  102. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_five_pillars.py +0 -0
  103. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_form_patterns.py +0 -0
  104. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_hooks.py +0 -0
  105. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_input_coercion.py +0 -0
  106. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_inputs.py +0 -0
  107. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_instance_limit.py +0 -0
  108. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_layout_pagination.py +0 -0
  109. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_layout_patterns.py +0 -0
  110. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_layout_persistent.py +0 -0
  111. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_leaderboard_patterns.py +0 -0
  112. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_menu_patterns.py +0 -0
  113. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_message_cleanup.py +0 -0
  114. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_middleware.py +0 -0
  115. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_nav_version.py +0 -0
  116. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_navigation_artifact_restore.py +0 -0
  117. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_owner_only.py +0 -0
  118. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_paginated_patterns.py +0 -0
  119. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_pagination.py +0 -0
  120. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_persistence.py +0 -0
  121. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_persistence_middleware.py +0 -0
  122. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_persistent_views.py +0 -0
  123. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_pillar_isolation.py +0 -0
  124. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_placement.py +0 -0
  125. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_roles_patterns.py +0 -0
  126. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_scoping.py +0 -0
  127. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_selects.py +0 -0
  128. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_state_slots.py +0 -0
  129. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_tab_patterns.py +0 -0
  130. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_theming.py +0 -0
  131. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_update_coalescing.py +0 -0
  132. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_v2_helpers.py +0 -0
  133. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_validation.py +0 -0
  134. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_wizard_patterns.py +0 -0
  135. {pycascadeui-3.3.1 → pycascadeui-3.3.3}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.3.1
3
+ Version: 3.3.3
4
4
  Summary: Redux-inspired UI framework for discord.py
5
5
  Author-email: HollowTheSilver <hollow@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -665,6 +665,7 @@ for row in rows:
665
665
  - Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
666
666
  - Interaction serialization so rapid clicks process sequentially
667
667
  - Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
668
+ - `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
668
669
  - Silent snowflake coercion at every public boundary
669
670
  - Class-attribute validation at subclass-definition time
670
671
 
@@ -622,6 +622,7 @@ for row in rows:
622
622
  - Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
623
623
  - Interaction serialization so rapid clicks process sequentially
624
624
  - Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
625
+ - `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
625
626
  - Silent snowflake coercion at every public boundary
626
627
  - Class-attribute validation at subclass-definition time
627
628
 
@@ -12,6 +12,14 @@ import logging as _logging
12
12
  _logging.getLogger(__name__).addHandler(_logging.NullHandler())
13
13
 
14
14
  from .components.base import DynamicPersistentButton, StatefulButton, StatefulSelect
15
+ from .components.buttons import (
16
+ DangerButton,
17
+ LinkButton,
18
+ PrimaryButton,
19
+ SecondaryButton,
20
+ SuccessButton,
21
+ ToggleButton,
22
+ )
15
23
  from .components.inputs import Checkbox, CheckboxGroup, FileUpload, Modal, RadioGroup, TextInput
16
24
  from .components.patterns import (
17
25
  ConfirmationButtons,
@@ -40,6 +48,13 @@ from .components.patterns import (
40
48
  toggle_button,
41
49
  toggle_section,
42
50
  )
51
+ from .components.selects import (
52
+ ChannelSelect,
53
+ Dropdown,
54
+ MentionableSelect,
55
+ RoleSelect,
56
+ UserSelect,
57
+ )
43
58
  from .components.types import EmojiInput, MediaInput
44
59
  from .components.v1_composition import CompositeComponent, get_component, register_component
45
60
  from .components.wrappers import with_confirmation, with_cooldown, with_loading_state
@@ -132,7 +147,7 @@ if "SQLiteBackend" in _persistence_all:
132
147
  # // ========================================( Script )======================================== // #
133
148
 
134
149
 
135
- __version__ = "3.3.1"
150
+ __version__ = "3.3.3"
136
151
 
137
152
  # Export public API
138
153
  __all__ = [
@@ -179,6 +194,17 @@ __all__ = [
179
194
  "StatefulButton",
180
195
  "StatefulSelect",
181
196
  "DynamicPersistentButton",
197
+ "PrimaryButton",
198
+ "SecondaryButton",
199
+ "SuccessButton",
200
+ "DangerButton",
201
+ "LinkButton",
202
+ "ToggleButton",
203
+ "Dropdown",
204
+ "RoleSelect",
205
+ "ChannelSelect",
206
+ "UserSelect",
207
+ "MentionableSelect",
182
208
  "CompositeComponent",
183
209
  "ConfirmationButtons",
184
210
  "PaginationControls",
@@ -14,7 +14,6 @@ from discord.ui import ActionRow, TextDisplay
14
14
  from .components.base import StatefulButton, StatefulSelect
15
15
  from .components.patterns import action_section, alert, card, divider, gap, key_value
16
16
  from .exceptions import InstanceLimitError
17
- from .state.actions import ActionCreators
18
17
  from .state.computed import _SENTINEL as _SENTINEL_COMPUTED
19
18
  from .state.computed import ComputedValue, computed
20
19
  from .state.singleton import get_store
@@ -43,15 +42,15 @@ _LIST_DISPLAY_CAP = 15
43
42
  async def _cleanup_ghost_view(store, view_id: str) -> None:
44
43
  """Drop a ghost view (state row with no live instance) from the store.
45
44
 
46
- Three-step mop-up: remove from the active-view registry, remove the
47
- subscriber slot, dispatch VIEW_DESTROYED so the state-tree reducer
48
- trims ``state["views"][view_id]`` and session membership. Used when
49
- an instance dies without routing through ``exit()`` -- the state
50
- row outlives the object and only the inspector can clean it.
45
+ Removes the subscriber slot, then tears the view down through the atomic
46
+ ``_destroy_view`` seam: VIEW_DESTROYED trims ``state["views"][view_id]``
47
+ and session membership, and the active-registry entry is cleared only
48
+ after state confirms the removal. Used when an instance dies without
49
+ routing through ``exit()`` -- the state row outlives the object and only
50
+ the inspector can clean it.
51
51
  """
52
- store._unregister_view(view_id)
53
52
  store._unsubscribe(view_id)
54
- await store.dispatch("VIEW_DESTROYED", ActionCreators.view_destroyed(view_id))
53
+ await store._destroy_view(view_id)
55
54
 
56
55
 
57
56
  # // ========================================( Computed Aggregations )======================================== // #
@@ -152,15 +151,33 @@ class InspectorView(TabLayoutView):
152
151
  notification and the Views tab's Channel / Msg columns stay null
153
152
  until the user manually refreshes.
154
153
  """
154
+ # Filter to the same rows the Views / Sessions tabs display (exclude
155
+ # every inspector, honor the guild scope) so a guild-scoped inspector
156
+ # does not rebuild on out-of-scope cross-guild activity.
155
157
  views = frozenset(
156
158
  (k, v.get("message_id"), v.get("channel_id"))
157
159
  for k, v in state.get("views", {}).items()
158
160
  if k != self.id
161
+ and v.get("type") != "InspectorView"
162
+ and self._in_scope(v.get("guild_id"))
163
+ )
164
+ view_rows = state.get("views", {})
165
+ inspector_sessions = {
166
+ v.get("session_id") for v in view_rows.values() if v.get("type") == "InspectorView"
167
+ }
168
+ inspector_sessions.add(self.session_id)
169
+ sessions = frozenset(
170
+ k
171
+ for k, v in state.get("sessions", {}).items()
172
+ if k not in inspector_sessions and self._in_scope(v.get("guild_id"))
159
173
  )
160
- sessions = frozenset(k for k in state.get("sessions", {}) if k != self.session_id)
161
174
  return (views, sessions)
162
175
 
163
- def __init__(self, *args, **kwargs):
176
+ def __init__(self, *args, scope_guild_id=None, **kwargs):
177
+ # scope_guild_id: an int restricts the Views / Sessions tabs to that
178
+ # guild; None shows every guild. The /cascadeui inspect command
179
+ # defaults this to the executing guild.
180
+ self._scope_guild_id = scope_guild_id
164
181
  tabs = {
165
182
  "\U0001f4ca Overview": self.build_overview,
166
183
  "\U0001f441\ufe0f Views": self.build_views,
@@ -173,15 +190,46 @@ class InspectorView(TabLayoutView):
173
190
 
174
191
  # // ==================( Filtering )================== // #
175
192
 
193
+ def _in_scope(self, guild_id) -> bool:
194
+ """Whether a row's guild belongs to the inspector's scope.
195
+
196
+ A ``None`` scope is global -- every guild passes. A guildless row
197
+ (DM-originated, ``guild_id`` is ``None``) only passes the global scope.
198
+ """
199
+ return self._scope_guild_id is None or guild_id == self._scope_guild_id
200
+
176
201
  def _filtered_views(self):
177
- """Return state views excluding the inspector's own entry."""
202
+ """State views, excluding every inspector and rows outside the scope.
203
+
204
+ Filtering by ``type`` rather than ``self.id`` drops other inspectors
205
+ open in other guilds too, not just this one -- they are observer-effect
206
+ noise wherever they appear.
207
+ """
178
208
  views = self.state_store.state.get("views", {})
179
- return {k: v for k, v in views.items() if k != self.id}
209
+ return {
210
+ k: v
211
+ for k, v in views.items()
212
+ if k != self.id
213
+ and v.get("type") != "InspectorView"
214
+ and self._in_scope(v.get("guild_id"))
215
+ }
180
216
 
181
217
  def _filtered_sessions(self):
182
- """Return state sessions excluding the inspector's own session."""
218
+ """State sessions, excluding inspector sessions and out-of-scope guilds."""
219
+ views = self.state_store.state.get("views", {})
220
+ # Drop this inspector's own session plus any session whose view is
221
+ # another inspector. self.session_id covers the common case where the
222
+ # inspector's own view row is not present in the probed state.
223
+ inspector_sessions = {
224
+ v.get("session_id") for v in views.values() if v.get("type") == "InspectorView"
225
+ }
226
+ inspector_sessions.add(self.session_id)
183
227
  sessions = self.state_store.state.get("sessions", {})
184
- return {k: v for k, v in sessions.items() if k != self.session_id}
228
+ return {
229
+ k: v
230
+ for k, v in sessions.items()
231
+ if k not in inspector_sessions and self._in_scope(v.get("guild_id"))
232
+ }
185
233
 
186
234
  def _filtered_history(self):
187
235
  """Return action history excluding the inspector's own dispatches."""
@@ -242,30 +290,36 @@ class InspectorView(TabLayoutView):
242
290
  components = self._filtered_components()
243
291
  modals = self._filtered_modals()
244
292
 
245
- # Total counts come from the module-level @computed aggregations; the
246
- # inspector subtracts its own contribution only when registered.
247
- # TabLayoutView.send() calls build_overview() before super().send()
248
- # dispatches VIEW_CREATED / SESSION_CREATED, so on first render the
249
- # inspector's own ids are not yet in state -- subtracting 1
250
- # unconditionally would under-count every other view by one.
251
- # Probing state membership picks the right subtraction every render.
252
- state_views = store.state.get("views", {})
253
- state_sessions = store.state.get("sessions", {})
254
- self_view_present = self.id in state_views
255
- self_session_present = self.session_id in state_sessions
256
- view_count = max(0, store.computed["total_views"] - (1 if self_view_present else 0))
257
- session_count = max(
258
- 0,
259
- store.computed["total_sessions"] - (1 if self_session_present else 0),
260
- )
293
+ # Scoped counts come from the filtered helpers so the Overview agrees
294
+ # with the Views / Sessions tabs -- both exclude inspectors and honor
295
+ # the guild scope.
296
+ view_count = len(self._filtered_views())
297
+ session_count = len(self._filtered_sessions())
261
298
 
299
+ scope_label = (
300
+ f"Guild `{self._scope_guild_id}`"
301
+ if self._scope_guild_id is not None
302
+ else "Global (all guilds)"
303
+ )
262
304
  stats = {
305
+ "Scope": scope_label,
263
306
  "Views": view_count,
264
307
  "Sessions": session_count,
265
308
  "Components": len(components),
266
309
  }
267
310
  if modals:
268
311
  stats["Modals"] = len(modals)
312
+ if self._scope_guild_id is not None:
313
+ # All-guilds totals from the @computed aggregates. TabLayoutView.send()
314
+ # builds this tab before VIEW_CREATED / SESSION_CREATED dispatch, so
315
+ # the inspector's own ids may not be in state yet -- probe membership
316
+ # to subtract its own contribution only once registered.
317
+ views_state = store.state.get("views", {})
318
+ sessions_state = store.state.get("sessions", {})
319
+ self_view = 1 if self.id in views_state else 0
320
+ self_session = 1 if self.session_id in sessions_state else 0
321
+ stats["Views (all guilds)"] = max(0, store.computed["total_views"] - self_view)
322
+ stats["Sessions (all guilds)"] = max(0, store.computed["total_sessions"] - self_session)
269
323
 
270
324
  overview = card(
271
325
  "## State Inspector",
@@ -1258,11 +1312,25 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
1258
1312
 
1259
1313
  # // ==================( View Commands )================== // #
1260
1314
 
1315
+ @staticmethod
1316
+ def _scope_guild(ctx: Context):
1317
+ """Guild id the text commands scope to, or None in a DM (all guilds).
1318
+
1319
+ The listing and bulk-exit commands filter to this guild; None passes
1320
+ every row.
1321
+ """
1322
+ return ctx.guild.id if ctx.guild else None
1323
+
1261
1324
  @cascadeui_group.command(name="views", description="List active CascadeUI views.")
1262
1325
  async def list_views(self, ctx: Context) -> None:
1263
- """List all active views in the state store."""
1326
+ """List active views in the executing guild."""
1264
1327
  store = get_store()
1265
- views = store.state.get("views", {})
1328
+ gid = self._scope_guild(ctx)
1329
+ views = {
1330
+ k: v
1331
+ for k, v in store.state.get("views", {}).items()
1332
+ if gid is None or v.get("guild_id") == gid
1333
+ }
1266
1334
  active = store.get_active_views()
1267
1335
 
1268
1336
  if not views:
@@ -1278,7 +1346,8 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
1278
1346
  if len(views) > _LIST_DISPLAY_CAP:
1279
1347
  lines.append(f"*...and {len(views) - _LIST_DISPLAY_CAP} more*")
1280
1348
 
1281
- header = f"**{len(views)} view(s)** ({len(active)} live)\n"
1349
+ live = sum(1 for vid in views if vid in active)
1350
+ header = f"**{len(views)} view(s)** ({live} live)\n"
1282
1351
  await ctx.send(header + "\n".join(lines), ephemeral=True)
1283
1352
 
1284
1353
  @cascadeui_group.command(name="exit", description="Exit a specific CascadeUI view by ID.")
@@ -1319,14 +1388,17 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
1319
1388
 
1320
1389
  @cascadeui_group.command(name="exitall", description="Exit all active CascadeUI views.")
1321
1390
  async def exit_all(self, ctx: Context) -> None:
1322
- """Exit all active views and clean ghost entries."""
1391
+ """Exit active views in the executing guild and clean their ghosts."""
1323
1392
  store = get_store()
1393
+ gid = self._scope_guild(ctx)
1324
1394
  views = dict(store.state.get("views", {}))
1325
1395
  active = dict(store.get_active_views())
1326
1396
  exited = 0
1327
1397
  failed = 0
1328
1398
 
1329
1399
  for view_id, view in list(active.items()):
1400
+ if gid is not None and getattr(view, "guild_id", None) != gid:
1401
+ continue
1330
1402
  try:
1331
1403
  await view.exit()
1332
1404
  exited += 1
@@ -1334,10 +1406,13 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
1334
1406
  logger.debug("exit_all: view %s failed to exit cleanly: %s", view_id, exc)
1335
1407
  failed += 1
1336
1408
 
1337
- for view_id in list(views.keys()):
1338
- if view_id not in active:
1339
- await _cleanup_ghost_view(store, view_id)
1340
- exited += 1
1409
+ for view_id, view_data in list(views.items()):
1410
+ if view_id in active:
1411
+ continue
1412
+ if gid is not None and view_data.get("guild_id") != gid:
1413
+ continue
1414
+ await _cleanup_ghost_view(store, view_id)
1415
+ exited += 1
1341
1416
 
1342
1417
  suffix = f" ({failed} failed)" if failed else ""
1343
1418
  await ctx.send(f"Exited {exited} view(s){suffix}.", ephemeral=True)
@@ -1447,9 +1522,14 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
1447
1522
 
1448
1523
  @cascadeui_group.command(name="sessions", description="List active CascadeUI sessions.")
1449
1524
  async def list_sessions(self, ctx: Context) -> None:
1450
- """List all sessions in the state store."""
1525
+ """List sessions in the executing guild."""
1451
1526
  store = get_store()
1452
- sessions = store.state.get("sessions", {})
1527
+ gid = self._scope_guild(ctx)
1528
+ sessions = {
1529
+ k: v
1530
+ for k, v in store.state.get("sessions", {}).items()
1531
+ if gid is None or v.get("guild_id") == gid
1532
+ }
1453
1533
 
1454
1534
  if not sessions:
1455
1535
  return await ctx.send("No active sessions.", ephemeral=True)
@@ -1499,19 +1579,40 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
1499
1579
  # // ==================( Inspector Command )================== // #
1500
1580
 
1501
1581
  @cascadeui_group.command(name="inspect", description="Open the CascadeUI state inspector.")
1502
- async def inspect(self, ctx: Context) -> None:
1582
+ async def inspect(self, ctx: Context, scope: str = None) -> None:
1503
1583
  """Open the visual state inspector.
1504
1584
 
1505
1585
  A tabbed V2 dashboard showing the live state tree, active views,
1506
1586
  sessions, action history, and store configuration with interactive
1507
1587
  controls for managing views, sessions, and state.
1588
+
1589
+ Scope defaults to the current guild. Pass ``global`` (or ``all``) to
1590
+ inspect every guild, or a guild id to target a specific guild.
1508
1591
  """
1592
+ scope_guild_id = self._resolve_inspect_scope(ctx, scope)
1509
1593
  try:
1510
- view = InspectorView(context=ctx)
1594
+ view = InspectorView(context=ctx, scope_guild_id=scope_guild_id)
1511
1595
  await view.send()
1512
1596
  except InstanceLimitError:
1513
1597
  await ctx.send("Inspector already open.", ephemeral=True)
1514
1598
 
1599
+ @staticmethod
1600
+ def _resolve_inspect_scope(ctx: Context, scope: str = None):
1601
+ """Map the optional scope argument to a guild id, or None for global.
1602
+
1603
+ No argument defaults to the executing guild (None in a DM). ``global``
1604
+ or ``all`` widen to every guild. A numeric string targets that guild
1605
+ id. Anything unrecognized falls back to the executing guild.
1606
+ """
1607
+ if scope is None:
1608
+ return ctx.guild.id if ctx.guild else None
1609
+ normalized = scope.strip().lower()
1610
+ if normalized in ("global", "all"):
1611
+ return None
1612
+ if normalized.isdigit():
1613
+ return int(normalized)
1614
+ return ctx.guild.id if ctx.guild else None
1615
+
1515
1616
  # // ==================( Registry Commands )================== // #
1516
1617
 
1517
1618
  @cascadeui_group.command(
@@ -596,33 +596,20 @@ class PersistenceManager:
596
596
  # subscriber + registry entry + undo-tracking row, all
597
597
  # of which must go back before the next row is tried.
598
598
  self._store._unsubscribe(view.id)
599
- self._store._unregister_view(view.id)
600
599
  self._store._undo_enabled_views.pop(view.id, None)
601
- # If state registration succeeded before the failure,
602
- # the SESSION_CREATED + VIEW_CREATED actions already
603
- # landed in store state. Dispatch VIEW_DESTROYED to
604
- # tear those down -- otherwise the session and view
605
- # entries would persist as zombies for the rest of
606
- # the process lifetime. ``reduce_view_destroyed``
607
- # cleans up the session entry too when its members
608
- # list empties.
609
600
  if state_registered:
610
- # Late import: ``state.actions`` would not introduce
611
- # a circular dep, but the file's convention is to
612
- # late-import every state-domain symbol (matches the
613
- # ``PersistenceMiddleware`` import below).
614
- from ..state.actions import ActionCreators
615
-
616
- try:
617
- await self._store.dispatch(
618
- "VIEW_DESTROYED",
619
- ActionCreators.view_destroyed(view.id),
620
- )
621
- except Exception as cleanup_exc:
622
- logger.error(
623
- f"Rollback dispatch failed for {persistence_key!r}: " f"{cleanup_exc}",
624
- exc_info=True,
625
- )
601
+ # State registration landed SESSION_CREATED + VIEW_CREATED,
602
+ # so tear them down through the atomic seam: _destroy_view
603
+ # dispatches VIEW_DESTROYED, then clears the active-registry
604
+ # entry only after state confirms the removal. A failed
605
+ # dispatch leaves both registries intact instead of
606
+ # stranding a ghost; _destroy_view catches and logs the
607
+ # dispatch failure internally. ``reduce_view_destroyed``
608
+ # cleans up the session entry too when its members empty.
609
+ await self._store._destroy_view(view.id)
610
+ else:
611
+ # State never registered; just drop the active entry.
612
+ self._store._unregister_view(view.id)
626
613
  return "failed"
627
614
 
628
615
  # // ========================================( Middleware install )======================================== // #
@@ -3,7 +3,7 @@
3
3
 
4
4
  from typing import Any, Dict, List, Optional
5
5
 
6
- from .types import ComponentId, SessionId, UserId, ViewId
6
+ from .types import ComponentId, GuildId, SessionId, UserId, ViewId
7
7
 
8
8
  # Type alias for action payloads
9
9
  ActionPayload = Dict[str, Any]
@@ -21,6 +21,7 @@ class ActionCreators:
21
21
  view_type: str,
22
22
  user_id: Optional[UserId] = None,
23
23
  session_id: Optional[SessionId] = None,
24
+ guild_id: GuildId = None,
24
25
  **props,
25
26
  ) -> ActionPayload:
26
27
  """Create a VIEW_CREATED action payload."""
@@ -29,6 +30,7 @@ class ActionCreators:
29
30
  "view_type": view_type,
30
31
  "user_id": user_id,
31
32
  "session_id": session_id,
33
+ "guild_id": guild_id,
32
34
  "props": props,
33
35
  }
34
36
 
@@ -44,10 +46,18 @@ class ActionCreators:
44
46
 
45
47
  @staticmethod
46
48
  def session_created(
47
- session_id: SessionId, user_id: Optional[UserId] = None, **data
49
+ session_id: SessionId,
50
+ user_id: Optional[UserId] = None,
51
+ guild_id: GuildId = None,
52
+ **data,
48
53
  ) -> ActionPayload:
49
54
  """Create a SESSION_CREATED action payload."""
50
- return {"session_id": session_id, "user_id": user_id, "shared_data": data}
55
+ return {
56
+ "session_id": session_id,
57
+ "user_id": user_id,
58
+ "guild_id": guild_id,
59
+ "shared_data": data,
60
+ }
51
61
 
52
62
  @staticmethod
53
63
  def session_updated(session_id: SessionId, **data) -> ActionPayload:
@@ -76,6 +76,7 @@ async def reduce_view_created(action: Action, state: StateData) -> StateData:
76
76
  "id": view_id,
77
77
  "type": payload.get("view_type"),
78
78
  "user_id": payload.get("user_id"),
79
+ "guild_id": payload.get("guild_id"),
79
80
  "session_id": payload.get("session_id"),
80
81
  "created_at": action["timestamp"],
81
82
  "updated_at": action["timestamp"],
@@ -182,6 +183,7 @@ async def reduce_session_created(action: Action, state: StateData) -> StateData:
182
183
  new_session = {
183
184
  "id": session_id,
184
185
  "user_id": payload.get("user_id"),
186
+ "guild_id": payload.get("guild_id"),
185
187
  "created_at": action["timestamp"],
186
188
  "updated_at": action["timestamp"],
187
189
  "members": [],
@@ -12,6 +12,7 @@ from typing import Any, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Uni
12
12
 
13
13
  from ..utils.errors import with_error_boundary
14
14
  from ..utils.tasks import get_task_manager
15
+ from .actions import ActionCreators
15
16
  from .slots import access_slot, read_slot
16
17
  from .types import Action, HookFn, MiddlewareFn, ReducerFn, SelectorFn, StateData, SubscriberFn
17
18
 
@@ -912,6 +913,57 @@ class StateStore:
912
913
  if p_key is not None and p_key != scope_key:
913
914
  self._remove_from_instance_index(view_id, view_type, p_key)
914
915
 
916
+ async def _destroy_view(self, view_id: str, *, source_id: Optional[str] = None) -> bool:
917
+ """Atomic view teardown: dispatch ``VIEW_DESTROYED``, then drop the active entry.
918
+
919
+ A live view occupies two registries: ``state["views"]`` (the Redux
920
+ source of truth, mutated only through the reducer) and ``_active_views``
921
+ (the sync instance-limit and inspector index). This method tears them
922
+ down in a fixed order. The async ``VIEW_DESTROYED`` dispatch removes the
923
+ ``state["views"]`` entry first; ``_unregister_view`` clears the
924
+ ``_active_views`` entry only once state confirms the view is gone.
925
+
926
+ The ordering keeps the two registries consistent under failure. A
927
+ raising middleware, a reducer error the dispatch chain logs and
928
+ absorbs, or a cancellation mid-dispatch all leave both registries
929
+ intact rather than producing the inspector-flagged divergence (a view
930
+ present in ``state["views"]`` but absent from ``_active_views``).
931
+ Over-retention is transient and self-heals on the next teardown or
932
+ restart.
933
+
934
+ Idempotent and safe under double-teardown. Returns ``True`` when the
935
+ view was fully removed, ``False`` when the state removal did not land
936
+ and the active-registry entry was retained.
937
+ """
938
+ try:
939
+ await self.dispatch(
940
+ "VIEW_DESTROYED", ActionCreators.view_destroyed(view_id), source_id=source_id
941
+ )
942
+ except Exception:
943
+ # Log and fall through to the post-dispatch state check below: if
944
+ # the reducer ran before the exception (state already clean), the
945
+ # finally clears the active entry and the check returns True; if not,
946
+ # the check returns False and retains both registries.
947
+ logger.exception(
948
+ f"VIEW_DESTROYED dispatch failed for {view_id}; "
949
+ f"checking whether the state entry was removed."
950
+ )
951
+ finally:
952
+ # Clear the active entry only once state confirms the removal. The
953
+ # finally clause also covers a cancellation mid-dispatch: when the
954
+ # reducer already removed the state entry before the await was
955
+ # cancelled, the active entry still gets cleared, so a cancelled
956
+ # teardown cannot leave the view stranded in _active_views.
957
+ if view_id not in self.state.get("views", {}):
958
+ self._unregister_view(view_id)
959
+ if view_id not in self.state.get("views", {}):
960
+ return True
961
+ logger.warning(
962
+ f"VIEW_DESTROYED did not remove {view_id} from state; "
963
+ f"retaining active-registry entry to avoid a ghost."
964
+ )
965
+ return False
966
+
915
967
  def _get_active_views(self, view_type: str, scope_key: str) -> list:
916
968
  """Return active view instances for a type+scope, oldest-first. Internal plumbing."""
917
969
  key = (view_type, scope_key)
@@ -8,6 +8,7 @@ ViewId = str
8
8
  SessionId = str
9
9
  ComponentId = str
10
10
  UserId = Optional[int]
11
+ GuildId = Optional[int]
11
12
  Timestamp = str
12
13
 
13
14
  # Simple action type
@@ -88,6 +88,22 @@ class _InteractionMixin:
88
88
  if self.auto_defer and not interaction.response.is_done():
89
89
  try:
90
90
  await interaction.response.defer()
91
+ except discord.HTTPException as e:
92
+ # 40060 means Discord acknowledged a request the
93
+ # acting-view fast path cancelled locally (cancellation
94
+ # race) -- the interaction is already acked, so this is
95
+ # benign and routine. Any other status is a genuine ack
96
+ # failure the user saw as an interaction-failed toast.
97
+ if e.code == 40060:
98
+ logger.debug(
99
+ f"Post-callback defer raced an existing ack in "
100
+ f"{self.__class__.__name__} (40060)"
101
+ )
102
+ else:
103
+ logger.warning(
104
+ f"Post-callback defer failed in {self.__class__.__name__}: "
105
+ f"status={e.status} code={e.code}"
106
+ )
91
107
  except Exception:
92
108
  logger.debug(
93
109
  f"Post-callback defer failed in {self.__class__.__name__} "
@@ -189,9 +205,24 @@ class _InteractionMixin:
189
205
  send operations. Prevents double-defer when auto-defer or
190
206
  ``serialize_interactions`` has already acknowledged the
191
207
  interaction before the callback runs.
208
+
209
+ The ack is bounded by ``auto_defer_delay``: this call runs inside
210
+ the interaction lock, and a Discord ack endpoint that stalls past
211
+ the ack window would pin the lock on a hung socket. A defer that
212
+ cannot land in time is useless anyway -- the auto-defer timer,
213
+ running outside the lock, is the backstop -- so the stall is
214
+ cancelled and swallowed rather than propagated.
192
215
  """
193
216
  if not interaction.response.is_done():
194
- await interaction.response.defer()
217
+ try:
218
+ await asyncio.wait_for(
219
+ interaction.response.defer(), timeout=max(0.5, self.auto_defer_delay)
220
+ )
221
+ except asyncio.TimeoutError:
222
+ logger.debug(
223
+ f"Ack defer stalled past {self.auto_defer_delay}s in "
224
+ f"{type(self).__name__}; auto-defer timer backstops the ack."
225
+ )
195
226
 
196
227
  # // ==================( Ephemeral Refresh )================== // #
197
228
 
@@ -341,11 +372,11 @@ class _InteractionMixin:
341
372
  # panel becomes a harmless orphan the user can dismiss.
342
373
  if self._message:
343
374
  try:
344
- await self._message.delete()
345
- except (discord.NotFound, discord.HTTPException):
375
+ await self._bounded(self._message.delete())
376
+ except (discord.NotFound, discord.HTTPException, asyncio.TimeoutError):
346
377
  try:
347
- await self._message.edit(view=self)
348
- except (discord.NotFound, discord.HTTPException):
378
+ await self._bounded(self._message.edit(view=self))
379
+ except (discord.NotFound, discord.HTTPException, asyncio.TimeoutError):
349
380
  pass
350
381
 
351
382
  await self.exit()