diff --git a/api/index.py b/api/index.py index e608456c..ff6dfeba 100644 --- a/api/index.py +++ b/api/index.py @@ -193,7 +193,9 @@ async def repo_info(data: RepoRequest, _=Depends(public_or_auth)): stats = await g.stats() finally: await g.close() - info = await async_get_repo_info(data.repo, data.branch) + # Use the parsed project/branch so full graph keys + # (``code:{project}:{branch}``) resolve the same as plain repo names. + info = await async_get_repo_info(g.project, g.branch) if info is None: return JSONResponse({"status": f'Missing repository "{data.repo}"'}, status_code=400) diff --git a/app/src/App.tsx b/app/src/App.tsx index 156b1313..25c1b5f6 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,7 +13,7 @@ import { Drawer, DrawerContent, DrawerDescription, DrawerTitle, DrawerTrigger } import Input from './components/Input'; import { Labels } from './components/labels'; import { Toolbar } from './components/toolbar'; -import { cn, GraphRef, Message, Path, PathData, PathNode } from '@/lib/utils'; +import { cn, composeGraphName, GraphRef, Message, Path, PathData, PathNode, projectNameFromURL, RepoOption } from '@/lib/utils'; import type { GraphNode } from '@falkordb/canvas'; import { Toaster } from '@/components/ui/toaster'; import GTM from './GTM'; @@ -71,7 +71,7 @@ export default function App() { const [createURL, setCreateURL] = useState("") const [createOpen, setCreateOpen] = useState(false) const [tipOpen, setTipOpen] = useState(false) - const [options, setOptions] = useState([]); + const [options, setOptions] = useState([]); const [path, setPath] = useState(); const [isSubmit, setIsSubmit] = useState(false); const desktopChartRef = useRef(null) @@ -138,17 +138,26 @@ export default function App() { return } - const graphName = createURL.split('/').pop()! + const json = await result.json() + // Mirror the backend's `urlparse(url).path.split('/')[-1]` so the composed + // graph name matches the one analyze_repo actually created — a raw split + // would keep any query string (".../repo?tab=readme" -> "repo?tab=readme"). + const project = projectNameFromURL(createURL) + const option: RepoOption = { + project, + branch: json.branch, + graph: composeGraphName(project, json.branch), + } - setOptions(prev => [...prev, graphName]) - setSelectedValue(graphName) + setOptions(prev => [...prev, option]) + setSelectedValue(option.graph) setCreateURL("") setCreateOpen(false) setIsSubmit(false) toast({ title: "Success", - description: `Project ${graphName} created successfully`, + description: `Project ${project} created successfully`, }) } diff --git a/app/src/components/code-graph.tsx b/app/src/components/code-graph.tsx index c0b2a083..bc64c340 100644 --- a/app/src/components/code-graph.tsx +++ b/app/src/components/code-graph.tsx @@ -13,7 +13,7 @@ import Input from './Input'; import { Checkbox } from '@/components/ui/checkbox'; import type { Position } from "./graphView"; import { prepareArg } from '../utils'; -import { GraphRef } from "@/lib/utils"; +import { GraphRef, RepoOption } from "@/lib/utils"; import { dataToGraphData } from "@falkordb/canvas"; import type { Node as CanvasNode, Link as CanvasLink, GraphData as CanvasData } from "@falkordb/canvas"; import GraphView from "./graphView"; @@ -29,8 +29,8 @@ interface Props { setData: Dispatch>, onFetchGraph: (graphName: string) => Promise, onFetchNode: (nodeIds: number[]) => Promise, - options: string[] - setOptions: Dispatch> + options: RepoOption[] + setOptions: Dispatch> isShowPath: boolean setPath: Dispatch> canvasRef: GraphRef diff --git a/app/src/components/combobox.tsx b/app/src/components/combobox.tsx index 93c6720c..820acd64 100644 --- a/app/src/components/combobox.tsx +++ b/app/src/components/combobox.tsx @@ -1,14 +1,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; import { useEffect, useState } from "react"; +import { RepoOption, repoLabel, toRepoOption } from "@/lib/utils"; const AUTH_HEADERS: HeadersInit = import.meta.env.VITE_SECRET_TOKEN ? { 'Authorization': `Bearer ${import.meta.env.VITE_SECRET_TOKEN}` } : {}; interface Props { - options: string[] - setOptions: (options: string[]) => void + options: RepoOption[] + setOptions: (options: RepoOption[]) => void selectedValue: string onSelectedValue: (value: string) => Promise @@ -37,7 +38,8 @@ export default function Combobox({ options, setOptions, selectedValue, onSelecte } const json = await result.json() - setOptions(json.repositories) + const repositories: unknown[] = Array.isArray(json.repositories) ? json.repositories : [] + setOptions(repositories.map(toRepoOption)) } useEffect(() => { @@ -65,8 +67,8 @@ export default function Combobox({ options, setOptions, selectedValue, onSelecte { options.length !== 0 && options.map((option) => ( - - {option} + + {repoLabel(option)} )) } diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index 73cf2c9c..3d745324 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -8,6 +8,57 @@ export type PathData = { links: any[] } +export const DEFAULT_BRANCH = "_default" + +// A single entry returned by /api/list_repos: `graph` is the underlying +// FalkorDB graph name to query, `project`/`branch` are used for display. +export type RepoOption = { + project: string + branch: string + graph: string +} + +// Normalizes a /api/list_repos entry into a RepoOption. Older/production +// backends may still return plain repo name strings instead of +// {project, branch, graph} objects — handle both so the UI never renders +// "undefined (undefined)" or crashes when pointed at a mismatched backend. +export function toRepoOption(entry: unknown): RepoOption { + if (typeof entry === "string") { + return { project: entry, branch: DEFAULT_BRANCH, graph: entry } + } + const repo = entry as Partial | null | undefined + const branch = repo?.branch ?? DEFAULT_BRANCH + // A backend that reports a project/branch pair without the composed key + // still has to be queried by that key, otherwise the UI would show the + // branch but silently load the default one. + const graph = repo?.graph + ?? (repo?.project ? composeGraphName(repo.project, branch) : "") + return { + project: repo?.project ?? graph, + branch, + graph, + } +} + +export function repoLabel(repo: RepoOption): string { + return repo.branch === DEFAULT_BRANCH ? repo.project : `${repo.project} (${repo.branch})` +} + +export function composeGraphName(project: string, branch?: string | null): string { + return `code:${project}:${branch || DEFAULT_BRANCH}` +} + +// Mirrors the backend's `urlparse(url).path.split('/')[-1]`, so the name the +// UI derives for a freshly analyzed repo matches the one the API created. +// Falls back to a plain split for inputs URL() cannot parse. +export function projectNameFromURL(url: string): string { + try { + return new URL(url).pathname.split("/").pop() ?? "" + } catch { + return url.split("/").pop() ?? "" + } +} + export type PathNode = { id?: number name?: string diff --git a/e2e/logic/POM/codeGraph.ts b/e2e/logic/POM/codeGraph.ts index bd2f4249..b68d366c 100644 --- a/e2e/logic/POM/codeGraph.ts +++ b/e2e/logic/POM/codeGraph.ts @@ -363,7 +363,35 @@ export default class CodeGraph extends BasePage { async isAtBottom(): Promise { const { scrollTop, scrollHeight, clientHeight } = await this.getScrollMetrics(); - return Math.abs(scrollTop + clientHeight - scrollHeight) < 1; + // Fractional scroll metrics (device pixel ratio / zoom) mean the sum + // rarely lands on an exact integer, so allow a sub-pixel tolerance. + return Math.abs(scrollTop + clientHeight - scrollHeight) <= 2; + } + + /** + * Waits for the chat's auto-scroll to settle at the bottom. + * + * The answer streams in and the scroll is animated, so the container + * keeps growing for an indeterminate time after the message is sent — + * polling avoids depending on a fixed delay that is racy on slower CI + * machines. Requires the content to have stopped growing as well, so an + * auto-scroll that reaches the bottom once and then stops following the + * streamed response still fails. + */ + async waitForAtBottom(timeout = 15000): Promise { + const pollingInterval = 250; + const deadline = Date.now() + timeout; + let previousHeight = -1; + + do { + const { scrollTop, scrollHeight, clientHeight } = await this.getScrollMetrics(); + const atBottom = Math.abs(scrollTop + clientHeight - scrollHeight) <= 2; + if (atBottom && scrollHeight === previousHeight) return true; + previousHeight = scrollHeight; + await this.page.waitForTimeout(pollingInterval); + } while (Date.now() < deadline); + + return false; } async getpreviousQuestionLoadingImage(): Promise { diff --git a/e2e/seed_test_data.py b/e2e/seed_test_data.py index a6fa19ac..bd209022 100644 --- a/e2e/seed_test_data.py +++ b/e2e/seed_test_data.py @@ -98,11 +98,12 @@ def main(): getattr(graphrag_sdk, "__version__", "?"), sdk_path, ) - Project( + sdk_project = Project( name="GraphRAG-SDK", path=sdk_path, url="https://github.com/FalkorDB/GraphRAG-SDK", - ).analyze_sources() + ) + sdk_project.analyze_sources() for url in REPOS: logger.info("Seeding %s ...", url) @@ -110,8 +111,12 @@ def main(): proj.analyze_sources() logger.info("Done seeding %s", url) - ensure_calls_edges("GraphRAG-SDK") - ensure_search_term_variety("GraphRAG-SDK") + # Use the actual composed graph name (e.g. "code:GraphRAG-SDK:_default") + # that analyze_sources() wrote into -- not the bare project name, which + # would silently create/write a separate, unused graph. + graph_name = sdk_project.graph.name + ensure_calls_edges(graph_name) + ensure_search_term_variety(graph_name) logger.info("All test data seeded successfully.") diff --git a/e2e/tests/chat.spec.ts b/e2e/tests/chat.spec.ts index 4de6d0e1..48958cff 100644 --- a/e2e/tests/chat.spec.ts +++ b/e2e/tests/chat.spec.ts @@ -55,8 +55,7 @@ test.describe("Chat tests", () => { const { scrollTop } = await chat.getScrollMetrics(); expect(scrollTop).toBeLessThanOrEqual(1); await chat.sendMessage(Node_Question); - await delay(500); // delay for scroll - expect(await chat.isAtBottom()).toBe(true); + expect(await chat.waitForAtBottom()).toBe(true); }); test(`Validate consistent UI responses for repeated questions in chat`, async () => {