The workspace graph is ADE's visual topology canvas for lanes and
the relationships between them: stack structure, pairwise conflict
risk, sync state, PR overlays, merge simulation entry points, and
integration proposals. It's rendered with React Flow (@xyflow/react)
in renderer/components/graph/.
The graph is not a separate data layer — it projects the same lane, conflict, PR, and git service state the rest of the app uses into a spatial view. Data flows in staged layers so the canvas becomes usable before every overlay finishes loading.
Every backing data feed (lane list, conflict batch assessment, sync
status, auto-rebase status, PR list, integration proposals,
operations) is served by the active ADE runtime for the window's
project binding — the local ADE daemon for local-bound windows or the
SSH-attached remote runtime for remote-bound windows. The renderer
calls into the runtime through preload's
callProjectRuntimeActionOr(...) helpers and falls back to the legacy
in-process IPC handlers when no runtime is bound. Persisted graph
preferences (node positions, view mode, filters) are stored through
the runtime's graph_state action domain — they live in the runtime's
state store so the layout follows the project binding (and survives
across desktop windows pointed at the same project). The renderer
itself owns no service state; it is purely a projection of runtime
data.
For remote-bound windows the entire data graph is computed on the remote machine; the desktop renderer just receives the snapshots and event deltas.
Core renderer files (apps/desktop/src/renderer/components/graph/):
| File | Responsibility |
|---|---|
WorkspaceGraphPage.tsx |
Top-level page (4.4k lines). Owns state, staged loading, refresh scheduling, interaction handlers |
graphTypes.ts |
Node/edge data shapes, graph PR overlay, dialog state types |
graphHelpers.ts |
View-mode metadata, icon/color palettes, proposal helpers, risk-edge coloring, laneSummaryConflictsWith |
graphLayout.ts |
Auto-layout per view mode, filter defaults, session/preferences persistence, legacy migration |
graphPrData.ts |
buildGraphPrOverlay — derives GraphPrOverlay from a PrSummary + optional live detail bundle |
graphNodes/LaneNode.tsx |
Lane node rendering (badges, status, PR overlay) |
graphNodes/ProposalNode.tsx |
Integration proposal node rendering |
graphEdges/RiskEdge.tsx |
Edge renderer with risk-level coloring and animations |
graphDialogs/ConflictPanel.tsx |
Inline conflict resolution panel for edge clicks |
shared/RiskMatrix.tsx |
Project-wide pairwise risk grid with animations |
shared/RiskTooltip.tsx |
Hover detail for a matrix cell |
Detail doc in this folder:
data-sources.md— how lane/PR/mission state feeds the graph and what the hydration stages look like.
Lane topology (parent-child stack relationships)
+ Primary-to-worktree relationships
+ Conflict-risk overlays (edges colored by pairwise risk)
+ PR overlays (per-lane badges, edge coloring)
+ Sync and activity signals (dots/chips)
+ Merge simulation entry points (edge clicks open ConflictPanel)
+ Integration proposal nodes (with "Fed By" source chips)
GraphViewMode — one of:
| Mode | Label | What changes |
|---|---|---|
all |
Overview | Primary-centric tree. Stack edges are shown; risk "overlap web" is hidden by default behind a "Show overlap web" toggle. |
stack |
Dependencies | Same tree layout as Overview. Emphasis on parent-child stack edges; drag to reparent. |
risk |
Conflict Risk | Same tree layout, risk edges always drawn between overlapping lanes. Matrix panel available for file-level detail. |
activity |
Activity | Same row grid, but siblings within a row sort by activity score (high → low) before stack depth and name. |
All view modes share a single primary-centric row layout. The
primary lane sits at the top, each descendant appears on row
depth * Y_STEP below it, and row members are spaced at
X_PITCH centered horizontally. Lanes that can't be traced back
to the workspace primary via parent links are bucketed into a
single "orphan" row at the bottom (depthByLaneId = 10_000).
Implementation:
laneHierarchyFromPrimary(lanes)returns{ primary: LaneSummary | null, depthByLaneId, parentNameByLaneId }. Returns an empty shape whenlanesis empty — callers must handleprimary: null(this matters during project open/switch when the lane list briefly empties out).layoutPrimaryCentricRows(lanes, activityScoreByLaneId, tieBreak)produces the actual positions.tieBreakis"activity"in activity mode,"stack"everywhere else.computeAutoLayout(lanes, viewMode, activityScoreByLaneId, _environmentByLaneId)is the public entry point the page calls. The environment map parameter is accepted for signature stability but no longer influences layout — lanes aren't split into "core" vs "others" any more.
User drags override auto-positions and persist in the session layout snapshot per view mode.
GraphSessionState per view mode:
type GraphLayoutSnapshot = {
nodePositions: Record<string, { x: number; y: number }>;
collapsedLaneIds: string[];
viewMode: GraphViewMode;
filters: GraphFilterState;
updatedAt: string;
};GraphFilterState — status filters, lane type filters, tag
filters, hidePrimary, hideAttached, hideArchived,
rootLaneId, search.
GraphPersistedState.lastViewMode is saved globally so the user
returns to their preferred view across sessions.
normalizeGraphPreferences(state) migrates legacy schemas (including
the older presets: […] shape) to the current format.
The persistence callback dedupes against the value just loaded:
GraphInner keeps a skipNextGraphPreferencePersistRootRef set to the
project root that was just hydrated. The next viewMode-watcher run
skips its graphState.set because the load handler's setViewMode
would otherwise echo the loaded preference straight back to disk on
every project switch.
Every lane node carries enough derived state to render without additional IPC calls during drag/interaction:
type GraphNodeData = {
lane: LaneSummary;
status: ConflictStatus["status"] | "unknown";
remoteSync: GitUpstreamSyncStatus | null;
autoRebaseStatus: AutoRebaseLaneStatus | null;
activeSessions: number;
collapsedChildCount: number;
/** Steps from the workspace primary lane along parent links (0 = primary). */
hierarchyDepth: number;
/** Immediate parent lane name when parent exists in the workspace. */
parentLaneName: string | null;
dimmed: boolean;
activityBucket: "min" | "low" | "medium" | "high";
viewMode: GraphViewMode;
lastActivityAt: string | null;
environment: { env: string; color: string | null } | null;
highlight: boolean;
rebaseFailed: boolean;
rebasePulse: boolean;
mergeInProgress: boolean;
mergeDisappearing: boolean;
isIntegration: boolean;
focusGlow: boolean;
isVirtualProposal: boolean;
integrationSources: Array<{ laneId: string; laneName: string }>;
pr: GraphPrOverlay | null;
proposalOutcome?: "clean" | "conflict" | "blocked";
proposalId?: string;
};hierarchyDepth and parentLaneName come from
laneHierarchyFromPrimary(lanes) (memoized once per lane list as
primaryHierarchyMeta) and are threaded into every lane node
during derivation. Orphan lanes (not under primary) use the
sentinel 10_000; LaneNode treats anything >= 1000 as an
orphan and suppresses the depth badge.
The LaneNode renderer:
- Chooses sync/PR badges entirely from this data — see
syncBadge/prBadgeIIFEs ingraphNodes/LaneNode.tsx. - Renders a role-label chip top-right using lane terminology:
"Primary lane"forlaneType === "primary","Attached lane"for"attached", and"Lane"for"worktree". Integration lanes get a distinctive purpleIntegrationbadge instead. - Shows the custom
lane.iconglyph when set; the primary lane falls back to aHouseicon if no custom icon is configured. - Renders an
L{depth}badge next to the branch ref (TreeStructureicon) when the lane sits under primary. For orphans, the badge is replaced with an amber "Not stacked under the workspace primary" hint. - Renders a parent-lane breadcrumb ("On ") underneath
the header when
parentLaneNameis non-null.
type GraphEdgeData = {
edgeType: "topology" | "stack" | "risk" | "integration" | "proposal";
riskLevel?: "none" | "low" | "medium" | "high";
overlapCount?: number;
stale?: boolean;
dimmed?: boolean;
highlight?: boolean;
proposalConflict?: boolean;
pr?: GraphPrOverlay;
};RiskEdge (in graphEdges/RiskEdge.tsx) renders edges with colors
from getPrEdgeColor (PR-aware edges) or from the risk level
palette (conflict edges). Stale edges render at reduced opacity.
The page has a showOverviewRiskEdges boolean (toggled by the
"Show overlap web" / "Hide overlap web" button in the filter bar,
shown only when viewMode === "all"). It resets to false on
any view-mode change.
Risk edges render when:
viewMode === "risk"
|| (viewMode === "all" && showOverviewRiskEdges)
Both the render pass and the riskPairsWithVisibleEdge
population (used to decide whether PR overlays get their own edge
or piggyback on an existing risk/stack edge) use this same gate,
so PR overlays in Overview consistently stick to the visible
topology/stack edge when the overlap web is hidden.
In Overview, redundant "primary → lane" spokes are also suppressed when the lane already has a parent within the workspace — the stack edge chain already communicates the tree, so the extra spoke would just add clutter.
- Pan/zoom — React Flow default.
- Node drag — positions persist to the session layout snapshot keyed by view mode.
- Node click — select lane; context-dependent side panel updates.
- Edge click — open
ConflictPanelwith merge simulation + overlapping file list + AI proposal apply flow. - Right-click / context menu — reparent, archive, delete, create child, view diff, open terminal.
- Collapse/expand —
collapsedLaneIds[]hides descendants; the parent node showscollapsedChildCountso the user sees there's hidden depth. - Filter bar — status (
GraphStatusFilter), lane type, tag chips, search. Active filter count drivesFunnelicon badge.
ReactFlow is wrapped with:
<MiniMap />— standard React Flow minimap.<Background variant={BackgroundVariant.Dots} />for the dot grid.- Custom
<Panel />regions for filters, zoom controls, and the activeConflictPanel/PrDetailPaneoverlays.
WorkspaceGraphPage owns several refresh paths with different
intervals and in-flight guards:
| What | Who | Cadence |
|---|---|---|
Lane list (useAppStore.refreshLanes) |
refreshLanes |
On focus, on explicit action |
Sync status (getLaneUpstreamSync) |
refreshLaneSyncStatuses |
Every 60 s |
| Auto-rebase status | refreshAutoRebaseStatuses |
Every 60 s |
| Risk matrix batch | refreshRiskBatch |
Staged after first paint; on explicit action |
| Activity (recent sessions) | refreshActivity |
Debounced, coalesced with in-flight guard; bounded limits |
| PRs | refreshPrs |
Debounced, scheduled after first paint; 60 s background |
In-flight guards:
const syncRefreshInFlightRef = React.useRef(false);
const syncRefreshQueuedRef = React.useRef(false);
// …repeat for autoRebase, activity, PRWhen a refresh is requested mid-flight, the queue flag is set so exactly one follow-up runs after the current one completes. This prevents refresh storms when several events arrive in quick succession.
GRAPH_ACTIVITY_SESSION_LIMIT = 150 — only the 150 most recent
sessions are inspected when computing per-lane activity scores.
GRAPH_ACTIVITY_OPERATION_LIMIT = 150 — same for operations.
This is the intentional bound documented in
data-sources.md. It keeps scoring O(150)
regardless of project history depth.
Activity scoring prioritizes:
- running sessions > awaiting-input > ended
- recent operations > old operations
- session-derived signals over history-backed recompute (the latter is reserved for slower timers and focus/visibility return)
activityBucket (on each node): "min" | "low" | "medium" | "high".
Drives node size and shadow intensity in LaneNode.
graphDialogs/ConflictPanel.tsx is the inline resolution UI:
- Header with lane A ↔ lane B names.
- Merge simulation outcome (clean / conflict / count).
- Overlapping files list (from
overlapFilesByPairmap). - "Apply to" lane selector (target branch chooser).
- AI proposal flow: prepare → request → apply with mode selector
(
unstaged | staged | commit) + optional commit message.
Data wiring:
props: {
conflictPanel: ConflictPanelState;
setConflictPanel: React.Dispatch<…>;
laneById: Map<string, LaneSummary>;
overlapFilesByPair: Map<string, string[]>;
refreshRiskBatch: () => Promise<void>;
refreshLanes: () => Promise<void>;
}The panel issues IPC calls directly to ade.conflicts.simulateMerge,
.prepareProposal, .requestProposal, .applyProposal.
shared/RiskMatrix.tsx renders the pairwise matrix as a grid.
Cells:
- Color-coded by risk (
high/medium/low/none). - Selected cell rings in accent color.
- Stale cells at reduced opacity with a clock icon and "Last computed N min ago" tooltip.
- Animated change effects:
increasedanddecreasedflashes when a cell's risk level transitions between polls. - Entry animation on first appearance.
- Progress indicator driven by
prediction-progressevents (completedPairs / totalPairs).
pairKey(a, b) and hasSamePair normalize ordered pairs so matrix
lookups are symmetric.
The renderer's buildGraphPrOverlay(args) converts a PR summary +
live detail into GraphPrOverlay so the graph can show PR state
without every PR detail loaded:
number,title,url,state,checksStatus,reviewStatuspendingCheckCount,approvedCount,changeRequestCount,commentCount,reviewCountisMergeable,mergeConflicts,behindBaseBylastActivityAt(max ofupdatedAt,lastSyncedAt, check times, review times, comment times)activityState(derived viaderivePrActivityState)detailLoaded— whether the live detail bundle was present
Nodes render PR badges via prBadge IIFE; edges can carry PR
metadata via GraphEdgeData.pr.
Older versions of the graph persisted state under presets: […]
with nested byViewMode maps and an activePreset name.
normalizeGraphPreferences(state) reads both shapes and produces
createGraphPreferences(lastViewMode) as the canonical form.
migrated: true is returned so the caller knows to rewrite
persistence on next save.
- Make topology visible first: one shared primary-centric row layout across Overview / Dependencies / Conflict Risk / Activity so switching modes doesn't rearrange the canvas.
- Stage non-essential overlays (risk, PR, sync) after first paint.
- Hide the overlap web by default in Overview — stack edges are enough on their own; the overlap web is one click away.
- Bound activity and polling work.
- Avoid history-backed activity recompute on every terminal event; use the live PTY signal instead.
- Keep risk, PR, and sync overlays fresh enough without constant churn.
See data-sources.md for the hydration
sequence in detail.
GraphInnerhas >100 hooks. The module sets// @refresh resetso HMR forces a clean remount. Do not remove this directive — partial HMR in this component causes hook-order crashes.- ReactFlow requires
ReactFlowProvider. The page wrapsGraphInnerin<ReactFlowProvider>; child components that useuseReactFlow()must be inside that provider. - Node dimensions change with activity bucket.
nodeDimensionsscales with activity to surface busy lanes; layout computations must account for variable node sizes. GRAPH_ACTIVITY_SESSION_LIMITis load-bearing. Raising it makes activity scans O(N) in session history and regresses the "canvas becomes interactive first" contract.- Refresh coalescing. Direct
refresh*calls bypass the in-flight guard; preferscheduleRefresh*variants from the refresh-scheduling section. - Node positions persist per view mode. Every view mode auto-layouts to the same primary-centric rows, so the canvas stays stable when switching modes as long as the user hasn't dragged nodes. Once the user drags in a given mode, that mode's snapshot diverges and auto-layout stops applying until "Reset View" clears it.
laneHierarchyFromPrimarycan returnprimary: null. The workspace briefly has zero lanes during project open/switch; callers (layout, edge derivation, node data) must tolerate the null primary rather than dereference it. Regression coverage lives ingraphLayout.test.ts.- Integration lane nodes use distinctive styling (purple
gradient, dashed integration badge).
isIntegrationis set viaisIntegrationLaneFromMetadata(lane)fromrenderer/lib/integrationLanes.ts.