diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19358afd3..c160eec98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367)
- [EE] Fixed Ask Sourcebot mermaid diagrams overflowing their container by contain-fitting them to both width and height, and made revealing a diagram from the answer jump it into view instantly to avoid over/undershooting. [#1373](https://github.com/sourcebot-dev/sourcebot/pull/1373)
- Passed Zoekt index parameters via argv to preserve revision names with punctuation. [#1376](https://github.com/sourcebot-dev/sourcebot/pull/1376)
+- Kept selected language filters visible while narrowing the filter list. [#1377](https://github.com/sourcebot-dev/sourcebot/pull/1377)
## [5.0.4] - 2026-06-18
diff --git a/packages/web/src/app/(app)/search/components/filterPanel/entry.tsx b/packages/web/src/app/(app)/search/components/filterPanel/entry.tsx
index e55cccdb4..a56a65241 100644
--- a/packages/web/src/app/(app)/search/components/filterPanel/entry.tsx
+++ b/packages/web/src/app/(app)/search/components/filterPanel/entry.tsx
@@ -34,9 +34,13 @@ export const Entry = ({
countText = "999+";
}
return (
-
onClicked()}
>
-
+
{Icon ? Icon : (
)}
-
+
- {displayName}
+ {displayName}
{displayName}
-
-
-
+
+
+
{countText}
-
-
+
+
);
}
diff --git a/packages/web/src/app/(app)/search/components/filterPanel/filter.test.tsx b/packages/web/src/app/(app)/search/components/filterPanel/filter.test.tsx
new file mode 100644
index 000000000..cc6982799
--- /dev/null
+++ b/packages/web/src/app/(app)/search/components/filterPanel/filter.test.tsx
@@ -0,0 +1,92 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { TooltipProvider } from '@/components/ui/tooltip';
+import { Filter } from './filter';
+import type { Entry } from './entry';
+
+afterEach(() => {
+ cleanup();
+});
+
+const entries: Entry[] = [
+ {
+ key: 'powershell',
+ displayName: 'PowerShell',
+ count: 4,
+ isSelected: true,
+ isHidden: false,
+ isDisabled: false,
+ },
+ {
+ key: 'c',
+ displayName: 'C',
+ count: 9,
+ isSelected: false,
+ isHidden: false,
+ isDisabled: false,
+ },
+ {
+ key: 'go',
+ displayName: 'Go',
+ count: 12,
+ isSelected: false,
+ isHidden: false,
+ isDisabled: false,
+ },
+];
+
+describe('Filter', () => {
+ test('renders entries as accessible toggle buttons', () => {
+ const onEntryClicked = vi.fn();
+
+ render(
+
+
+ ,
+ );
+
+ const selectedEntry = screen.getByRole('button', { name: /PowerShell/ });
+ const unselectedEntry = screen.getByRole('button', { name: /C/ });
+
+ expect(selectedEntry.getAttribute('aria-pressed')).toBe('true');
+ expect(unselectedEntry.getAttribute('aria-pressed')).toBe('false');
+
+ fireEvent.click(selectedEntry);
+
+ expect(onEntryClicked).toHaveBeenCalledWith('powershell');
+ });
+
+ test('keeps selected entries visible and pinned while filtering', () => {
+ render(
+
+ undefined}
+ isStreaming={false}
+ />
+ ,
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('Search languages'), {
+ target: { value: 'C' },
+ });
+
+ const selectedEntry = screen.getByText('PowerShell');
+ const matchedEntry = screen.getByText('C');
+
+ expect(selectedEntry).toBeTruthy();
+ expect(matchedEntry).toBeTruthy();
+ expect(screen.queryByText('Go')).toBeNull();
+ expect(
+ selectedEntry.compareDocumentPosition(matchedEntry) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+});
diff --git a/packages/web/src/app/(app)/search/components/filterPanel/filter.tsx b/packages/web/src/app/(app)/search/components/filterPanel/filter.tsx
index 0a76a50e6..51de864c3 100644
--- a/packages/web/src/app/(app)/search/components/filterPanel/filter.tsx
+++ b/packages/web/src/app/(app)/search/components/filterPanel/filter.tsx
@@ -36,8 +36,19 @@ export const Filter = ({
threshold: 0.3,
});
+ const selectedEntries = entries.filter((entry) => entry.isSelected);
const result = fuse.search(searchFilter);
- return result.map((result) => result.item);
+ const filteredEntries = new Map();
+
+ for (const entry of selectedEntries) {
+ filteredEntries.set(entry.key, entry);
+ }
+
+ for (const { item } of result) {
+ filteredEntries.set(item.key, item);
+ }
+
+ return [...filteredEntries.values()];
}, [entries, searchFilter]);
return (
@@ -61,7 +72,7 @@ export const Filter = ({
- {filteredEntries
+ {[...filteredEntries]
.sort((entryA, entryB) => compareEntries(entryB, entryA))
.map((entry) => (