feat(ui): the Flow strip — how one symbol reaches another, one card per hop (CG-50)

Ask "how does execute reach getFile" in the search box and the viewer draws the
call path between them, left to right, opening every card at the exact line that
makes the next call. Dynamic-dispatch hops are dashed and name the site they
were wired at; "Read as flow" turns a trail walked by hand into the same strip.

The path finder is NOT new. `codegraph_explore` already leads its answers with
the longest call chain among the symbols an agent named, and a viewer that drew
a different path would get the two quoted against each other in a review. So the
search moved out of `ToolHandler` into `src/graph/named-symbol-flow.ts` and both
callers ride it — same tokens, same overload rules, same synthesized edges. What
stayed behind in `tools.ts` is the prose.

A pinned from/to question is the same search with two options changed, because
both ends being named is the evidence explore's one-unnamed-bridge cap stands in
for: it bridges freely, keeps twelve candidates per endpoint instead of six
(the CLI's own `main` sorts seventh of ten), and searches from both ends at once
— identical paths to the one-way walk on twelve measured pairs, 3-6x faster.

`/api/flow` is deliberately the one endpoint with no cache: its cards carry
source read from disk, and a drift verdict changes without the index changing.

Verified on this repo (`execute` to `rowToFileRecord`, 8 hops; `main` to
`resolveOne`, 7) and on a fresh excalidraw index, where `mutateElement` to
`renderStaticScene` crosses callback, react-render and jsx-child hops and lists
exactly the hops `codegraph_explore` prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-27 03:19:24 -05:00
co-authored by Claude Opus 5
parent 6d0f60f32c
commit 62e0a89b0e
24 changed files with 3326 additions and 313 deletions
+6 -3
View File
@@ -43,7 +43,8 @@ src/
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
lib/kinds.ts kind glyph letters
lib/map-model.ts the Map's deterministic layered layout (pure)
components/ TopBar, TrailBar, KindGlyph, map/, symbol/, file/
lib/flow-model.ts the Flow strip's card/link geometry — a DAG (pure)
components/ TopBar, TrailBar, KindGlyph, map/, flow/, symbol/, file/
views/ one component per route
```
@@ -59,9 +60,11 @@ announce the project to a font CDN.
| `#/s/<id>?hl=<line>&t=<trail>` | symbol view |
| `#/file/<path>?hl=<line>` | file view |
| `#/map?root=&depth=&tests=1` | module map |
| `#/flow[/<key>]` | flow strip — reserved, phase 2 |
| `#/flow?from=&to=` | flow strip — the call path between two symbols |
| `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
| `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
Node ids and file paths are encoded per slash-separated segment, so
`#/file/src/mcp/tools.ts` stays readable and still round-trips a segment
containing a reserved character. Build hashes with `symbolHref()` /
`fileHref()` rather than by hand.
`fileHref()` / `mapHref()` / `flowHref()` rather than by hand.
+6 -1
View File
@@ -100,7 +100,12 @@
{:else if route.view === 'map'}
<MapView root={route.root} depth={route.depth} tests={route.tests} />
{:else if route.view === 'flow'}
<FlowView flowKey={route.key} />
<FlowView
from={route.from}
to={route.to}
symbols={route.symbols}
trailParam={route.trail}
/>
{:else if route.view === 'unknown'}
<NotFoundView path={route.path} />
{:else}
+5
View File
@@ -71,6 +71,11 @@
<span class="nm">{item.url}</span>
<span class="sig">{item.handler}</span>
</span>
{:else if item.type === 'flow'}
<KindGlyph kind="route" />
<span class="mid">
<span class="nm">{item.name}</span>
</span>
{:else}
<KindGlyph kind={item.node.kind} />
<span class="mid">
+8
View File
@@ -40,6 +40,14 @@
* put a `→` in the trail that describes no call.
*/
export function pick(item: PaletteItem): void {
// A flow is not a place in the graph, so it does not join the trail: it is
// a question about two symbols, and the Flow view answers it.
if (item.type === 'flow') {
palette.reset();
input?.blur();
navigate(flowHref({ from: item.from, to: item.to }));
return;
}
const id = item.type === 'route' ? item.nodeId : item.id;
// A route whose handler never resolved to a node has nowhere to go; the
// row stays, because "this URL exists and we could not place it" is true.
+8 -11
View File
@@ -3,14 +3,6 @@
import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
import { navigate, symbolHref, flowHref } from '../lib/router.svelte';
/**
* "Read as flow" replays the trail as a computed path in the Flow view,
* which is phase 2 (CG-50). The control is built and wired; it stays hidden
* until there is a view to send it to, because a button that lands on a
* placeholder is worse than no button.
*/
const READ_AS_FLOW = false;
let hops = $derived(trail.hops);
function step(index: number) {
@@ -20,9 +12,14 @@
navigate(symbolHref(hop.id, { trail: encodeTrail(trail.hops) }));
}
/**
* The walk itself IS the flow: the Flow view does not search for a path, it
* looks up the edge already joining each consecutive pair and draws the cards
* at those lines. So the trail travels under the same `t` param it uses
* everywhere else — a flow read from a trail is one walk under two lenses.
*/
function readAsFlow() {
// The flow key is the walk itself; the Flow view (phase 2) replays it.
navigate(flowHref(encodeTrail(hops)));
navigate(flowHref({ trail: encodeTrail(hops) }));
}
/**
@@ -84,7 +81,7 @@
<span class="spacer"></span>
{#if READ_AS_FLOW && hops.length > 1}
{#if hops.length > 1}
<button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
{/if}
{#if hops.length > 0}
+246
View File
@@ -0,0 +1,246 @@
<!--
One hop of a flow: the symbol, where it lives, and the seven lines around the
call that carries the reader to the next card (design spec §3.5).
The card is a Svelte Flow node, but nothing about it is Svelte Flow's: the
handles are hidden ports at the vertical middle of each side, the position
came from `buildFlowLayout`, and the height is the one that layout computed —
pinned here so the arrows land where the arithmetic said they would.
The source window is the Symbol view's code block with the noise removed. It
keeps the two things that make the code readable: the server's TextMate
classification, and one accent link on the identifier the graph resolved. It
drops gutter ports and multi-window folding, because a seven-line card has
neither a gutter worth reading nor anything to fold.
-->
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte';
import KindGlyph from '../KindGlyph.svelte';
import { tokenClass, tokensByLine, type Token } from '../../lib/highlight';
import { assignRefs, basename, type LineRef } from '../../lib/symbol-model';
import type { FlowCardLayout } from '../../lib/flow-model';
interface Props {
data: {
card: FlowCardLayout;
current: boolean;
dimmed: boolean;
onOpen: (card: FlowCardLayout) => void;
onFollow: (card: FlowCardLayout) => void;
};
}
let { data }: Props = $props();
let card = $derived(data.card);
let hop = $derived(card.hop);
let source = $derived(hop.source);
/** The call site as the code block's overlay wants it: one ref on one line. */
let refs = $derived.by<Map<number, LineRef[]>>(() => {
const byLine = new Map<number, LineRef[]>();
const ref = hop.callRef;
if (!ref) return byLine;
byLine.set(ref.line, [
{
ident: ref.name,
col: ref.col,
targetId: ref.targetId,
uncertain: false,
outside: false,
title: ref.backwards
? `${hop.node.name} calls ${ref.name} here`
: `calls ${ref.name}`,
},
]);
return byLine;
});
let tokens = $derived.by<Map<number, Token[]>>(() =>
source?.lines ? tokensByLine(source.lines, source.from, source.highlight) : new Map()
);
interface Part {
text: string;
cls: string | null;
ref: LineRef | null;
}
let rows = $derived.by(() => {
if (!source?.lines) return [];
return source.lines.map((text, offset) => {
const n = source.from + offset;
const lineTokens = tokens.get(n) ?? [{ cls: 'other' as const, text, col: 0 }];
const claimed = assignRefs(lineTokens, refs.get(n) ?? []);
return {
n,
call: n === hop.callRef?.line,
parts: lineTokens.map((token, index): Part => {
const ref = claimed.get(index) ?? null;
return { text: token.text, cls: ref ? null : tokenClass(token.cls), ref };
}),
};
});
});
</script>
<div
class="card"
class:cur={data.current}
class:dim={data.dimmed}
style={`width:${card.width}px;height:${card.height}px`}
>
<Handle type="target" position={Position.Left} id="in" isConnectable={false} />
<Handle type="source" position={Position.Right} id="out" isConnectable={false} />
<button type="button" class="head" onclick={() => data.onOpen(card)}>
<KindGlyph kind={hop.node.kind} />
<span class="nm">{hop.node.name}</span>
<span class="loc">{basename(hop.node.file)}:{hop.node.line}</span>
</button>
{#if rows.length > 0}
<div class="code">
{#each rows as row (row.n)}
<div class="ln" class:call={row.call}>
<span class="no">{row.n}</span>
<span class="tx"
>{#each row.parts as part, i (i)}{#if part.ref}<button
type="button"
class="ref"
title={part.ref.title}
onclick={() => data.onFollow(card)}>{part.text}</button
>{:else if part.cls}<span class={part.cls}>{part.text}</span
>{:else}{part.text}{/if}{/each}</span
>
</div>
{/each}
</div>
{:else}
<p class="nosource">
{source?.drift
? 'Changed on disk after the last index sync — source is not shown.'
: (source?.reason ?? 'Source outside this slice or this index.')}
</p>
{/if}
</div>
<style>
.card {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--paper);
border: 1px solid var(--rule-soft);
text-align: left;
}
.card:hover {
border-color: var(--ink);
}
.card.cur {
border-color: var(--accent);
}
.card.dim {
opacity: 0.4;
}
.head {
display: grid;
align-items: baseline;
padding: 10px 12px 6px;
border-bottom: 1px solid var(--rule-faint);
background: none;
color: var(--ink);
gap: 8px;
grid-template-columns: 16px 1fr auto;
text-align: left;
}
.head:hover .nm {
color: var(--accent);
}
.nm {
overflow: hidden;
font: 600 13px var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.loc {
color: var(--ink-3);
font: 11px var(--mono);
white-space: nowrap;
}
.code {
padding: 6px 0;
font: 12px / 19px var(--mono);
}
.ln {
display: grid;
align-items: stretch;
grid-template-columns: 40px 1fr 6px;
}
.ln.call {
background: var(--accent-soft);
}
.no {
padding-right: 10px;
color: var(--ink-4);
font-size: 11px;
text-align: right;
user-select: none;
}
.tx {
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
.nosource {
margin: 0;
padding: 6px 12px;
color: var(--ink-3);
font-size: 12px;
line-height: 19px;
}
/* Token classes — the same near-monochrome ramp the Symbol view paints
(design spec §2.2); the class names come from the server's theme. */
.t-c {
color: var(--code-comment);
}
.t-s {
color: var(--ink-2);
}
.t-k {
font-weight: 500;
}
.t-n {
color: var(--ink-2);
}
/* The only colour in the window: the call this card is opened at. */
.ref {
padding: 0;
background: none;
color: var(--accent);
border: 0;
cursor: pointer;
font: inherit;
text-decoration: underline;
text-decoration-color: var(--accent-line);
text-underline-offset: 3px;
}
.ref:hover {
text-decoration-color: var(--accent);
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<!--
The connector between two cards (design spec §3.5): an 86px hairline with a
filled arrowhead, labelled with the edge and the line it was recorded at.
The line style is the honesty in the picture. A solid line is a call the
resolver read out of the source; `2 3` is a name-only match under 0.6
confidence; `5 3` is a synthesized dynamic-dispatch bridge, and its label
names the mechanism and the site it was wired at — a hop nobody can see in the
source has to say where it came from.
Straight, not curved: two cards on the same row sit at the same height, and a
bezier between them would be a decorative wobble. The path bends only when a
branch puts them on different rows.
-->
<script lang="ts">
import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
import type { FlowLinkLayout } from '../../lib/flow-model';
let { sourceX, sourceY, targetX, targetY, data }: EdgeProps = $props();
const d = $derived(data as unknown as { link: FlowLinkLayout; dimmed: boolean });
const path = $derived.by(() => {
if (Math.abs(sourceY - targetY) < 0.5) return `M${sourceX},${sourceY} L${targetX},${targetY}`;
const midX = (sourceX + targetX) / 2;
return `M${sourceX},${sourceY} C${midX},${sourceY} ${midX},${targetY} ${targetX},${targetY}`;
});
/** The spec's `76,3 84,7 76,11` arrowhead, placed at the target's port. */
const head = $derived(
`${targetX - 10},${targetY - 4} ${targetX - 2},${targetY} ${targetX - 10},${targetY + 4}`
);
const labelX = $derived((sourceX + targetX) / 2);
const labelY = $derived((sourceY + targetY) / 2);
const dashStyle = $derived(d.link.dash ? `stroke-dasharray:${d.link.dash}` : '');
/** Stacked upwards from the line, so the last clause sits nearest it. */
const above = $derived(d.link.labelLines);
</script>
<BaseEdge {path} class={`flink${d.dimmed ? ' dimmed' : ''}`} style={dashStyle} />
<polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
<g class={`flabel${d.dimmed ? ' dimmed' : ''}`}>
<title>{d.link.label}{d.link.lineLabel ? ` (${d.link.lineLabel})` : ''}</title>
{#each above as line, i (i)}
<text x={labelX} y={labelY - 8 - (above.length - 1 - i) * 13} text-anchor="middle">{line}</text>
{/each}
{#if d.link.lineLabel}
<text x={labelX} y={labelY + 17} text-anchor="middle">{d.link.lineLabel}</text>
{/if}
</g>
<style>
:global(.svelte-flow__edge-path.flink) {
stroke: var(--ink-3);
stroke-width: 1px;
fill: none;
}
:global(.svelte-flow__edge-path.flink.dimmed) {
stroke-opacity: 0.25;
}
.fhead {
fill: var(--ink-3);
}
.fhead.dimmed {
fill-opacity: 0.25;
}
.flabel text {
fill: var(--ink-3);
font: 11px var(--mono);
}
.flabel.dimmed text {
fill-opacity: 0.25;
}
</style>
+94
View File
@@ -367,6 +367,79 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
return body as T;
}
/* ------------------------------------------------------------- flow strip -- */
export interface WireFlowEdge extends WireEdge {
/** The link's label: "calls", "via callback · registered at file:line". */
label: string;
/** This hop reads callee → caller — the reader stepped UP into it. */
upward: boolean;
/** Confidence below 0.6: the link is dashed `2 3`. */
uncertain: boolean;
/** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
synthesized: boolean;
}
export interface WireFlowSource {
file: string;
language: string;
from: number;
to: number;
/** Absent when `drift` — a mis-sliced window is worse than an empty card. */
lines?: string[];
highlight?: WireHighlight;
drift: boolean;
reason?: string;
}
/** The call site a card is opened at — the identifier drawn as an accent link. */
export interface WireFlowCallRef {
line: number;
col: number | null;
name: string;
targetId: string;
/** The link points back at the previous card, not on to the next one. */
backwards: boolean;
}
export interface WireFlowHop {
node: WireNodeRef;
/** The edge from the PREVIOUS hop into this one; null on the first. */
edge: WireFlowEdge | null;
callRef: WireFlowCallRef | null;
source: WireFlowSource | null;
}
export interface WireFlow {
id: string;
/** "execute → rowToFileRecord", for the header's flow picker. */
label: string;
hops: WireFlowHop[];
}
export interface WireFlowAmbiguity {
token: string;
chosen: WireNodeRef | null;
others: WireNodeRef[];
}
export interface WireFlowPayload {
query: {
kind: 'directed' | 'symbols' | 'trail';
from: string | null;
to: string | null;
symbols: string[];
};
flows: WireFlow[];
ambiguous: WireFlowAmbiguity[];
/** Tokens that named nothing in this index. */
unresolved: string[];
/** Why there is no flow, when there is none. */
reason: string | null;
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
/* -------------------------------------------------------------- the map -- */
export interface WireMapModule {
@@ -487,3 +560,24 @@ export function fetchMap(
const query = params.toString();
return getJson<WireMapPayload>(`api/map${query ? `?${query}` : ''}`, signal);
}
/**
* A flow. Exactly one of the three shapes is sent:
*
* - `{ from, to }` — "how does X reach Y", from the search box.
* - `{ symbols }` — `codegraph_explore`'s own question, verbatim.
* - `{ trail }` — the hops the reader walked, as `<dir><id>` strings. Each one
* is its own parameter, because a node id can be a file path and a file path
* can contain a comma.
*/
export function fetchFlow(
spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] },
signal?: AbortSignal
): Promise<WireFlowPayload> {
const params = new URLSearchParams();
if (spec.from) params.set('from', spec.from);
if (spec.to) params.set('to', spec.to);
if (spec.symbols) params.set('symbols', spec.symbols);
for (const hop of spec.trail ?? []) params.append('hop', hop);
return getJson<WireFlowPayload>(`api/flow?${params}`, signal);
}
+337
View File
@@ -0,0 +1,337 @@
/**
* The Flow strip's geometry, without a browser.
*
* The strip reads left to right: one card per hop, opened at the line that
* makes the next call, linked by an 86px connector carrying the edge. That is a
* straight line for one path — but two paths that share endpoints are one
* picture, not two, so the layout is a small DAG over the union of whatever
* flows are on screen, and a single chain is just the DAG with one node per
* column.
*
* Two rules make it deterministic, which is the whole point of not using a
* physics layout (design spec §1):
*
* - **A card's column is its longest distance from a start.** Two routes that
* rejoin therefore rejoin in the same column, and a card never sits left of
* something that calls it.
* - **A card's height is computed, not measured.** The number of source lines
* is known before anything renders, so the rows can be packed without waiting
* for a `ResizeObserver` — and the card's CSS pins the same height, so the
* arrows land where the arithmetic said they would. The File view's outline
* works the same way and for the same reason.
*
* Tested in `__tests__/ui-flow-model.test.ts`.
*/
import type { WireFlow, WireFlowEdge, WireFlowHop } from './api';
/* ------------------------------------------------------------ dimensions -- */
/** Card width (design spec §3.5). */
export const CARD_WIDTH = 380;
/** Connector width between two cards, when the label fits inside it. */
export const LINK_WIDTH = 86;
/** Distance between two columns' left edges, for a link with an ordinary label. */
export const COLUMN_PITCH = CARD_WIDTH + LINK_WIDTH;
/**
* Advance of IBM Plex Mono at the 11px a connector label is set in, and the
* clear space kept either side of the longest line.
*
* A gap only ever GROWS past {@link LINK_WIDTH}: 86px holds `calls` and
* `line 2029` comfortably, but a synthesized hop's `registered at App.tsx:3764`
* is twenty-six characters, and at a fixed pitch it ran underneath the cards on
* both sides of it — on excalidraw's `mutateElement` flow, over the source of
* the very card the label was explaining. The label is the evidence for a hop
* nobody can see in the source, so the picture makes room for it.
*/
const LABEL_CHAR_WIDTH = 6.65;
const LABEL_PAD = 18;
/** Card header: `10px 12px 6px` padding around one 18px row, plus a rule. */
export const HEADER_HEIGHT = 35;
/** Source window: `12px/19px` mono with 6px of padding above and below. */
export const CODE_LINE_HEIGHT = 19;
export const CODE_PADDING = 12;
/** A card with no source still says why, in one line of the same height. */
export const NO_SOURCE_HEIGHT = CODE_LINE_HEIGHT + CODE_PADDING;
/** Clear space between two cards stacked in one column. */
export const ROW_GAP = 24;
/** Canvas padding around the whole strip. */
export const PADDING = 32;
/** Exact rendered height of a card, which its CSS then pins. */
export function cardHeight(hop: WireFlowHop): number {
const lines = hop.source?.lines?.length ?? 0;
const body = lines > 0 ? lines * CODE_LINE_HEIGHT + CODE_PADDING : NO_SOURCE_HEIGHT;
return HEADER_HEIGHT + body;
}
/* ----------------------------------------------------------------- model -- */
export interface FlowCardLayout {
/** Node id — unique in the DAG even when two flows both contain it. */
id: string;
hop: WireFlowHop;
x: number;
y: number;
width: number;
height: number;
column: number;
/** Flows this card belongs to, by flow id — what dims when one is picked. */
flows: string[];
/** Position in the ACTIVE flow, or -1 when it is not on it. */
step: number;
}
export interface FlowLinkLayout {
id: string;
source: string;
target: string;
edge: WireFlowEdge;
/** Flows this link belongs to. */
flows: string[];
/** The full label, for the connector's tooltip. */
label: string;
/**
* The label broken into short centred lines, longest path segments shortened
* to a basename. Eighty-six pixels is about eleven monospace characters, so a
* synthesized hop's `via interface impl / registered at payroll.go:37` has to
* stack rather than run over both cards it sits between.
*/
labelLines: string[];
/** `line 2029` — drawn under the connector, when the edge recorded one. */
lineLabel: string | null;
/** SVG dasharray, or null for a solid line. */
dash: string | null;
}
export interface FlowLayout {
cards: FlowCardLayout[];
links: FlowLinkLayout[];
width: number;
height: number;
/** Longest chain on screen, in cards. */
columns: number;
/** Connector width after each column — {@link LINK_WIDTH} unless a label needed more. */
gaps: number[];
}
/** Dash pattern for a link (design spec §3.5). Heuristic wins over uncertain. */
export function dashFor(edge: WireFlowEdge): string | null {
if (edge.synthesized) return '5 3';
if (edge.uncertain) return '2 3';
return null;
}
/** Longest a connector label line may be before it is cut. */
export const LABEL_MAX_CHARS = 26;
/** `src/a/b/thing.go:37` reads as `thing.go:37` under an 86px connector. */
function shortenSites(text: string): string {
return text.replace(/[\w.@$/\\-]+[/\\]([\w.$-]+:\d+)/g, '$1');
}
/**
* The label, stacked. `\u00b7`-separated clauses become their own lines, and
* anything still too long is cut with an ellipsis — the connector's tooltip
* carries the untruncated text.
*/
export function labelLinesFor(edge: WireFlowEdge): string[] {
return shortenSites(edge.label)
.split(' \u00b7 ')
.map((part) => part.trim())
.filter(Boolean)
.map((part) =>
part.length > LABEL_MAX_CHARS ? `${part.slice(0, LABEL_MAX_CHARS - 1)}\u2026` : part
);
}
/** `line 2029`, or null when the edge carries no line. */
export function lineLabelFor(edge: WireFlowEdge): string | null {
return typeof edge.line === 'number' && edge.line > 0 ? `line ${edge.line}` : null;
}
/**
* Lay out the union of `flows`, highlighting `activeId`.
*
* Passing one flow gives a single row of cards; passing several gives the DAG
* where they share hops. The active flow decides the vertical order — it is
* drawn along the top of its columns — so picking a flow never re-sorts the
* picture underneath the reader.
*/
export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | null): FlowLayout {
const active = flows.find((f) => f.id === activeId) ?? flows[0] ?? null;
const activeSteps = new Map<string, number>();
active?.hops.forEach((hop, index) => activeSteps.set(hop.node.id, index));
// ---- collect nodes and edges over every flow on screen -------------------
const cards = new Map<string, { hop: WireFlowHop; flows: string[]; order: number }>();
const links = new Map<
string,
{ source: string; target: string; edge: WireFlowEdge; flows: string[] }
>();
const successors = new Map<string, Set<string>>();
const indegree = new Map<string, number>();
let order = 0;
for (const flow of flows) {
for (let i = 0; i < flow.hops.length; i++) {
const hop = flow.hops[i] as WireFlowHop;
const id = hop.node.id;
const existing = cards.get(id);
if (existing) {
if (!existing.flows.includes(flow.id)) existing.flows.push(flow.id);
} else {
cards.set(id, { hop, flows: [flow.id], order: order++ });
indegree.set(id, 0);
successors.set(id, new Set());
}
const previous = flow.hops[i - 1];
if (!previous || hop.edge === null) continue;
// An upward hop is the same edge read backwards; the ARROW still points
// the way the reader travelled, which is what the strip is describing.
const from = previous.node.id;
const key = `${from} ${id}`;
const link = links.get(key);
if (link) {
if (!link.flows.includes(flow.id)) link.flows.push(flow.id);
continue;
}
links.set(key, { source: from, target: id, edge: hop.edge, flows: [flow.id] });
const outs = successors.get(from);
if (outs && !outs.has(id)) {
outs.add(id);
indegree.set(id, (indegree.get(id) ?? 0) + 1);
}
}
}
if (cards.size === 0) {
return { cards: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
}
// ---- column = longest distance from a start -----------------------------
const column = new Map<string, number>();
for (const id of cards.keys()) column.set(id, 0);
// Kahn order, so a node is placed only after everything that reaches it.
const pending = new Map(indegree);
const queue = [...cards.keys()].filter((id) => (pending.get(id) ?? 0) === 0);
const settled = new Set<string>();
while (queue.length > 0) {
const id = queue.shift() as string;
settled.add(id);
for (const next of successors.get(id) ?? []) {
column.set(next, Math.max(column.get(next) ?? 0, (column.get(id) ?? 0) + 1));
const left = (pending.get(next) ?? 0) - 1;
pending.set(next, left);
if (left === 0) queue.push(next);
}
}
// A cycle (a flow that calls back into itself) leaves nodes unsettled. They
// are still real hops, so they go one column past whatever reached them
// rather than disappearing.
for (const id of cards.keys()) {
if (settled.has(id)) continue;
let best = 0;
for (const [from, outs] of successors) {
if (outs.has(id)) best = Math.max(best, (column.get(from) ?? 0) + 1);
}
column.set(id, best);
}
// ---- pack each column, active flow first --------------------------------
const byColumn = new Map<number, string[]>();
for (const id of cards.keys()) {
const c = column.get(id) ?? 0;
const list = byColumn.get(c);
if (list) list.push(id);
else byColumn.set(c, [id]);
}
for (const list of byColumn.values()) {
list.sort((a, b) => {
const onA = activeSteps.has(a) ? 0 : 1;
const onB = activeSteps.has(b) ? 0 : 1;
if (onA !== onB) return onA - onB;
return (cards.get(a)?.order ?? 0) - (cards.get(b)?.order ?? 0);
});
}
const columns = Math.max(...byColumn.keys()) + 1;
// Each gap is wide enough for the widest label that crosses it. Labels are
// built here rather than in the render pass because the geometry depends on
// them — see LABEL_CHAR_WIDTH.
const labelled = [...links.entries()].map(([key, link]) => ({
key,
link,
lines: labelLinesFor(link.edge),
lineLabel: lineLabelFor(link.edge),
}));
const gaps = Array.from({ length: Math.max(0, columns - 1) }, () => LINK_WIDTH);
for (const { link, lines, lineLabel } of labelled) {
const from = column.get(link.source) ?? 0;
if (from < 0 || from >= gaps.length) continue;
const widest = Math.max(0, ...lines.map((l) => l.length), lineLabel?.length ?? 0);
gaps[from] = Math.max(gaps[from] as number, Math.ceil(widest * LABEL_CHAR_WIDTH) + LABEL_PAD);
}
const columnX: number[] = [PADDING];
for (let c = 1; c < columns; c++) {
columnX[c] = (columnX[c - 1] as number) + CARD_WIDTH + (gaps[c - 1] as number);
}
const heights = new Map<string, number>();
for (const [id, card] of cards) heights.set(id, cardHeight(card.hop));
// Rows are centred on the tallest column, so a one-card column sits opposite
// the middle of a two-card one instead of hugging the top of the canvas.
const columnHeights = new Map<number, number>();
for (const [c, list] of byColumn) {
columnHeights.set(
c,
list.reduce((sum, id) => sum + (heights.get(id) ?? 0), 0) + ROW_GAP * (list.length - 1)
);
}
const tallest = Math.max(...columnHeights.values());
const laidOut = new Map<string, FlowCardLayout>();
for (const [c, list] of byColumn) {
let y = PADDING + (tallest - (columnHeights.get(c) ?? 0)) / 2;
for (const id of list) {
const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
const height = heights.get(id) ?? 0;
laidOut.set(id, {
id,
hop: card.hop,
x: columnX[c] as number,
y,
width: CARD_WIDTH,
height,
column: c,
flows: card.flows,
step: activeSteps.get(id) ?? -1,
});
y += height + ROW_GAP;
}
}
return {
cards: [...laidOut.values()].sort((a, b) => a.column - b.column || a.y - b.y),
links: labelled.map(({ link, lines, lineLabel }) => ({
id: `${link.source}->${link.target}`,
source: link.source,
target: link.target,
edge: link.edge,
flows: link.flows,
label: link.edge.label,
labelLines: lines,
lineLabel,
dash: dashFor(link.edge),
})),
width: (columnX[columns - 1] as number) + CARD_WIDTH + PADDING,
height: PADDING * 2 + tallest,
columns,
gaps,
};
}
+33 -6
View File
@@ -9,7 +9,7 @@
* #/s/<id> symbol view (?hl=<line> highlights a line, ?t=<trail>)
* #/file/<path> file view (?hl=<line>)
* #/map module map (?root=&depth=&tests=1)
* #/flow[/<key>] flow strip — reserved, phase 2
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
*
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
* `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
@@ -24,7 +24,16 @@ export type Route =
| { view: 'symbol'; id: string; line: number | null }
| { view: 'file'; path: string; line: number | null }
| { view: 'map'; root: string | null; depth: number; tests: boolean }
| { view: 'flow'; key: string | null }
| {
view: 'flow';
/** "how does X reach Y" — both ends pinned. */
from: string | null;
to: string | null;
/** An explore-shaped bag of names, comma or space separated. */
symbols: string | null;
/** An encoded trail, read as a flow. Same format the `t` param uses. */
trail: string | null;
}
| { view: 'unknown'; path: string };
export type ViewName = Route['view'];
@@ -84,8 +93,16 @@ export function parseHash(hash: string): RouterLocation {
depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
tests: params.get('tests') === '1',
};
} else if (head === 'flow') {
route = { view: 'flow', key: rest.length > 0 ? rest.join('/') : null };
} else if (head === 'flow' && rest.length === 0) {
// The question travels in the URL exactly as it was asked, so a flow can be
// linked in a review and reopen as the same path.
route = {
view: 'flow',
from: params.get('from'),
to: params.get('to'),
symbols: params.get('symbols'),
trail: params.get('t'),
};
} else {
route = { view: 'unknown', path: pathPart };
}
@@ -119,8 +136,18 @@ export function mapHref(
return `#/map${query ? `?${query}` : ''}`;
}
export function flowHref(key?: string): string {
return key ? `#/flow/${encodePath(key)}` : '#/flow';
export function flowHref(
opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
): string {
const params = new URLSearchParams();
if (opts.from) params.set('from', opts.from);
if (opts.to) params.set('to', opts.to);
if (opts.symbols) params.set('symbols', opts.symbols);
// `t`, not `trail`: the trail already travels under that name everywhere
// else, and a flow read from one is the same walk under a different lens.
if (opts.trail) params.set('t', opts.trail);
const query = params.toString();
return `#/flow${query ? `?${query}` : ''}`;
}
/* ---------- the live route ---------- */
+27 -6
View File
@@ -30,10 +30,11 @@ export interface FlowQuery {
/**
* "how does X reach Y", "X -> Y", "X → Y".
*
* Phase 1 has no Flow view to send this to, but the question is worth
* recognising anyway: someone who types it gets both endpoints looked up
* instead of a search for the whole sentence, which matches nothing. CG-50
* turns the same parse into a computed path.
* The parse drives two things: the palette's first row, which opens the Flow
* strip for exactly this pair, and the search underneath it, which looks up
* both endpoints rather than searching the whole sentence (which matches
* nothing). Both are useful — the flow answers the question, the endpoints let
* a reader who spelled a name wrong see what they actually named.
*/
const FLOW_SENTENCE =
/^\s*(?:how\s+(?:does|do|would|can)\s+)?([\w$.]+)\s+(?:reach|reaches|call|calls|hit|hits|get\s+to|end\s+up\s+(?:in|at))\s+([\w$.]+)\s*\??\s*$/i;
@@ -58,7 +59,8 @@ export function parseFlowQuery(query: string): FlowQuery | null {
export type PaletteItem =
| { type: 'symbol'; id: string; node: WireNodeRef; name: string; meta: string; location: string }
| { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null };
| { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null }
| { type: 'flow'; id: string; from: string; to: string; name: string; meta: string; location: string };
export interface PaletteSection {
/** Sentence-case caption, e.g. "Methods", "Files that run something". */
@@ -177,10 +179,29 @@ export function buildSearchPalette(
: answers[0]?.results.items ?? [];
const sections = groupByKind(results);
// The flow row goes FIRST, so Enter opens the path: someone who typed
// "how does X reach Y" asked for the path, not for a list of symbols.
if (flow) {
sections.unshift({
title: 'Flow',
note: 'The call path between them, one card per hop.',
items: [
{
type: 'flow',
id: `flow:${flow.from}:${flow.to}`,
from: flow.from,
to: flow.to,
name: `${flow.from} → ${flow.to}`,
meta: '',
location: 'read as a flow',
},
],
});
}
const items = sections.flatMap((section) => section.items);
const hint = flow
? `Reading the path between two symbols arrives with the Flow view. Here is what ${flow.from} and ${flow.to} name.`
? `Reading the path from ${flow.from} to ${flow.to}. Below it, what each name matches.`
: null;
return {
+373 -14
View File
@@ -1,29 +1,388 @@
<!--
Reserved route. The flow strip (design spec §3.5) is phase 2.
The Flow strip (`#/flow`, design spec §3.5): how one symbol reaches another,
as one card per hop, each opened at the line that makes the next call.
The path is not computed here and is not computed by the server either — it
comes from `resolveNamedSymbolFlow`, the search `codegraph_explore` leads its
answers with. That is deliberate: a viewer that drew a different path from the
one the MCP tool describes would get the two quoted against each other in a
review, and one of them would be wrong.
Svelte Flow draws it, for pan, zoom and fit and nothing else: positions come
from `buildFlowLayout`, the flow picker is local state, and nothing is
draggable. Clicking a card opens the Symbol view with the trail set to the
path so far, so the strip hands the reader off to the view that goes deep.
-->
<script lang="ts">
import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import FlowCard from '../components/flow/FlowCard.svelte';
import FlowLink from '../components/flow/FlowLink.svelte';
import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
import { navigate, symbolHref } from '../lib/router.svelte';
import { trail, encodeTrail, type TrailHop } from '../lib/trail.svelte';
import { decodeTrail } from '../lib/trail-codec';
import { buildFlowLayout, type FlowCardLayout, type FlowLayout } from '../lib/flow-model';
import { basename } from '../lib/symbol-model';
interface Props {
flowKey: string | null;
from: string | null;
to: string | null;
symbols: string | null;
/** An encoded trail, when the flow is the reader's own walk. */
trailParam: string | null;
}
let { from, to, symbols, trailParam }: Props = $props();
let payload = $state<WireFlowPayload | null>(null);
let error = $state<string | null>(null);
let loading = $state(true);
let picked = $state<string | null>(null);
/** True when the picker is on "All paths" — the union is drawn as a DAG. */
let showAll = $state(false);
const ALL = 'all-paths';
/**
* The strip opens at 1:1, top left — it never fits itself to the window.
*
* Fitting an eight-hop flow into a laptop's width lands at about 0.38 zoom,
* which is a picture of eight grey rectangles: the source inside them is the
* answer, and source you cannot read is not an answer. So the reader arrives
* at the first card, full size, and pans. The Controls' fit button is still
* there for anyone who wants the shape rather than the code.
*/
const START_VIEWPORT = { x: 0, y: 0, zoom: 1 };
const nodeTypes = { flow: FlowCard };
const edgeTypes = { flow: FlowLink };
/** The hops the trail form asks for, as `<dir><id>` — the wire's own spelling. */
const trailHops = $derived<TrailHop[]>(trailParam ? decodeTrail(trailParam) : []);
$effect(() => {
const spec = trailParam
? { trail: trailHops.map((h) => `${h.dir === 'start' ? 's' : h.dir === 'up' ? 'u' : 'd'}${h.id}`) }
: symbols
? { symbols }
: { from: from ?? '', to: to ?? '' };
if (!trailParam && !symbols && !(from && to)) {
payload = null;
loading = false;
error = null;
return;
}
const controller = new AbortController();
loading = true;
error = null;
fetchFlow(spec, controller.signal)
.then((next) => {
payload = next;
picked = next.flows[0]?.id ?? null;
showAll = false;
loading = false;
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
error = err instanceof Error ? err.message : String(err);
loading = false;
});
return () => controller.abort();
});
const flows = $derived<WireFlow[]>(payload?.flows ?? []);
const shown = $derived<WireFlow[]>(
showAll ? flows : flows.filter((f) => f.id === picked).slice(0, 1)
);
const layout = $derived<FlowLayout | null>(
shown.length === 0 ? null : buildFlowLayout(showAll ? flows : shown, picked)
);
const activeFlow = $derived(flows.find((f) => f.id === picked) ?? flows[0] ?? null);
const nodes = $derived.by<Node[]>(() => {
if (layout === null) return [];
return layout.cards.map((card) => ({
id: card.id,
type: 'flow',
position: { x: card.x, y: card.y },
draggable: false,
selectable: false,
connectable: false,
data: {
// The accent border marks the picked path, and only means something
// when there is more than one on screen. A single flow whose every
// card is accented has said nothing.
card,
current: showAll && card.step >= 0,
dimmed: showAll && card.step < 0,
onOpen: openCard,
onFollow: followCard,
},
}));
});
const edges = $derived.by<Edge[]>(() => {
if (layout === null) return [];
return layout.links.map((link) => ({
id: link.id,
source: link.source,
target: link.target,
sourceHandle: 'out',
targetHandle: 'in',
type: 'flow',
selectable: false,
deletable: false,
data: { link, dimmed: showAll && picked !== null && !link.flows.includes(picked) },
}));
});
/**
* Open a card in the Symbol view with the trail set to the path so far.
*
* The prefix, not the whole flow: the reader is standing at that hop, and a
* trail that ran on past them would claim a walk they had not taken.
*/
function openCard(card: FlowCardLayout): void {
const hops = activeFlow?.hops ?? [];
const at = hops.findIndex((hop) => hop.node.id === card.id);
const prefix = at >= 0 ? hops.slice(0, at + 1) : [];
trail.clear();
prefix.forEach((hop, index) =>
trail.push({
id: hop.node.id,
name: hop.node.name,
kind: hop.node.kind,
dir: index === 0 ? 'start' : hop.edge?.upward ? 'up' : 'down',
})
);
if (prefix.length === 0) {
trail.push({ id: card.id, name: card.hop.node.name, kind: card.hop.node.kind, dir: 'start' });
}
navigate(
symbolHref(card.id, {
trail: encodeTrail(trail.hops),
...(card.hop.callRef ? { line: card.hop.callRef.line } : {}),
})
);
}
/** The accent link inside a card: step to the symbol it names. */
function followCard(card: FlowCardLayout): void {
const target = card.hop.callRef?.targetId;
if (!target) return;
const next = layout?.cards.find((c) => c.id === target);
if (next) openCard(next);
}
function note(p: WireFlowPayload): string {
if (p.query.kind === 'trail') {
return 'Your trail, read as a flow: each card is opened at the line that carried you to the next one.';
}
if (p.query.kind === 'directed') {
return 'Every card is a call the graph recorded. A dashed link is a hop no one can see in the source — a callback, an interface, a re-render — and it names where it was wired.';
}
return 'The longest call path among the symbols you named, the same one codegraph_explore leads with.';
}
let { flowKey = null }: Props = $props();
</script>
<div class="scroll">
<div class="emptystate">
<h2>Flow</h2>
<p>
Reading a trail as a left-to-right flow — one card per hop, showing the line that makes each
call — is not part of this release.
</p>
{#if flowKey}
<p class="dim">Requested flow: <span class="mono">{flowKey}</span></p>
<div class="flowview">
<header class="fhead">
<h1>Flow</h1>
{#if flows.length > 0}
<select
aria-label="Which path to draw"
value={showAll ? ALL : (picked ?? '')}
onchange={(event) => {
const value = (event.currentTarget as HTMLSelectElement).value;
showAll = value === ALL;
if (!showAll) picked = value;
}}
>
{#each flows as flow (flow.id)}
<option value={flow.id}>{flow.label} · {flow.hops.length} hops</option>
{/each}
{#if flows.length > 1}
<option value={ALL}>All {flows.length} paths</option>
{/if}
</select>
{/if}
{#if payload}
<p class="note">{note(payload)}</p>
{/if}
</header>
<div class="fstage">
{#if error !== null}
<div class="state">
<h2>The flow could not be built</h2>
<p>{error}</p>
</div>
{:else if loading && payload === null}
<div class="state"><p class="dim">Following the calls…</p></div>
{:else if payload === null}
<div class="state">
<h2>Nothing to follow yet</h2>
<p>
Ask for a path in the search box — “how does execute reach getFile”, or
<span class="mono">execute -&gt; getFile</span> — or walk a trail and read it as a flow.
</p>
</div>
{:else if layout === null}
<div class="state">
<h2>No path between them</h2>
<p>{payload.reason}</p>
{#if payload.query.from && payload.query.to}
<p class="dim">
Asked: <span class="mono">{payload.query.from}</span> to
<span class="mono">{payload.query.to}</span>.
</p>
{/if}
</div>
{:else}
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
{edgeTypes}
initialViewport={START_VIEWPORT}
fitViewOptions={{ padding: 0.1, maxZoom: 1, minZoom: 0.2 }}
minZoom={0.2}
maxZoom={1.4}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
proOptions={{ hideAttribution: true }}
>
<Controls position="bottom-right" showLock={false} />
</SvelteFlow>
{/if}
</div>
{#if payload && (payload.ambiguous.length > 0 || payload.unresolved.length > 0)}
<footer class="fnote">
{#each payload.ambiguous as amb (amb.token)}
<p>
<span class="mono">{amb.token}</span> names {amb.others.length + 1} definitions.
{#if amb.chosen}
This path runs through the one in
<span class="mono">{basename(amb.chosen.file)}:{amb.chosen.line}</span>.
{:else}
None of them are on this path.
{/if}
</p>
{/each}
{#each payload.unresolved as token (token)}
<p><span class="mono">{token}</span> names nothing in this index.</p>
{/each}
</footer>
{/if}
</div>
<style>
.scroll {
.flowview {
display: grid;
height: 100%;
overflow: auto;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.fhead {
display: flex;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--rule-soft);
gap: 12px;
}
.fhead h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.fhead select {
padding: 3px 6px;
background: var(--paper-2);
color: var(--ink);
border: 1px solid var(--rule-soft);
border-radius: 0;
font: 12.5px var(--sans);
}
.note {
max-width: 78ch;
margin: 0;
color: var(--ink-3);
font-size: 12px;
}
.fstage {
position: relative;
overflow: hidden;
background: var(--paper);
}
/* Svelte Flow paints its own surface and controls; both are re-tokenised so
the canvas belongs to the paper/ink system. Same treatment as the Map. */
.fstage :global(.svelte-flow) {
background: var(--paper);
}
.fstage :global(.svelte-flow__handle) {
width: 1px;
height: 1px;
min-width: 0;
min-height: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
.fstage :global(.svelte-flow__node) {
cursor: default;
}
.fstage :global(.svelte-flow__controls) {
border: 1px solid var(--rule-soft);
box-shadow: none;
}
.fstage :global(.svelte-flow__controls-button) {
background: var(--paper);
border: 0;
border-bottom: 1px solid var(--rule-soft);
border-radius: 0;
box-shadow: none;
fill: var(--ink-2);
}
.state {
max-width: 52ch;
padding: 40px;
}
.state h2 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
}
.state p {
margin: 0 0 8px;
color: var(--ink-2);
font-size: 12.5px;
line-height: 1.5;
}
.dim {
color: var(--ink-3);
}
.mono {
font-family: var(--mono);
}
.fnote {
padding: 8px 18px;
border-top: 1px solid var(--rule-soft);
background: var(--paper-2);
color: var(--ink-2);
font-size: 12px;
}
.fnote p {
margin: 0 0 2px;
}
</style>
+7 -1
View File
@@ -12,7 +12,7 @@
import PaletteRows from '../components/PaletteRows.svelte';
import { palette } from '../lib/palette.svelte';
import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
import { fileHref, navigate } from '../lib/router.svelte';
import { fileHref, flowHref, navigate } from '../lib/router.svelte';
import { walkTo } from '../lib/walk';
interface Props {
@@ -27,6 +27,12 @@
let entries = $derived(buildEntryPalette(palette.entries));
function pick(item: PaletteItem) {
// The empty screen only ever shows entry points, which are never flows —
// but the row type is shared, so the branch is here rather than assumed away.
if (item.type === 'flow') {
navigate(flowHref({ from: item.from, to: item.to }));
return;
}
const id = item.type === 'route' ? item.nodeId : item.id;
if (!id) return;
// A file opens the File view — its outline plus the import rails. The