Kaynağa Gözat

feat(steps): rows read in the code's order; a hop written inside another call says so

- branch-guards callSiteInTree: a call's span and the call it is written inside the arguments of (`within`), stopping at a function or block boundary
- steps.ts: each step records the hop that first reached it (position, span, enclosing call — the fold's first hop out of the root, inherited down the fold); a row is ordered by that position, a hop inside another site's arguments before that site, and `WireStep.order` carries it; links carry `within`
- map-model: an `order` option — the row's initial order, sweeps over parents only, tie-broken by it; the Map and Screens tabs pass none and are unchanged
- viewer: rows laid out by `order`; `inside res.json(…)` in the panel rows and the tooltip
- tests: servers fixture (a token signed inside the reply's arguments: `within`, and the row `create · queue · mail · jwt.sign · 201`), model row order; spec §3.13, CHANGELOG

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Colby McHenry 6 gün önce
ebeveyn
işleme
783f3954ec

+ 2 - 0
CHANGELOG.md

@@ -46,6 +46,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge.
 
+- **A row reads in the code's order.** The boxes one step away from a handler now sit left to right as the code runs them — the database read, the token signed while the reply is built, the reply, the error reply — instead of in an arbitrary order; a call written inside another call's arguments (`generateToken(…)` in `res.json({ token: generateToken(…) })`) comes before it, and its link says `inside res.json(…)` in the panel and the tooltip.
+
 - **Each way an endpoint answers is its own box.** A handler that replies `200` or `401` now draws two reply boxes instead of one `200 · 401`, so each line from the handler carries its own condition on the picture — `→ user && (await user.matchPassword(password))` into the 200, `→ NOT (…)` into the 401 — the way a screen's transitions do; replies whose status the code does not spell out share one box labelled by the call.
 
 - **A reply that sets no status is a 200.** `res.json(user)`, `res.send(…)`, `reply.send(…)`, `NextResponse.json(…)`, a `JSONResponse` or `jsonify(…)` with no status in the chain now count as `200`, so an endpoint's success reply has a box of its own beside the `401`'s and its row carries its code; a status set by the statement just before (`res.status(202); res.json(user)`) is that reply's. An Express handler written inline at the registration keeps its own replies too — they were filtered out with the framework noise.

+ 25 - 2
__tests__/ui-steps-api-servers.test.ts

@@ -49,7 +49,13 @@ beforeAll(async () => {
       '  if (!user.verified) {\n' +
       '    await sendVerification(user)\n' +
       '  }\n' +
-      '  res.status(201).json(user)\n' +
+      '  res.status(201).json({\n' +
+      '    id: user.id,\n' +
+      '    token: signToken(user.id),\n' +
+      '  })\n' +
+      '}\n' +
+      'function signToken(id) {\n' +
+      "  return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: '1d' })\n" +
       '}\n' +
       'export async function getUser(id: string) {\n' +
       '  const user = await prisma.user.findUnique({ where: { id } })\n' +
@@ -312,7 +318,24 @@ describe('Express', () => {
     expect(res.label).toBe('201');
     expect(res.effect?.statuses).toEqual([201]);
     const resLink = p.links.find((l) => l.to === res.id)!;
-    expect(resLink.sites[0]).toMatchObject({ text: 'res.status(201).json', args: 'user', status: 201 });
+    expect(resLink.sites[0]).toMatchObject({ text: 'res.status(201).json', args: '{ id, token }', status: 201 });
+    // The token is signed while the reply is built: the link says so, and
+    // the row reads in the code's order — the write, the job, the mail, the
+    // signing (inside the reply's arguments), then the reply.
+    const auth = effect(p, 'auth')!;
+    expect(auth.label).toBe('jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })');
+    const authLink = p.links.find((l) => l.to === auth.id)!;
+    expect(authLink.via.map((v) => v.name)).toEqual(['signToken']);
+    expect(authLink.within).toBe('res.status(201).json');
+    expect(resLink.within).toBeUndefined();
+    const row = p.steps.filter((s) => s.depth === 1).sort((a, b) => a.order! - b.order!).map((s) => s.label);
+    expect(row).toEqual([
+      'prisma.user.create({ data })',
+      "emailQueue.add('welcome', { userId })",
+      'transporter.sendMail({ to })',
+      'jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })',
+      '201',
+    ]);
   });
 
   it('walks an inline handler as the route itself, into the service’s read and its 404', async () => {

+ 13 - 0
__tests__/ui-steps-model.test.ts

@@ -137,3 +137,16 @@ describe('words per project', () => {
     expect(triggerWords({ kind: 'load', name: 'GET', of: '/blog/[slug]', in: 'page.tsx' })).toBe('page load · /blog/[slug]');
   });
 });
+
+describe('row order', () => {
+  it('lays a row out in the order the server gave, not by id', () => {
+    const anchor = step('/login', 'screen', 0, { anchor: true });
+    const a = step('User.findOne', 'effect', 1, { order: 0 });
+    const b = step('jwt.sign', 'effect', 1, { order: 1 });
+    const c = step('200', 'effect', 1, { order: 2 });
+    const d = step('401', 'effect', 1, { order: 3 });
+    const model = buildStepsModel(payload([anchor, d, c, b, a], [link(anchor, a), link(anchor, b), link(anchor, c), link(anchor, d)]));
+    const row = model.layout.nodes.filter((n) => n.id !== anchor.id).sort((x, y) => x.x - y.x).map((n) => n.id);
+    expect(row).toEqual([a.id, b.id, c.id, d.id]);
+  });
+});

+ 6 - 1
docs/design/codegraph-ui-design-spec.md

@@ -551,7 +551,12 @@ going on into the handler; a job, an event or a message arriving draws as `⇠ w
 within its lines, so the landing walks on into what the handler does. Test suites and generated files are never sources: forty supertest
 calls would make a route a hub. A mounted Express router (`app.use('/api', routes)`, nested) names its routes by the path a request takes.
 
-Rows = distance from the anchor as the server counted it (first discovery), anchor on top with the entry mark. Boxes:
+Rows = distance from the anchor as the server counted it (first discovery), anchor on top with the entry mark; **within a row,
+the code's order** — each step carries the position of the hop that first reached it (`WireStep.order`), a hop written inside
+another site's arguments counting before that site, so `generateToken(…)` in `res.json({ token: generateToken(…) })` sits left
+of the `200` it is part of; the layout (`map-model.ts` `order`) takes that as the row's initial order and its sweeps move a box
+only to sit under its parents. A link whose first hop is written inside another call's arguments says so (`WireStepLink.within`,
+`inside res.json(…)` in the panel and the tooltip) — the nesting is stated, never drawn as an edge out of an effect. Boxes:
 the §3.12 screen box for a screen or a handler; **bridge / event** add a 3px `--accent` left rule (the language
 changes under the code) and lead with `⇢` / `⇠ <event name>`; **store** sits on `--paper-2`; **effect** is dashed
 `--ink-3` (a place the graph cannot follow into), labelled by the API (`client.post`) over `category · caller`. Edges,

+ 37 - 1
src/graph/branch-guards.ts

@@ -435,6 +435,30 @@ export interface CallSiteText {
   argList: string[];
   /** A status code written as an object property in the arguments (`{ status: 201 }`), which the abbreviation to keys would hide. */
   status?: number;
+  /** Where the call starts and ends in the source (1-based lines) — the span another site may be written inside. */
+  span?: { start: { line: number; column: number }; end: { line: number; column: number } };
+  /**
+   * The call this one is written inside the arguments of — `res.json` for the
+   * `generateToken(…)` in `res.json({ token: generateToken(…) })` — normalised
+   * like `callee`. Absent at the top of a statement, and never reaching out of
+   * the function or block the call is in.
+   */
+  within?: string;
+}
+
+/** A node the climb to an enclosing call must not cross: the call is then a statement of its own inside a callback. */
+const CALL_BOUNDARY = /function|lambda|closure|block|statement|body|declaration/;
+
+/** The nearest call whose ARGUMENTS contain `call`, as its callee chain; null when there is none this side of a function or block. */
+function enclosingCallText(call: SyntaxNode): string | null {
+  for (let node = call.parent, up = 0; node && up < 12; node = node.parent, up++) {
+    if (CALL_BOUNDARY.test(node.type)) return null;
+    if (!CALL_TYPES.has(node.type)) continue;
+    const container = argumentsOf(node);
+    if (container && container.startIndex <= call.startIndex && call.endIndex <= container.endIndex) return calleeChainText(node, container);
+    return null;
+  }
+  return null;
 }
 
 const STATUS_KEY = /^(?:status|statusCode|status_code|code)$/;
@@ -502,7 +526,19 @@ export function callSiteInTree(root: SyntaxNode, source: string, line: number, c
   }
   const text = parts.join(', ');
   if (status === null) status = statusSetBefore(call, callee);
-  return { callee, args: text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text, argList: parts, ...(status !== null ? { status } : {}) };
+  const span = {
+    start: { line: call.startPosition.row + 1, column: call.startPosition.column },
+    end: { line: call.endPosition.row + 1, column: call.endPosition.column },
+  };
+  const within = enclosingCallText(call);
+  return {
+    callee,
+    args: text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text,
+    argList: parts,
+    span,
+    ...(within ? { within } : {}),
+    ...(status !== null ? { status } : {}),
+  };
 }
 
 const BODY_REPLY = /^(?:res|response|reply|rep|ctx|c|context)\.(?:json|jsonp|send|render|sendFile|download|end|text|html|body)$/;

+ 102 - 8
src/ui-server/api/steps.ts

@@ -115,6 +115,12 @@ export interface WireStep {
   events?: string[];
   /** For a handler: what fires it — the first binding the walk met. */
   trigger?: WireStepTrigger;
+  /**
+   * The step's place in its row, in the code's order: by the position of the
+   * hop that first reached it, a hop written inside another site's arguments
+   * counting before that site. The viewer lays the row out in it.
+   */
+  order?: number;
   /**
    * For a screen or an endpoint: its path and the symbol that serves it — the
    * component a screen renders, the handler an endpoint runs. `endpoint` when
@@ -153,6 +159,8 @@ export interface WireStepLink {
   when: string;
   /** How the last hop was established when it was not a plain call — `via rn-event-channel · registered at file:line`. */
   label: string;
+  /** The call the first hop is written inside the arguments of — `res.json` for a token signed while building the reply. */
+  within?: string;
   synthesized: boolean;
   uncertain: boolean;
   sites: WireStepSite[];
@@ -309,14 +317,31 @@ function dependenciesIn(text: string): string[] {
 // The endpoint
 // =============================================================================
 
+/**
+ * Where a step is first reached from its parent's root: the hop's position,
+ * its call's span, and the call it is written inside — what orders a row the
+ * way the code reads, and says `inside res.json(…)` on the link.
+ */
+interface HopSite {
+  file: string;
+  line: number;
+  column: number;
+  end: { line: number; column: number };
+  within: string | null;
+}
+
 interface Fold {
   node: Node;
   /** [first folded node, …, this node]; empty for the step's own root. */
   chain: Node[];
   whens: string[];
+  /** The hop out of the step's root this fold descends from; null for the root itself. */
+  first: HopSite | null;
 }
 
 interface StepRecord extends WireStep {
+  /** The hop that first reached this step, for the row's order; the anchor has none. */
+  first?: HopSite;
   /** Where exploration from this step begins: a screen's component, otherwise the node itself. */
   root: Node | null;
 }
@@ -349,6 +374,24 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
   };
   /** The call as written at a site, and what it passes — one read for both. */
   const callAt = (caller: Node, site: { line?: number; column?: number; callee?: string }) => calls.callSite(caller, site);
+  /** A hop's position with its call's span and enclosing call, read from the tree; the bare position when unreadable. */
+  const hopAt = async (caller: Node, at: { line?: number; column?: number }, callee?: string): Promise<HopSite> => {
+    const line = at.line ?? caller.startLine;
+    const column = at.column ?? 0;
+    const read = at.line ? await callAt(caller, { ...at, ...(callee ? { callee } : {}) }) : null;
+    return {
+      file: caller.filePath,
+      line: read?.span?.start.line ?? line,
+      column: read?.span?.start.column ?? column,
+      end: read?.span?.end ?? { line, column },
+      within: read?.within ?? null,
+    };
+  };
+  const pointHop = (caller: Node, at: { line?: number; column?: number }): HopSite => {
+    const line = at.line ?? caller.startLine;
+    const column = at.column ?? 0;
+    return { file: caller.filePath, line, column, end: { line, column }, within: null };
+  };
 
   // The declared type of a receiver: `OwnerRepository owners` in a Spring
   // controller makes `owners.save` the database; `private readonly
@@ -650,7 +693,15 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
     if (args !== null) wireSite.args = args;
     if (status !== null) wireSite.status = status;
-    link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at)));
+    const hop: HopSite = fold.first ?? {
+      file: fold.node.filePath,
+      line: site?.span?.start.line ?? ref.line,
+      column: site?.span?.start.column ?? ref.column ?? 0,
+      end: site?.span?.end ?? { line: ref.line, column: ref.column ?? 0 },
+      within: site?.within ?? null,
+    };
+    if (!target.first) target.first = hop;
+    link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at)), hop.within);
     return true;
   };
 
