diff --git a/docs/evidence/issue131/README.md b/docs/evidence/issue131/README.md new file mode 100644 index 00000000..70595a7a --- /dev/null +++ b/docs/evidence/issue131/README.md @@ -0,0 +1,43 @@ +# Preserved Fleet changes: local API verification + +Reviewed against `1c933a39e452b3936afc605173f8c384a565c2c0` on 2026-09-06. + +The preserved `103e70b` proposal identified local-only API consumers. Current +`main` already blocks remote relay file catalogs, but ordinary file mentions, +file-link resolution, token usage, and command-palette file/Git operations still +used the hub endpoints. Two mounted regressions reproduced requests to the hub's +same-ID project and an editor opening after selection moved to a peer. + +The correction requires both the current route and selected project to be local, +invalidates stale file/Git callbacks, ignores late token/file responses, and hides +the local editor when its owning project is no longer selected. Remote token +usage continues to come from host-qualified transcript history. No new Fleet +capability or peer file/Git operation is introduced. + +## Reproduce the browser check + +```sh +npm run client -- --host 127.0.0.1 --port 4342 --strictPort +``` + +Open `/scripts/cua/fleet-local-api-fixture.html` on that local Vite origin. This +mounts the production command palette with synthetic same-ID projects and +simulated responses; it never calls a real backend or changes Git repositories. + +1. Select **Peer project** and open the palette: observed requests stay `[]`; + no local files, commits, branches, or Git actions appear. +2. Close it, select **Local project**, and reopen: the existing local session, + file, commit, and branch endpoints are requested and their rows are visible. +3. Return to the peer and reopen: local rows disappear and requests again stay + `[]`. Local new-chat creation is unavailable on the peer. + +These three interactions were verified in Chrome. This is browser component +evidence with simulated API responses, not release-grade CUA or a real peer test. + +![Peer palette](peer-palette.jpg) +![Local palette](local-palette.jpg) + +Mounted tests cover same-ID peers, absent locality evidence, delayed response +bodies, host transitions, stale action callbacks, local success/failure, and +retained local basename/diff resolution. The existing follow-tail tests also pass; +the preserved `2084ad0` design was already adopted and extended by PR #99. diff --git a/docs/evidence/issue131/local-palette.jpg b/docs/evidence/issue131/local-palette.jpg new file mode 100644 index 00000000..d4ef0abb Binary files /dev/null and b/docs/evidence/issue131/local-palette.jpg differ diff --git a/docs/evidence/issue131/peer-palette.jpg b/docs/evidence/issue131/peer-palette.jpg new file mode 100644 index 00000000..11efde73 Binary files /dev/null and b/docs/evidence/issue131/peer-palette.jpg differ diff --git a/scripts/cua/fleet-local-api-fixture.html b/scripts/cua/fleet-local-api-fixture.html new file mode 100644 index 00000000..5d95ce40 --- /dev/null +++ b/scripts/cua/fleet-local-api-fixture.html @@ -0,0 +1,43 @@ + +ChatMux Fleet local API review +

Fleet local API review

Local UI fixture · synthetic projects · all API requests simulated

Observed requests

