2026 03 20_arrangement_graph_projection_plan - mark-ik/graphshell GitHub Wiki
Date: 2026-03-20 Revised: 2026-03-21 Status: Implementation complete โ all phases shipped (2026-03-21) Priority: Architecture โ foundational
Related:
-
WORKBENCH.mdโ workbench owns arrangement interaction/session mutation truth -
graph_first_frame_semantics_spec.mdโ Frame as graph-first organizational object; handle model -
frame_persistence_format_spec.mdโ FrameSnapshot bundle shape and restore contract -
../canvas/2026-03-14_graph_relation_families.mdโ ArrangementRelation family; FamilyPhysicsPolicy -
../canvas/multi_view_pane_spec.mdโ hosted surface contract; GraphViewId identity -
workbench_frame_tile_interaction_spec.mdโ arrangement interaction contracts; ยง4.2 routing priority -
../navigator/navigator_interaction_contract.mdโ Navigator projection over relation families -
../PLANNING_REGISTER.mdโ execution control-plane -
../../TERMINOLOGY.mdโ Frame, TileGroup, HostedSurface, ArrangementRelation, Graphlet
The current architecture treats the tile tree as the primary truth for
workbench arrangement: splits, tab groups, frame membership, tab order, and
active-tab markers all live in egui_tiles::Tree<TileKind> and survive only
as a FrameSnapshot. This creates four concrete problems:
-
Navigator and workbench can diverge. Navigator projects tile-tree snapshots; workbench projects live
egui_tilesstate. Any drift between them produces ordering inconsistencies that are undetectable at the contract level. -
Arrangement state is not graph-backed.
ArrangementRelationis defined as a forthcomingEdgeKindfamily ingraph_relation_families.md ยง2.4(sub-kinds:frame-member,tile-group,split-pair), but no arrangement state is read back from the graph to drive the tile tree. Frame membership, tab ordering, and active-tab markers exist only in the tile tree. -
Workbench invocation is ad hoc. Opening a node in a split, adding it to a tab group, or promoting an overlay are all tile-tree-direct mutations with no graph-backed audit trail and no clear routing authority.
-
Tile grouping is not graphlet-causative. The edges that connect nodes in the graph โ hyperlinks, history traversal, explicit user groupings, frame membership โ do not drive which nodes appear together in the workbench tile tree. A user who filters edges to isolate a connected component (graphlet) and then opens a member node gets no automatic grouping of that graphlet's other members. The graph's structure and the workbench's structure are semantically unrelated.
The insight from graph_relation_families.md ยง1.1: if arrangement belongs to
one relation family, the Navigator, the workbench, the physics engine, and the
persistence layer can all share one model rather than maintaining parallel
structures.
Authority split:
-
Graph carries membership truth. The canonical graph (nodes, edges,
NodeLifecycle) is the authority for which nodes belong together and whether each is currently presented.UserGroupedandFrameMemberedges are the durable record; lifecycle state is the presence record. There is no separate in-memory arrangement graph. -
Workspace state carries presentation truth. How those nodes are
arranged โ split geometry, tab order, active-tab identity โ lives in
session-local workbench objects (
SplitContainer,SetGroupActiveMemberhistory) and is captured in theFrameSnapshotfor workspace restore. This state is not in the graph edge layer.
The workbench tile tree is a projection of both layers. The Navigator reads the same sources. They agree structurally, not incidentally.
Every node carries a lifecycle state:
| State | Meaning | Tile presence |
|---|---|---|
Active |
Live renderer (WebView / viewer) running | Has a tile; renderer is live |
Warm |
Tile exists; renderer pending or suspended | Has a tile |
Cold |
In graph with edges intact; no tile | No tile; visible in omnibar and Navigator with โ badge |
Dismiss gesture (DismissTile): close a tile without removing any edges.
The node's lifecycle moves to Cold. The node stays in every graphlet it
belonged to; its edges are entirely intact.
Delete gesture: remove the node from the graph. Separate action; more permanent; edges are retracted; the node leaves all graphlets.
These two gestures must never be aliased to the same UI action.
A graphlet is the set of nodes reachable from a given node by traversing edges that pass the active lens filter โ the weakly connected component containing that node under the filtered edge set.
The graphlet is the primary arrangement unit. The tile tree shows the warm/active slice of each graphlet. The omnibar shows the full roster (warm and cold alike).
Graphlet G (under active filter):
N โ Active (has live tile)
M1 โ Cold (in graphlet, no tile)
M2 โ Cold (in graphlet, no tile)
M3 โ Warm (has live tile)
Tile tree: Container::Tabs { N tile | M3 tile }
Omnibar: N โ M3 โ | M1 โ M2 โ
Navigator: graphlet row โ N โ M3 โ M1 โ M2 โ
When the filter changes, the graphlet boundary changes. Nodes may join or leave a visible graphlet. Their lifecycle state and edge connections are not affected by filter changes โ only the grouping is recalculated.
Any edge that passes the active lens filter contributes to graphlet connectivity:
| Family | Durability | Graphlet character |
|---|---|---|
UserGrouped |
Durable | Explicit user-created connection; filter-independent |
ArrangementRelation(FrameMember) |
Durable | Named frame membership; filter-independent |
Hyperlink |
Filter-derived | Navigated-between pages |
History |
Filter-derived | Browsing session traversal chain |
ContainmentRelation |
Filter-derived | Same domain / URL path |
AgentDerived |
Session (decay) | Agent-inferred similarity |
Durable graphlets: formed by UserGrouped and FrameMember edges.
Persist across sessions regardless of filter. These are the graphlets the user
explicitly creates or that a named frame creates.
Circumstantial graphlets: formed by filter-included Hyperlink, History,
or ContainmentRelation edges. Exist when those edges pass the filter; change
as the filter changes.
Long-chain graphlets: History traversal edges chain nodes into browsing
session paths. A History-inclusive filter makes the whole traversal chain one
graphlet.
For implementation purposes the workbench tracks these session-local objects.
They are not graph nodes and have no verso:// addresses.
| Object | Identifier | Purpose |
|---|---|---|
HostedSurface |
HostedSurfaceId |
Binds a warm/active node to its live renderer; presents_node: NodeKey is a struct field, not an edge |
SplitContainer |
SplitContainerId |
Carries split axis, share proportions, and ordered child references; relates tile groups spatially |
SplitContainer persistence: SplitContainer identity and parent/child
structure are captured in the FrameSnapshot for named frames (debounced
autosave). The snapshot records the ordered sequence of split children and the
committed share proportions. For unnamed session contexts (no FrameMember
edges to a named frame anchor), SplitContainer state is purely ephemeral and
is not persisted. Split geometry is not in the graph edge layer.
Ephemeral session state โ does not enter arrangement truth:
- Focus and hover state
- Drag target and drag preview
- Split resize geometry during an active drag (committed on drag-end)
- Ephemeral panes (QuarterPane / HalfPane / FullPane) before enrollment into the arrangement
SetGroupActiveMember records which node is the active tab within a tile
group. Persistence rules:
-
Named frames: the active-tab node identity is written into the
FrameSnapshoton each debounced autosave. On workspace restore, the saved active-tab node is made active (if it is still warm/active); otherwise the most-recently-activated warm member is used. - Unnamed session contexts: active-tab is session-only. It is not persisted; on restart the default ordering (most-recently-activated) applies.
SetGroupActiveMember is independent of keyboard/accessibility focus routing.
Moving focus to the Navigator does not change the active-tab marker.
A node may have FrameMember edges to multiple named frames
simultaneously. In a live session:
- The node's tile resides in the most-recently-active frame context for that node (last frame in which the node was warm or active).
- In all other frames, the node appears as cold (โ badge) in the omnibar and Navigator roster for that frame.
-
RemoveFromGraphletin one frame retracts only theFrameMemberedge to that frame's anchor; membership in other frames is unaffected.
A node has exactly one NodeLifecycle state at any time. Being a member of
multiple frames does not create multiple lifecycle states.
Durable edges (UserGrouped, ArrangementRelation(FrameMember)) persist
naturally in the graph store (redb WAL). Graphlet membership for named frames
survives restarts because FrameMember edges survive restarts.
NodeLifecycle::Cold is stored on the node. Cold graphlet members persist
across sessions. On startup, the graph's edges reconstruct graphlet membership
directly; no separate bootstrap step is required.
The FrameSnapshot bundle (per frame_persistence_format_spec.md) captures
presentation state at save time: which nodes were warm or active, the
active-tab identity for each tile group, and split structure/proportions. Its
role is workspace restore โ re-opening the saved tiles and arrangement
shape when a frame is loaded โ not carrying membership truth. Graph edges carry
that truth.
The bundle and the graph are mutually consistent for named frames. If they diverge (e.g. a graph edge was added without a snapshot update), graph edges take precedence for membership; the snapshot's presentation shape is applied on top.
Named frames autosave (debounced, 1 s quiescence) to keep FrameSnapshot
current for workspace restore. Unnamed session contexts (no FrameMember edges
to a named anchor) evaporate on close โ their transient tiles are not
persisted.
Significant events trigger an immediate autosave write for named frames: frame naming, node added to or removed from a named frame (edge asserted or retracted).
FrameSnapshot bundles reference nodes by stable UUID (per
frame_persistence_format_spec.md ยง4.2), making them portable across
instances. Graphlet membership for durable graphlets is carried by graph edges,
which are also UUID-stable.
-
Durable
FrameMemberedges for named frames โGraphIntent::AssertArrangementRelation/RetractArrangementRelation. These produce durableArrangementRelationedges in the graph store. -
UserGrouped edges (e.g. from growing a graphlet via new tile) โ
GraphIntent::CreateUserGroupedEdge. -
Lifecycle changes โ
GraphIntent::PromoteNodeToActive,DemoteNodeToWarm,DemoteNodeToCold. -
Active-tab identity โ
SetGroupActiveMember(session state); for named frames, autosave captures the active-tab node inFrameSnapshot. -
Split geometry โ
WorkbenchIntentupdatingSplitContainer.sharesand child ordering (session state); autosave captures the committed split structure inFrameSnapshotfor named frames.
The workbench tile tree is built by:
- Reading the active lens filter to determine the visible edge set.
- Computing graphlets: weakly connected components over the filtered graph
(
graph.weakly_connected_components()applied to the filter-projected edge set). - For each graphlet with at least one warm/active member: ensure the tile
tree contains a
Container::Tabsholding those warm members. - For each warm node with no graphlet peers: ensure a single tile (no tab container wrapper).
- Applying saved
SplitContainergeometry to arrange tile groups spatially. - Overlaying ephemeral session state (focus ring, drag preview, hover).
Filter stability for durable groups: filter changes only add or remove
nodes at the boundary of a durable graphlet โ they do not split or merge
existing durable groups. A durable graphlet (formed by UserGrouped or
FrameMember edges) remains intact regardless of filter state. A filter change
may cause a circumstantially connected node to join or leave the durable group's
graphlet boundary; nodes that leave become standalone graphlets or join other
reachable components, but their lifecycle is not changed and their tiles (if
warm) remain live.
Bidirectional binding: changes flow in both directions.
- Graph โ workbench: edge added or removed โ graphlet recomputed โ reconciler updates tile tree. Filter change โ graphlets recomputed โ reconciler updates tile tree. Node promoted/demoted โ reconciler adds or removes tile.
-
Workbench โ graph: open tile โ
PromoteNodeToActive; dismiss tile โDemoteNodeToCold; grow graphlet by opening new tile โ edge created; remove from graphlet โ edges retracted.
Invariant: the tile tree is never semantic arrangement truth. If it drifts from the expected projection, the graph (edges + lifecycle) wins.
- Compute N's graphlet G under the active filter.
- Determine the destination tile group:
a. If a tile group for G already exists (any warm member present) โ route N
into that group.
b. If no tile group exists โ create a new
Container::Tabsfor G. -
PromoteNodeToActive(N)โ N gets a live tile. - Graphlet peers (M1, M2, โฆ) remain cold. They appear in the omnibar roster with โ badges but do not get tiles automatically.
- If G has
FrameMemberedges to a named frame anchor โ frame routing applies perworkbench_frame_tile_interaction_spec.md ยง4.2: prefer last-active frame, then deterministic fallback.
When the user opens a new tile within an existing tile group:
- A new node is created (or an existing node is chosen from the omnibar).
- A
UserGroupededge is created from the new node to any existing graphlet member (or to the frame anchor if one exists). This makes the new node a durable graphlet member regardless of the active filter. -
PromoteNodeToActive(new_node)โ the new node gets a live tile in the same tab group.
This is the mechanism by which explicit tile-opening grows the graphlet. The edge ensures that even if the filter later changes to exclude other circumstantial edges, the explicitly added node stays in the graphlet.
-
DemoteNodeToCold(N)โ N's lifecycle becomesCold. - Close N's
HostedSurface(tile closed; renderer released). - All edges connecting N to its graphlet remain intact.
- N remains in the omnibar roster with โ badge.
- If all members of the graphlet become cold: the tile group is removed from the tile tree. The graphlet is intact in the graph and will reappear in the tile tree when any member is next activated.
DismissTile is the standard "close a tile" gesture. It is not destructive.
RemoveFromGraphlet retracts only the durable arrangement edges connecting
N to graphlet G โ UserGrouped and ArrangementRelation(FrameMember) edges.
Circumstantial edges (Hyperlink, History, ContainmentRelation) are not
retracted; semantic relationships the user navigated are not erased.
- Retract all
UserGroupededges between N and any member of G. - If G is a named frame: emit
GraphIntent::RetractArrangementRelationfor theFrameMemberedges connecting N to G's anchor. - N is no longer a durable graphlet member and does not appear in the omnibar roster or Navigator row for G.
- N's lifecycle is unchanged. If N had a live tile, it is now a standalone tile (or part of another graphlet if N retains durable edges to other nodes).
- N may still appear in circumstantial graphlets if Hyperlink/History edges to G's members pass the active filter โ this is correct behavior, not a bug.
RemoveFromGraphlet is the "leave this arranged cohort" gesture. It is more
permanent than dismiss (which only changes lifecycle), but it does not erase
semantic history. Calling RemoveFromGraphlet on a node that is connected to
G only via circumstantial edges has no effect on the graph (no durable edges
to retract).
From omnibar: The omnibar lists all graphlet members (warm โ and cold โ).
Selecting a cold entry triggers OpenNode(N) with the current graphlet's tile
group as the routing target. N gets a tile in the existing tab group.
From canvas: Multiselect cold nodes on the graph canvas โ "Warm Select"
action โ OpenNode for each selected node, routed into their respective
graphlets' tile groups.
| Action | Arrangement effect |
|---|---|
OpenInSplit |
Creates a SplitContainer; places existing tile group and new tile group as split children |
SetGroupActiveMember |
Updates the active-tab marker for a tile group (session state; debounced autosave for named frames) |
ActivateSurface |
Routes keyboard/accessibility focus via Focus Subsystem; independent from SetGroupActiveMember
|
EnrollOverlayInArrangement |
Converts an ephemeral pane into a warm graphlet member; creates UserGrouped or FrameMember edge |
CommitSplitShares |
Writes final split share values on drag-end; triggers debounced autosave |
A graphlet's roster = all nodes connected to any warm member via filter-visible edges. The roster has two slices:
-
Warm slice: nodes with
NodeLifecycle::ActiveorWarmโ have live tiles in the tile tree. -
Cold slice: nodes with
NodeLifecycle::Coldโ in the graphlet, no tile.
The tile tree shows the warm slice. The omnibar and Navigator show the full roster.
The omnibar within a workbench context shows the active tile group's graphlet roster:
- Warm members: โ indicator; click to focus their tile.
- Cold members: โ indicator; click to activate (opens tile in same tab group).
- Ordered: warm members first (by last-activation order), then cold members (by last-activation recency).
The omnibar is the primary discovery surface for cold graphlet members. It answers "what else belongs here that I'm not looking at right now?"
Cold graphlet members appear in the Navigator with a cold residency badge
(โ). They are not hidden. This is a deliberate update to
navigator_interaction_contract.md ยง2.1, which previously suppressed nodes
without a live tile representation.
Under the graphlet model, graph membership (edges) is sufficient for Navigator projection. The tile tree is not the authority.
- Single-click cold node: select the node in the graph.
- Double-click cold node:
OpenNode(N)โ activates the node, opens a tile in the graphlet's tab group. - Right-click โ "Remove from graphlet":
RemoveFromGraphlet(N, G).
The Navigator reads graphlet membership (edge connectivity + filter) and lifecycle state directly from the graph โ the same source the workbench projection reads. Agreement is structural, not incidental.
| Navigator concern | Source |
|---|---|
| Which nodes are in a tile group | Graphlet connectivity (edges under active filter) |
| Warm / cold status |
NodeLifecycle field on each node |
| Active tab within group |
SetGroupActiveMember history / last-activation order |
| Frame membership |
ArrangementRelation(FrameMember) edges |
| Split structure |
SplitContainer session layout preferences |
Graph state:
N โ[UserGrouped]โ M1
N โ[UserGrouped]โ M2
N โ[Hyperlink]โ M3 (Hyperlink visible under current filter)
N โ[FrameMember]โ FrameAnchor "Research"
Lifecycle:
N: Active M1: Cold M2: Cold M3: Warm
Graphlet G = { N, M1, M2, M3 }
Tile tree: Container::Tabs { N tile | M3 tile }
Omnibar: N โ M3 โ | M1 โ M2 โ
Navigator: Frame "Research" โ G: N โ M3 โ M1 โ M2 โ
User removes Hyperlink edges from the active filter:
Graphlet G' = { N, M1, M2 } (M3 is now its own singleton graphlet)
Tile tree: Container::Tabs { N tile } (M3 tile still live, now standalone)
Omnibar: N โ | M1 โ M2 โ
Navigator: Frame "Research" โ G': N โ M1 โ M2 โ
(M3 appears in its own row as a singleton)
M3's tile is not destroyed by the filter change โ lifecycle is not changed by
filter changes. M3's NodeLifecycle remains Warm. M3 simply belongs to a
different (singleton) graphlet now.
The reconciler runs when:
- A node's lifecycle changes (
Active/Warm/Cold) - An edge is added or removed (graphlet boundary change)
- The active filter changes (graphlet recomputation)
- The tile tree drifts from its expected projection state
Scoping: the reconciler is scoped to the affected graphlet(s), not the full graph. An edge change between nodes N and M only triggers recomputation for the connected component containing N and M. A lifecycle change on node N only triggers recomputation for N's graphlet. A filter change triggers full recomputation (all graphlets are potentially affected). This keeps per-interaction cost O(component size), not O(V+E) for the whole graph.
Steps for each affected graphlet:
- Compute graphlet membership under the active filter (weakly connected components restricted to the affected component set).
- For each graphlet with โฅ 1 warm/active member:
a. Ensure a
Container::Tabsexists for those warm members. b. Ensure tab order reflectsSetGroupActiveMemberhistory or most-recent-activation order. - For each warm/active node with no warm graphlet peers: single tile, no tab container.
- Apply
SplitContainergeometry from saved layout preferences. - Remove empty containers.
The reconciler never changes NodeLifecycle. Lifecycle changes are driven
exclusively by explicit user actions (OpenNode, DismissTile,
RemoveFromGraphlet) and the PromoteNodeToActive / DemoteNodeToCold
intent path. The reconciler only mutates the tile tree.
Drag preview, split preview, and resize drag bypass the reconciler for their duration. On confirm or cancel, the reconciler resumes.
- A
Container::Tabswhose entire warm slice has been dismissed is removed from the tile tree. The graphlet remains intact in the graph. - A
SplitContainerreduced to one child is collapsed: the sole child is promoted to the parent container. - A
SplitContainerwith zero children is removed unconditionally.
Cleanup runs at the end of each reconcile pass. No empty container rows appear in the Navigator.
Arrangement edge writes that would create a cycle (e.g. a FrameMember loop)
are rejected as a precondition check before the write. Not repaired after.
SplitContainer.shares are not updated during an active resize drag. Live
resize geometry is ephemeral session state for the drag's duration โ the
reconciler is bypassed and the tile tree holds the live geometry. On drag-end
(mouse release), CommitSplitShares writes the final share values and triggers
debounced autosave for named frames.
| Channel | Severity | Emitted when |
|---|---|---|
arrangement:graphlet_computed |
Info | Filter change triggers graphlet recomputation |
arrangement:membership_changed |
Info | Node joins or leaves a graphlet (edge add/remove or filter change) |
arrangement:lifecycle_transition |
Info | Node lifecycle changes (Active / Warm / Cold) |
arrangement:mutation_failure |
Error | Arrangement edge write fails (graph intent rejected, validation error) |
arrangement:cycle_detected |
Error | Arrangement mutation would create a cycle; write rejected |
arrangement:reconciliation_drift |
Warn | Tile tree shape differs from expected projection at reconcile time |
arrangement:autosave_failure |
Error | Autosave write of FrameSnapshot to redb fails |
arrangement:autosave_write |
Info | Autosave successfully refreshed a named frame's FrameSnapshot |
arrangement:bootstrap_populated |
Info | Graphlet membership populated from graph edges at startup |
Phased. Each phase is independently shippable.
- Implement
compute_graphlets(graph, filter) -> Vec<Vec<NodeKey>>usinggraph.weakly_connected_components()applied to the filter-projected edge set. This is a pure query; no graph mutations. - Wire: filter change signal โ graphlet recomputation โ
WorkbenchProjectionRefreshRequested. - No workbench behavior change yet.
- Gate:
compute_graphletsreturns correct components for test graphs with mixed edge families and filter configurations.
- Reconciler reads graphlets + lifecycle โ drives tile tree.
-
DismissTileโDemoteNodeToCold+ close tile; edges preserved. -
OpenNodeโ compute graphlet, route to correct tab group. - Gate: tile tree reflects exactly the warm/active members of each graphlet.
- Gate:
DismissTiledoes not remove any edges from the graph.
- Omnibar lists full graphlet roster (warm โ + cold โ) for the active tile group.
-
OmnibarMatch::ColdGraphletMember(NodeKey)variant added;TabsLocalempty-query branch extended to append cold peers of all warm nodes in the tree;apply_omnibar_matchroutes viaSelectNode+ToolbarOpenMode::Tab. - Cold nodes activatable from omnibar.
- Gate met: cold members visible in omnibar; activating opens tile via graphlet routing in same tab group.
- Update Navigator to show cold graphlet members with โ badge.
-
WorkbenchNavigatorMember.is_coldfield already carriedis_cold; Navigator render loop updated to prepend "โ " prefix for cold members. -
arrangement_navigator_groupsalready extended with coldUserGroupedpeers. -
SidebarAction::ActivateNodealready callsopen_node_with_graphlet_routingfor cold nodes โ no change needed. - Gate met: cold nodes appear in Navigator with โ badge; double-click opens tile in graphlet tab group.
- "Open new tile in group" creates new node +
UserGroupededge โ grows graphlet durably. -
CreateNodeNearCenterAndOpen { mode: Tab }handler captures the active primary selection before callingcreate_new_node_near_center()(which overwrites the selection with the new node's key), then creates theUserGroupededge and enqueuesReconcileGraphletTiles. - Gate met: new node appears as permanent graphlet member; new-tile edge survives filter changes that would exclude other circumstantial edges.
- Multiselect cold nodes on graph canvas โ "Warm Select" activates them into their graphlets' tab groups.
-
ACTION_GRAPH_SELECTION_WARM_SELECTdispatchesOpenNodeInPanefor each cold selected node; cold nodes are opened via graphlet routing. - Gate met: selected cold nodes get tiles in correct tab groups.
Audit (2026-03-21) confirmed no routing or invocation callsites remain that use the tile tree as arrangement authority. Findings:
-
tile_grouping.rs+tile_post_render.rsโ drag-detection that firesGraphIntent::CreateUserGroupedEdgeon tab-drop. This is the correct workbench โ graph direction: it writes to arrangement truth in response to a gesture. Intentionally kept; not a tile-tree-as-truth read. -
toolbar_omnibar.rs(tab_node_keys_in_tree) โ lists open node-pane keys for omnibar tab suggestions. Display/search callsite only; does not influence arrangement routing or invocation decisions. Intentionally kept. -
FrameTabSemanticsโ was never implemented in code. No removal needed.
FrameSnapshot is workspace-restore format only; graph edges (UserGrouped,
FrameMember) and NodeLifecycle are arrangement membership truth. This
invariant holds throughout the codebase.
Intentional tile-tree reads (not in scope for removal):
- Layout reads (split geometry, active-tile focus ring) โ tile tree is the layout authority; these are correct by design.
- Display/search queries (omnibar tab listing) โ read-only; not arrangement authority callsites.
- Drag-detection edge writes โ write to graph from tile-tree events; correct direction.
Gate met: no direct tile-tree reads for arrangement ordering from routing or invocation callsites.
-
Graph carries membership truth; workspace state carries presentation
truth. Graph edges (
UserGrouped,FrameMember) andNodeLifecycleare the authority for which nodes belong together and whether each is presented. Split geometry, tab order, and active-tab identity are workspace-local presentation state captured inFrameSnapshotfor named frames. Neither layer is reducible to the other. - Graphlet membership is determined by edges + active filter. Different filters โ different graphlets. Lifecycle state is not changed by filter changes.
-
Durable graphlets are not split or merged by filter changes. A
UserGroupedorFrameMembergroup survives filter changes intact. Filter changes only affect circumstantial boundary nodes. - The tile tree shows the warm/active slice of each graphlet. Cold members are in the omnibar and Navigator but not the tile tree.
-
Dismiss โ cold. Delete โ remove.
DismissTilepreserves edges and graphlet membership; delete retracts edges and removes the node. These gestures must never be aliased. -
RemoveFromGraphletretracts only durable edges.UserGroupedandFrameMemberedges are retracted; circumstantial edges (Hyperlink, History) are not touched. -
Growing a graphlet via new tile creates a durable
UserGroupededge. The new node's graphlet membership is filter-independent. -
Filter change recalculates graphlets but does not change lifecycle. A
node that exits a graphlet due to a filter change retains its
NodeLifecycle::WarmorActive; its tile remains live. - Navigator and tile tree agree because they read the same graph. Agreement is structural, not incidental.
-
GraphViewIdremains graph-owned even when hosted in a tile surface. -
HostedSurfaceis workbench-local. Noverso://surface/graph node;HostedSurfaceIdis a session-only binding;presents_nodeis a struct field, not a graph edge. -
Closing a tile does not remove edges.
DemoteNodeToCold+ tile close; edges intact; graphlet membership unchanged. - Cycle detection is a precondition. Arrangement edge writes that would create cycles are rejected before write, not repaired after.
-
Split resize geometry is ephemeral during drag.
SplitContainer.sharescommitted on drag-end only. Reconciler bypassed during resize. -
FrameSnapshotis workspace-restore format, not membership truth. Graph edges reconstruct durable membership at startup without a separate bootstrap. On conflict, graph edges win for membership; snapshot wins for presentation shape. -
Active-tab persistence is scoped to named frames.
SetGroupActiveMemberis session-only for unnamed contexts; written toFrameSnapshotfor named frames. -
Multi-presence is permitted. A node may hold
FrameMemberedges to multiple named frames. Its tile lives in the most-recently-active frame context; other frames show it as cold. - The omnibar is the primary cold-node discovery surface. Cold graphlet members are always accessible via omnibar or Navigator; they are not hidden or lost.
- Reconciler cost is scoped to affected components. Only the connected component(s) containing changed nodes are recomputed per edge/lifecycle event. Full recompute only on filter change.
| Criterion | Verification |
|---|---|
| Graphlet computed from edges + filter | Test: add Hyperlink edge AโB; filter includes Hyperlink; A and B in same graphlet |
| Filter change updates graphlet | Test: remove Hyperlink from filter; A and B in separate graphlets; lifecycle unchanged |
| Warm members appear in tile tree | Test: A active, B cold; tile tree contains A tile, no B tile |
| Cold members appear in omnibar with โ | Test: A active, B cold; omnibar shows A โ B โ |
| Activating cold node from omnibar opens tile in same group | Test: click B โ in omnibar; B tile opens in same Container::Tabs as A |
| Canvas multiselect warm-select | Test: select B, C cold on canvas; warm-select โ B and C tiles open in graphlet tab group |
DismissTile โ cold, edges preserved |
Test: close A tile; NodeLifecycle::Cold; A's edges intact; A in graphlet; A in omnibar with โ |
DismissTile does not affect graphlet peers |
Test: close A tile; B tile and B lifecycle unchanged |
| All members cold โ tile group removed from tile tree | Test: dismiss all tiles in graphlet; no Container::Tabs in tile tree; graphlet intact in graph |
| Cold member activated โ tile group recreated | Test: dismiss all tiles, then activate one cold member; Container::Tabs appears in tile tree |
Open new tile in group โ UserGrouped edge created |
Test: open new tile in tile group; new node has UserGrouped edge to an existing graphlet member |
| New-tile node is durable graphlet member | Test: open new tile; change filter to exclude other edge families; new node still in graphlet (UserGrouped edge survives filter) |
RemoveFromGraphlet retracts edges |
Test: remove N from graphlet; N's graphlet edges retracted; N absent from tab group, omnibar roster, and Navigator graphlet row |
Named-frame FrameMember edges persist across restart |
Test: create named frame (assert FrameMember edges); restart; edges restored from graph store |
FrameSnapshot captures warm members for workspace restore |
Test: named frame with A warm, B cold; autosave; restart; A re-opened as warm; B remains cold (NodeLifecycle::Cold) |
| Cold nodes appear in Navigator with โ badge | Test: dismiss A tile; Navigator shows A with cold badge in graphlet row |
| Double-click cold Navigator node opens tile | Test: double-click cold A in Navigator; OpenNode fires; A tile opens in graphlet tab group |
| Filter change does not change lifecycle | Test: remove Hyperlink from filter; B exits graphlet; B's NodeLifecycle unchanged; B tile (if warm) still live |
| Single warm node = single tile, no tab container | Test: graphlet with exactly 1 warm node; tile tree has no Container::Tabs wrapper |
| Split shares committed on drag-end | Test: resize drag; reconciler bypassed mid-drag; mouse release fires CommitSplitShares; shares updated; debounced autosave triggered |
| Reconciler does not change lifecycle | Architecture invariant: reconciler only mutates tile tree structure; no PromoteNodeToActive or DemoteNodeToCold from reconciler |
SetGroupActiveMember and focus routing are independent |
Test: move keyboard focus to Navigator; active-tab marker in tile group unchanged |
| Cycle write rejected | Test: write FrameMember edge that would create cycle; write rejected; arrangement:cycle_detected emitted |
EnrollOverlayInArrangement makes ephemeral pane a graphlet member |
Test: promote ephemeral pane; UserGrouped or FrameMember edge asserted; node joins graphlet |
Automated tests covering the ยง12 acceptance criteria live in
shell/desktop/tests/scenarios/grouping.rs and
shell/desktop/ui/workbench_sidebar.rs (test module).
| Test | File | Criterion covered |
|---|---|---|
create_user_grouped_edge_from_primary_selection_creates_grouped_edge |
grouping.rs |
UserGrouped edge creation |
dismiss_tile_demotes_lifecycle_and_preserves_edges |
grouping.rs |
DismissTile โ cold, edges preserved |
dismissed_node_remains_in_durable_graphlet |
grouping.rs |
Dismissed node remains graphlet peer |
open_node_with_graphlet_routing_joins_warm_peer_tab_container |
grouping.rs |
Activating cold node routes into existing tab group |
cold_node_reactivated_joins_existing_tab_group |
grouping.rs |
Cold member activated โ joins existing tab group |
reconcile_graphlet_merges_tiles_from_different_tab_containers |
grouping.rs |
ReconcileGraphletTiles merges separate containers |
remove_from_graphlet_action_retracts_durable_edges_only |
grouping.rs |
RemoveFromGraphlet retracts only durable edges |
warm_select_action_dispatches_open_intent_for_cold_selected_nodes |
grouping.rs |
Canvas multiselect warm-select |
new_tile_as_tab_creates_durable_graphlet_edge |
grouping.rs |
New-tile-in-group creates UserGrouped edge (Phase 5) |
active_graphlet_roster_marks_cold_peers_as_cold |
workbench_sidebar.rs |
Cold roster entry is_cold = true (Phase 3) |
arrangement_navigator_member_marks_dismissed_cold_peer_as_cold |
workbench_sidebar.rs |
Navigator cold badge is_cold = true (Phase 4) |