@@ -662,7 +713,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     whens: string[],
     site: WireStepSite,
     edge: Edge | null,
-    trigger: WireStepTrigger | null = null
+    trigger: WireStepTrigger | null = null,
+    within: string | null = null
   ): void => {
     const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
     const synthesized = edge?.provenance === 'heuristic';
@@ -687,6 +739,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
         existing.sites[sameLine] = stamped;
       }
       if (!existing.trigger && trigger) existing.trigger = trigger;
+      if (!existing.within && within) existing.within = within;
       if (when !== existing.when) {
         if (!when || !existing.when) existing.when = '';
         else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
@@ -705,6 +758,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
       sites: [stamped],
       ...(trigger ? { trigger } : {}),
+      ...(within ? { within } : {}),
     });
     if (trigger && to.kind === 'trigger' && !to.trigger) to.trigger = trigger;
   };
@@ -758,7 +812,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
 
     // Breadth-first through the plumbing until the next steps.
     const visited = new Set<string>([step.root.id]);
-    let frontier: Fold[] = [{ node: step.root, chain: [], whens: [] }];
+    let frontier: Fold[] = [{ node: step.root, chain: [], whens: [], first: null }];
     for (let hop = 0; hop <= MAX_FOLD_DEPTH && frontier.length > 0; hop++) {
       const next: Fold[] = [];
       const ids = frontier.map((f) => f.node.id);
@@ -1029,7 +1083,12 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             const written = await callAt(fold.node, at);
             site = written && written.callee ? { ...a.site, text: written.callee, args: written.args } : await withArgs(a.site, fold.node, at);
           } else if (a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls') site = await withArgs(a.site, fold.node, at);
-          link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger);
+          // Where this step is first reached from: the hop out of the root
+          // this fold descends from, else this site — its position orders the row.
+          const isCallHop = a.e.kind === 'calls' || a.e.kind === 'instantiates' || a.e.kind === 'navigates';
+          const hop = fold.first ?? (isCallHop ? await hopAt(fold.node, at, a.target.name) : pointHop(fold.node, at));
+          if (!to.first) to.first = hop;
+          link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
           if (to.root !== null && !explored.has(to.id)) {
             explored.add(to.id);
             queue.push(to);
@@ -1064,7 +1123,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             if (known.id !== step.id) {
               const at = { line: e.line, column: e.column };
               const when = await whenAt(fold.node, at);
-              link(step, known, 'calls', fold.chain, [...fold.whens, when], await withArgs(a.site, fold.node, at), e, a.trigger);
+              const hop = fold.first ?? (await hopAt(fold.node, at, target.name));
+              link(step, known, 'calls', fold.chain, [...fold.whens, when], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
             }
             continue;
           }
@@ -1085,7 +1145,12 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           }
           visited.add(target.id);
           const when = await whenAt(fold.node, { line: e.line, column: e.column });
