haybale-haystack 0.2.4__tar.gz → 0.3.1__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 (44) hide show
  1. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/.gitignore +3 -1
  2. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/PKG-INFO +5 -5
  3. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/README.md +6 -5
  4. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/OVERVIEW.md +6 -5
  5. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/QUICKREF.md +7 -6
  6. haybale_haystack-0.3.1/haybale_haystack/docs/haybale-haystack.panel.EditMacroMenuPanel.md +18 -0
  7. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.panel.GraphRunSettingsPanel.md +2 -0
  8. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.panel.OpenInHaystackMenuPanel.md +2 -0
  9. haybale_haystack-0.3.1/haybale_haystack/docs/haybale-haystack.panel.UpdateMacroMenuPanel.md +21 -0
  10. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.setting.HaystackSettings.md +2 -0
  11. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.state.HaystackState.md +2 -0
  12. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/editors/haystack_editor.py +180 -25
  13. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/farmhands/graph_tools.py +4 -9
  14. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/graph_entry.py +40 -2
  15. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/haybale.toml +1 -1
  16. haybale_haystack-0.3.1/haybale_haystack/panels/__init__.py +11 -0
  17. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/panels/file_browser/menu/file.py +1 -0
  18. haybale_haystack-0.3.1/haybale_haystack/panels/graph/menu/macro.py +269 -0
  19. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/panels/properties/introspect/graph_run_settings_panel.py +2 -1
  20. haybale_haystack-0.3.1/haybale_haystack/settings/graph_run_settings.py +27 -0
  21. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/settings/haystack_settings.py +1 -1
  22. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/state/haystack_state.py +137 -21
  23. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/pyproject.toml +5 -5
  24. haybale_haystack-0.2.4/haybale_haystack/docs/haybale-haystack.panel.EditMacroMenuPanel.md +0 -16
  25. haybale_haystack-0.2.4/haybale_haystack/panels/__init__.py +0 -11
  26. haybale_haystack-0.2.4/haybale_haystack/panels/graph/menu/macro.py +0 -105
  27. haybale_haystack-0.2.4/haybale_haystack/settings/graph_run_settings.py +0 -16
  28. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/__init__.py +0 -0
  29. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.editor.HaystackEditor.md +0 -0
  30. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.close_graph.md +0 -0
  31. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.compile_graph.md +0 -0
  32. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.create_graph.md +0 -0
  33. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.list_graphs.md +0 -0
  34. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.open_graph.md +0 -0
  35. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.rename_graph.md +0 -0
  36. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.save_graph.md +0 -0
  37. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.start_graph.md +0 -0
  38. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/docs/haybale-haystack.farmhand.stop_graph.md +0 -0
  39. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/editors/__init__.py +0 -0
  40. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/farmhands/__init__.py +0 -0
  41. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/persistence.py +0 -0
  42. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/settings/__init__.py +0 -0
  43. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/signals.py +0 -0
  44. {haybale_haystack-0.2.4 → haybale_haystack-0.3.1}/haybale_haystack/state/__init__.py +0 -0
