diff --git a/graph-ui/src/browser-ai/BrowserChatDock.handover.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.handover.test.tsx index d3ec4da79..a806424b2 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.handover.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.handover.test.tsx @@ -114,6 +114,43 @@ describe('the loaded model across an in-page project switch (K24)', () => { expect(container.textContent).not.toContain('Old project question'); }); + it.each(['token counting', 'answer generation'] as const)('keeps the old %s barrier across successive project switches', async stage => { + const { props, runtime } = fixture(); + const counting = deferred(), answer = deferred(); + await load(props, 'django-demo'); + if (stage === 'token counting') runtime.countTokens.mockReturnValueOnce(counting.promise); + else runtime.chat.mockReturnValueOnce(answer.promise); + await type('Old project question'); + await act(async () => button('Send ↑').click()); + expect(runtime.countTokens).toHaveBeenCalledOnce(); + expect(runtime.chat).toHaveBeenCalledTimes(stage === 'token counting' ? 0 : 1); + + try { + await switchTo(props, 'cbm'); + await switchTo(props, 'third-project'); + await type('Final project question'); + // The first operation stays blocked by our promise, independent of timing. + await act(async () => { container.querySelector('textarea')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); + expect(runtime.countTokens).toHaveBeenCalledOnce(); + expect(runtime.chat).toHaveBeenCalledTimes(stage === 'token counting' ? 0 : 1); + expect(button('Stopping…').disabled).toBe(true); + expect(runtime.dispose).not.toHaveBeenCalled(); + } finally { + await act(async () => { counting.resolve(100); answer.resolve('OLD_PROJECT_ANSWER'); }); + } + + await act(async () => button('Send ↑').click()); + expect(runtime.countTokens).toHaveBeenCalledTimes(2); + expect(runtime.chat).toHaveBeenCalledTimes(stage === 'token counting' ? 1 : 2); + const sent = runtime.chat.mock.calls.at(-1)![0].map(message => message.content).join('\n'); + expect(sent).toContain('Final project question'); + expect(sent).toContain('SOURCE_OF_third-project'); + expect(sent).not.toContain('SOURCE_OF_django-demo'); + expect(container.textContent).not.toContain('OLD_PROJECT_ANSWER'); + expect(props.createRuntime).toHaveBeenCalledOnce(); + expect(runtime.prepare).toHaveBeenCalledOnce(); + }); + it('keeps a model that is still loading and finishes loading it in the new project', async () => { const { props, runtime, states } = fixture(); const loading = deferred(); runtime.prepare.mockReturnValueOnce(loading.promise); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index d2c6229bb..bb8fe48fa 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -539,6 +539,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " const adopted = handover.runtime; runtime.current = adopted; runtimeModel.current = handover.modelId; adopted.setFatalHandler?.(failure => { if (runtime.current === adopted) invalidateRuntime(failure); }); + // A further project switch must carry the same unfinished operation with it. + operationSettled.current = handover.settled; const waiting = handover.ready ?? handover.settled; if (!waiting) { setPhase('ready'); return; } const ticket = epoch.current; @@ -550,6 +552,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " } else { stopRequested.current = true; setStopping(true); setPhase('counting'); } void waiting.then(() => { if (epoch.current !== ticket || runtime.current !== adopted) return; + if (operationSettled.current === handover.settled) operationSettled.current = undefined; pending.current = false; stopRequested.current = false; loading.current = undefined; setStopping(false); if (handover.ready) { setCached(previous => new Set(previous).add(handover.modelId)); setProgress(undefined); } setPhase('ready'); diff --git a/graph-ui/src/galaxy/graph-scope.test.ts b/graph-ui/src/galaxy/graph-scope.test.ts index 663172fb7..b0d71eb78 100644 --- a/graph-ui/src/galaxy/graph-scope.test.ts +++ b/graph-ui/src/galaxy/graph-scope.test.ts @@ -79,6 +79,19 @@ describe('complete scoped graph acquisition', () => { expect(result.data.nodes.map(node => node.id)).toEqual([1, 2]); expect(result.data.edges.map(edge => edge.id)).toEqual([1]); }); + it.each(['file', 'folder'] as const)('preserves edge-limit partial metadata when an unexpanded %s stops between pages', async kind => { + const a = node(1), b = node(2), c = node(3); + const queryGraph = vi.fn().mockResolvedValueOnce(page([row(a), row(b), row(c)])) + .mockResolvedValueOnce(page([edgeRow(1, a, b), edgeRow(2, b, c)], { total: 3, offset: 0, nextOffset: 2, nextCursor: 'third', hasMore: true })) + .mockResolvedValueOnce(page([edgeRow(3, c, a)], { total: 3, offset: 2 })); + const result = await loadGraphScope('p', { kind, path: kind === 'file' ? 'src/a.ts' : 'src', name: kind }, 0, 'both', undefined, + { client: { queryGraph }, limits: { nodes: 100, edges: 1 } }); + expect(queryGraph).toHaveBeenCalledTimes(2); + expect(result.depth).toBe(0); + expect(result.data.nodes.map(node => node.id)).toEqual([1, 2, 3]); + expect(result.data.edges.map(edge => edge.id)).toEqual([1, 2]); + expect(result.partial).toEqual({ layer: 1, nodes: 3, edges: 2, limit: 'edges' }); + }); }); it('render limits retain selected roots and never retain dangling edges', () => { diff --git a/graph-ui/src/galaxy/graph-scope.ts b/graph-ui/src/galaxy/graph-scope.ts index d87f70272..e15723597 100644 --- a/graph-ui/src/galaxy/graph-scope.ts +++ b/graph-ui/src/galaxy/graph-scope.ts @@ -241,7 +241,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth: // A cluster starts with all of its members and their internal edges. if (depth === 0 && roots.size > 1) { const initial = [...edges.values()].filter(edge => roots.has(edge.source) && roots.has(edge.target)); - return { data: arrangeScopedGraph([...nodes.values()].filter(node => roots.has(node.id)), initial, roots, levels), roots, depth: 0, exhausted: edgeTypes?.length === 0, frontier: edgeTypes?.length === 0 ? [] : [...roots], levels, traversalKey }; + return { data: arrangeScopedGraph([...nodes.values()].filter(node => roots.has(node.id)), initial, roots, levels), roots, depth: 0, exhausted: edgeTypes?.length === 0, frontier: edgeTypes?.length === 0 ? [] : [...roots], levels, traversalKey, ...(partial ? { partial } : {}) }; } return { data: arrangeScopedGraph([...nodes.values()], [...edges.values()], roots, levels), roots, depth: reachedDepth, exhausted: !partial && (edgeTypes?.length === 0 || (depth > 0 && frontier.length === 0)), frontier, levels, traversalKey, ...(partial ? { partial } : {}) }; diff --git a/graph-ui/tools/handtest-fixes-k27.mjs b/graph-ui/tools/handtest-fixes-k27.mjs index e6bb7d046..523942953 100644 --- a/graph-ui/tools/handtest-fixes-k27.mjs +++ b/graph-ui/tools/handtest-fixes-k27.mjs @@ -32,8 +32,9 @@ */ import { chromium } from 'playwright'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { prepareHandtestProfile } from './lib/handtest-profile.mjs'; const argv = process.argv.slice(2); const arg = (name, fallback) => { @@ -142,9 +143,9 @@ const historyButton = (page, label) => page.locator(`.atlas-architecture [aria-l /* ------------------------------------------------------------------ */ /* Ablauf */ -await rm(PROFILE, { recursive: true, force: true }); +const runProfile = await prepareHandtestProfile(PROFILE); await mkdir(OUT, { recursive: true }); -const context = await chromium.launchPersistentContext(PROFILE, { +const context = await chromium.launchPersistentContext(runProfile, { headless: true, viewport: VIEWPORT, deviceScaleFactor: 2, args: ['--enable-unsafe-webgpu', '--ignore-gpu-blocklist'], }); diff --git a/graph-ui/tools/handtest-profile.test.mjs b/graph-ui/tools/handtest-profile.test.mjs new file mode 100644 index 000000000..abe414163 --- /dev/null +++ b/graph-ui/tools/handtest-profile.test.mjs @@ -0,0 +1,28 @@ +import { afterEach, expect, it } from 'vitest'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { prepareHandtestProfile } from './lib/handtest-profile.mjs'; + +const fixtures = []; +afterEach(async () => { + await Promise.all(fixtures.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +it('preserves an existing profile and creates separate fresh run directories', async () => { + const parent = await mkdtemp(join(tmpdir(), 'cbm-profile-test-')); + fixtures.push(parent); + const marker = join(parent, 'existing-profile-data'); + await writeFile(marker, 'keep this profile'); + + const first = await prepareHandtestProfile(parent); + expect(await readFile(marker, 'utf8').catch(() => undefined)).toBe('keep this profile'); + expect(dirname(first)).toBe(parent); + expect((await stat(first)).isDirectory()).toBe(true); + await writeFile(join(first, 'previous-run'), 'keep this run too'); + + const second = await prepareHandtestProfile(parent); + expect(second).not.toBe(first); + expect(dirname(second)).toBe(parent); + expect(await readFile(join(first, 'previous-run'), 'utf8')).toBe('keep this run too'); +}); diff --git a/graph-ui/tools/lib/handtest-profile.mjs b/graph-ui/tools/lib/handtest-profile.mjs new file mode 100644 index 000000000..34cee40e1 --- /dev/null +++ b/graph-ui/tools/lib/handtest-profile.mjs @@ -0,0 +1,7 @@ +import { mkdir, mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; + +export async function prepareHandtestProfile(parent) { + await mkdir(parent, { recursive: true }); + return mkdtemp(join(parent, 'run-')); +}