-          next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, when] });
+          const first =
+            fold.first ??
+            (e.kind === 'calls' || e.kind === 'instantiates'
+              ? await hopAt(fold.node, { line: e.line, column: e.column }, target.name)
+              : pointHop(fold.node, { line: e.line, column: e.column }));
+          next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, when], first });
         }
       }
       frontier = next;
@@ -1122,12 +1187,25 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     step.label = label.length > MAX_EFFECT_LABEL ? `${label.slice(0, MAX_EFFECT_LABEL - 2)}…)` : label;
   }
 
-  const ordered = [...steps.values()].sort((a, b) => a.depth - b.depth || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
+  // A row reads in the code's order: by the position of the hop that first
+  // reached each step, a hop written inside another site's arguments before
+  // that site — `generateToken(…)` in `res.json({ token: generateToken(…) })`
+  // signs the token before the 200 is sent, so it comes first.
+  const byDepth = new Map<number, StepRecord[]>();
+  for (const s of steps.values()) byDepth.set(s.depth, [...(byDepth.get(s.depth) ?? []), s]);
+  for (const row of byDepth.values()) {
+    row.sort((a, b) => hopCompare(a.first, b.first) || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
+    row.forEach((s, i) => {
+      s.order = i;
+    });
+  }
+
+  const ordered = [...steps.values()].sort((a, b) => a.depth - b.depth || (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
   return {
     anchor: toNodeRef(anchor),
     ambiguous,
     project,
-    steps: ordered.map(({ root: _root, ...step }) => step),
+    steps: ordered.map(({ root: _root, first: _first, ...step }) => step),
     links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
     depth: depthCap,
     limit,
@@ -1281,6 +1359,22 @@ function hopLabel(meta: Record<string, unknown>, synthesized: boolean): string {
   return parts.join(' · ');
 }
 
+/** Source order of two hops: a hop written inside the other's call runs first; else by position; another file sorts after. */
+function hopCompare(a: HopSite | undefined, b: HopSite | undefined): number {
+  if (!a || !b) return a ? -1 : b ? 1 : 0;
+  if (a.file !== b.file) return a.file.localeCompare(b.file);
+  if (hopInside(a, b)) return -1;
+  if (hopInside(b, a)) return 1;
+  return a.line - b.line || a.column - b.column;
+}
+
+/** `x` starts strictly after `y` starts and before `y` ends. */
+function hopInside(x: HopSite, y: HopSite): boolean {
+  const afterStart = x.line > y.line || (x.line === y.line && x.column > y.column);
+  const beforeEnd = x.line < y.end.line || (x.line === y.end.line && x.column < y.end.column);
+  return afterStart && beforeEnd;
+}
+
 function posix(p: string): string {
   return p.replace(/\\/g, '/');
 }

+ 13 - 3
ui/src/lib/map-model.ts

@@ -239,6 +239,13 @@ export interface MapLayoutOptions {
    * longest chain of screens above the login page.
    */
   layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
+  /**
+   * A row order the view already knows — the Steps view's rows read in the
+   * code's order. It is the initial order, and the sweeps then move a box
+   * only to sit under its parents (a barycenter over parents alone, not
+   * children), tie-broken by this order rather than by id.
+   */
+  order?: (id: string) => number;
   /**
    * Vertical room between two layers; {@link LAYER_GAP} unless a view says
    * otherwise. The Screens view widens it because its edges carry labels, and
@@ -328,13 +335,16 @@ export function buildMapLayout(
   const layerCount = Math.max(1, ...[...layer.values()].map((v) => v + 1));
   const rows: string[][] = Array.from({ length: layerCount }, () => []);
   for (const module of modules) rows[layer.get(module.id) ?? 0]!.push(module.id);
-  for (const row of rows) row.sort();
+  const given = options.order;
+  for (const row of rows) row.sort(given ? (a, b) => given(a) - given(b) || a.localeCompare(b) : undefined);
 
   // --- barycenter ordering, three sweeps -----------------------------------
+  // With an order given, a box's barycenter is over its parents alone, so
+  // siblings under one parent keep the order they came in.
   const neighbours = new Map<string, string[]>(modules.map((m) => [m.id, []]));
   for (const link of acyclic) {
-    neighbours.get(link.source)?.push(link.target);
     neighbours.get(link.target)?.push(link.source);
+    if (!given) neighbours.get(link.source)?.push(link.target);
   }
   const position = new Map<string, number>();
   for (const row of rows) row.forEach((id, i) => position.set(id, i));
@@ -350,7 +360,7 @@ export function buildMapLayout(
         const bb = bary.get(b) ?? 0;
         if (ba !== bb && Number.isFinite(ba - bb)) return ba - bb;
         if (ba !== bb) return ba < bb ? -1 : 1;
-        return (position.get(a) ?? 0) - (position.get(b) ?? 0) || a.localeCompare(b);
+        return (position.get(a) ?? 0) - (position.get(b) ?? 0) || (given ? given(a) - given(b) : 0) || a.localeCompare(b);
       });
       row.forEach((id, i) => position.set(id, i));
     }

+ 2 - 0
ui/src/lib/steps-model.ts

@@ -260,6 +260,8 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
         return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
       },
       layering,
+      // The server ordered each row the way the code reads; keep it.
+      order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
       layerGap: SCREEN_LAYER_GAP,
       portPitch: PORT_PITCH,
       ports: 'directional',

+ 4 - 0
ui/src/lib/wire.ts

@@ -760,6 +760,8 @@ export interface WireStep {
   events?: string[];
   /** For a handler: what fires it. */
   trigger?: WireStepTrigger;
+  /** The step's place in its row, in the code's order (a hop written inside another site's arguments before that site). */
+  order?: number;
   /**
    * For a screen or an endpoint — also a `bridge` step that is an endpoint
    * reached across a tier: its path and the symbol that serves it.
@@ -795,6 +797,8 @@ export interface WireStepLink {
   when: string;
   /** How the last hop was established when it was not a plain call. */
   label: string;
+  /** The call the first hop is written inside the arguments of — `res.json` for a token signed while building the reply. */
+  within?: string;
   synthesized: boolean;
   uncertain: boolean;
   sites: WireStepSite[];

+ 3 - 0
ui/src/views/StepsView.svelte

@@ -568,6 +568,7 @@
             <div class="tiprow">
               {#if link.trigger}<span class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></span>{/if}
               {#if link.via.length > 0}<span class="via">via {stepViaText(link)}</span>{/if}
+              {#if link.within}<span class="dim">inside {link.within}(…)</span>{/if}
               {#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
               <span class="when">{@render words(conditionTokens(link.when))}</span>
               {#if link.label}<span class="dim">{link.label}</span>{/if}
@@ -666,6 +667,7 @@
             <button class="peer mono" onclick={() => (selected = link.from)}>{nameOf(link.from)}</button>
             {#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
             {#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
+            {#if link.within}<div class="via dim">inside {link.within}(…)</div>{/if}
             {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
             {#if link.label}<div class="via dim">{link.label}</div>{/if}
             {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
@@ -712,6 +714,7 @@
             <button class="peer mono" onclick={() => (selected = link.to)}>{nameOf(link.to)}</button>
             {#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
             {#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
+            {#if link.within}<div class="via dim">inside {link.within}(…)</div>{/if}
             {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
             {#if link.label}<div class="via dim">{link.label}</div>{/if}
             {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}