@@ -220,6 +220,8 @@ packages/haywire-core/src/haywire/_baked_docs/*
220
220
  # Haywire project config (user-local, not repo state)
221
221
  .haywire/
222
222
 
223
- # Local symlink to external dev project (machine-specific path)
223
+ # Local symlinks to external dev projects (machine-specific paths)
224
224
  barn/haybale-visiongraph
225
+ barn/haybale-spandrel
225
226
 
227
+ assets/*
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: haybale-haystack
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Summary: File-centric multi-graph manager
5
5
  License: MIT
6
6
  Keywords: graph-management
7
7
  Requires-Python: >=3.12
8
- Requires-Dist: haybale-graph-editor>=0.2.4
9
- Requires-Dist: haybale-studio>=0.2.4
10
- Requires-Dist: haywire-core>=0.2.4
11
- Requires-Dist: nicegui>=3.12.1
8
+ Requires-Dist: haybale-graph-editor>=0.3.1
9
+ Requires-Dist: haybale-studio>=0.3.1
10
+ Requires-Dist: haywire-core>=0.3.1
11
+ Requires-Dist: nicegui>=3.18.0
12
12
  Requires-Dist: toml>=0.10.2
@@ -7,7 +7,7 @@
7
7
  File-centric multi-graph manager
8
8
 
9
9
  ## Settings
10
- - **Haystack** —
10
+ - **Haystack** — Per-workspace settings for haystack scalars.
11
11
 
12
12
  ## Farmhands
13
13
  - **Close graph** — Close an open graph entry. NEVER deletes the file on disk.
@@ -21,12 +21,13 @@ File-centric multi-graph manager
21
21
  - **Stop graph** — Stop a running graph (bounded grace, then teardown).
22
22
 
23
23
  ## States
24
- - **Haystack State** —
24
+ - **Haystack State** — In-memory registry of open graphs, one entry per file path.
25
25
 
26
26
  ## Panels
27
- - **Edit Macro** —
28
- - **Graph Settings** —
29
- - **Load into Haystack** —
27
+ - **Edit Macro** — Open the document behind a macro placement, in its own editor tab.
28
+ - **Graph Settings** — Renders the GraphRunSettings for the currently active graph.
29
+ - **Load into Haystack** — Load a .haywire graph file into the Haystack (does not open an editor tab).
30
+ - **Update Macro** — Send a modified instance's changes back to the macro it came from.
30
31
 
31
32
  ## Editors
32
33
  - **Haystack** — All open graphs. Click to switch; "+" to create a new graph.
@@ -3,7 +3,7 @@
3
3
  File-centric multi-graph manager
4
4
 
5
5
  ## Settings
6
- - **Haystack** —
6
+ - **Haystack** — Per-workspace settings for haystack scalars.
7
7
 
8
8
  ## Farmhands
9
9
  - **Close graph** — Close an open graph entry. NEVER deletes the file on disk.
@@ -17,12 +17,13 @@ File-centric multi-graph manager
17
17
  - **Stop graph** — Stop a running graph (bounded grace, then teardown).
18
18
 
19
19
  ## States
20
- - **Haystack State** —
20
+ - **Haystack State** — In-memory registry of open graphs, one entry per file path.
21
21
 
22
22
  ## Panels
23
- - **Edit Macro** —
24
- - **Graph Settings** —
25
- - **Load into Haystack** —
23
+ - **Edit Macro** — Open the document behind a macro placement, in its own editor tab.
24
+ - **Graph Settings** — Renders the GraphRunSettings for the currently active graph.
25
+ - **Load into Haystack** — Load a .haywire graph file into the Haystack (does not open an editor tab).
26
+ - **Update Macro** — Send a modified instance's changes back to the macro it came from.
26
27
 
27
28
  ## Editors
28
29
  - **Haystack** — All open graphs. Click to switch; "+" to create a new graph.
@@ -1,7 +1,7 @@
1
- # haybale-haystack — component index (v0.2.4)
1
+ # haybale-haystack — component index (v0.3.1)
2
2
 
3
3
  ## setting
4
- - `haybale-haystack:setting:HaystackSettings` — Haystack —
4
+ - `haybale-haystack:setting:HaystackSettings` — Haystack — Per-workspace settings for haystack scalars.
5
5
 
6
6
  ## farmhand
7
7
  - `haybale-haystack:farmhand:close_graph` — Close graph — Close an open graph entry. NEVER deletes the file on disk.
@@ -15,12 +15,13 @@
15
15
  - `haybale-haystack:farmhand:stop_graph` — Stop graph — Stop a running graph (bounded grace, then teardown).
16
16
 
17
17
  ## state
18
- - `haybale-haystack:state:HaystackState` — Haystack State —
18
+ - `haybale-haystack:state:HaystackState` — Haystack State — In-memory registry of open graphs, one entry per file path.
19
19
 
20
20
  ## panel
21
- - `haybale-haystack:panel:EditMacroMenuPanel` — Edit Macro —
22
- - `haybale-haystack:panel:GraphRunSettingsPanel` — Graph Settings —
23
- - `haybale-haystack:panel:OpenInHaystackMenuPanel` — Load into Haystack —
21
+ - `haybale-haystack:panel:EditMacroMenuPanel` — Edit Macro — Open the document behind a macro placement, in its own editor tab.
22
+ - `haybale-haystack:panel:GraphRunSettingsPanel` — Graph Settings — Renders the GraphRunSettings for the currently active graph.
23
+ - `haybale-haystack:panel:OpenInHaystackMenuPanel` — Load into Haystack — Load a .haywire graph file into the Haystack (does not open an editor tab).
24
+ - `haybale-haystack:panel:UpdateMacroMenuPanel` — Update Macro — Send a modified instance's changes back to the macro it came from.
24
25
 
25
26
  ## editor
26
27
  - `haybale-haystack:editor:HaystackEditor` — Haystack — All open graphs. Click to switch; "+" to create a new graph.
@@ -0,0 +1,18 @@
1
+ # Edit Macro
2
+
3
+ `haybale-haystack:panel:EditMacroMenuPanel` · kind: panel
4
+
5
+ Open the document behind a macro placement, in its own editor tab.
6
+
7
+ ## Details
8
+
9
+ - **surface**: `selection-macro`
10
+ - **order**: `10`
11
+
12
+ ## Notes
13
+
14
+ Open the document behind a macro placement, in its own editor tab.
15
+
16
+ Only visible on a placement. Its interior is runtime state rebuilt from the
17
+ template, so it cannot be edited in place; this row opens the document that
18
+ can.
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:panel:GraphRunSettingsPanel` · kind: panel
4
4
 
5
+ Renders the GraphRunSettings for the currently active graph.
6
+
5
7
  ## Details
6
8
 
7
9
  - **surface**: `execution`
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:panel:OpenInHaystackMenuPanel` · kind: panel
4
4
 
5
+ Load a .haywire graph file into the Haystack (does not open an editor tab).
6
+
5
7
  ## Details
6
8
 
7
9
  - **surface**: `file`
@@ -0,0 +1,21 @@
1
+ # Update Macro
2
+
3
+ `haybale-haystack:panel:UpdateMacroMenuPanel` · kind: panel
4
+
5
+ Send a modified instance's changes back to the macro it came from.
6
+
7
+ ## Details
8
+
9
+ - **surface**: `selection-macro`
10
+ - **order**: `30`
11
+
12
+ ## Notes
13
+
14
+ Send a modified instance's changes back to the macro it came from.
15
+
16
+ Only visible on a Group that was detached from a macro. The card becomes a
17
+ placement of that macro again, and the macro opens in its own tab with the
18
+ instance's contents, unsaved. Saving it updates every placement.
19
+
20
+ Greyed out when the macro is not installed, cannot be edited in place, or
21
+ has unsaved edits in its tab.
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:setting:HaystackSettings` · kind: setting
4
4
 
5
+ Per-workspace settings for haystack scalars.
6
+
5
7
  ## Notes
6
8
 
7
9
  Per-workspace settings for haystack scalars.
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:state:HaystackState` · kind: state
4
4
 
5
+ In-memory registry of open graphs, one entry per file path.
6
+
5
7
  ## Notes
6
8
 
7
9
  In-memory registry of open graphs (one entry per file path)
@@ -6,7 +6,9 @@ from typing import Optional
6
6
 
7
7
  from nicegui import ui
8
8
 
9
+ from haywire.core.access import AccessTier, LevelMode
9
10
  from haywire.ui import elements as hui
11
+ from haywire.ui.components.popup import Popup
10
12
  from haywire.ui.editor import BaseEditor, SlotName, editor
11
13
  from haywire.core.session import (
12
14
  IAppState,
@@ -19,29 +21,41 @@ from haywire.core.signals import (
19
21
  BroadcastClose,
20
22
  Close,
21
23
  GraphDataMutated,
24
+ GraphUnsavedChanged,
22
25
  Reveal,
23
26
  RevealGraphInstance,
27
+ Save,
24
28
  )
25
29
  from haywire.ui.modals import confirm_modal, pick_modal, rename_modal
26
30
 
31
+ from haybale_graph_editor.notices import GraphNotice, GraphNoticesChanged
27
32
  from haybale_graph_editor.state.edit_state import EditState
28
33
  from haybale_graph_editor.state.graph_app_state import GraphAppState
29
- from haybale_graph_editor.editors.graph_editor import GraphEditor
34
+ from haybale_graph_editor.editors.graph_editor import GraphEditor, definition_path
30
35
  from haybale_graph_editor.editors.graph_save_as import open_graph_save_as_dialog
31
36
 
32
- from ..signals import HaystackReloaded, HaystackTeardown
33
- from ..state.haystack_state import HaystackState
34
- from ..graph_entry import EntryKind, GraphEntry
37
+ from haybale_haystack.signals import HaystackReloaded, HaystackTeardown
38
+ from haybale_haystack.state.haystack_state import HaystackState
39
+ from haybale_haystack.graph_entry import EntryKind, GraphEntry
35
40
 
36
41
  logger = logging.getLogger(__name__)
37
42
 
38
43
 
44
+ def _refused(context: "SessionContext", required: AccessTier, action: str) -> bool:
45
+ """Return whether the principal lacks ``required``, telling them ``"<action> needs <tier> access"``."""
46
+ if context.can_access(required):
47
+ return False
48
+ ui.notify(f"{action} needs {required.value} access", type="warning", position="top-right")
49
+ return True
50
+
51
+
39
52
  @editor(
40
53
  label="Haystack",
41
54
  icon=hui.icon.haystack,
42
55
  default_slot=SlotName.ACTION,
43
56
  description='All open graphs. Click to switch; "+" to create a new graph.',
44
57
  order=20,
58
+ requires=AccessTier.VIEW,
45
59
  )
46
60
  class HaystackEditor(BaseEditor):
47
61
  """
@@ -63,12 +77,16 @@ class HaystackEditor(BaseEditor):
63
77
  def __init__(self, wrapper):
64
78
  super().__init__(wrapper)
65
79
  self._list_container = None
80
+ self._dismissed_notices: set[tuple[str, str]] = set()
81
+ self._notice_popups: list[Popup] = []
66
82
 
67
83
  # ------------------------------------------------------------------
68
84
  # Signal-bus subscriptions / draw
69
85
  # ------------------------------------------------------------------
70
86
 
71
- @redraw_on(ActiveGraphMoved, GraphDataMutated, HaystackReloaded)
87
+ @redraw_on(
88
+ ActiveGraphMoved, GraphDataMutated, GraphUnsavedChanged, HaystackReloaded, GraphNoticesChanged
89
+ )
72
90
  def _refresh_on_state_move(self, context: "SessionContext", event) -> None:
73
91
  """Empty body — the decorator triggers ``wrapper.redraw()`` after
74
92
  this returns, which re-runs ``draw`` and ``_render_list``."""
@@ -85,25 +103,36 @@ class HaystackEditor(BaseEditor):
85
103
  """
86
104
  self._on_haystack_teardown(context, event)
87
105
 
106
+ @react_on(Save)
107
+ def _on_save(self, context: "SessionContext", event: Save) -> None:
108
+ """Save the loaded haystack in place, or ask for a name when none is loaded."""
109
+ self._on_save_haystack_in_place(context)
110
+
88
111
  @react_on(RevealGraphInstance)
89
112
  def _on_reveal_graph_instance_reopen(
90
113
  self, context: "SessionContext", event: "RevealGraphInstance"
91
114
  ) -> None:
92
- """Reopen a loaded-but-tabless graph so it can receive future selects.
115
+ """Bring forward the tab of the document holding ``event.graph_id``, opening it if needed.
93
116
 
94
- Accepted gap: this only re-opens the tab via Reveal (idempotent —
95
- find-or-add — even if a GraphEditor in this session also matched
96
- and already self-revealed). It does NOT retroactively select the
97
- node/edge in the freshly-constructed GraphEditor, which was not
98
- alive to receive the original signal.
117
+ The graph may be the document's own or a Group or macro placement interior
118
+ in it, open or not. A tab this opens carries out the reveal when it draws
119
+ (``EditState.pending_reveal``); a tab already open carries it out itself.
99
120
  """
100
121
  graph_app_state = context.app_data[GraphAppState]
101
122
  for container in graph_app_state.all_containers():
102
- if container.editor.graph.graph_id == event.graph_id:
123
+ graph = container.editor.graph
124
+ if graph.graph_id != event.graph_id and definition_path(graph, event.graph_id) is None:
125
+ continue
126
+ edit_state = context.data[EditState]
127
+ edit_state.pending_reveal = event
128
+ try:
103
129
  context.session.publish(
104
130
  Reveal(editor=GraphEditor, binding_id=container.binding_id, label=container.display_name)
105
131
  )
106
- return
132
+ finally:
133
+ # A tab already open draws nothing, so the reveal must not wait for a later draw.
134
+ edit_state.pending_reveal = None
135
+ return
107
136
 
108
137
  def _on_haystack_teardown(self, context: "SessionContext", signal: "HaystackTeardown") -> None:
109
138
  """Translate the teardown fact into local tab-close lifecycle commands.
@@ -129,7 +158,7 @@ class HaystackEditor(BaseEditor):
129
158
 
130
159
  # No on_focus override needed: @redraw_on fires regardless of active-tab
131
160
  # state, so backgrounded HaystackEditor instances stay current with
132
- # ActiveGraphMoved / GraphDataMutated / HaystackReloaded.
161
+ # ActiveGraphMoved / GraphDataMutated / GraphUnsavedChanged / HaystackReloaded.
133
162
 
134
163
  def draw(self, context: "SessionContext", container: ui.element) -> None:
135
164
  with container:
@@ -144,13 +173,16 @@ class HaystackEditor(BaseEditor):
144
173
  # ------------------------------------------------------------------
145
174
 
146
175
  def _render_header(self, context: "SessionContext") -> None:
176
+ """Draw the title, unsaved dot and haystack actions; the actions are drawn only for edit."""
147
177
  title = self._get_active_haystack_name(context) or "Haystacks"
148
178
  hs = context.app_data[HaystackState]
149
179
  is_dirty = hs.is_haystack_dirty
150
- # The dirty dot (when is_dirty=True) is rendered by panel_header
180
+ # The dirty dot (when is_dirty=True) is rendered by slot_header
151
181
  # itself between the icon and the title — we only add the save
152
182
  # icon and the overflow menu on the action side here.
153
- with hui.panel_header(title, icon=hui.icon.haystack, is_dirty=is_dirty):
183
+ with hui.slot_header(title, icon=hui.icon.haystack, is_dirty=is_dirty):
184
+ if not context.can_edit():
185
+ return
154
186
  # Save icon — paired with the dirty dot; only when dirty.
155
187
  if is_dirty:
156
188
  hui.icon_action(
@@ -199,11 +231,12 @@ class HaystackEditor(BaseEditor):
199
231
  if self._list_container is None:
200
232
  return
201
233
  self._list_container.clear()
234
+ self._delete_notice_popups()
202
235
 
203
236
  app = context.app
204
237
  if app is None:
205
238
  with self._list_container:
206
- ui.label("Graph manager not available").classes("text-xs hw-text-dim p-2 italic")
239
+ hui.hint("Graph manager not available").classes("p-2 italic")
207
240
  return
208
241
 
209
242
  hs = context.app_data[HaystackState]
@@ -212,7 +245,7 @@ class HaystackEditor(BaseEditor):
212
245
 
213
246
  with self._list_container:
214
247
  if not graphs:
215
- ui.label("No graphs open").classes("text-xs hw-text-dim p-2 italic")
248
+ hui.hint("No graphs open").classes("p-2 italic")
216
249
  else:
217
250
  for entry in graphs:
218
251
  self._render_entry(entry, context)
@@ -221,14 +254,17 @@ class HaystackEditor(BaseEditor):
221
254
  # haystack and cannot be run, so it does not belong among the
222
255
  # entries the play controls and the TOML apply to.
223
256
  if macros:
224
- ui.label("Macros").classes("text-xs hw-text-dim px-2 pt-2 pb-0.5 uppercase")
257
+ hui.section_label("Macros")
225
258
  for entry in macros:
226
259
  self._render_entry(entry, context)
227
260
 
228
- self._render_add_bar(context)
261
+ if context.can_edit():
262
+ self._render_add_bar(context)
229
263
 
230
264
  def _render_entry(self, entry: "GraphEntry", context: "SessionContext") -> None:
231
265
  is_active = entry.graph is context.data[EditState].active_graph
266
+ can_edit = context.can_edit()
267
+ can_operate = context.can_access(AccessTier.OPERATE)
232
268
  is_unsaved = entry.unsaved or entry.path is None
233
269
  is_executing = entry.is_executing
234
270
 
@@ -261,9 +297,12 @@ class HaystackEditor(BaseEditor):
261
297
  # to run, so it carries its kind's icon instead of a control whose
262
298
  # only answer would be a refusal.
263
299
  if not entry.kind.is_executable():
264
- ui.icon(hui.icon.node_source, size="16px").classes("hw-text-dim").tooltip(
300
+ ui.icon(hui.icon.macro, size="16px").classes("hw-text-dim").tooltip(
265
301
  "A macro is run by placing it in a graph"
266
302
  )
303
+ elif not can_operate:
304
+ # The row's execution border still shows a running graph.
305
+ pass
267
306
  elif is_executing:
268
307
  hui.icon_action(
269
308
  hui.icon.execution_stop,
@@ -277,6 +316,8 @@ class HaystackEditor(BaseEditor):
277
316
  on_click=lambda eid=eid: self._on_start_execution(eid, context),
278
317
  )
279
318
 
319
+ self._render_notices(eid, context)
320
+
280
321
  # Dirty dot — amber when unsaved, hidden when clean
281
322
  if is_unsaved:
282
323
  ui.element("div").classes("w-2 h-2 rounded-full flex-shrink-0 bg-amber-400").style(
@@ -293,7 +334,7 @@ class HaystackEditor(BaseEditor):
293
334
  ui.label("not saved").classes("text-xs hw-text-warning-dim")
294
335
 
295
336
  # Save icon — paired with the dirty dot; only when unsaved.
296
- if is_unsaved:
337
+ if is_unsaved and can_edit:
297
338
  hui.icon_action(
298
339
  hui.icon.save,
299
340
  tooltip="Save",
@@ -301,7 +342,7 @@ class HaystackEditor(BaseEditor):
301
342
  )
302
343
 
303
344
  # Overflow menu — hidden during execution
304
- if not is_executing:
345
+ if can_edit and not is_executing:
305
346
  with (
306
347
  ui.button(icon="more_vert")
307
348
  .props("flat round dense size=xs")
@@ -332,6 +373,89 @@ class HaystackEditor(BaseEditor):
332
373
  on_click=lambda eid=eid: self._on_entry_delete(eid, context),
333
374
  )
334
375
 
376
+ # ------------------------------------------------------------------
377
+ # graph notices
378
+ # ------------------------------------------------------------------
379
+
380
+ def _delete_notice_popups(self) -> None:
381
+ for popup in self._notice_popups:
382
+ popup.delete()
383
+ self._notice_popups.clear()
384
+
385
+ def _visible_notices(self, binding_id: str, context: "SessionContext") -> list[GraphNotice]:
386
+ """Return the notices on a graph that this editor has not dismissed."""
387
+ posted = list(context.app_data[GraphAppState].notices(binding_id))
388
+ return [n for n in posted if (binding_id, n.key) not in self._dismissed_notices]
389
+
390
+ def _render_notices(self, binding_id: str, context: "SessionContext") -> None:
391
+ """Draw the notice icon, tinted warning, and the popup listing each notice and its action."""
392
+ notices = self._visible_notices(binding_id, context)
393
+ if not notices:
394
+ return
395
+ popup = Popup(
396
+ title="Notices", width="26rem", closable=True, backdrop_click_close=True, escape_close=True
397
+ )
398
+ self._notice_popups.append(popup)
399
+ with popup:
400
+ for notice in notices:
401
+ self._render_notice_line(binding_id, notice, popup, context)
402
+ icons = {n.icon for n in notices}
403
+ shared = icons.pop() if len(icons) == 1 else None
404
+ btn = ui.button().props("flat round dense size=xs data-hw-mark=graph-notice")
405
+ with btn:
406
+ # The icon carries the opt-out: the .hw-panel dim rule beats an inline colour.
407
+ ui.icon(shared or hui.icon.notice).classes("hw-use-props-color hw-text-warning")
408
+ btn.tooltip("\n".join(n.text for n in notices))
409
+ btn.on("click.stop", popup.open)
410
+
411
+ def _render_notice_line(
412
+ self, binding_id: str, notice: GraphNotice, popup: Popup, context: "SessionContext"
413
+ ) -> None:
414
+ with (
415
+ ui.column()
416
+ .classes("w-full gap-1 p-2 rounded")
417
+ .style(
418
+ "border-left: 3px solid var(--hw-warning);"
419
+ " background: color-mix(in srgb, var(--hw-warning) 12%, transparent)"
420
+ )
421
+ ):
422
+ with ui.row().classes("w-full items-start gap-2 no-wrap"):
423
+ ui.icon(notice.icon or hui.icon.notice, size="sm").classes(
424
+ "hw-use-props-color hw-text-warning"
425
+ ).props("data-hw-mark=graph-notice-line-icon")
426
+ ui.label(notice.text).classes("flex-1 min-w-0 text-sm")
427
+ if notice.dismissable:
428
+ hui.icon_action(
429
+ hui.icon.close,
430
+ tooltip="Dismiss",
431
+ on_click=lambda k=notice.key: self._dismiss_notice(binding_id, k, context),
432
+ )
433
+ if notice.action is not None and notice.action_label and context.can_access(notice.requires):
434
+ with ui.row().classes("w-full justify-end"):
435
+ hui.button(
436
+ notice.action_label,
437
+ on_click=lambda n=notice: self._run_notice_action(n, popup, context),
438
+ ).props("data-hw-mark=graph-notice-action").classes("w-auto")
439
+
440
+ def _run_notice_action(self, notice: GraphNotice, popup: Popup, context: "SessionContext") -> None:
441
+ """Run a notice's action with this session's context, then close the popup."""
442
+ client = ui.context.client
443
+ popup.close()
444
+ if notice.action is None:
445
+ return
446
+ try:
447
+ notice.action(context)
448
+ except Exception as exc:
449
+ logger.warning(f"HaystackEditor: notice action {notice.key} failed: {exc}")
450
+ with client:
451
+ ui.notify(f"{notice.action_label or 'Action'} failed: {exc}", type="negative")
452
+
453
+ def _dismiss_notice(self, binding_id: str, key: str, context: "SessionContext") -> None:
454
+ """Hide a notice in this editor instance only."""
455
+ self._dismissed_notices.add((binding_id, key))
456
+ # The list redraw deletes the popup (and this button's slot) holding the handler.
457
+ self._render_list(context)
458
+
335
459
  # ------------------------------------------------------------------
336
460
  # per-entry file actions
337
461
  # ------------------------------------------------------------------
@@ -352,6 +476,9 @@ class HaystackEditor(BaseEditor):
352
476
 
353
477
  def _on_entry_save(self, binding_id: str, context: "SessionContext") -> None:
354
478
  """Save a graph; opens save-as dialog if untitled."""
479
+ if not context.can_edit():
480
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
481
+ return
355
482
  app = context.app
356
483
  if app is None:
357
484
  ui.notify("Graph manager not available", type="warning")
@@ -393,6 +520,9 @@ class HaystackEditor(BaseEditor):
393
520
 
394
521
  def _on_entry_save_as(self, binding_id: str, context: "SessionContext") -> None:
395
522
  """Always open the save-as dialog."""
523
+ if not context.can_edit():
524
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
525
+ return
396
526
  app = context.app
397
527
  if app is None:
398
528
  ui.notify("Graph manager not available", type="warning")
@@ -416,6 +546,8 @@ class HaystackEditor(BaseEditor):
416
546
 
417
547
  def _on_entry_rename(self, binding_id: str, context: "SessionContext") -> None:
418
548
  """Rename: inline edit for file-backed graphs, save-as for untitled."""
549
+ if _refused(context, AccessTier.EDIT, "Renaming"):
550
+ return
419
551
  entry = self._resolve_entry(binding_id, context)
420
552
  if entry is None:
421
553
  return
@@ -435,6 +567,8 @@ class HaystackEditor(BaseEditor):
435
567
  confirmation that warns about losing unsaved work. Clean entries
436
568
  get a milder confirm that calls out that the file stays on disk.
437
569
  """
570
+ if _refused(context, AccessTier.EDIT, "Removing"):
571
+ return
438
572
  entry = self._resolve_entry(binding_id, context)
439
573
  if entry is None:
440
574
  return
@@ -498,6 +632,8 @@ class HaystackEditor(BaseEditor):
498
632
  edit_state = context.data[EditState]
499
633
  edit_state.active_graph = None
500
634
  edit_state.active_graph_path = None
635
+ edit_state.active_route = None
636
+ context.level_mode = LevelMode.VIEW
501
637
  if session:
502
638
  session.publish(ActiveGraphMoved())
503
639
 
@@ -552,6 +688,8 @@ class HaystackEditor(BaseEditor):
552
688
 
553
689
  def _on_new(self, context: "SessionContext") -> None:
554
690
  """Create a new unnamed graph and activate it."""
691
+ if _refused(context, AccessTier.EDIT, "A new graph"):
692
+ return
555
693
  app: IAppState = context.app
556
694
  session = context.session
557
695
  if app is None or session is None:
@@ -589,6 +727,8 @@ class HaystackEditor(BaseEditor):
589
727
 
590
728
  def _on_start_execution(self, binding_id: str, context: "SessionContext") -> None:
591
729
  """Start execution on a graph entry."""
730
+ if _refused(context, AccessTier.OPERATE, "Starting and stopping"):
731
+ return
592
732
  entry = self._resolve_entry(binding_id, context)
593
733
  if entry is None:
594
734
  return
@@ -603,6 +743,8 @@ class HaystackEditor(BaseEditor):
603
743
 
604
744
  def _on_stop_execution(self, binding_id: str, context: "SessionContext") -> None:
605
745
  """Stop execution on a graph entry."""
746
+ if _refused(context, AccessTier.OPERATE, "Starting and stopping"):
747
+ return
606
748
  entry = self._resolve_entry(binding_id, context)
607
749
  if entry is None:
608
750
  return
@@ -615,7 +757,10 @@ class HaystackEditor(BaseEditor):
615
757
  # ------------------------------------------------------------------
616
758
 
617
759
  def _on_save_haystack(self, context: "SessionContext") -> None:
618
- """Save the current set of open graphs as a haystack ("Save As…")."""
760
+ """Save the current set of open graphs as a haystack ("Save As…"); refused below edit."""
761
+ if not context.can_edit():
762
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
763
+ return
619
764
  app: IAppState = context.app
620
765
  if app is None:
621
766
  ui.notify("Graph manager not available", type="warning")
@@ -642,8 +787,11 @@ class HaystackEditor(BaseEditor):
642
787
 
643
788
  Falls through to :meth:`_on_save_haystack` (the Save-As modal) when
644
789
  no haystack is loaded — there's nothing to overwrite, so the user
645
- has to name it.
790
+ has to name it. Refused below edit.
646
791
  """
792
+ if not context.can_edit():
793
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
794
+ return
647
795
  active = self._get_active_haystack_name(context)
648
796
  if not active:
649
797
  self._on_save_haystack(context)
@@ -667,6 +815,8 @@ class HaystackEditor(BaseEditor):
667
815
 
668
816
  def _on_load_haystack(self, context: "SessionContext") -> None:
669
817
  """Load a haystack, replacing all currently open graphs."""
818
+ if _refused(context, AccessTier.EDIT, "Loading a haystack"):
819
+ return
670
820
  app: IAppState = context.app
671
821
  if app is None:
672
822
  ui.notify("Graph manager not available", type="warning")
@@ -731,6 +881,8 @@ class HaystackEditor(BaseEditor):
731
881
  a GraphEditor tab. The user opens it in an editor by selecting the
732
882
  new entry from the list once it appears.
733
883
  """
884
+ if _refused(context, AccessTier.EDIT, "Loading a graph"):
885
+ return
734
886
  app = context.app
735
887
  if app is None:
736
888
  ui.notify("Graph manager not available", type="warning")
@@ -790,6 +942,8 @@ class HaystackEditor(BaseEditor):
790
942
 
791
943
  def _on_rename_haystack(self, context: "SessionContext") -> None:
792
944
  """Open a modal to rename the current haystack."""
945
+ if _refused(context, AccessTier.EDIT, "Renaming"):
946
+ return
793
947
  old_name = self._get_active_haystack_name(context)
794
948
  if not old_name:
795
949
  ui.notify("No haystack is currently loaded", type="warning")
@@ -837,4 +991,5 @@ class HaystackEditor(BaseEditor):
837
991
  # ------------------------------------------------------------------
838
992
 
839
993
  def cleanup(self) -> None:
994
+ self._delete_notice_popups()
840
995
  self._list_container = None