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.
+
+
+
+
+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],
);
}