diff --git a/apps/extension/entrypoints/sidepanel/agents-session-list.test.tsx b/apps/extension/entrypoints/sidepanel/agents-session-list.test.tsx
new file mode 100644
index 0000000000..d49bd2c0a1
--- /dev/null
+++ b/apps/extension/entrypoints/sidepanel/agents-session-list.test.tsx
@@ -0,0 +1,170 @@
+/* eslint-disable jest/max-expects, promise/avoid-new -- one flow needs many focus assertions; the debounce flush needs a real-timer sleep */
+// @vitest-environment jsdom
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { StrictMode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { AgentsMode } from './agents-mode';
+import type { ExtensionAgentsContextValue } from './agents-provider';
+
+/**
+ * The provider value must be reachable from the hoisted vi.mock factory, so it
+ * lives in vi.hoisted storage and is assigned per test.
+ */
+const mocks = vi.hoisted(() => ({
+ value: null as ExtensionAgentsContextValue | null,
+}));
+
+// eslint-disable-next-line vitest/prefer-import-in-mock, jest/no-untyped-mock-factory -- provider value is assigned per test from vi.hoisted storage
+vi.mock('./agents-provider', () => ({
+ useExtensionAgents: (): ExtensionAgentsContextValue => {
+ if (mocks.value === null) {
+ throw new Error('useExtensionAgents test value was not assigned');
+ }
+ return mocks.value;
+ },
+}));
+
+const ACTIVE_SESSIONS = [
+ {
+ connectionId: 'cli-owner-1',
+ gitBranch: 'fix/login',
+ gitUrl: 'https://github.com/org/repo',
+ id: 'ses_active0000000000000000001',
+ status: 'running',
+ title: 'Fix login bug',
+ },
+];
+
+const HISTORY_SESSIONS = [
+ {
+ session_id: 'ses_hist00000000000000000001',
+ title: 'Refactor auth module',
+ updated_at: '2026-08-30T12:00:00.000Z',
+ },
+ {
+ session_id: 'ses_hist00000000000000000002',
+ title: 'Fix login flake',
+ updated_at: '2026-08-29T12:00:00.000Z',
+ },
+ {
+ session_id: 'ses_hist00000000000000000003',
+ title: 'Configure CI',
+ updated_at: '2026-08-28T12:00:00.000Z',
+ },
+];
+
+const createTrpcStub = () => ({
+ activeSessions: {
+ list: {
+ // React Query awaits the plain result, so a non-promise return is enough here.
+ query: vi.fn(() => ({ sessions: ACTIVE_SESSIONS })),
+ },
+ },
+ cliSessionsV2: {
+ list: {
+ query: vi.fn(() => ({ cliSessions: HISTORY_SESSIONS, nextCursor: null })),
+ },
+ search: {
+ query: vi.fn((input: { search_string: string }) => {
+ const term = input.search_string.toLowerCase();
+ return {
+ results: HISTORY_SESSIONS.filter(session => session.title.toLowerCase().includes(term)),
+ };
+ }),
+ },
+ },
+});
+
+const unsubscribe = (): void => {};
+
+const createAgentsValue = (trpcClient: unknown): ExtensionAgentsContextValue => {
+ const context = {
+ manager: {},
+ organizationId: null,
+ store: {},
+ trpcClient,
+ userWebConnection: {
+ isConnected: () => true,
+ onConnectionChange: vi.fn(() => unsubscribe),
+ onSessionEvent: vi.fn(() => unsubscribe),
+ },
+ };
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test double for the provider context; only trpcClient and userWebConnection are exercised
+ return context as unknown as ExtensionAgentsContextValue;
+};
+
+const renderSessionList = (): void => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { refetchOnWindowFocus: false, retry: false },
+ },
+ });
+ // Render through AgentsMode, the live parent of the session list; the provider it reads from is mocked above.
+ render(
+
+
+
+
+
+ );
+};
+
+/** Flush the 300 ms search debounce and any state updates it schedules. */
+const flushDebounce = async (): Promise => {
+ await new Promise(resolve => {
+ setTimeout(resolve, 350);
+ });
+};
+
+/**
+ * Reproduces the reported defect: focus the session-list search field, type
+ * three characters, and confirm focus survives every keystroke whose results
+ * update. The search below the 2-character minimum updates no results, so the
+ * result updates happen on keystrokes 2 ('fi') and 3 ('fix').
+ */
+describe('agents session list search focus', () => {
+ it('keeps focus on the search input while typed results update', async () => {
+ const trpcClient = createTrpcStub();
+ mocks.value = createAgentsValue(trpcClient);
+ renderSessionList();
+
+ // History rows render first; the search box is visible with results present.
+ await screen.findByText('Refactor auth module');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the label is the search input by construction
+ const input = screen.getByLabelText('Search sessions') as HTMLInputElement;
+ input.focus();
+ expect(document.activeElement).toBe(input);
+
+ // Keystroke 1: 'f' — below the search minimum, results do not change.
+ fireEvent.change(input, { target: { value: 'f' } });
+ await flushDebounce();
+ expect(trpcClient.cliSessionsV2.search.query).not.toHaveBeenCalled();
+ expect(document.activeElement).toBe(input);
+
+ // Keystroke 2: 'fi' — results update from history rows to search rows.
+ fireEvent.change(input, { target: { value: 'fi' } });
+ await waitFor(() => {
+ expect(trpcClient.cliSessionsV2.search.query).toHaveBeenCalledWith(
+ expect.objectContaining({ search_string: 'fi' })
+ );
+ });
+ await screen.findByText('Fix login flake');
+ expect(screen.queryByText('Refactor auth module')).toBeNull();
+ expect(document.activeElement).toBe(input);
+
+ // Keystroke 3: 'fix' — result set narrows to a single row.
+ fireEvent.change(input, { target: { value: 'fix' } });
+ await waitFor(() => {
+ expect(trpcClient.cliSessionsV2.search.query).toHaveBeenCalledWith(
+ expect.objectContaining({ search_string: 'fix' })
+ );
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Configure CI')).toBeNull();
+ });
+ expect(screen.getByText('Fix login flake')).toBeTruthy();
+ expect(document.activeElement).toBe(input);
+ });
+});