2026 03 12_workspace_decomposition_and_renaming_plan - mark-ik/graphshell GitHub Wiki
Status: Draft (Execution-ready)
Purpose: Decompose the current GraphWorkspace monolith into explicit state containers with names that reflect actual ownership. This plan extends, and does not replace, the March 6 foundational reset work.
Companion docs:
2026-03-06_foundational_reset_graphbrowserapp_field_ownership_map.md2026-03-06_foundational_reset_implementation_plan.md2026-03-08_graph_app_decomposition_plan.md2026-03-08_unified_focus_architecture_plan.md
GraphWorkspace is no longer a coherent concept.
Today it holds a mixture of:
- durable domain truth,
- workbench/session state,
- runtime UI state,
- derived caches and indexes,
- frame-loop authority queues and staged commands.
This is causing two kinds of drift:
- ownership drift: fields with different truth models are stored in one container and named as if they shared a common owner,
-
terminology drift: the word
workspaceincreasingly implies semantic ownership over data that is actually node-owned, workbench-owned, or runtime-only.
workspace.semantic_tags is the clearest current example: the name suggests workspace-scoped semantic truth, while the intended meaning is canonical node-associated tagging.
Move from:
GraphBrowserApp {
workspace: GraphWorkspace,
services: AppServices,
}where GraphWorkspace is a mixed state bucket,
to a model where the major state families are explicit:
GraphBrowserApp {
domain: DomainState,
session: WorkbenchSessionState,
ui_runtime: UiRuntimeState,
runtime_cache: RuntimeDerivedState,
authority: RuntimeAuthorityState,
services: AppServices,
}This is a conceptual target, not a one-shot rewrite requirement. The extraction may proceed incrementally while keeping GraphBrowserApp as the façade.
Do not replace workspace with one new giant synonym.
The correct end state is multiple named containers:
DomainStateWorkbenchSessionStateUiRuntimeStateRuntimeDerivedStateRuntimeAuthorityState
If an interim single-container rename is needed before full decomposition, the least-wrong replacement for GraphWorkspace is:
GraphSessionState
Why:
- it better describes “current live operator/session state”,
- it does not falsely imply semantic ownership of node truth,
- it aligns with the already-emerging distinction between durable domain state and live workbench/session behavior.
Why not use it as the final state model:
- it is still too broad for the actual ownership families,
- it would only rename the monolith, not fix it.
Recommendation: postpone the actual type rename until after the first extraction pass lands. Otherwise the codebase pays the rename cost before gaining the ownership clarity.
Owns durable semantic truth:
- graph nodes and edges,
- node metadata,
- note documents,
- canonical node tags,
- future durable frame/tile-group graph entities.
Rule:
- if the value should survive independently of a particular workbench/session arrangement, it belongs here.
Owns live session and arrangement state:
- active views,
- focused view,
- graph-view layout manager,
- workbench selection,
- tab-strip selection,
- named frame recency and frame membership indexes,
- autosave/change tracking for workbench layout.
Rule:
- if the value describes how the operator currently has the environment arranged or selected, it belongs here.
Owns transient UI/editor state:
- open command/help/radial surfaces,
- tag panel state,
- graph search draft/restore UI state,
- highlighted edge targeting,
- hovered graph node,
- modal and prompt staging that is purely UI-facing.
Rule:
- if the value exists only because an interactive surface is open, focused, hovered, or staged, it belongs here.
Owns derived caches and indexes:
- semantic index,
- dirty flags for derived indexes,
- hop-distance cache,
- render cache state (
egui_state, culling cache), - memory-pressure telemetry,
- graph-view frame caches,
- search/index-derived projections.
Rule:
- if it can be rebuilt from canonical state plus runtime observations, it belongs here.
Owns frame-loop and orchestration authority:
- pending workbench intents,
- pending app commands,
- pending host-create tokens,
- focus authority,
- command/restore queues,
- other staged control-plane carriers.
Rule:
- if it mediates live runtime orchestration rather than representing durable or UI state, it belongs here.
This section refines the March 6 ownership map based on the current field set in graph_app.rs.
Already correctly separated:
services: AppServicesworkbench_tile_selection: WorkbenchTileSelectionState
These should remain independent of the former workspace monolith.
These are canonical or should become canonical:
domain-
semantic_tags→ migrate to node-owned tags insideDomainStaterather than a parallel map
Notes:
-
semantic_tagsis the highest-priority ownership correction. -
semantic_indexstays derived; it does not move withsemantic_tags.
Current fields:
viewsgraph_view_layout_managerfocused_view-
camera(until fully eliminated or reduced to per-view storage) selected_tab_nodestab_selection_anchorsearch_display_modefile_tree_projection_statelast_session_workspace_layout_hashlast_session_workspace_layout_jsonworkspace_autosave_intervalworkspace_autosave_retentionworkspace_activation_seqnode_last_active_workspacenode_workspace_membershipcurrent_workspace_is_synthesizedworkspace_has_unsaved_changesunsaved_workspace_prompt_warned- persisted defaults/preferences that are really workbench/session preferences:
toast_anchor_preferencecommand_palette_shortcuthelp_panel_shortcutradial_menu_shortcutcontext_command_surface_preferencekeyboard_pan_stepkeyboard_pan_input_modecamera_pan_inertia_enabledcamera_pan_inertia_dampinglasso_binding_preferenceomnibar_preferred_scopeomnibar_non_at_orderdefault_registry_lens_iddefault_registry_physics_iddefault_registry_theme_id
Current fields:
show_command_palettecommand_palette_contextual_modeshow_radial_menuhovered_graph_nodeactive_graph_search_queryactive_graph_search_match_countactive_graph_search_originactive_graph_search_neighborhood_anchoractive_graph_search_neighborhood_depthgraph_search_historypinned_graph_searchtag_panel_statehighlighted_graph_edge
Possible later split:
- graph-search state could become its own
GraphSearchUiState.
Current fields:
graph_view_frameshop_distance_cacheegui_stateegui_state_dirtylast_culled_node_keysmemory_pressure_levelmemory_available_mibmemory_total_mibsemantic_indexsemantic_index_dirtysuggested_semantic_tags
Important note:
-
suggested_semantic_tagsis not canonical truth, but it is not just raw UI state either. Treat it as a derived/background-surfaced semantic hint cache.
Current fields:
pending_workbench_intentspending_app_commandspending_host_create_tokens
And any focus-authority carriers that are still nested elsewhere should converge here when practical.
These are valid runtime-only families but may deserve their own nested structs instead of one top-level bucket:
- history preview/replay fields
- form draft capture flag
- any remaining runtime block / webview policy / physics live state fields not shown in the current slice
Recommended nested families:
HistoryRuntimeStateRenderRuntimeStateViewerRuntimeState
Current problem:
-
semantic_tagsis stored asHashMap<NodeKey, HashSet<String>> - naming implies workspace/session scope
- semantics imply node-owned truth
Target:
Node.tags-
RuntimeDerivedState.semantic_indexremains derived -
UiRuntimeState.tag_panel_stateremains transient
This is the first recommended migration because it fixes both terminology and ownership drift.
Current problem:
-
views, layout state, autosave tracking, and render caches live beside each other
Target:
-
WorkbenchSessionStatefor view/layout/session truth -
RuntimeDerivedStatefor render/cache/index projections
Current problem:
- open panel booleans and queued authority commands are mixed in one container
Target:
-
UiRuntimeStatefor what the operator sees/interacts with -
RuntimeAuthorityStatefor how the runtime stages and applies control flow
- Add this plan and align it with the March 6 reset docs.
- Treat new top-level
GraphWorkspacefields as blocked unless they are classified into one of the target owners.
Done gate:
- no new unclassified state is added to
GraphWorkspace.
Inside GraphWorkspace, introduce nested state carriers without changing all callsites immediately:
session: WorkbenchSessionStateui_runtime: UiRuntimeStatederived: RuntimeDerivedStateauthority: RuntimeAuthorityState
During this phase, GraphWorkspace remains the outer shell.
Done gate:
- new fields land only inside a named nested carrier, not on the outer struct.
Recommended order:
-
semantic_tags-> node-owned canonical tags - graph-search + tag-panel + command/help/radial open state ->
UiRuntimeState - semantic index / hop-distance / egui caches ->
RuntimeDerivedState - pending workbench/app command queues ->
RuntimeAuthorityState - view/layout/autosave families ->
WorkbenchSessionState
Done gate:
- top-level outer fields shrink materially and the worst ownership mismatches are gone.
If the outer shell still exists after the main extraction:
- rename
GraphWorkspace->GraphSessionState
Only do this after Phase C is mostly complete. Before then, the rename is churn without clarity.
If the explicit carriers are stable enough, GraphBrowserApp may hold them directly and the former shell type can disappear.
This is optional. The architectural win comes from explicit ownership, not from deleting one wrapper type.
Add or extend contract tests so new code cannot reintroduce mixed ownership casually.
Useful guardrails:
- no direct writes to canonical node tag truth outside reducer paths
- no new top-level mixed state fields on the outer shell
- no UI-only fields inside
DomainState - no derived-cache recomputation logic writing canonical truth
For each moved family:
- keep behavior tests unchanged where possible
- add one “owner moved but behavior identical” test for the migration seam
Important examples:
- tag add/remove still updates pin sync and semantic index invalidation
- graph search UI still restores correctly after state move
- focus authority queues still reconcile correctly after authority-state extraction
The correct move is:
- decompose first
- rename second
Do not spend effort renaming GraphWorkspace while it still contains mixed ownership families.
The strongest immediate actions are:
- move canonical tags onto nodes,
- introduce explicit nested carriers for session/UI/cache/authority,
- block further top-level field accretion.
That gives Graphshell a state model that matches what the system is already becoming:
- durable domain truth,
- workbench session state,
- UI runtime state,
- derived caches,
- runtime authority/control-plane state.
Concrete implementation plan captured from session. Follows directly from the decomposition principles above.
Target struct layout (graph_app.rs ~line 985):
pub struct GraphWorkspace {
pub domain: DomainState, // unchanged
pub graph_runtime: GraphViewRuntimeState,
pub workbench_session: WorkbenchSessionState,
pub chrome_ui: ChromeUiState,
}New file: app/workspace_state.rs — defines three sub-states:
GraphViewRuntimeState — physics, selection, views, search, history, egui, memory, semantic:
physics, physics_running_before_interaction, webview_to_node, node_to_webview,
embedded_content_focus_webview, runtime_block_state, runtime_caches,
active_webview_nodes, active_lru, active_webview_limit, warm_cache_lru,
warm_cache_limit, is_interacting, drag_release_frames_remaining, views,
graph_view_layout_manager, graph_view_frames, focused_view, egui_state,
egui_state_dirty, last_culled_node_keys, undo_stack, redo_stack,
hop_distance_cache, selection_by_scope, camera, graph_reader_state,
hovered_graph_node, highlighted_graph_edge, navigator_projection_state
(renamed from file_tree_projection_state — this is navigator projection runtime state,
not a file-tree concern), selected_tab_nodes, tab_selection_anchor,
search_display_mode, active_graph_search_*, graph_search_history,
pinned_graph_search, tag_panel_state, clip_inspector_state,
pending_clip_inspector_highlight_clear, history_* fields, memory_pressure_level,
memory_available_mib, memory_total_mib, semantic_index, semantic_index_dirty,
semantic_depth_restore_dimensions, suggested_semantic_tags.
Note: pending_app_commands and pending_host_create_tokens stay in
GraphViewRuntimeState — they are broad app-command and lifecycle orchestration queues,
not workbench-session-specific.
WorkbenchSessionState — frame lifecycle, autosave, arrangement sync caches:
last_session_workspace_layout_hash, last_session_workspace_layout_json,
workspace_autosave_interval, workspace_autosave_retention, last_workspace_autosave_at,
workspace_activation_seq, node_last_active_workspace, node_workspace_membership,
current_workspace_is_synthesized, workspace_has_unsaved_changes,
unsaved_workspace_prompt_warned, pending_workbench_intents: Vec<WorkbenchIntent>.
ChromeUiState — overlay toggles, shortcuts, UI preferences:
show_settings_overlay, show_help_panel, show_command_palette,
show_context_palette, command_palette_contextual_mode, context_palette_anchor,
show_radial_menu, show_clip_inspector, history_manager_tab, settings_tool_page,
toast_anchor_preference, command_palette_shortcut, help_panel_shortcut,
radial_menu_shortcut, context_command_surface_preference, keyboard_pan_step,
keyboard_pan_input_mode, camera_pan_inertia_enabled, camera_pan_inertia_damping,
lasso_binding_preference, omnibar_preferred_scope, omnibar_non_at_order,
wry_enabled, form_draft_capture_enabled, default_registry_lens_id,
default_registry_physics_id, default_registry_theme_id.
Migration approach: Create app/workspace_state.rs, declare as mod workspace_state;,
re-export three types from graph_app.rs, replace all GraphWorkspace fields with four
sub-state fields, update both constructors (new_from_dir, new_for_testing), update all
field accesses across ~35 files: workspace.<field> → workspace.<sub>.<field>.
Verification gate: cargo check + cargo test pass.
Problem: workbench_commands.rs writes directly to GraphBrowserApp graph state from
workbench tile layout changes — an implicit workbench→graph sync with no contract.
Target: New file app/arrangement_graph_bridge.rs:
/// Apply an arrangement snapshot to graph truth.
/// This is the single authorised path from workbench arrangement state
/// into graph structure mutations.
pub(crate) fn apply_arrangement_snapshot(
&mut self,
snapshot: &ArrangementSnapshot,
) -> ArrangementGraphDelta-
ArrangementSnapshot— plain data struct carrying tile tree shape (frame name, member node keys, tile group members). Built by callers from the tile tree before calling. -
ArrangementGraphDelta— return value: what nodes were created, what edges changed. - Existing helpers (
ensure_internal_surface_node,replace_internal_surface_membership_edges, etc.) move into this module as private helpers called only byapply_arrangement_snapshot. - Call sites in
workbench_commands.rsupdated to: (1) buildArrangementSnapshotfrom tile tree, (2) callself.apply_arrangement_snapshot(&snapshot).
Verification gate: cargo check + cargo test pass. Grep confirms no direct calls to
ensure_internal_surface_node or replace_internal_surface_membership_edges outside
arrangement_graph_bridge.rs.
Problem: apply_reducer_intent_internal (graph_app.rs:2501) is a ~300-arm match.
Constraint: Current function does not receive a view_id: GraphViewId parameter.
Step 3 must use the current function signature unchanged and resolve focused view
internally.
Target: Four phase-handler functions in app/intent_phases.rs, all &mut self only:
fn handle_workspace_view_intent(&mut self, intent: &GraphIntent) -> bool;
fn handle_chrome_ui_intent(&mut self, intent: &GraphIntent) -> bool;
fn handle_workbench_bridge_intent(&mut self, intent: &GraphIntent) -> bool;
fn handle_domain_graph_intent(&mut self, intent: GraphIntent);apply_reducer_intent_internal becomes a dispatch chain:
fn apply_reducer_intent_internal(&mut self, intent: GraphIntent) {
if self.handle_workspace_view_intent(&intent) { return; }
if self.handle_chrome_ui_intent(&intent) { return; }
if self.handle_workbench_bridge_intent(&intent) { return; }
self.handle_domain_graph_intent(intent);
}Intent arms move verbatim — no logic changes.
Verification gate: cargo check + cargo test pass. Behavior is identical.
Fields involved: workbench_session.node_last_active_workspace,
workbench_session.node_workspace_membership.
Problem: Two separate cleanup calls on node deletion — graph_mutations.rs and
workbench_commands.rs both clear the same fields directly (duplicate path).
Target: Add a method to WorkbenchSessionState:
impl WorkbenchSessionState {
pub(crate) fn on_node_deleted(&mut self, uuid: Uuid) {
self.node_last_active_workspace.remove(&uuid);
self.node_workspace_membership.remove(&uuid);
}
}Replace both cleanup sites with a single call:
self.workspace.workbench_session.on_node_deleted(node_uuid);
This is a small ownership boundary change, not just cleanup: node deletion now notifies
WorkbenchSessionState via its own method rather than having callers reach in and scrub
fields directly.
Verification gate: cargo check + cargo test pass. Grep confirms no direct field
access to node_last_active_workspace or node_workspace_membership outside
workspace_state.rs and persistence_ops.rs (the rebuild path).
-
app/workspace_state.rs— three sub-state struct definitions +WorkbenchSessionState::on_node_deleted -
app/arrangement_graph_bridge.rs—ArrangementSnapshot,ArrangementGraphDelta,apply_arrangement_snapshot, private helpers -
app/intent_phases.rs— four phase handler functions
-
graph_app.rs— struct reshape, both constructors,apply_reducer_intent_internaldispatch -
app/workbench_commands.rs— callers updated to buildArrangementSnapshot+ call bridge; graph-writing helpers removed -
app/graph_mutations.rs— field path updates; node-deletion cleanup replaced withon_node_deleted - All other
app/*.rsimpl files — field path updates (~35 files total)
- Step 1 — struct split + field rename; largest change, compiler-driven completeness
- Step 2 — arrangement reconciler; isolated to bridge module + two call sites
- Step 3 — intent handler phasing; restructuring only, no logic changes
- Step 4 — cache cleanup consolidation; small but explicit ownership change