[]
+ diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index b6c53d13..f1a55c3c 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; -import { authenticatedFetch } from '../../../utils/api'; import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; @@ -9,6 +8,7 @@ import type { ChatMessage } from '../types/types'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { normalizedToChatMessages } from './useChatMessages'; +import { useLocalTokenUsage } from './useLocalTokenUsage'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; @@ -926,28 +926,7 @@ export function useChatSessionState({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatMessages.length, isLoadingSessionMessages, searchTarget]); - // Initial token usage fetch for providers with file-backed usage data. - useEffect(() => { - if (!selectedProject || !selectedSession?.id) { - setTokenBudget(null); - return; - } - const fetchInitialTokenUsage = async () => { - try { - // The backend resolves the provider from the indexed session row. - const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`; - const response = await authenticatedFetch(url); - if (response.ok) { - setTokenBudget(await response.json()); - } else { - setTokenBudget(null); - } - } catch (error) { - console.error('Failed to fetch initial token usage:', error); - } - }; - fetchInitialTokenUsage(); - }, [selectedProject, selectedSession?.id]); + useLocalTokenUsage(selectedProject, selectedSession?.id, setTokenBudget); const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) return chatMessages; diff --git a/src/components/chat/hooks/useFileMentions.tsx b/src/components/chat/hooks/useFileMentions.tsx index 1bad7f08..46407cf2 100644 --- a/src/components/chat/hooks/useFileMentions.tsx +++ b/src/components/chat/hooks/useFileMentions.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react'; import { api } from '../../../utils/api'; +import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../../fleet/hostApi/urls'; import { escapeRegExp } from '../utils/chatFormatting'; import type { Project } from '../../../types/app'; @@ -48,6 +50,8 @@ const flattenFileTree = (files: ProjectFileNode[], basePath = ''): MentionableFi }; export function useFileMentions({ selectedProject, input, setInput, textareaRef }: UseFileMentionsOptions) { + const { storeScope } = useFleetHost(); + const projectId = localProjectIdForScope(storeScope, selectedProject); const [fileList, setFileList] = useState([]); const [fileMentions, setFileMentions] = useState([]); const [filteredFiles, setFilteredFiles] = useState([]); @@ -62,7 +66,6 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef const fetchProjectFiles = async () => { // File list is keyed by DB projectId now; the backend resolves it to // the project's path before reading. - const projectId = selectedProject?.projectId; setFileList([]); setFilteredFiles([]); if (!projectId) { @@ -77,6 +80,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef } const files = (await response.json()) as ProjectFileNode[]; + if (abortController.signal.aborted) return; setFileList(flattenFileTree(files)); } catch (error) { // Ignore aborts from rapid project switches; we only care about the latest request. @@ -91,7 +95,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef return () => { abortController.abort(); }; - }, [selectedProject?.projectId]); + }, [projectId]); useEffect(() => { const textBeforeCursor = input.slice(0, cursorPosition); @@ -260,8 +264,8 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef ); return { - showFileDropdown, - filteredFiles, + showFileDropdown: Boolean(projectId) && showFileDropdown, + filteredFiles: projectId ? filteredFiles : [], selectedFileIndex, renderInputWithMentions, selectFile, diff --git a/src/components/chat/hooks/useLocalTokenUsage.test.tsx b/src/components/chat/hooks/useLocalTokenUsage.test.tsx new file mode 100644 index 00000000..70df7441 --- /dev/null +++ b/src/components/chat/hooks/useLocalTokenUsage.test.tsx @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; + +import type { Project } from '../../../types/app'; + +import { useLocalTokenUsage } from './useLocalTokenUsage'; + +const local: Project = { projectId: 'same/project', displayName: 'Project', fullPath: '/same/path' }; +const peer = { ...local, hostId: '22222222-2222-4222-8222-222222222222' }; + +test('peer history usage survives a delayed hub token response and peer selection makes no local request', async (t) => { + let resolve!: (usage: Record) => void; + const pending = new Promise>((done) => { resolve = done; }); + const requests: string[] = []; + let signal: AbortSignal | undefined; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL, options?: RequestInit) => { + requests.push(String(input)); signal = options?.signal ?? undefined; + return { ok: true, json: () => pending } as Response; + }); + const updates: unknown[] = []; + const setUsage = (value: unknown) => updates.push(value); + function Surface({ project }: { project: Project }) { + useLocalTokenUsage(project, 'same/session', setUsage); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { project: local })); }); + t.after(() => act(() => renderer.unmount())); + await act(async () => { renderer.update(createElement(Surface, { project: peer })); }); + await act(async () => { resolve({ source: 'hub' }); }); + assert.equal(signal?.aborted, true); + assert.deepEqual(requests, ['/api/projects/same%2Fproject/sessions/same%2Fsession/token-usage']); + assert.deepEqual(updates, []); +}); + +test('local token usage handles success, HTTP failure, and network failure', async (t) => { + let mode = 'success'; + t.mock.method(globalThis, 'fetch', async () => { + if (mode === 'network') throw new Error('fixture failure'); + return mode === 'success' ? Response.json({ tokens: 12 }) : new Response(null, { status: 503 }); + }); + const updates: unknown[] = []; + const setUsage = (value: unknown) => updates.push(value); + function Surface({ session }: { session: string }) { + useLocalTokenUsage(local, session, setUsage); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { session: mode })); }); + t.after(() => act(() => renderer.unmount())); + for (mode of ['http', 'network']) { + await act(async () => { renderer.update(createElement(Surface, { session: mode })); }); + } + assert.deepEqual(updates, [{ tokens: 12 }, null, null]); +}); diff --git a/src/components/chat/hooks/useLocalTokenUsage.ts b/src/components/chat/hooks/useLocalTokenUsage.ts new file mode 100644 index 00000000..0a46de9c --- /dev/null +++ b/src/components/chat/hooks/useLocalTokenUsage.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; + +import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../../fleet/hostApi/urls'; +import type { Project } from '../../../types/app'; +import { authenticatedFetch } from '../../../utils/api'; + +/** Peer token usage comes from host-qualified history, never the hub's compatibility endpoint. */ +export function useLocalTokenUsage( + project: Project | null, + sessionId: string | undefined, + setTokenBudget: Dispatch | null>>, +): void { + const { storeScope } = useFleetHost(); + const projectId = localProjectIdForScope(storeScope, project); + useEffect(() => { + if (!projectId || !sessionId) return; + const controller = new AbortController(); + const url = `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/token-usage`; + void authenticatedFetch(url, { signal: controller.signal }) + .then(async (response) => { + const usage = response.ok ? await response.json() as Record : null; + if (!controller.signal.aborted) setTokenBudget(usage); + }) + .catch(() => { if (!controller.signal.aborted) setTokenBudget(null); }); + return () => controller.abort(); + }, [projectId, sessionId, setTokenBudget]); +} diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 97c5c32d..ac1808ba 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -15,6 +15,8 @@ import { import { useTheme } from '../../contexts/ThemeContext'; import { usePaletteOps, usePaletteOpsRegister } from '../../contexts/PaletteOpsContext'; import { useFleetHostCatalog } from '../../fleet/discovery/FleetHostCatalogContext'; +import { useFleetHost } from '../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../fleet/hostApi/urls'; import { EMPTY_HOST_ROW_SET } from '../../fleet/discovery/hostRows'; import type { AppTab, Project } from '../../types/app'; import type { SessionTarget } from '../../fleet/references'; @@ -72,6 +74,7 @@ export default function CommandPalette({ const navigate = useNavigate(); const ops = usePaletteOps(); const { catalog } = useFleetHostCatalog(); + const { storeScope } = useFleetHost(); const { t } = useTranslation('common'); const inventory = { catalog, projects }; const { pins, togglePin, openPin, unpin, storageUnavailable } = usePinnedSessionNavigation(inventory, (target) => { @@ -104,7 +107,7 @@ export default function CommandPalette({ const projectId = selectedProject?.projectId; const hostId = selectedProject?.hostId ?? null; - const isRemoteProject = hostId !== null && hostId !== catalog.localHostId; + const localProjectId = localProjectIdForScope({ ...storeScope, localHostId: catalog.localHostId }, selectedProject); const showActions = !page || page === 'actions'; const showSessions = !page || page === 'sessions'; @@ -114,12 +117,12 @@ export default function CommandPalette({ // A peer's roster arrives on the discovery stream; the hub's project route // would answer with its own sessions under the same project id. - const localSessions = useSessionsSource(projectId, open && showSessions && !isRemoteProject); + const localSessions = useSessionsSource(localProjectId, open && showSessions); const messageMatches = useSessionMessageSearch({ project: selectedProject ?? undefined, query: search, enabled: open && showSessions }); - const files = useFilesSource(projectId, open && showFiles); - const commits = useCommitsSource(projectId, open && showCommits); - const branches = useBranchesSource(projectId, open && showBranches); - const git = useGitActions(projectId); + const files = useFilesSource(localProjectId, open && showFiles); + const commits = useCommitsSource(localProjectId, open && showCommits); + const branches = useBranchesSource(localProjectId, open && showBranches); + const git = useGitActions(localProjectId); const peerRows = (hostId === null ? undefined : catalog.hosts.get(hostId))?.rows ?? EMPTY_HOST_ROW_SET; const sessionRows = React.useMemo(() => ( @@ -231,8 +234,8 @@ export default function CommandPalette({ {showActions && ( { + const requests: unknown[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL, options?: RequestInit) => { + requests.push([String(input), JSON.parse(String(options?.body))]); + return Response.json({ success: true }); + }); + let actions!: ReturnType; + function Surface({ id }: { id?: string }) { actions = useGitActions(id); return null; } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { id: 'same-project' })); }); + const old = actions; + await act(async () => { await actions.fetch(); }); + assert.deepEqual(requests, [['/api/git/fetch', { project: 'same-project' }]]); + requests.length = 0; + await act(async () => { renderer.update(createElement(Surface)); }); + await act(async () => { await old.pull(); await actions.push(); }); + await act(async () => { renderer.update(createElement(Surface, { id: 'same-project' })); }); + await act(async () => { await old.checkout('main'); }); + await act(async () => { renderer.unmount(); }); + await actions.push(); + assert.deepEqual(requests, []); +}); diff --git a/src/components/command-palette/sources/useGitActions.ts b/src/components/command-palette/sources/useGitActions.ts index cf765f34..f33c14d3 100644 --- a/src/components/command-palette/sources/useGitActions.ts +++ b/src/components/command-palette/sources/useGitActions.ts @@ -1,6 +1,8 @@ -import { useCallback } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { authenticatedFetch } from '../../../utils/api'; +import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { isLocalHostScope } from '../../../fleet/hostApi/urls'; async function postGit(path: string, body: Record) { const res = await authenticatedFetch(path, { @@ -11,27 +13,36 @@ async function postGit(path: string, body: Record) { } export function useGitActions(projectId: string | undefined) { + const { storeScope } = useFleetHost(); + const localId = isLocalHostScope(storeScope) ? projectId : undefined; + const selection = useMemo(() => ({ localId }), [localId]); + const current = useRef(selection); + current.current = selection; + useLayoutEffect(() => { + current.current = selection; + return () => { current.current = null; }; + }, [selection]); + const run = useCallback((path: string, extra: Record = {}) => { + if (!localId || current.current !== selection) return Promise.resolve(); + return postGit(path, { project: localId, ...extra }); + }, [localId, selection]); const fetch = useCallback(() => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/fetch', { project: projectId }); - }, [projectId]); + return run('/api/git/fetch'); + }, [run]); const pull = useCallback(() => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/pull', { project: projectId }); - }, [projectId]); + return run('/api/git/pull'); + }, [run]); const push = useCallback(() => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/push', { project: projectId }); - }, [projectId]); + return run('/api/git/push'); + }, [run]); const checkout = useCallback( (branch: string) => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/checkout', { project: projectId, branch }); + return run('/api/git/checkout', { branch }); }, - [projectId], + [run], ); return { fetch, pull, push, checkout }; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 44bd8182..eeebc7e0 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense } from 'react'; +import React, { lazy, Suspense, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { SquareTerminal } from 'lucide-react'; @@ -8,6 +8,7 @@ import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../../fleet/hostApi/urls'; import { useExternalPaneOutput } from '../hooks/useExternalPaneOutput'; import { useTranscriptCliTarget } from '../hooks/useTranscriptCliTarget'; @@ -66,7 +67,8 @@ function MainContent({ }: MainContentProps) { const { preferences } = useUiPreferences(); const { t } = useTranslation('chat'); - const { activeSessionKey } = useFleetHost(); + const { activeSessionKey, storeScope } = useFleetHost(); + const localProjectId = localProjectIdForScope(storeScope, selectedProject); const { showRawParameters, showThinking, showImagePreviews, sendByCtrlEnter } = preferences; const { @@ -97,17 +99,18 @@ function MainContent({ handleToggleEditorExpand, handleResizeStart, } = useEditorSidebar({ - selectedProject, + selectedProject: localProjectId ? selectedProject : null, isMobile, }); // Resolves bare/partial file references (e.g. links inside chat messages) to // real project files before opening them in the in-app editor. const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen); + useEffect(() => { handleCloseEditor(); }, [localProjectId, handleCloseEditor]); usePaletteOpsRegister({ openFile: (filePath: string) => { - handleFileOpen(filePath); + resolvedFileOpen(filePath); }, // Opens the editor side panel in place, keeping the current tab (e.g. chat). openFileInEditor: (filePath: string) => { @@ -211,7 +214,7 @@ function MainContent({ liveSessionProcessing={liveSessionProcessing} ws={ws} sendMessage={sendMessage} - onFileOpen={handleFileOpen} + onFileOpen={resolvedFileOpen} onInputFocusChange={onInputFocusChange} onSessionProcessing={onSessionProcessing} onSessionIdle={onSessionIdle} @@ -244,7 +247,7 @@ function MainContent({ - {editingFile && ( + {editingFile && localProjectId && editingFile.projectId === localProjectId && ( { + assert.equal(localProjectIdForScope(unknown, { projectId: PROJECT }), PROJECT); + assert.equal(localProjectIdForScope(local, { projectId: PROJECT, hostId: LOCAL }), PROJECT); + assert.equal(localProjectIdForScope(local, { projectId: PROJECT, hostId: PEER_A }), undefined); + assert.equal(localProjectIdForScope(peerA, { projectId: PROJECT }), undefined); + assert.equal(localProjectIdForScope(peerA, { projectId: PROJECT, hostId: LOCAL }), undefined); + assert.equal(localProjectIdForScope(unknown, { projectId: PROJECT, hostId: PEER_A }), undefined); + assert.equal(localProjectIdForScope(local, null), undefined); +}); + test('Given a scope, when locality is asked, then only the local host and an unknown identity are local', () => { // Given / When / Then assert.equal(isLocalHostScope(local), true); diff --git a/src/fleet/hostApi/urls.ts b/src/fleet/hostApi/urls.ts index 0df2f7c6..bd02c1d3 100644 --- a/src/fleet/hostApi/urls.ts +++ b/src/fleet/hostApi/urls.ts @@ -28,6 +28,17 @@ export function isLocalHostScope(scope: HostScope): boolean { return scope.hostId === null || scope.hostId === scope.localHostId; } +/** Local-only APIs require both the current route and the selected project to be local. */ +export function localProjectIdForScope( + scope: HostScope, + project: { readonly projectId?: string; readonly hostId?: string } | null | undefined, +): string | undefined { + return isLocalHostScope(scope) + && isLocalHostScope({ hostId: project?.hostId ?? scope.hostId, localHostId: scope.localHostId }) + ? project?.projectId + : undefined; +} + function hostPrefix(scope: HostScope): string { return `/api/hosts/${encodeURIComponent(scope.hostId ?? '')}`; } diff --git a/src/hooks/localProjectAccess.mounted.test.tsx b/src/hooks/localProjectAccess.mounted.test.tsx new file mode 100644 index 00000000..c11b85e1 --- /dev/null +++ b/src/hooks/localProjectAccess.mounted.test.tsx @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createElement, createRef } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; + +import { useFileMentions } from '../components/chat/hooks/useFileMentions'; +import type { Project } from '../types/app'; + +import { useFileOpenResolver } from './useFileOpenResolver'; + +const PEER = '22222222-2222-4222-8222-222222222222'; +const project = (hostId?: string): Project => ({ projectId: 'collision', displayName: 'Project', fullPath: '/same/path', ...(hostId ? { hostId } : {}) }); + +test('peer file mentions and file links never read or open the hub project with the same id', async (t) => { + const requests: string[] = []; + const opened: string[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + requests.push(String(input)); + return Response.json([{ type: 'file', name: 'hub.txt', path: 'hub.txt' }]); + }); + let mentions!: ReturnType; + let open!: ReturnType; + function Surface() { + mentions = useFileMentions({ selectedProject: project(PEER), input: '', setInput: () => {}, textareaRef: createRef() }); + open = useFileOpenResolver(project(PEER), (file) => opened.push(file)); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface)); }); + t.after(() => act(() => renderer.unmount())); + await act(async () => { open('hub.txt'); }); + assert.deepEqual(requests, []); + assert.deepEqual(opened, []); + assert.deepEqual(mentions.filteredFiles, []); +}); + +test('a late local file lookup cannot open the editor after switching to a peer', async (t) => { + let resolve!: (response: Response) => void; + const pending = { promise: new Promise((done) => { resolve = done; }) }; + t.mock.method(globalThis, 'fetch', () => pending.promise); + const opened: string[] = []; + let open!: ReturnType; + function Surface({ value }: { value: Project }) { + open = useFileOpenResolver(value, (file) => opened.push(file)); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { value: project() })); }); + t.after(() => act(() => renderer.unmount())); + const oldOpen = open; + await act(async () => { open('hub.txt'); }); + await act(async () => { renderer.update(createElement(Surface, { value: project(PEER) })); }); + await act(async () => { + resolve(Response.json([{ type: 'file', name: 'hub.txt', path: 'hub/hub.txt' }])); + }); + await act(async () => { oldOpen('hub.txt'); }); + assert.deepEqual(opened, []); +}); + +test('local file links still resolve basenames and preserve diff details', async (t) => { + const requests: string[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + requests.push(String(input)); + return Response.json([{ type: 'file', name: 'file.ts', path: 'src/file.ts' }]); + }); + const opened: unknown[][] = []; + let open!: ReturnType; + function Surface() { + open = useFileOpenResolver(project(), (...args) => opened.push(args)); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface)); }); + t.after(() => act(() => renderer.unmount())); + await act(async () => { open('file.ts', { before: 'old' }); }); + assert.deepEqual(requests, ['/api/projects/collision/files']); + assert.deepEqual(opened, [['src/file.ts', { before: 'old' }]]); +}); diff --git a/src/hooks/useFileOpenResolver.ts b/src/hooks/useFileOpenResolver.ts index ed5fd1fa..0dffe390 100644 --- a/src/hooks/useFileOpenResolver.ts +++ b/src/hooks/useFileOpenResolver.ts @@ -1,6 +1,8 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { api } from '../utils/api'; +import { useFleetHost } from '../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../fleet/hostApi/urls'; import type { Project } from '../types/app'; type FileNode = { @@ -61,9 +63,17 @@ export function useFileOpenResolver( selectedProject: Project | null | undefined, onFileOpen: OnFileOpen, ): OnFileOpen { - const projectId = selectedProject?.projectId; - const cacheRef = useRef<{ projectId?: string; files: Promise | null }>({ - projectId: undefined, + const { storeScope } = useFleetHost(); + const projectId = localProjectIdForScope(storeScope, selectedProject); + const selection = useMemo(() => ({ projectId }), [projectId]); + const currentSelection = useRef(selection); + currentSelection.current = selection; + useLayoutEffect(() => { + currentSelection.current = selection; + return () => { currentSelection.current = null; }; + }, [selection]); + const cacheRef = useRef<{ selection?: typeof selection; files: Promise | null }>({ + selection: undefined, files: null, }); @@ -71,7 +81,7 @@ export function useFileOpenResolver( if (!projectId) { return Promise.resolve([]); } - if (cacheRef.current.projectId === projectId && cacheRef.current.files) { + if (cacheRef.current.selection === selection && cacheRef.current.files) { return cacheRef.current.files; } @@ -91,18 +101,20 @@ export function useFileOpenResolver( } })(); - cacheRef.current = { projectId, files: filesPromise }; + cacheRef.current = { selection, files: filesPromise }; return filesPromise; - }, [projectId]); + }, [projectId, selection]); return useCallback( (filePath: string, diffInfo?: any) => { + if (!projectId || currentSelection.current !== selection) return; const ref = normalize(filePath).trim(); void loadFiles().then((files) => { + if (currentSelection.current !== selection) return; const match = findBestMatch(files, ref); onFileOpen(match ?? filePath, diffInfo); }); }, - [loadFiles, onFileOpen], + [loadFiles, onFileOpen, projectId, selection], ); }