feat(ui): where the graph stops — the Flow strip's dynamic-dispatch end cap (CG-51)

A flow that does not reach what it was asked about now ends in a cap instead
of in silence: the dispatch form that ended it, the line, the static key when
the source spells one out, the candidate runtime targets as clickable rows,
and the name-only matches under 0.6 the search refused to follow. A flow that
does reach its destination never shows one.

The verdict is lifted out of `ToolHandler` into
`src/graph/dynamic-boundary-report.ts` and both callers render it —
`codegraph_explore`'s prose and `/api/flow`'s `WireFlowBoundary` — the same
move `named-symbol-flow.ts` made for the path finder, and for the same reason:
a reader holding the strip and the MCP answer must not be told two different
things. The explore prose is unchanged, byte for byte.

When nothing connects at all and a dispatch site explains why, the strip is
that site: one card opened at the line where the static path ends, plus the
cap. When nothing explains it, no stopping point is invented.
This commit is contained in:
Colby McHenry
2026-08-27 04:54:37 -05:00
parent ecd6e1cd15
commit dc7f1e590e
16 changed files with 1666 additions and 160 deletions
+54 -15
View File
@@ -18,6 +18,7 @@
import '@xyflow/svelte/dist/style.css';
import FlowCard from '../components/flow/FlowCard.svelte';
import FlowLink from '../components/flow/FlowLink.svelte';
import FlowEndCap from '../components/flow/FlowEndCap.svelte';
import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { navigate, symbolHref } from '../lib/router.svelte';
@@ -55,7 +56,7 @@
* 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 nodeTypes = { flow: FlowCard, cap: FlowEndCap };
const edgeTypes = { flow: FlowLink };
/** The hops the trail form asks for, as `<dir><id>` — the wire's own spelling. */
@@ -107,24 +108,41 @@
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 },
const caps: Node[] = layout.endCaps.map((cap) => ({
id: cap.id,
type: 'cap',
position: { x: cap.x, y: cap.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,
cap,
dimmed: showAll && picked !== null && !cap.flows.includes(picked),
onOpen: openNode,
},
}));
// Caps first, so a card that overlaps one paints on top of it.
return [
...caps,
...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[]>(() => {
@@ -172,6 +190,19 @@
);
}
/**
* A row on the end cap: a candidate runtime target, or a continuation the
* search refused to follow.
*
* It opens as a fresh start rather than as another hop, because neither is a
* call the graph recorded — pushing one onto the trail would draw a step
* nobody took. That is the whole reason the cap exists.
*/
function openNode(nodeId: string): void {
trail.clear();
navigate(symbolHref(nodeId));
}
/** The accent link inside a card: step to the symbol it names. */
function followCard(card: FlowCardLayout): void {
const target = card.hop.callRef?.targetId;
@@ -184,6 +215,9 @@
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.flows.some((f) => f.partial)) {
return 'No static path connects them. The card is where the looking stopped — a call whose target is chosen at runtime — and the cap names the form, the key and who could be on the other side.';
}
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.';
}
@@ -205,7 +239,9 @@
}}
>
{#each flows as flow (flow.id)}
<option value={flow.id}>{flow.label} · {flow.hops.length} hops</option>
<option value={flow.id}
>{flow.label}{flow.hops.length > 1 ? ` · ${flow.hops.length} hops` : ''}</option
>
{/each}
{#if flows.length > 1}
<option value={ALL}>All {flows.length} paths</option>
@@ -265,8 +301,11 @@
{/if}
</div>
{#if payload && (payload.ambiguous.length > 0 || payload.unresolved.length > 0)}
{#if payload && (payload.reason !== null || payload.ambiguous.length > 0 || payload.unresolved.length > 0) && layout !== null}
<footer class="fnote">
{#if payload.reason !== null}
<p>{payload.reason}</p>
{/if}
{#each payload.ambiguous as amb (amb.token)}
<p>
<span class="mono">{amb.token}</span> names {amb.others.length + 1} definitions.