From df051286424e13bca0b8a2444897555e1797ac21 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 27 Sep 2026 10:20:52 +0300 Subject: [PATCH] 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 (cherry picked from commit 4da454022dd03ffb03cba2b4e6c62b8452bc9636) --- __tests__/ui-flow-api.test.ts | 12 ++++++++---- __tests__/ui-package.test.ts | 37 +++++++++++++++++++++++++++++++++++ src/ui-server/api/flow.ts | 5 +++-- ui/src/lib/api.ts | 18 +++++++++++++++-- ui/src/lib/trail.svelte.ts | 11 +++++++++-- 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/__tests__/ui-flow-api.test.ts b/__tests__/ui-flow-api.test.ts index 8a977ed9fe..394ec943b3 100644 --- a/__tests__/ui-flow-api.test.ts +++ b/__tests__/ui-flow-api.test.ts @@ -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 () => { diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts index ed0ed19d3d..946ae7db11 100644 --- a/__tests__/ui-package.test.ts +++ b/__tests__/ui-package.test.ts @@ -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); + }); +}); diff --git a/src/ui-server/api/flow.ts b/src/ui-server/api/flow.ts index 789daaaae8..cc73838966 100644 --- a/src/ui-server/api/flow.ts +++ b/src/ui-server/api/flow.ts @@ -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 diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 66d9f1688b..df1862bf0d 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -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 { - 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 { + 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( diff --git a/ui/src/lib/trail.svelte.ts b/ui/src/lib/trail.svelte.ts index 27bdf98fef..7125d5f694 100644 --- a/ui/src/lib/trail.svelte.ts +++ b/ui/src/lib/trail.svelte.ts @@ -16,6 +16,13 @@ export type { HopDirection, TrailHop } from './trail-codec'; let hops = $state([]); +/** + * 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; });