diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45f4d08c7..bbf24384f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Made the default home page configurable with `DEFAULT_HOME_VIEW_PAGE`, defaulting to Code Search and supporting Ask. [#1677](https://github.com/sourcebot-dev/sourcebot/pull/1677)
- Require authentication for the streaming and blocking Ask APIs in Public SaaS deployments. [#1679](https://github.com/sourcebot-dev/sourcebot/pull/1679)
+- Fixed recently opened files appearing across different browse revisions. [#1686](https://github.com/sourcebot-dev/sourcebot/pull/1686)
## [5.1.14] - 2026-09-17
diff --git a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.test.tsx b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.test.tsx
new file mode 100644
index 000000000..22926e684
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.test.tsx
@@ -0,0 +1,116 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FileSearchCommandDialog } from './fileSearchCommandDialog';
+
+const mocks = vi.hoisted(() => ({
+ params: { repoName: 'github.com/org/repo', revisionName: 'main' as string | undefined },
+ navigateToPath: vi.fn(),
+ updateBrowseState: vi.fn(),
+}));
+
+vi.mock('../hooks/useBrowseParams', () => ({ useBrowseParams: () => mocks.params }));
+vi.mock('../hooks/useBrowseNavigation', () => ({
+ useBrowseNavigation: () => ({ navigateToPath: mocks.navigateToPath }),
+}));
+vi.mock('../hooks/useBrowseState', () => ({
+ useBrowseState: () => ({
+ state: { isFileSearchOpen: true },
+ updateBrowseState: mocks.updateBrowseState,
+ }),
+}));
+vi.mock('react-hotkeys-hook', () => ({ useHotkeys: vi.fn() }));
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => ({
+ data: [
+ { type: 'blob', name: 'main.ts', path: 'src/main.ts' },
+ { type: 'blob', name: 'feature.ts', path: 'src/feature.ts' },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+vi.mock('@/app/api/(client)/client', () => ({ getFiles: vi.fn() }));
+vi.mock('@/app/(app)/browse/components/fileTreeItemIcon', () => ({ FileTreeItemIcon: () => null }));
+
+beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ mocks.params = { repoName: 'github.com/org/repo', revisionName: 'main' };
+ vi.stubGlobal('ResizeObserver', class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ HTMLElement.prototype.scrollIntoView = vi.fn();
+ HTMLElement.prototype.scrollTo = vi.fn();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+const selectFile = (name: string) => {
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: name } });
+ fireEvent.click(screen.getByRole('option'));
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } });
+};
+
+describe('file search recents', () => {
+ it('keeps separate histories when switching revisions and restores them after remounting', () => {
+ const view = render();
+ selectFile('main.ts');
+ expect(mocks.navigateToPath).toHaveBeenLastCalledWith({
+ repoName: mocks.params.repoName,
+ revisionName: 'main',
+ path: 'src/main.ts',
+ pathType: 'blob',
+ });
+
+ mocks.params.revisionName = 'feature';
+ view.rerender();
+ expect(screen.queryByText('main.ts')).toBeNull();
+ selectFile('feature.ts');
+
+ mocks.params.revisionName = 'main';
+ view.rerender();
+ expect(screen.getByText('main.ts')).toBeTruthy();
+ expect(screen.queryByText('feature.ts')).toBeNull();
+
+ view.unmount();
+ mocks.params.revisionName = 'feature';
+ render();
+ expect(screen.getByText('feature.ts')).toBeTruthy();
+ expect(screen.queryByText('main.ts')).toBeNull();
+ });
+
+ it('shares history between the default revision and explicit HEAD', () => {
+ mocks.params.revisionName = undefined;
+ const view = render();
+ selectFile('main.ts');
+
+ mocks.params.revisionName = 'HEAD';
+ view.rerender();
+ expect(screen.getByText('main.ts')).toBeTruthy();
+ });
+
+ it('does not inherit legacy history whose revision is unknown', () => {
+ localStorage.setItem(`recentlyOpenedFiles-${mocks.params.repoName}`, JSON.stringify([
+ { type: 'blob', name: 'old.ts', path: 'src/old.ts' },
+ ]));
+
+ render();
+
+ expect(screen.queryByText('old.ts')).toBeNull();
+ });
+
+ it('keeps repository and revision pairs distinct even when names contain separators', () => {
+ mocks.params = { repoName: 'repo@branch', revisionName: 'feature' };
+ const view = render();
+ selectFile('main.ts');
+
+ mocks.params = { repoName: 'repo', revisionName: 'branch@feature' };
+ view.rerender();
+ expect(screen.queryByText('main.ts')).toBeNull();
+ });
+});
diff --git a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx
index ac743d231..7b1a16f0e 100644
--- a/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx
+++ b/packages/web/src/app/(app)/browse/components/fileSearchCommandDialog.tsx
@@ -35,7 +35,10 @@ export const FileSearchCommandDialog = () => {
const [searchQuery, setSearchQuery] = useState('');
const { navigateToPath } = useBrowseNavigation();
- const [recentlyOpened, setRecentlyOpened] = useLocalStorage(`recentlyOpenedFiles-${repoName}`, []);
+ const [recentlyOpened, setRecentlyOpened] = useLocalStorage(
+ `recentlyOpenedFiles-${JSON.stringify([repoName, revisionName ?? 'HEAD'])}`,
+ [],
+ );
useHotkeys("mod+p", (event) => {
event.preventDefault();
@@ -265,4 +268,4 @@ const ResultsSkeleton = () => {
))}
);
-};
\ No newline at end of file
+};