Bug fixes

This commit is contained in:
Ingo Schnabel
2026-07-14 14:46:57 +02:00
parent b8539ca011
commit e98e9930f1
14 changed files with 481 additions and 74 deletions

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# Stamped by deploy.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via deploy.sh.
version=27
version=32

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the MCP
# 'version' tool/server-info (referenced below via property expression, not duplicated).
agenticcode.version=27
agenticcode.version=32
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode

5
ac-ui/.gitignore vendored
View File

@@ -4,6 +4,11 @@ dist-ssr
*.local
.vite
# Playwright e2e
test-results
playwright-report
.playwright
# NOT ignored (committed on purpose):
# src/api/schema.ts — generated OpenAPI types (npm run gen:api), committed so the app
# builds without the server running

View File

@@ -0,0 +1,109 @@
import {expect, Page, test} from "@playwright/test";
/**
* Ctrl-clicks a specific identifier *inside the CodeMirror editor* (not the outline or any other UI),
* at the exact character rectangle, reproducing the source viewer's identify gesture. CodeMirror
* renders a line as a single text node, so we locate the substring with a DOM Range to get real
* coordinates.
*/
async function ctrlClickToken(page: Page, text: string): Promise<void> {
const box = await page.evaluate((needle) => {
const content = document.querySelector(".cm-content");
if (!content) return null;
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const idx = (node.textContent ?? "").indexOf(needle);
if (idx >= 0) {
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + needle.length);
const r = range.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
}
}
return null;
}, text);
if (!box) throw new Error(`token "${text}" not found in the editor viewport`);
await page.keyboard.down("Control");
await page.mouse.click(box.x, box.y);
await page.keyboard.up("Control");
}
async function openSource(page: Page, project: string, module: string, nearLine: number, token: string): Promise<void> {
await page.goto(`/p/${project}/m/${module}?tab=source&line=${nearLine}`);
await expect(page.locator(".cm-content")).toBeVisible();
// Wait until the target token is actually rendered in the viewport.
await expect
.poll(async () =>
page.evaluate((t) => (document.querySelector(".cm-content")?.textContent ?? "").includes(t), token),
)
.toBe(true);
}
/**
* REFERENCE click → definition. Clicking a PERFORM of ESCAPE-LOOP (line 721) jumps to the subroutine's
* definition (323). Resolved via the scoped function list, so it works even in `upms` where 80+ modules
* define their own ESCAPE-LOOP and WGEAGB0S's one falls outside the capped global search — and no
* cross-module "N matches" list appears (the originally-reported bug).
*/
test("Ctrl-click a PERFORM reference jumps to the definition, scoped to this module (upms/WGEAGB0S)", async ({page}) => {
await openSource(page, "upms", "WGEAGB0S", 715, "ESCAPE-LOOP");
await ctrlClickToken(page, "ESCAPE-LOOP");
await expect(page).toHaveURL(/[?&]line=323\b/);
await expect(page.getByText(/\d+ matches/)).toHaveCount(0);
});
/** Same reference→definition jump in the single-match fixture project. */
test("Ctrl-click a PERFORM reference jumps to the definition (ac/WGEAGB0S)", async ({page}) => {
await openSource(page, "ac", "WGEAGB0S", 715, "ESCAPE-LOOP");
await ctrlClickToken(page, "ESCAPE-LOOP");
await expect(page).toHaveURL(/[?&]line=323\b/);
await expect(page.getByText(/\d+ matches/)).toHaveCount(0);
});
/**
* DEFINITION click, single caller → jump to it. INITIALIZATION is defined at 383 and PERFORMed exactly
* once (line 311), so clicking the DEFINE line jumps straight to 311.
*/
test("Ctrl-click a subroutine's definition jumps to its single PERFORM site (ac/WGEAGB0S)", async ({page}) => {
await openSource(page, "ac", "WGEAGB0S", 383, "INITIALIZATION");
await ctrlClickToken(page, "INITIALIZATION");
await expect(page).toHaveURL(/[?&]line=311\b/);
await expect(page.getByText(/PERFORM sites/)).toHaveCount(0);
});
/**
* DEFINITION click, multiple callers → a picker (no auto-jump). ADD-XML-LINE (def 645) is PERFORMed
* many times; picking a site navigates there.
*/
test("Ctrl-click a subroutine's definition with multiple PERFORM sites opens a picker (ac/WGEAGB0S)", async ({page}) => {
await openSource(page, "ac", "WGEAGB0S", 645, "ADD-XML-LINE");
await ctrlClickToken(page, "ADD-XML-LINE");
await expect(page.getByText(/PERFORM sites \(\d+\)/)).toBeVisible();
const sites = page.getByRole("button", {name: /^PERFORM line \d+$/});
await expect.poll(async () => sites.count()).toBeGreaterThan(1);
await sites.first().click();
await expect(page).toHaveURL(/[?&]line=\d+/);
});
/**
* The user's exact reported case in the real project: clicking ESCAPE-LOOP on its DEFINE line (323) in
* upms/WGEAGB0S must show its local PERFORM-site picker — NOT a list of the 80+ other modules'
* ESCAPE-LOOP. It has 4 PERFORM sites here.
*/
test("Ctrl-click a subroutine's definition shows local PERFORM sites, not a cross-module list (upms/WGEAGB0S)", async ({page}) => {
await openSource(page, "upms", "WGEAGB0S", 323, "ESCAPE-LOOP");
await ctrlClickToken(page, "ESCAPE-LOOP");
await expect(page.getByText(/PERFORM sites \(\d+\)/)).toBeVisible();
await expect(page.getByText(/\d+ matches/)).toHaveCount(0);
await expect(page.getByText(/in other modules/)).toHaveCount(0);
const sites = page.getByRole("button", {name: /^PERFORM line \d+$/});
await expect.poll(async () => sites.count()).toBeGreaterThan(1);
});

