fix(ui): one trail length for the bar, the store and /api/flow (#1976)

The trail store saves up to 64 hops, /api/flow read at most 24, the
in-memory trail had no limit, and /api/nodes answers 60 ids per request.
So "Read as flow" failed on any walk of 25 or more hops, including trails
the store had just saved, and a cold load of a 61-64 hop link never got its
names back.

/api/flow now reads up to the store's MAX_TRAIL_HOPS. The trail keeps the
same 64 hops, dropping the oldest on push and on hydrate. resolveTrailNames
asks /api/nodes in batches of 60 and merges the answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-09-27 10:20:52 +03:00
parent ba3c21e50d
commit 4da454022d
6 changed files with 74 additions and 10 deletions
+1
View File
@@ -266,6 +266,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### Symbols, tests and the viewer
- In `codegraph ui`, "Read as flow" now works on any trail the viewer can save, up to 64 hops, and a shared link to a long trail gets all its hop names back. A trail longer than that keeps its most recent 64 hops. (#1976)
- Kotlin functions and methods now carry their signature — `(params): ReturnType` — in `codegraph_explore`, `node` and the viewer, instead of no signature at all. Re-index Kotlin projects after upgrading. (#1495)
- TypeScript/JavaScript value aliases — `export const alias = fn`, `export { fn as alias }`, object-literal `api = { run: fn }`, and same-file `const local = fn` — now forward calls edges to the aliased function, so callers and impact on the implementation include consumers that call through the alias instead of stopping at the binding. Genuine wrappers (`() => fn()`) are unchanged. Re-index after upgrading. Thanks @valkyriweb. (#1482, #1485)
- `codegraph affected` now finds Go, Python and JVM test files that previously went unreported, while preserving custom `--filter` behavior (thanks @danusha2345; #1507, #1688).
+8 -4
View File
@@ -29,6 +29,7 @@ import * as path from 'path';
import CodeGraph from '../src/index';
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
import { MAX_TRAIL_HOPS } from '../src/ui-server/api/trail-store';
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
import { ToolHandler } from '../src/mcp/tools';
import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
@@ -600,11 +601,14 @@ describe('GET /api/flow — refusals', () => {
expect(payload.hint).toMatch(/\?from=/);
});
it('caps the number of trail hops it will read', async () => {
const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
const payload = await getFlow(`?${query}`, 400);
it('caps the number of trail hops it will read at what the trail store saves (#1976)', async () => {
const hopsQuery = (n: number) => Array.from({ length: n }, (_, i) => `hop=s${i}xx`).join('&');
// A 64-hop trail is one the store saves, so it must be readable as a flow.
const saved = await request(`/api/flow?${hopsQuery(MAX_TRAIL_HOPS)}`);
expect(saved.status).toBe(200);
const payload = await getFlow(`?${hopsQuery(MAX_TRAIL_HOPS + 1)}`, 400);
expect(payload.code).toBe('bad-request');
expect(payload.error).toMatch(/longer than this endpoint reads/);
expect(payload.error).toMatch(/longer than this endpoint reads \(64\)/);
});
it('is listed on the API index', async () => {
+37
View File
@@ -42,6 +42,7 @@ import {
setNavigationDriver,
symbolHref,
trail,
resolveTrailNames,
type GraphAdapter,
type NavigationDriver,
type WireFlowPayload,
@@ -784,3 +785,39 @@ describe('@colbymchenry/codegraph-ui — the published shape', () => {
expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
});
});
describe('a long trail (#1976)', () => {
it('keeps at most the 64 hops the store saves and /api/flow reads, dropping the oldest', () => {
for (let i = 0; i < 70; i++) trail.push({ id: `function:h${i}` });
expect(trail.hops).toHaveLength(64);
expect(trail.hops[0]?.id).toBe('function:h6');
expect(trail.hops[63]?.id).toBe('function:h69');
});
it('asks for its names in batches /api/nodes accepts', async () => {
const { adapter } = mockAdapter();
const batches: number[] = [];
adapter.nodes = (ids) => {
batches.push(ids.length);
const items = ids.map(
(id) =>
({
id,
kind: 'function',
name: id.slice('function:'.length),
qualifiedName: id,
file: 'src/a.ts',
line: 1,
endLine: 1,
language: 'typescript',
}) as WireNodeRef
);
return Promise.resolve({ items, missing: [] });
};
setGraphAdapter(adapter);
for (let i = 0; i < 64; i++) trail.push({ id: `function:h${i}` });
await resolveTrailNames();
expect(batches).toEqual([60, 4]);
expect(trail.hops.every((hop) => hop.name)).toBe(true);
});
});
+3 -2
View File
@@ -49,6 +49,9 @@ import {
} from '../../graph/dynamic-boundary-report';
import { highlightLines, type HighlightResult } from '../highlight';
import { badRequest, intParam } from './respond';
// Hops accepted from a trail: the same cap the trail store saves under, so a
// trail that can be saved can also be read as a flow (#1976).
import { MAX_TRAIL_HOPS } from './trail-store';
import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
import { resolveProjectFile } from '../security';
import {
@@ -80,8 +83,6 @@ const HIGHLIGHT_LEAD_MAX = 200;
/** Distinct paths returned. The header's flow picker is a short list or nothing. */
export const MAX_FLOWS = 4;
/** Hops accepted from a trail. The trail bar itself is not much longer than this. */
const MAX_TRAIL_HOPS = 24;
// =============================================================================
// Wire shapes
+16 -2
View File
@@ -65,8 +65,22 @@ export function fetchSearch(
}
/** Names and locations for ids you already have — what the trail redraws with. */
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
return getGraphAdapter().nodes(ids, signal);
/** Ids `/api/nodes` answers per request (`MAX_NODE_REFS` on the server). */
const NODE_REFS_PER_REQUEST = 60;
/**
* Names and kinds for a set of ids. A trail can hold more hops than one request
* answers, so the ids go out in batches and the answers are merged (#1976).
*/
export async function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
if (ids.length <= NODE_REFS_PER_REQUEST) return getGraphAdapter().nodes(ids, signal);
const merged: WireNodeRefs = { items: [], missing: [] };
for (let i = 0; i < ids.length; i += NODE_REFS_PER_REQUEST) {
const batch = await getGraphAdapter().nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal);
merged.items.push(...batch.items);
merged.missing.push(...batch.missing);
}
return merged;
}
export function fetchEntryPoints(
+9 -2
View File
@@ -16,6 +16,13 @@ export type { HopDirection, TrailHop } from './trail-codec';
let hops = $state<TrailHop[]>([]);
/**
* Hops one trail keeps — the trail store's `MAX_TRAIL_HOPS`, which `/api/flow`
* reads up to as well, so any trail on the bar can be saved and read as a flow.
* Past it the oldest hop falls off the front (#1976).
*/
const MAX_TRAIL_HOPS = 64;
/**
* Every name this session has learned, by id.
*
@@ -70,7 +77,7 @@ export const trail = {
kind: hop.kind ?? null,
dir: hop.dir ?? (hops.length === 0 ? 'start' : 'down'),
},
];
].slice(-MAX_TRAIL_HOPS);
},
/**
@@ -119,7 +126,7 @@ export const trail = {
if (encodeTrail(decoded) === encodeTrail(hops)) return;
// Names survive the change — including for hops this trail dropped earlier
// and history has just brought back.
hops = decoded.map((h) => {
hops = decoded.slice(-MAX_TRAIL_HOPS).map((h) => {
const seen = known.get(h.id);
return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
});