feat(dep-graph): quieter refresh, persistent viewport, public surface, search highlight

- plugin: fingerprint the analyzer output (services/edges/unknownTokens,
  excluding generatedAt) and only invalidate the virtual module on real
  content diffs, so ordinary edits no longer trigger a browser reload.
- web viewer: persist the ReactFlow viewport (x/y/zoom) to sessionStorage
  and restore on mount, so reloads no longer snap back to fitView.
- analyzer: expose each service's public interface surface via a new
  optional ServiceNode.publicMembers (methods + property signatures,
  _serviceBrand filtered out), and register PRODUCTION_OVERRIDES so
  bootstrap-seeded bindings resolve to their real backends.
- web viewer: seed node inPorts from publicMembers so uncalled methods
  still render; dim the handle and label of ports with no incoming edge
  so the connected-vs-declared distinction reads at a glance.
- web viewer: extend search to also match publicMembers, and turn search
  into a highlight/dim treatment (matches + neighbors stay bright, the
  rest dim) instead of filtering — matched nodes get a cyan outline
  distinct from the yellow selection outline.
This commit is contained in:
haozhe.yang 2026-07-01 20:00:24 +08:00
parent b9259abd3f
commit 6e8beccba1
5 changed files with 415 additions and 54 deletions

View file

@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url';
import {
type CallExpression,
type ClassDeclaration,
type InterfaceDeclaration,
type Node,
type ParameterDeclaration,
Project,
@ -70,6 +71,28 @@ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: s
{ token: 'IAgentScopeContext', scope: 'Agent', impl: 'AgentScopeContext' },
];
/**
* Production composition-root bindings seeded by `bootstrap()` via
* `ScopeOptions.extra`. `buildCollection` applies `extra` AFTER the static
* `registerScopedService` registry, so these take precedence at runtime: they
* override a static default where one exists (e.g. `ISkillCatalogStore`
* `FileSkillCatalogStore`) and supply the binding where the layer ships no
* in-package default (the Storage-layer tokens `FileStorageService`, whose
* in-memory backend is no longer auto-registered). The analyzer mirrors that
* so the graph reflects the backend that actually runs in production.
*
* Each entry's `file`/`line`/`domain` are derived from the impl class
* declaration at analysis time, so the node points at the real backend rather
* than any registration site it replaces.
*/
const PRODUCTION_OVERRIDES: readonly { token: string; scope: ServiceScope; impl: string }[] = [
{ token: 'IStorageService', scope: 'App', impl: 'FileStorageService' },
{ token: 'IAppendLogStorage', scope: 'App', impl: 'FileStorageService' },
{ token: 'IAtomicDocumentStorage', scope: 'App', impl: 'FileStorageService' },
{ token: 'IBlobStorage', scope: 'App', impl: 'FileStorageService' },
{ token: 'ISkillCatalogStore', scope: 'App', impl: 'FileSkillCatalogStore' },
];
/**
* Turn a `(scope, token)` pair into the unique node id used across the
* graph. This matches the DI registration identity: one `registerScopedService`
@ -169,6 +192,53 @@ function sameRef(a: EdgeRef, b: EdgeRef): boolean {
);
}
/**
* Collect every top-level `interface` declaration in the tree, keyed by
* name. Used to pull each service's public callable surface out of its
* token interface (e.g. `interface IAgentSystemReminderService { ... }`)
* so the graph view can render every method as a port row even when
* nothing calls into it yet.
*
* Duplicate names win latest TS itself would merge them via declaration
* merging, but the codebase does not intentionally split a service
* interface across files, so ties here are effectively edge cases.
*/
function collectInterfaces(sourceFiles: SourceFile[]): Map<string, InterfaceDeclaration> {
const out = new Map<string, InterfaceDeclaration>();
for (const file of sourceFiles) {
for (const iface of file.getInterfaces()) {
const name = iface.getName();
if (!name) continue;
out.set(name, iface);
}
}
return out;
}
/**
* Return the public callable surface names on an interface every method
* signature name plus every property name sorted and de-duplicated.
* Skips `_serviceBrand` (the DI type-erased identity marker) and index /
* call signatures (they have no member name to render as a port). TS
* interfaces cannot declare `private` members, so every remaining name is
* part of the public API by construction.
*/
function collectInterfaceMembers(iface: InterfaceDeclaration): string[] {
const names = new Set<string>();
for (const member of iface.getMembers()) {
const kind = member.getKind();
if (kind === SyntaxKind.MethodSignature) {
const name = member.asKindOrThrow(SyntaxKind.MethodSignature).getName();
names.add(name);
} else if (kind === SyntaxKind.PropertySignature) {
const name = member.asKindOrThrow(SyntaxKind.PropertySignature).getName();
if (name === '_serviceBrand') continue;
names.add(name);
}
}
return [...names].sort();
}
/**
* Extract the token identifier from a `registerScopedService(...)` call.
* Returns `undefined` if the call doesn't match the expected shape.
@ -471,6 +541,7 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
const sourceFiles = project.getSourceFiles();
const { services, implClasses, bindings } = collectServices(sourceFiles);
const interfacesByName = collectInterfaces(sourceFiles);
// Seed the framework tokens as synthetic nodes so edges to them resolve
// like any other registered service. They are marked domain=`framework`
@ -495,6 +566,40 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
if (!scopeMap.has(node.scope)) scopeMap.set(node.scope, node);
}
// Apply production composition-root bindings: bootstrap() seeds these tokens
// via `extra`, which the container applies after the static registry. They
// override any static default (skill catalog) or supply the binding outright
// (storage layer, which ships no in-package default). Mirror that here so
// edges resolve to the backend that actually runs in production.
for (const override of PRODUCTION_OVERRIDES) {
const id = nodeId(override.scope, override.token);
const cls = implClasses.get(override.impl);
const file = cls ? relFromRepo(cls.getSourceFile().getFilePath()) : SRC_ROOT;
const domain = cls ? domainOf(cls.getSourceFile().getFilePath()) : 'unknown';
const line = cls ? cls.getStartLineNumber() : 0;
const node: ServiceNode = {
id,
token: override.token,
impl: override.impl,
scope: override.scope,
domain,
file,
line,
};
const existingIndex = services.findIndex((s) => s.id === id);
if (existingIndex >= 0) {
services[existingIndex] = node;
} else {
services.push(node);
}
let scopeMap = bindings.get(override.token);
if (!scopeMap) {
scopeMap = new Map();
bindings.set(override.token, scopeMap);
}
scopeMap.set(override.scope, node);
}
const acc: EdgeAccumulator = {
services,
edges: new Map(),
@ -502,6 +607,18 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
unknownRefs: new Set(),
};
// Attach each service's public callable surface. Runs after registration,
// framework seeding, and PRODUCTION_OVERRIDES so every node in the graph
// gets the same treatment. Nodes whose token has no interface declaration
// in `src/` (framework tokens, synthetic overrides) simply get no
// `publicMembers` field — the view falls back to the edge-derived ports.
for (const svc of services) {
const iface = interfacesByName.get(svc.token);
if (!iface) continue;
const members = collectInterfaceMembers(iface);
if (members.length > 0) svc.publicMembers = members;
}
for (const svc of services) {
const cls = implClasses.get(svc.impl);
if (!cls) continue;
@ -516,6 +633,53 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
collectRuntimeEdges(cls, svc, injectedFields, acc);
}
// Synthesise interface-only nodes for tokens referenced by edges but with no
// registered impl at any scope. Each unresolved edge already targets
// `unresolved::${token}`; creating a matching node lets the viewer render it
// (with a distinct border) instead of dropping the edge as dangling. The node
// is placed at the outer-most scope that references it — a hint at where the
// missing binding is first needed — and inherits the interface's declared
// public surface so its ports read like a real service.
const nodeById = new Map(services.map((s) => [s.id, s]));
const unresolvedReferrers = new Map<string, Set<ServiceScope>>();
for (const edge of acc.edges.values()) {
if (!edge.unresolved) continue;
let scopes = unresolvedReferrers.get(edge.token);
if (!scopes) {
scopes = new Set();
unresolvedReferrers.set(edge.token, scopes);
}
const source = nodeById.get(edge.from);
if (source) scopes.add(source.scope);
}
for (const [token, scopes] of unresolvedReferrers) {
let scope: ServiceScope = 'App';
let minLevel = Number.POSITIVE_INFINITY;
for (const s of scopes) {
const lvl = SCOPE_LEVEL[s];
if (lvl < minLevel) {
minLevel = lvl;
scope = s;
}
}
const node: ServiceNode = {
id: `unresolved::${token}`,
token,
impl: token,
scope,
domain: 'unresolved',
file: '',
line: 0,
unresolved: true,
};
const iface = interfacesByName.get(token);
if (iface) {
const members = collectInterfaceMembers(iface);
if (members.length > 0) node.publicMembers = members;
}
services.push(node);
}
return {
generatedAt: options.generatedAt ?? new Date(0).toISOString(),
services: services.sort(

View file

@ -41,6 +41,22 @@ export interface ServiceNode {
file: string;
/** 1-indexed line of the `registerScopedService(...)` call. */
line: number;
/**
* Public callable surface of this service the method/property names
* declared on the interface identified by `token`. Sorted, deduped, with
* the `_serviceBrand` DI marker filtered out. Absent when the analyzer
* couldn't locate an interface declaration for the token (e.g. synthetic
* framework bindings whose token has no interface in `src/`).
*/
publicMembers?: string[];
/**
* True for synthesized interface-only nodes: the token is referenced by at
* least one edge but has no implementation registered at any scope. These
* nodes have no real impl (so `impl` mirrors `token`) and the viewer renders
* them with a distinct border so missing bindings stand out from concrete
* services rather than being dropped as dangling edges.
*/
unresolved?: true;
}
export interface EdgeRef {

View file

@ -45,31 +45,59 @@ interface PluginOptions {
writeSnapshotFile?: boolean;
}
/**
* Structural fingerprint of a graph: services + edges + unknownTokens only,
* with `generatedAt` deliberately excluded. The analyzer already sorts each
* of these arrays deterministically, so a stable `JSON.stringify` is enough
* to detect real content changes and ignore metadata-only churn (e.g. the
* HEAD sha bumping without any DI edit).
*/
function fingerprint(g: Graph): string {
return JSON.stringify({
services: g.services,
edges: g.edges,
unknownTokens: g.unknownTokens,
});
}
export function depGraphPlugin(options: PluginOptions = {}): Plugin {
const shouldWrite = options.writeSnapshotFile ?? true;
let cached: Graph | undefined;
let cachedFingerprint: string | undefined;
let server: ViteDevServer | undefined;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let watcher: FSWatcher | undefined;
function analyzeNow(reason: string): void {
/**
* Re-run the analyzer and swap `cached` only when the structural
* fingerprint changed. Returns whether the graph actually changed so the
* caller can decide whether to invalidate the virtual module.
*/
function analyzeNow(reason: string): boolean {
const started = Date.now();
cached = analyze({ generatedAt: tag() });
if (shouldWrite) writeSnapshot(cached);
const next = analyze({ generatedAt: tag() });
const nextFingerprint = fingerprint(next);
const changed = nextFingerprint !== cachedFingerprint;
if (changed) {
cached = next;
cachedFingerprint = nextFingerprint;
if (shouldWrite) writeSnapshot(next);
}
const took = Date.now() - started;
console.log(
`[dep-graph] ${reason}${summarize(cached)}${
shouldWrite ? ` (wrote ${relative(process.cwd(), SNAPSHOT_PATH)})` : ''
} in ${took}ms`,
);
const suffix = changed
? shouldWrite
? ` (wrote ${relative(process.cwd(), SNAPSHOT_PATH)})`
: ''
: ' (no change)';
console.log(`[dep-graph] ${reason}${summarize(next)}${suffix} in ${took}ms`);
return changed;
}
function scheduleRefresh(reason: string): void {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = undefined;
analyzeNow(reason);
invalidate();
if (analyzeNow(reason)) invalidate();
}, DEBOUNCE_MS);
}

View file

@ -62,9 +62,10 @@ export function Filters({ graph, domains, state, onChange }: FiltersProps): JSX.
</div>
<input
placeholder="filter by token/impl…"
placeholder="search service, interface, method…"
value={state.search}
onChange={(e) => onChange({ ...state, search: e.target.value })}
title="Substring match across impl class, token interface, domain, and public members"
style={{
width: '100%',
padding: '6px 8px',

View file

@ -9,6 +9,7 @@ import {
Position,
ReactFlow,
type Edge as RFEdge,
type Viewport,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useMemo } from 'react';
@ -36,6 +37,13 @@ const PORTS_PAD_TOP = 4;
interface ServicePortsInfo {
inPorts: string[];
outPorts: string[];
/**
* Subset of `inPorts` that actually has at least one edge terminating on
* it (as opposed to being seeded from the interface's declared surface
* with no caller). Used to dim the handle / label so unused public
* methods stand out visually.
*/
connectedIn: Set<string>;
}
interface GraphViewProps {
@ -49,6 +57,12 @@ interface GraphViewProps {
interface ServiceNodeData extends Record<string, unknown> {
service: ServiceNode;
selected: boolean;
/**
* True when the search box has content and this node matches. Rendered
* as a distinct cyan outline so search hits are visually separable from
* the yellow-outlined click-selected node.
*/
matched: boolean;
dim: boolean;
ports: ServicePortsInfo;
}
@ -71,18 +85,34 @@ function effectiveToMethod(kind: EdgeKind, refTo: string | undefined): string |
/**
* Build the port lists per node from a set of edges.
*
* The port list depends on which edges are actually rendered filtering out
* a whole edge kind, for example, also hides the port rows it would have
* populated. Computing this off the filtered edges keeps the node from
* showing dangling ports with nothing connected.
* `inPorts` are seeded from `service.publicMembers` every method /
* property declared on the service's interface, whether anything actually
* calls it or not, so the node advertises its full public surface. Any
* inbound edge method that isn't already in that seed (unusual usually
* event-bus edges named after the kind) is folded in too.
*
* `outPorts` remain edge-driven: they are the methods on THIS service
* that make a call outward, so filtering out an edge kind naturally
* collapses the rows it would have populated.
*/
function computeServicePorts(
services: ServiceNode[],
edges: Edge[],
): Map<string, ServicePortsInfo> {
const acc = new Map<string, { in: Set<string>; out: Set<string> }>();
const acc = new Map<
string,
{ in: Set<string>; out: Set<string>; connectedIn: Set<string> }
>();
for (const s of services) {
acc.set(s.id, { in: new Set(), out: new Set() });
const bucket = {
in: new Set<string>(),
out: new Set<string>(),
connectedIn: new Set<string>(),
};
if (s.publicMembers) {
for (const name of s.publicMembers) bucket.in.add(name);
}
acc.set(s.id, bucket);
}
for (const e of edges) {
const src = acc.get(e.from);
@ -90,7 +120,10 @@ function computeServicePorts(
for (const ref of e.refs) {
const toMethod = effectiveToMethod(e.kind, ref.toMethod);
if (ref.fromMethod !== undefined && src) src.out.add(ref.fromMethod);
if (toMethod !== undefined && dst) dst.in.add(toMethod);
if (toMethod !== undefined && dst) {
dst.in.add(toMethod);
dst.connectedIn.add(toMethod);
}
}
}
const result = new Map<string, ServicePortsInfo>();
@ -98,6 +131,7 @@ function computeServicePorts(
result.set(id, {
inPorts: [...sets.in].sort(),
outPorts: [...sets.out].sort(),
connectedIn: sets.connectedIn,
});
}
return result;
@ -110,17 +144,36 @@ function nodeHeight(ports: ServicePortsInfo): number {
}
function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
const { service, selected, dim, ports } = data;
const { service, selected, matched, dim, ports } = data;
const bg = SCOPE_STYLE[service.scope].color;
const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length);
// Interface-only node: the token is referenced but has no registered impl.
// Flagged with a dashed warning border so missing bindings stand out from
// concrete services at a glance. Selection / search-match still win so the
// active node stays unambiguous.
const isUnresolved = service.unresolved === true;
const borderColor = selected
? '#ffdf5d'
: matched
? '#79c0ff'
: isUnresolved
? '#f85149'
: 'rgba(0,0,0,0.4)';
const borderWidth = selected || matched || isUnresolved ? 2 : 1;
const borderStyle = isUnresolved && !selected && !matched ? 'dashed' : 'solid';
const glow = selected
? '0 0 0 3px rgba(255,223,93,0.25)'
: matched
? '0 0 0 3px rgba(121,192,255,0.25)'
: 'none';
return (
<div
style={{
background: bg,
color: 'white',
borderRadius: 6,
border: selected ? '2px solid #ffdf5d' : '1px solid rgba(0,0,0,0.4)',
boxShadow: selected ? '0 0 0 3px rgba(255,223,93,0.25)' : 'none',
border: `${borderWidth}px ${borderStyle} ${borderColor}`,
boxShadow: glow,
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
@ -171,7 +224,7 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Elemen
</span>
</div>
<div style={{ fontSize: 10, opacity: 0.65, marginTop: 2, fontStyle: 'italic' }}>
{service.token}
{isUnresolved ? 'no implementation registered' : service.token}
</div>
<div style={{ fontSize: 10, opacity: 0.75, marginTop: 2 }}>{service.domain}</div>
</div>
@ -215,7 +268,13 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Elemen
id={`in:${inn}`}
type="target"
position={Position.Right}
style={{ background: '#a8c8f6' }}
// Dim handle when the port is only there because it's
// declared on the interface — nothing calls into it.
// The connected-vs-declared distinction reads at a
// glance without hunting for edges.
style={{
background: ports.connectedIn.has(inn) ? '#a8c8f6' : '#3d444d',
}}
/>
)}
<div
@ -247,7 +306,10 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Elemen
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#c8e0fb',
color:
inn !== undefined && !ports.connectedIn.has(inn)
? '#6e7681'
: '#c8e0fb',
}}
>
{inn ?? ''}
@ -289,6 +351,42 @@ function BandLabelView({ data }: NodeProps<Node<{ scope: string; width: number }
const nodeTypes = { service: ServiceNodeView, band: BandLabelView };
/**
* Persist the pan/zoom viewport across dev-server reloads so a source-code
* edit (which triggers a `full-reload` from the `virtual:dep-graph` plugin)
* doesn't wipe the position the user carefully panned to. Scoped to
* `sessionStorage` so each fresh browser session starts with `fitView`.
*/
const VIEWPORT_STORAGE_KEY = 'agent-core-v2:dep-graph:viewport';
function loadViewport(): Viewport | undefined {
try {
const raw = sessionStorage.getItem(VIEWPORT_STORAGE_KEY);
if (raw === null) return undefined;
const parsed = JSON.parse(raw) as Partial<Viewport> | null;
if (
parsed === null ||
typeof parsed.x !== 'number' ||
typeof parsed.y !== 'number' ||
typeof parsed.zoom !== 'number'
) {
return undefined;
}
return { x: parsed.x, y: parsed.y, zoom: parsed.zoom };
} catch {
return undefined;
}
}
function saveViewport(v: Viewport): void {
try {
sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(v));
} catch {
// Storage disabled (private mode / quota) — silently drop; the graph
// still works, it just won't remember the viewport across reloads.
}
}
function passesFilter(
service: ServiceNode,
filters: FilterState,
@ -296,29 +394,40 @@ function passesFilter(
): boolean {
if (!filters.scopes.has(service.scope)) return false;
if (filters.hiddenDomains.has(service.domain)) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
const hay = `${service.token} ${service.impl} ${service.domain}`.toLowerCase();
if (!hay.includes(q)) return false;
}
// NOTE: search intentionally does NOT filter here — it drives the
// highlight/dim treatment below so context around a hit stays visible.
if (filters.hideOrphans && !connected.has(service.id)) return false;
return true;
}
/**
* Case-insensitive substring match across the identity fields and public
* surface. Kept close to `passesFilter` so the two search-related pieces
* (highlight input, matches predicate) stay obviously in sync.
*/
function matchesSearch(service: ServiceNode, query: string): boolean {
const members = service.publicMembers ? ` ${service.publicMembers.join(' ')}` : '';
const hay = `${service.token} ${service.impl} ${service.domain}${members}`.toLowerCase();
return hay.includes(query);
}
export function GraphView({
graph,
filters,
selectedId,
onSelect,
}: GraphViewProps): JSX.Element {
// Compute once at mount so a re-render that adds nodes doesn't yank the
// viewport back to the stored value while the user is panning.
const initialViewport = useMemo(() => loadViewport(), []);
const { nodes, edges, selectedService, selectedEdges } = useMemo(() => {
// Which edges survive the edge-kind filter?
const survivingEdges: Edge[] = graph.edges
.filter((e) => filters.kinds.has(e.kind))
// Drop unresolved edges — their `to` points at a pseudo id that isn't
// in the node set. The lint reports them separately; showing them
// here would just clutter the graph with dangling arrows.
.filter((e) => !e.unresolved);
// Which edges survive the edge-kind filter? Unresolved edges are kept: the
// analyzer now synthesises an interface-only node for each unresolved token
// (rendered with a distinct border), so their `to` resolves to a real node
// instead of dangling. Edges whose endpoint is filtered out are dropped
// below via the `visibleIds` check.
const survivingEdges: Edge[] = graph.edges.filter((e) => filters.kinds.has(e.kind));
// Node ids that appear on either end of any surviving edge — for the
// orphan filter.
@ -342,16 +451,35 @@ export function GraphView({
// dead weight on the node, so we compute after filter+visibility.
const ports = computeServicePorts(visibleServices, finalEdges);
// Neighbours of the selected node — used to dim non-related ones.
const highlighted = new Set<string>();
if (selectedId) {
highlighted.add(selectedId);
for (const e of finalEdges) {
if (e.from === selectedId) highlighted.add(e.to);
if (e.to === selectedId) highlighted.add(e.from);
// Compute the two focus drivers:
// • `selectedId` — the click-selected node (0 or 1 at a time).
// • `matched` — every node whose identity or public surface hits
// the current search string.
// Their neighbours (nodes touched by any surviving edge) are folded in
// so the graph keeps enough context around a hit to be readable —
// this is the "act like a click" behaviour: nothing disappears, just
// dims. `focused` is the union used to decide dim vs bright.
const searchQuery = filters.search.trim().toLowerCase();
const matched = new Set<string>();
if (searchQuery) {
for (const s of visibleServices) {
if (matchesSearch(s, searchQuery)) matched.add(s.id);
}
}
const focused = new Set<string>();
const seedFocus = (id: string): void => {
focused.add(id);
for (const e of finalEdges) {
if (e.from === id) focused.add(e.to);
if (e.to === id) focused.add(e.from);
}
};
if (selectedId !== undefined) seedFocus(selectedId);
for (const id of matched) seedFocus(id);
const focusActive = selectedId !== undefined || matched.size > 0;
const layout = layoutDagre(visibleServices, finalEdges, {
groupByScope: filters.groupByScope,
nodeSize: (id) => {
@ -369,8 +497,13 @@ export function GraphView({
data: {
service,
selected: service.id === selectedId,
dim: selectedId !== undefined && !highlighted.has(service.id),
ports: ports.get(service.id) ?? { inPorts: [], outPorts: [] },
matched: matched.has(service.id),
dim: focusActive && !focused.has(service.id),
ports: ports.get(service.id) ?? {
inPorts: [],
outPorts: [],
connectedIn: new Set<string>(),
},
},
}),
);
@ -396,8 +529,11 @@ export function GraphView({
const rfEdges: RFEdge[] = [];
for (const e of finalEdges) {
const style = EDGE_STYLE[e.kind];
const isHighlighted =
selectedId !== undefined && (e.from === selectedId || e.to === selectedId);
// With a focus (click or search) active, an edge is bright when both
// ends are in the focus set — i.e. it either sits directly on a hit
// or bridges two things adjacent to a hit. When no focus is active
// every edge stays at its default opacity.
const isHighlighted = focusActive && focused.has(e.from) && focused.has(e.to);
// Group refs by (fromMethod, effectiveToMethod) so identical method
// pairs on different lines collapse into a single arrow between the
// same two handles instead of stacking.
@ -423,7 +559,7 @@ export function GraphView({
stroke: style.color,
strokeWidth: isHighlighted ? 2.2 : 1.2,
strokeDasharray: style.dashed ? '4 3' : undefined,
opacity: selectedId !== undefined ? (isHighlighted ? 1 : 0.1) : 0.75,
opacity: focusActive ? (isHighlighted ? 1 : 0.1) : 0.75,
},
animated: false,
});
@ -446,7 +582,14 @@ export function GraphView({
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
// Only `fitView` on the very first mount of a fresh browser session.
// Once a viewport is remembered, hand it to React Flow as
// `defaultViewport` so the pan/zoom the user last landed on is
// preserved across dev-server reloads.
{...(initialViewport
? { defaultViewport: initialViewport }
: { fitView: true })}
onMoveEnd={(_, viewport) => saveViewport(viewport)}
minZoom={0.1}
maxZoom={1.6}
onNodeClick={(_, node) => {
@ -464,7 +607,8 @@ export function GraphView({
nodeColor={(n) => {
if (n.id.startsWith('band::')) return 'transparent';
const service = (n.data as ServiceNodeData | undefined)?.service;
return service ? SCOPE_STYLE[service.scope].color : '#7d8590';
if (!service) return '#7d8590';
return service.unresolved ? '#f85149' : SCOPE_STYLE[service.scope].color;
}}
/>
<Controls showInteractive={false} style={{ background: '#151b23' }} />
@ -512,13 +656,21 @@ function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JS
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{service.impl}</div>
<div style={{ color: '#a5b0bc', fontSize: 11 }}>{service.token}</div>
{service.unresolved ? (
<div style={{ color: '#f85149', fontSize: 11, marginTop: 2 }}>
No implementation registered
</div>
) : (
<div style={{ color: '#a5b0bc', fontSize: 11 }}>{service.token}</div>
)}
<div style={{ color: '#7d8590', fontSize: 11 }}>
<b>{service.scope}</b> · {service.domain}
</div>
<div style={{ color: '#7d8590', fontSize: 10, marginTop: 4, wordBreak: 'break-all' }}>
{service.file}:{service.line}
</div>
{!service.unresolved && (
<div style={{ color: '#7d8590', fontSize: 10, marginTop: 4, wordBreak: 'break-all' }}>
{service.file}:{service.line}
</div>
)}
</div>
<button
onClick={onClose}