View File

@@ -0,0 +1,18 @@
import {expect, test} from "@playwright/test";
/**
* The module explorer's search box accepts a regex, matched (case-insensitively) against module
* name and source file. `^WGEAGB0S$` anchors to exactly one module.
*/
test("module list filters by regex", async ({page}) => {
await page.goto("/p/upms");
// Wait for the list to load (the count badge shows "<n> modules").
await expect(page.getByText(/\d{2,} modules/)).toBeVisible();
await page.getByPlaceholder(/Search name or file/).fill("^WGEAGB0S$");
await expect(page.getByText(/^1 modules$/)).toBeVisible();
// A broader anchored pattern matches several (WACOMB2S, WACOMB3S, …).
await page.getByPlaceholder(/Search name or file/).fill("^WACOMB.S$");
await expect(page.getByText(/^[2-9]\d* modules$/)).toBeVisible();
});

View File

@@ -25,6 +25,7 @@
"sigma": "^3.0.3"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -941,6 +942,22 @@
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -2679,6 +2696,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View File

@@ -8,7 +8,8 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"gen:api": "openapi-typescript http://localhost:8787/q/openapi -o src/api/schema.ts"
"gen:api": "openapi-typescript http://localhost:8787/q/openapi -o src/api/schema.ts",
"e2e": "playwright test"
},
"dependencies": {
"@codemirror/lang-java": "^6.0.1",
@@ -28,6 +29,7 @@
"sigma": "^3.0.3"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",

View File

@@ -0,0 +1,19 @@
import {defineConfig, devices} from "@playwright/test";
/**
* E2E tests run against the deployed stack (nginx UI container + ac-code-server), i.e.
* `./deploy.sh up` must be running and the `ac` project ingested. The UI is served at :5174 and
* proxies /api to the server, so tests exercise the real API — no mocking.
*/
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: {timeout: 10_000},
fullyParallel: true,
reporter: [["list"]],
use: {
baseURL: process.env.AC_UI_URL ?? "http://localhost:5174",
trace: "on-first-retry",
},
projects: [{name: "chromium", use: {...devices["Desktop Chrome"]}}],
});

