Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/evidence/issue131/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/evidence/issue131/local-palette.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/evidence/issue131/peer-palette.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 43 additions & 0 deletions scripts/cua/fleet-local-api-fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ChatMux Fleet local API review</title></head>
<body class="bg-background text-foreground"><main style="padding:24px"><h1>Fleet local API review</h1><p>Local UI fixture · synthetic projects · all API requests simulated</p><div id="app"></div><h2>Observed requests</h2><pre id="requests">[]</pre></main>
<script type="module">
import React from 'react';
import {createRoot} from 'react-dom/client';
import {MemoryRouter, Routes, Route, useNavigate} from 'react-router-dom';
import i18next from 'i18next';
import {I18nextProvider} from 'react-i18next';
import '/src/index.css';
import en from '/src/i18n/locales/en/common.json';
import {ThemeProvider} from '/src/contexts/ThemeContext.jsx';
import {PaletteOpsProvider,usePaletteOps} from '/src/contexts/PaletteOpsContext.tsx';
import {FleetHostCatalogContext} from '/src/fleet/discovery/FleetHostCatalogContext.tsx';
import FleetSessionRoute from '/src/fleet/FleetSessionRoute.tsx';
import {setLocalHostIdentity} from '/src/fleet/hostIdentity.ts';
import CommandPalette from '/src/components/command-palette/CommandPalette.tsx';
const h=React.createElement;
const LOCAL='11111111-1111-4111-8111-111111111111', PEER='22222222-2222-4222-8222-222222222222';
const requests=[];
window.fetch=async(input)=>{
const url=String(input); requests.push(url); document.querySelector('#requests').textContent=JSON.stringify(requests,null,2);
if(url==='/api/fleet/identity')return Response.json({installationId:LOCAL});
if(url.endsWith('/files'))return Response.json([{type:'file',name:'hub-only.txt',path:'hub-only.txt'}]);
if(url.includes('/git/commits'))return Response.json({commits:[{hash:'abc123',message:'hub-only-commit',author:'fixture'}]});
if(url.includes('/git/branches'))return Response.json({localBranches:['hub-only-branch']});
return Response.json({sessions:[]});
};
setLocalHostIdentity(LOCAL);
const i18n=i18next.createInstance();
await i18n.init({lng:'en',resources:{en:{common:en}},defaultNS:'common',interpolation:{escapeValue:false}});
const catalog={localHostId:LOCAL,hosts:new Map()};
function Surface(){
const [remote,setRemote]=React.useState(true), ops=usePaletteOps(), navigate=useNavigate();
const project={projectId:'collision',hostId:remote?PEER:LOCAL,displayName:remote?'Peer project':'Local project',fullPath:'/fixture',sessions:[]};
return h(React.Fragment,null,
h('label',null,'Selected project ',h('select',{'aria-label':'Selected project',value:remote?'peer':'local',onChange:e=>{const next=e.target.value==='peer';setRemote(next);navigate(next?`/hosts/${PEER}/session/collision`:'/session/collision')}},h('option',{value:'peer'},'Peer project'),h('option',{value:'local'},'Local project'))),
h('button',{onClick:()=>{requests.length=0;document.querySelector('#requests').textContent='[]';ops.openCommandPalette();},style:{margin:'16px',padding:'8px',border:'1px solid'}},'Open command palette'),
h(CommandPalette,{selectedProject:project,projects:[],currentSession:null,onOpenPinnedSession:()=>{},onStartNewChat:()=>{},onOpenSettings:()=>{}}));
}
const surface=h(FleetSessionRoute,null,h(Surface));
createRoot(document.querySelector('#app')).render(h(I18nextProvider,{i18n},h(ThemeProvider,null,h(PaletteOpsProvider,null,h(FleetHostCatalogContext.Provider,{value:{catalog,hasRemoteHosts:true,refresh:()=>{}}},h(MemoryRouter,{initialEntries:[`/hosts/${PEER}/session/collision`]},h(Routes,null,h(Route,{path:'/hosts/:hostId/session/:sessionId',element:surface}),h(Route,{path:'/session/:sessionId',element:surface}))))))));
</script></body></html>
25 changes: 2 additions & 23 deletions src/components/chat/hooks/useChatSessionState.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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';
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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 8 additions & 4 deletions src/components/chat/hooks/useFileMentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<MentionableFile[]>([]);
const [fileMentions, setFileMentions] = useState<string[]>([]);
const [filteredFiles, setFilteredFiles] = useState<MentionableFile[]>([]);
Expand All @@ -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) {
Expand All @@ -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.
Expand All @@ -91,7 +95,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
return () => {
abortController.abort();
};
}, [selectedProject?.projectId]);
}, [projectId]);

useEffect(() => {
const textBeforeCursor = input.slice(0, cursorPosition);
Expand Down Expand Up @@ -260,8 +264,8 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
);

return {
showFileDropdown,
filteredFiles,
showFileDropdown: Boolean(projectId) && showFileDropdown,
filteredFiles: projectId ? filteredFiles : [],
selectedFileIndex,
renderInputWithMentions,
selectFile,
Expand Down
58 changes: 58 additions & 0 deletions src/components/chat/hooks/useLocalTokenUsage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void;
const pending = new Promise<Record<string, unknown>>((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]);
});
29 changes: 29 additions & 0 deletions src/components/chat/hooks/useLocalTokenUsage.ts
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<Record<string, unknown> | 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<string, unknown> : null;
if (!controller.signal.aborted) setTokenBudget(usage);
})
.catch(() => { if (!controller.signal.aborted) setTokenBudget(null); });
return () => controller.abort();
}, [projectId, sessionId, setTokenBudget]);
}
19 changes: 11 additions & 8 deletions src/components/command-palette/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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';
Expand All @@ -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(() => (
Expand Down Expand Up @@ -231,8 +234,8 @@ export default function CommandPalette({

{showActions && (
<PaletteActionGroups
selectedProject={selectedProject}
projectId={projectId}
selectedProject={localProjectId ? selectedProject : null}
projectId={localProjectId}
git={git}
run={run}
onStartNewChat={onStartNewChat}
Expand Down
30 changes: 30 additions & 0 deletions src/components/command-palette/sources/useGitActions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';

import { useGitActions } from './useGitActions';

test('Git callbacks cannot act after selection changes, returns to the same id, or unmounts', async (t) => {
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<typeof useGitActions>;
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, []);
});
Loading
Loading