View File

@@ -128,6 +128,25 @@ export function useCalls(project: string | undefined, name: string | undefined,
});
}
/**
* Internal (intra-module) callees of a module — the FUNCTION→FUNCTION PERFORM edges with their
* call-site line numbers. Used to resolve "where is this subroutine PERFORMed" (the callee entry's
* lineNos are its PERFORM sites). Enabled lazily.
*/
export function useInternalCallees(project: string | undefined, module: string | undefined, enabled: boolean) {
return useQuery({
enabled: enabled && !!project && !!module,
queryKey: ["callees-internal", project, module],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/callees", {
params: {path: {project: project!, name: module!}, query: {scope: "internal"}},
});
if (error) throw new Error("Failed to load internal callees");
return data;
},
});
}
/** Name-based identifier lookup (M1 click-to-identify). Disabled until a name is given. */
export function useIdentifierSearch(project: string | undefined, name: string | undefined) {
return useQuery({

View File

@@ -1,9 +1,15 @@
import {useState} from "react";
import {useIdentifierSearch, useVariableAccess} from "../api/hooks";
import {useEffect, useMemo, useState} from "react";
import {useIdentifierSearch, useInternalCallees, useModuleFunctions, useVariableAccess} from "../api/hooks";
interface Props {
project: string;
name: string;
/** The 1-based source line the click came from — distinguishes a definition click from a reference. */
clickLine?: number;
/** The module the click came from — used to resolve a module-local subroutine reliably. */
currentModuleName?: string;
/** The source file the click came from — used to scope module-local subroutines/fields. */
currentSourceFile?: string;
onOpenModule: (name: string) => void;
/** Open a file (by relative path) at a 1-based line — for jump-to-definition and access sites. */
onOpenFileLine: (file: string, line: number) => void;
@@ -12,44 +18,6 @@ interface Props {
onClose: () => void;
}
/**
* Name-based identifier lookup results. MODULE matches navigate to the module; VARIABLE (and other
* non-module) matches expand (M4) to jump-to-definition and list where the field is read/written.
*/
export function IdentifierPopover({project, name, onOpenModule, onOpenFileLine, onOpenFlow, onClose}: Props) {
const {data, isLoading, isError} = useIdentifierSearch(project, name);
const matches = data ?? [];
return (
<div
className="absolute right-3 top-3 z-10 w-96 rounded border border-neutral-300 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
<div className="flex items-center gap-2 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
<span className="truncate text-sm font-medium">{name}</span>
<span className="text-xs text-neutral-400">{matches.length} matches</span>
<button onClick={onClose} className="ml-auto text-neutral-400 hover:text-neutral-700"
aria-label="Close">
✕
</button>
</div>
<div className="max-h-96 overflow-auto p-1">
{isLoading && <p className="p-2 text-xs text-neutral-400">looking up…</p>}
{isError && <p className="p-2 text-xs text-red-500">lookup failed</p>}
{!isLoading && matches.length === 0 && <p className="p-2 text-xs text-neutral-400">no matches</p>}
{matches.map((m, i) => (
<MatchRow
key={`${m.name}-${m.sourceFile}-${i}`}
project={project}
match={m}
onOpenModule={onOpenModule}
onOpenFileLine={onOpenFileLine}
onOpenFlow={onOpenFlow}
/>
))}
</div>
</div>
);
}
type Match = {
type?: string;
name?: string;
@@ -60,9 +28,172 @@ type Match = {
unresolved?: boolean;
};
function MatchRow({project, match, onOpenModule, onOpenFileLine, onOpenFlow}: {
/**
* Name-based identifier lookup. Because Natural subroutines/fields are module-scoped and the same
* name recurs across modules, results are scoped to the click's source file: a single local FUNCTION
* auto-jumps to its definition (no popover); otherwise local matches lead and cross-module matches
* fold away. MODULE matches navigate; VARIABLE matches expand to reads/writes + data-flow.
*/
export function IdentifierPopover({
project,
name,
clickLine,
currentModuleName,
currentSourceFile,
onOpenModule,
onOpenFileLine,
onOpenFlow,
onClose
}: Props) {
const {data, isLoading, isError} = useIdentifierSearch(project, name);
const matches = useMemo(() => data ?? [], [data]);
const [autoJumped, setAutoJumped] = useState(false);
// Scoped lookup of a subroutine defined in THIS module — reliable regardless of how many modules
// share the name (the global identifier search is capped, so the local one can fall off the page).
const {data: localFns, isLoading: fnsLoading} = useModuleFunctions(project, currentModuleName);
const localFn = useMemo(
() => (localFns ?? []).find((f) => f.name?.toUpperCase() === name.toUpperCase()),
[localFns, name],
);
// Clicked on the subroutine's own DEFINE line → the user wants its callers (PERFORM sites), not
// to jump to itself. Otherwise a click on the name is a reference (PERFORM) → jump to the def.
const isDefinitionClick = !!localFn?.startLine && localFn.startLine === clickLine;
// PERFORM sites of this subroutine = its entry's call-site lineNos among the module's internal callees.
const {
data: internalCallees,
isLoading: calleesLoading
} = useInternalCallees(project, currentModuleName, isDefinitionClick);
const performSites = useMemo(() => {
const entry = (internalCallees?.items ?? []).find((i) => i.name?.toUpperCase() === name.toUpperCase());
return (entry?.lineNos ?? []).filter((n): n is number => typeof n === "number").sort((a, b) => a - b);
}, [internalCallees, name]);
const {local, others} = useMemo(() => {
const local: Match[] = [];
const others: Match[] = [];
for (const m of matches) {
(m.sourceFile && m.sourceFile === currentSourceFile ? local : others).push(m);
}
return {local, others};
}, [matches, currentSourceFile]);
// Reference click on a local subroutine → jump straight to its definition (no popover).
useEffect(() => {
if (autoJumped || isDefinitionClick) return;
if (localFn?.startLine && currentSourceFile) {
setAutoJumped(true);
onOpenFileLine(currentSourceFile, localFn.startLine);
onClose();
}
}, [localFn, isDefinitionClick, currentSourceFile, autoJumped, onOpenFileLine, onClose]);
// Definition click with exactly one PERFORM site → jump to it; multiple → show the picker below.
useEffect(() => {
if (autoJumped || !isDefinitionClick || calleesLoading) return;
if (performSites.length === 1 && currentSourceFile) {
setAutoJumped(true);
onOpenFileLine(currentSourceFile, performSites[0]);
onClose();
}
}, [isDefinitionClick, calleesLoading, performSites, currentSourceFile, autoJumped, onOpenFileLine, onClose]);
if (autoJumped) return null;
// Definition click → show its PERFORM sites (the picker for multiple; a note if none).
if (isDefinitionClick) {
return (
<div
className="absolute right-3 top-3 z-10 w-80 rounded border border-neutral-300 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
<div className="flex items-center gap-2 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
<span className="truncate text-sm font-medium">{name}</span>
<span className="text-xs text-neutral-400">PERFORM sites ({performSites.length})</span>
<button onClick={onClose} className="ml-auto text-neutral-400 hover:text-neutral-700"
aria-label="Close">✕
</button>
</div>
<div className="max-h-96 overflow-auto p-1">
{calleesLoading && <p className="p-2 text-xs text-neutral-400">looking up callers…</p>}
{!calleesLoading && performSites.length === 0 && (
<p className="p-2 text-xs text-neutral-400">No PERFORM of this subroutine found in the
module.</p>
)}
<ul className="space-y-0.5">
{performSites.map((ln) => (
<li key={ln}>
<button
onClick={() => currentSourceFile && onOpenFileLine(currentSourceFile, ln)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<span>PERFORM</span>
<span
className="ml-auto tabular-nums text-blue-600 dark:text-blue-400">line {ln}</span>
</button>
</li>
))}
</ul>
</div>
</div>
);
}
return (
<div
className="absolute right-3 top-3 z-10 w-96 rounded border border-neutral-300 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
<div className="flex items-center gap-2 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
<span className="truncate text-sm font-medium">{name}</span>
<span className="text-xs text-neutral-400">{matches.length} matches</span>
<button onClick={onClose} className="ml-auto text-neutral-400 hover:text-neutral-700"
aria-label="Close">✕
</button>
</div>
<div className="max-h-96 overflow-auto p-1">
{/* Hold until the scoped function lookup settles too, so a local subroutine auto-jumps
instead of briefly flashing the cross-module list. */}
{(isLoading || fnsLoading) && <p className="p-2 text-xs text-neutral-400">looking up…</p>}
{isError && <p className="p-2 text-xs text-red-500">lookup failed</p>}
{!isLoading && !fnsLoading && matches.length === 0 &&
<p className="p-2 text-xs text-neutral-400">no matches</p>}
{!isLoading && !fnsLoading && local.map((m, i) => (
<MatchRow key={`local-${m.name}-${i}`} project={project} match={m} inThisModule
onOpenModule={onOpenModule} onOpenFileLine={onOpenFileLine} onOpenFlow={onOpenFlow}/>
))}
{!isLoading && !fnsLoading && others.length > 0 && (
<OtherModules count={others.length}>
{others.map((m, i) => (
<MatchRow key={`other-${m.name}-${m.sourceFile}-${i}`} project={project} match={m}
onOpenModule={onOpenModule} onOpenFileLine={onOpenFileLine}
onOpenFlow={onOpenFlow}/>
))}
</OtherModules>
)}
</div>
</div>
);
}
function OtherModules({count, children}: { count: number; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div className="mt-1 border-t border-neutral-100 pt-1 dark:border-neutral-900">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-[11px] text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<span>{open ? "▾" : "▸"}</span> {count} in other modules
</button>
{open && children}
</div>
);
}
function MatchRow({project, match, inThisModule, onOpenModule, onOpenFileLine, onOpenFlow}: {
project: string;
match: Match;
inThisModule?: boolean;
onOpenModule: (name: string) => void;
onOpenFileLine: (file: string, line: number) => void;
onOpenFlow: (name: string) => void;
@@ -78,29 +209,32 @@ function MatchRow({project, match, onOpenModule, onOpenFileLine, onOpenFlow}: {
>
{!isModule && <span className="text-neutral-400">{open ? "▾" : "▸"}</span>}
<span className="truncate font-medium">{match.name}</span>
<span className="shrink-0 rounded bg-neutral-100 px-1 text-[10px] text-neutral-500 dark:bg-neutral-800">
{match.type}
</span>
<span
className="shrink-0 rounded bg-neutral-100 px-1 text-[10px] text-neutral-500 dark:bg-neutral-800">{match.type}</span>
{inThisModule && (
<span
className="shrink-0 rounded bg-blue-100 px-1 text-[10px] text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">this module</span>
)}
{match.sourceFile && (
<span className="ml-auto truncate text-neutral-400">{match.sourceFile.split("/").pop()}</span>
)}
</button>
{open && !isModule && (
<VariableActions project={project} match={match} onOpenFileLine={onOpenFileLine}
onOpenFlow={onOpenFlow}/>
<MatchActions project={project} match={match} onOpenFileLine={onOpenFileLine} onOpenFlow={onOpenFlow}/>
)}
</div>
);
}
function VariableActions({project, match, onOpenFileLine, onOpenFlow}: {
function MatchActions({project, match, onOpenFileLine, onOpenFlow}: {
project: string;
match: Match;
onOpenFileLine: (file: string, line: number) => void;
onOpenFlow: (name: string) => void;
}) {
const canJump = !!match.sourceFile && !!match.startLine && !match.unresolved;
const isFunction = match.type === "FUNCTION";
return (
<div className="ml-4 space-y-2 border-l border-neutral-200 py-1 pl-2 dark:border-neutral-800">
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-neutral-500">
@@ -110,23 +244,20 @@ function VariableActions({project, match, onOpenFileLine, onOpenFlow}: {
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
{canJump && (
<button
onClick={() => onOpenFileLine(match.sourceFile!, match.startLine!)}
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400"
>
open definition →
<button onClick={() => onOpenFileLine(match.sourceFile!, match.startLine!)}
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400">
{isFunction ? "go to subroutine →" : "open definition →"}
</button>
)}
{match.name && (
<button
onClick={() => onOpenFlow(match.name!)}
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400"
>
{!isFunction && match.name && (
<button onClick={() => onOpenFlow(match.name!)}
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400">
data-flow →
</button>
)}
</div>
{match.name && (
{/* reads/writes are variable concepts — not shown for FUNCTION (subroutine) matches. */}
{!isFunction && match.name && (
<div className="grid grid-cols-2 gap-2">
<AccessList project={project} name={match.name} dir="writes" onOpenFileLine={onOpenFileLine}/>
<AccessList project={project} name={match.name} dir="reads" onOpenFileLine={onOpenFileLine}/>

View File

@@ -68,7 +68,7 @@ export function ModuleView({
}: Props) {
const refresh = useRefreshModule(project);
const ingestNeighborhood = useIngestNeighborhood(project);
const [identify, setIdentify] = useState<string | null>(null);
const [identify, setIdentify] = useState<{ name: string; line: number } | null>(null);
return (
<div className="relative flex h-full flex-col border-l border-neutral-200 dark:border-neutral-800">
@@ -131,7 +131,7 @@ export function ModuleView({
moduleName={module.name!}
sourceFile={module.sourceFile}
line={line}
onIdentify={setIdentify}
onIdentify={(n, l) => setIdentify({name: n, line: l})}
overrideFile={srcFile}
onBackToModule={onBackToModule}
/>
@@ -168,7 +168,10 @@ export function ModuleView({
{identify && (
<IdentifierPopover
project={project}
name={identify}
name={identify.name}
clickLine={identify.line}
currentModuleName={module.name!}
currentSourceFile={module.sourceFile}
onOpenModule={(n) => {
setIdentify(null);
onOpen(n);

View File

@@ -11,8 +11,8 @@ interface Props {
sourceFile?: string;
/** Optional 1-based line to scroll to (deep-link). */
line?: number;
/** Ctrl/Cmd-click on an identifier → name-based lookup. */
onIdentify: (name: string) => void;
/** Ctrl/Cmd-click on an identifier → name-based lookup, with the 1-based click line. */
onIdentify: (name: string, line: number) => void;
/**
* Show an included file (e.g. a USING data area) by its relative path instead of the module's
* own source. When set, the file is loaded by path and a "back to module" banner is shown.
@@ -68,7 +68,7 @@ export function SourceView({project, moduleName, sourceFile, line, onIdentify, o
const w = wordAt(v.state.doc.toString(), pos, natural);
if (w) {
event.preventDefault();
onIdentify(w);
onIdentify(w, v.state.doc.lineAt(pos).number);
}
return true;
},

View File

@@ -22,12 +22,24 @@ export function Explorer() {
const modules = useMemo(() => {
const all = data ?? [];
const q = search.trim().toLowerCase();
return q
? all.filter(
(m) => m.name?.toLowerCase().includes(q) || m.sourceFile?.toLowerCase().includes(q),
)
: all;
const q = search.trim();
if (!q) return all;
// Treat the query as a case-insensitive regex over name/sourceFile; while it's an incomplete
// (invalid) pattern, fall back to a plain substring match so the list still filters as you type.
let re: RegExp | null = null;
try {
re = new RegExp(q, "i");
} catch {
re = null;
}
const lower = q.toLowerCase();
return all.filter((m) => {
const name = m.name ?? "";
const file = m.sourceFile ?? "";
return re
? re.test(name) || re.test(file)
: name.toLowerCase().includes(lower) || file.toLowerCase().includes(lower);
});
}, [data, search]);
const selected = modules.find((m) => m.name === name);
@@ -116,7 +128,7 @@ export function Explorer() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name or file…"
placeholder="Search name or file (regex)…"
className="w-56 rounded border border-neutral-300 bg-transparent px-2 py-1 text-sm dark:border-neutral-700"
/>
<select

View File

@@ -79,6 +79,12 @@ Frontend (detail per milestone in `x-docs/ui-proposal.md`):
Source tab gains a `?src=<file>` mode with an "included file … back to module" banner.
- **M4 Data-flow & impact — DONE 2026-07-14** (M4.1–M4.3). flow-forward/backward /
field-flow visualisation + "what breaks?" impact analysis.
- **M4.1a subroutine navigation — DONE 2026-07-14.** Ctrl/⌘-click a subroutine name resolves via
the *scoped* `module_functions` (reliable despite dozens of same-named subroutines across
modules — the global search is capped): clicking a **reference (PERFORM)** jumps to the
definition; clicking the **definition** goes to its caller(s) — the PERFORM sites from
`callees?scope=internal` `lineNos` (one → jump, many → a picker). Covered by
`e2e/identifier-popover.spec.ts` (Playwright; 5 cases incl. the upms 60-match reproduction).
- **M4.1 actionable variable matches — DONE 2026-07-14.** Identifier-popover
non-`MODULE` matches now expand: show `dataType`/`scope`, a **jump-to-definition**
(opens `sourceFile:startLine`, via the M3 source-by-path infra so a field defined in a
@@ -111,6 +117,9 @@ Frontend (detail per milestone in `x-docs/ui-proposal.md`):
`module_context.description` is shown in Overview as the current summary).
- **M6 Scale & polish** — virtualisation/large-graph performance, multi-project,
auth, theming, export (SVG/PNG/report).
- **Explorer regex filter — DONE 2026-07-14.** The module-list search box accepts a
case-insensitive **regex** over name/sourceFile (falls back to substring while the pattern is
incomplete). Covered by `e2e/module-explorer.spec.ts`.
Out of scope: parser/ingest semantic changes (the UI is a consumer) and write
access to code (read-only; notes are UI-side). Key risks: auto-ingest latency on
@@ -161,6 +170,22 @@ open item in this track:
## Agent API / MCP tooling gaps
- [ ] **52. Function-level callers ("who PERFORMs this subroutine")** (2026-07-14) — the
`callers`/`callees` endpoints are **module-granularity**; there's no way to ask "which
subroutines/PERFORM sites call this internal `FUNCTION`". The intra-module
`FUNCTION→FUNCTION` `CALLS` edges already exist in the graph (see `CypherQueries` intra-class
calls), so this is a query + surface addition: `GET /modules/{name}/functions/{fn}/callers`
(+ MCP tool + `ac-cli`). Surfaced by the Web-UI identifier popover, which can jump to a
subroutine's definition but can't list its callers. Deferred (2026-07-14).
- [ ] **53. `search_identifier` scoping (sourceFile/module filter)** (2026-07-14) — the name search
has no scope filter and is paginated, so in a project where a name recurs in dozens of modules the
*local* definition can fall outside the returned page. The Web-UI popover works around this **for
subroutines** by resolving the click via the scoped `module_functions` endpoint, but the same cap
affects **variables** (a module-local field named like 80 others may not appear as "this module").
Add an optional `sourceFile=`/`module=` filter to `search_identifier` (REST + MCP + CLI) so a caller
can ask "this name, in this module" directly. Deferred (2026-07-14).
- [ ] **26. MCP session reliability (POSTPONED 2026-07-13)** (investigated 2026-07-07, not fixed —
see below) — `mcp__agenticcode__module_functions` (and potentially other MCP
tools) intermittently failed with `"the first message from the client must be