From 7369cba01a61ce297d6842d4afdce0d79280cd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 17:10:37 +0200 Subject: [PATCH 1/4] feat(extension): default tab selector to window active tab Sample the side panel window's active tab in the shared tab-list query and resolve selection as stored > active > first. Persist-effect gains empty-list and hydration guards so a valid stored selection is never wiped. New conversations no longer inherit the selection: they are created synchronously without selectedTabId, then patched with a fresh click-time active-tab sample when still unset. --- .../sidepanel/agent-chat-panel.test.ts | 71 ++++++++++++++++++- .../sidepanel/agent-chat-panel.tsx | 71 +++++++++++++++++-- .../sidepanel/use-tab-debugger.test.ts | 46 ++++++++++++ .../entrypoints/sidepanel/use-tab-debugger.ts | 45 +++++++++++- 4 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 apps/extension/entrypoints/sidepanel/use-tab-debugger.test.ts diff --git a/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts b/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts index 55ceb4880f..e3c4381d57 100644 --- a/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts +++ b/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it, vi } from 'vitest'; // Agent-chat-panel transitively imports the WXT '#imports' virtual module; stub it so the graph loads under vitest. // eslint-disable-next-line vitest/prefer-import-in-mock, jest/no-untyped-mock-factory vi.mock('#imports', () => ({ - browser: { runtime: { sendMessage: vi.fn() } }, + browser: { runtime: { sendMessage: vi.fn() }, tabs: { query: vi.fn() } }, storage: { getItem: vi.fn(), setItem: vi.fn() }, })); // eslint-disable-next-line import/first -import { formatSelectedTabSystemEnvironment } from './agent-chat-panel'; +import { + formatSelectedTabSystemEnvironment, + getSelectedInspectableTabId, +} from './agent-chat-panel'; describe('selected tab context formatting', () => { it('redacts URL query and hash data and escapes page-controlled title text', () => { @@ -26,3 +29,67 @@ describe('selected tab context formatting', () => { expect(context).not.toContain('magic-link'); }); }); + +describe('inspectable tab selection resolution', () => { + const inspectableTabs = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + it('prefers a valid stored selection over the active tab', () => { + expect( + getSelectedInspectableTabId({ + activeTabId: 2, + inspectableTabs, + selectedTabId: 3, + }) + ).toBe(3); + }); + + it('prefers the active tab over the first inspectable tab', () => { + expect( + getSelectedInspectableTabId({ + activeTabId: 2, + inspectableTabs, + selectedTabId: undefined, + }) + ).toBe(2); + }); + + it('ignores an active tab that is not inspectable and falls back to first', () => { + expect( + getSelectedInspectableTabId({ + activeTabId: 99, + inspectableTabs, + selectedTabId: undefined, + }) + ).toBe(1); + }); + + it('falls back to the first inspectable tab when activeTabId is undefined', () => { + expect( + getSelectedInspectableTabId({ + activeTabId: undefined, + inspectableTabs, + selectedTabId: undefined, + }) + ).toBe(1); + }); + + it('returns undefined when the inspectable list is empty', () => { + expect( + getSelectedInspectableTabId({ + activeTabId: 2, + inspectableTabs: [], + selectedTabId: 1, + }) + ).toBeUndefined(); + }); + + it('ignores a stored selection that is no longer inspectable and uses active', () => { + expect( + getSelectedInspectableTabId({ + activeTabId: 2, + inspectableTabs, + selectedTabId: 99, + }) + ).toBe(2); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx b/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx index ed9504a291..0918df7e9a 100644 --- a/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx +++ b/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx @@ -1,5 +1,5 @@ /* eslint-disable import/max-dependencies, max-lines */ -import { storage } from '#imports'; +import { browser, storage } from '#imports'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { JSX, ReactNode } from 'react'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; @@ -46,7 +46,7 @@ import { AgentFooterControls } from './agent-footer-controls'; import { ContextDonut } from './context-donut'; import { runDangerousLlmTurn, runSafeLlmTurn } from './agent-turn-runners'; import { AUTO_COMPACT_RATIO, getContextRatio } from '@/src/shared/context-usage'; -import { useTabDebugger } from './use-tab-debugger'; +import { getActiveTabId, useTabDebugger } from './use-tab-debugger'; import { ConversationList } from './conversation-list'; import { ConversationTabs } from './conversation-tabs'; import { MessageComposer } from './message-composer'; @@ -71,10 +71,12 @@ interface ConversationRunState { readonly token: number; } -const getSelectedInspectableTabId = ({ +export const getSelectedInspectableTabId = ({ + activeTabId, inspectableTabs, selectedTabId, }: { + readonly activeTabId?: number | undefined; readonly inspectableTabs: readonly { readonly id: number }[]; readonly selectedTabId: number | undefined; }): number | undefined => { @@ -82,6 +84,10 @@ const getSelectedInspectableTabId = ({ return selectedTabId; } + if (activeTabId !== undefined && inspectableTabs.some(tab => tab.id === activeTabId)) { + return activeTabId; + } + return inspectableTabs[0]?.id; }; @@ -128,7 +134,12 @@ export const AgentChatPanel = ({ const runStatesRef = useRef(new Map()); const runTokenRef = useRef(0); const [remoteMcpToolWarning, setRemoteMcpToolWarning] = useState(); - const { inspectableTabs, isLoadingTabs, tabDebuggerError } = useTabDebugger(); + const [pendingCreateDefaultConversationId, setPendingCreateDefaultConversationId] = useState< + string | undefined + >(); + const { activeTabId, inspectableTabs, isLoadingTabs, tabDebuggerError } = useTabDebugger(); + const inspectableTabsRef = useRef(inspectableTabs); + const isCreateDefaultInFlightRef = useRef(false); const { modelLoadError, modelOptions, refetchModels } = useGatewayModels({ auth, organizationId, @@ -136,9 +147,11 @@ export const AgentChatPanel = ({ const activeConversation = getActiveStoredConversation(conversationStore); const { events, id: activeConversationId, mode = defaultMode } = activeConversation; const selectedTabId = getSelectedInspectableTabId({ + activeTabId, inspectableTabs, selectedTabId: activeConversation.selectedTabId, }); + inspectableTabsRef.current = inspectableTabs; const model = activeConversation.model ?? modelOptions[0]?.id ?? ''; const selectedModel = useMemo( () => modelOptions.find(option => option.id === model), @@ -327,7 +340,16 @@ export const AgentChatPanel = ({ }, [inspectableTabs, isLoadingTabs]); useEffect(() => { + if (!isConversationStoreLoaded || inspectableTabs.length === 0) { + return; + } + + if (pendingCreateDefaultConversationId === activeConversationId) { + return; + } + const nextSelectedTabId = getSelectedInspectableTabId({ + activeTabId, inspectableTabs, selectedTabId: activeConversation.selectedTabId, }); @@ -344,7 +366,10 @@ export const AgentChatPanel = ({ }, [ activeConversation.selectedTabId, activeConversationId, + activeTabId, inspectableTabs, + isConversationStoreLoaded, + pendingCreateDefaultConversationId, setConversationStore, ]); @@ -433,6 +458,7 @@ export const AgentChatPanel = ({ const runThinkingOptions = runSelectedModel?.variants ?? []; const runThinkingEffort = conversation.thinkingEffort ?? runThinkingOptions[0] ?? ''; const runSelectedTabId = getSelectedInspectableTabId({ + activeTabId, inspectableTabs, selectedTabId: conversation.selectedTabId, }); @@ -564,6 +590,7 @@ export const AgentChatPanel = ({ const conversation = getActiveStoredConversation(conversationStoreRef.current); const conversationModel = conversation.model ?? modelOptions[0]?.id ?? ''; const conversationSelectedTabId = getSelectedInspectableTabId({ + activeTabId, inspectableTabs, selectedTabId: conversation.selectedTabId, }); @@ -592,14 +619,13 @@ export const AgentChatPanel = ({ }; const createConversation = (): void => { - if (!isConversationStoreLoaded) { + if (!isConversationStoreLoaded || isCreateDefaultInFlightRef.current) { return; } const settings = { mode, model, - ...(selectedTabId === undefined ? {} : { selectedTabId }), thinkingEffort, }; @@ -609,6 +635,39 @@ export const AgentChatPanel = ({ settings ); setConversationStore(conversationStoreRef.current); + + const newConversationId = conversationStoreRef.current.activeConversationId; + + isCreateDefaultInFlightRef.current = true; + setPendingCreateDefaultConversationId(newConversationId); + + void (async (): Promise => { + try { + const freshActiveTabId = await getActiveTabId(browser.tabs); + const latestTabs = inspectableTabsRef.current; + + if (freshActiveTabId !== undefined && latestTabs.some(tab => tab.id === freshActiveTabId)) { + setConversationStore(currentStore => { + const conversation = currentStore.conversations.find( + item => item.id === newConversationId + ); + + if (conversation === undefined || conversation.selectedTabId !== undefined) { + return currentStore; + } + + return updateStoredConversationSettings(currentStore, newConversationId, { + selectedTabId: freshActiveTabId, + }); + }); + } + } finally { + isCreateDefaultInFlightRef.current = false; + setPendingCreateDefaultConversationId(current => + current === newConversationId ? undefined : current + ); + } + })(); }; const selectConversation = (conversationId: string): void => { diff --git a/apps/extension/entrypoints/sidepanel/use-tab-debugger.test.ts b/apps/extension/entrypoints/sidepanel/use-tab-debugger.test.ts new file mode 100644 index 0000000000..3f61fa8bab --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/use-tab-debugger.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Use-tab-debugger transitively imports the WXT '#imports' virtual module; stub it so the graph loads under vitest. +// eslint-disable-next-line vitest/prefer-import-in-mock, jest/no-untyped-mock-factory +vi.mock('#imports', () => ({ + browser: { runtime: { sendMessage: vi.fn() }, tabs: { query: vi.fn() } }, + storage: { getItem: vi.fn(), setItem: vi.fn() }, +})); + +// eslint-disable-next-line import/first +import { getActiveTabId } from './use-tab-debugger'; + +describe('active tab id lookup', () => { + it('returns the active tab id when the query yields a numeric id', async () => { + const tabsApi = { + query: vi.fn().mockResolvedValue([{ id: 42 }]), + }; + + await expect(getActiveTabId(tabsApi)).resolves.toBe(42); + expect(tabsApi.query).toHaveBeenCalledWith({ active: true, currentWindow: true }); + }); + + it('returns undefined when the query yields no active tab', async () => { + const tabsApi = { + query: vi.fn().mockResolvedValue([]), + }; + + await expect(getActiveTabId(tabsApi)).resolves.toBeUndefined(); + }); + + it('returns undefined when the active tab id is not a number', async () => { + const tabsApi = { + query: vi.fn().mockResolvedValue([{ id: 'not-a-number' }]), + }; + + await expect(getActiveTabId(tabsApi)).resolves.toBeUndefined(); + }); + + it('returns undefined when the query rejects', async () => { + const tabsApi = { + query: vi.fn().mockRejectedValue(new Error('tabs.query failed')), + }; + + await expect(getActiveTabId(tabsApi)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts b/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts index 206c461fa5..72adc92caf 100644 --- a/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts +++ b/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts @@ -25,9 +25,44 @@ const sendTabDebuggerRequest = async ( return response; }; +interface TabsQueryApi { + readonly query: (queryInfo: { + readonly active: true; + readonly currentWindow: true; + }) => Promise; +} + +/** + * Best-effort active-tab lookup for the side panel's own window. + * Never throws: query failures and invalid ids degrade to `undefined`. + */ +export const getActiveTabId = async (tabsApi: TabsQueryApi): Promise => { + try { + const tabs: unknown = await tabsApi.query({ active: true, currentWindow: true }); + + if (!Array.isArray(tabs) || tabs.length === 0) { + return undefined; + } + + const [firstTabCandidate] = tabs as unknown[]; + const firstTab: unknown = firstTabCandidate; + + if (typeof firstTab !== 'object' || firstTab === null || !('id' in firstTab)) { + return undefined; + } + + const { id } = firstTab; + + return typeof id === 'number' ? id : undefined; + } catch { + return undefined; + } +}; + let rememberedSelectedTabId: number | null = null; export const useTabDebugger = (): { + readonly activeTabId: number | undefined; readonly inspectableTabs: InspectableTab[]; readonly isLoadingTabs: boolean; readonly loadInspectableTabs: (options?: { readonly showLoading?: boolean }) => Promise; @@ -42,7 +77,7 @@ export const useTabDebugger = (): { ); const hasLoadedTabsRef = useRef(false); const { - data: tabs, + data, error: tabsError, isError, isLoading, @@ -59,12 +94,17 @@ export const useTabDebugger = (): { throw new Error('Extension background returned the wrong response.'); } - return response.tabs; + const activeTabId = await getActiveTabId(browser.tabs); + + return { activeTabId, tabs: response.tabs }; }, queryKey: getTabListQueryKey(), refetchInterval: 2000, }); + const tabs = data?.tabs; + const activeTabId = data?.activeTabId; + useEffect(() => { const nextState = deriveInspectableTabState({ currentSelectedTabId: selectedTabId, @@ -113,6 +153,7 @@ export const useTabDebugger = (): { }, [inspectableTabs]); return { + activeTabId, inspectableTabs, isLoadingTabs: isLoading, loadInspectableTabs, From 6e61c31b4e01af501e21b3ebffae83a377ddcdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 18:07:55 +0200 Subject: [PATCH 2/4] test(extension): Chrome E2E for active-tab default and non-inheritance Probe established that locator.click() on New conversation leaves the activated content tab active, so tests use the UI-click recipe. Cover the create-path active default with three-tab legs whose activation target always differs from both the frozen and first-listed labels, manual-pick persistence across poll cycles, single-tab fallback, the hydration-race and empty-list wipe guards, and rewrite the inheritance test for the R3 contract. --- .../tests/e2e/conversation-rendering.test.ts | 98 ++--- .../tests/e2e/tab-selection-default.test.ts | 352 ++++++++++++++++++ .../tests/e2e/tab-selection-e2e-helpers.ts | 204 ++++++++++ 3 files changed, 592 insertions(+), 62 deletions(-) create mode 100644 apps/extension/tests/e2e/tab-selection-default.test.ts create mode 100644 apps/extension/tests/e2e/tab-selection-e2e-helpers.ts diff --git a/apps/extension/tests/e2e/conversation-rendering.test.ts b/apps/extension/tests/e2e/conversation-rendering.test.ts index 4abb770de6..6880f4c7bb 100644 --- a/apps/extension/tests/e2e/conversation-rendering.test.ts +++ b/apps/extension/tests/e2e/conversation-rendering.test.ts @@ -1,6 +1,5 @@ -/* eslint-disable import/no-nodejs-modules */ +/* eslint-disable import/no-nodejs-modules, jest/no-conditional-in-test */ import { expect, test } from '@playwright/test'; -import type { Page } from '@playwright/test'; import { rm } from 'node:fs/promises'; import { mockKiloApi, readSidePanelScrollState } from './kilo-api-fixture'; import { @@ -9,60 +8,18 @@ import { setExtensionStorage, startFixtureServer, } from './extension-context-fixture'; - -const getSelectedTargetTabLabel = (sidePanel: Page): Promise => - sidePanel.locator('select[aria-label="Target tab"]').evaluate(element => { - if (!(element instanceof HTMLSelectElement)) { - throw new Error('Target tab select was not found.'); - } - - return element.selectedOptions[0]?.textContent?.trim() ?? ''; - }); - -const delayConversationStoreHydration = (sidePanel: Page): Promise => - sidePanel.addInitScript(() => { - const pageGlobal = globalThis as typeof globalThis & { - __resolveKiloConversationStoreHydration?: () => void; - browser?: { - storage?: { - local?: { - get: (keys: unknown) => Promise; - }; - }; - }; - }; - const storageLocal = pageGlobal.browser?.storage?.local; - - if (storageLocal === undefined) { - return; - } - - const originalGet = storageLocal.get.bind(storageLocal); - let isDelayed = false; - - storageLocal.get = async keys => { - if (!isDelayed && JSON.stringify(keys).includes('kiloAgentConversations')) { - isDelayed = true; - const { promise, resolve } = Promise.withResolvers(); - - pageGlobal.__resolveKiloConversationStoreHydration = resolve; - await promise; - } - - return originalGet(keys); - }; - }); - -const releaseConversationStoreHydration = (sidePanel: Page): Promise => - sidePanel.evaluate(() => { - ( - globalThis as typeof globalThis & { - __resolveKiloConversationStoreHydration?: () => void; - } - ).__resolveKiloConversationStoreHydration?.(); - }); - -test('new conversation inherits the selected target tab', async () => { +import { + createNewConversation, + delayConversationStoreHydration, + getSelectedTargetTabLabel, + getTargetTabOptionCount, + releaseConversationStoreHydration, + requireTwoOptionLabels, +} from './tab-selection-e2e-helpers'; + +test('new conversation does not inherit the selected target tab', async () => { + // R3: create samples the active tab fresh; it must not copy conv1's manual pick. + // Probe (A9) established locator.click() leaves the content tab active. const firstFixture = await startFixtureServer({ title: 'First target tab' }); const secondFixture = await startFixtureServer({ title: 'Second target tab' }); const { context, extensionId, userDataDir } = await launchExtensionContext(); @@ -74,21 +31,38 @@ test('new conversation inherits the selected target tab', async () => { await firstPage.goto(firstFixture.url); const secondPage = await context.newPage(); await secondPage.goto(secondFixture.url); - await firstPage.bringToFront(); const sidePanel = await context.newPage(); await sidePanel.goto(`chrome-extension://${extensionId}/sidepanel.html`); await seedExtensionAuth(sidePanel); await sidePanel.reload(); - const targetTabSelect = sidePanel.getByLabel('Target tab'); + await expect.poll(() => getTargetTabOptionCount(sidePanel), { timeout: 10_000 }).toBe(2); - await targetTabSelect.selectOption({ label: 'Second target tab' }); - await expect.poll(() => getSelectedTargetTabLabel(sidePanel)).toBe('Second target tab'); + const { firstListed, otherLabel: nonFirstListed } = await requireTwoOptionLabels(sidePanel); - await sidePanel.getByLabel('New conversation').click(); + // Conv1 picks first-listed so inheritance would differ from activating non-first-listed. + await sidePanel.getByLabel('Target tab').selectOption({ label: firstListed }); + await expect.poll(() => getSelectedTargetTabLabel(sidePanel)).toBe(firstListed); + + const pagesByLabel = new Map([ + ['First target tab', firstPage], + ['Second target tab', secondPage], + ]); + const activatedPage = pagesByLabel.get(nonFirstListed); + + if (activatedPage === undefined) { + throw new Error(`No page for label ${nonFirstListed}`); + } + + await activatedPage.bringToFront(); + await createNewConversation(sidePanel); - await expect.poll(() => getSelectedTargetTabLabel(sidePanel)).toBe('Second target tab'); + // New conversation defaults to the ACTIVATED tab — not conv1's pick (old inheritance). + await expect + .poll(() => getSelectedTargetTabLabel(sidePanel), { timeout: 10_000 }) + .toBe(nonFirstListed); + expect(nonFirstListed).not.toBe(firstListed); } finally { await context.close(); await firstFixture.close(); diff --git a/apps/extension/tests/e2e/tab-selection-default.test.ts b/apps/extension/tests/e2e/tab-selection-default.test.ts new file mode 100644 index 0000000000..92a3b154f5 --- /dev/null +++ b/apps/extension/tests/e2e/tab-selection-default.test.ts @@ -0,0 +1,352 @@ +/* eslint-disable import/no-nodejs-modules, jest/no-conditional-in-test, max-lines */ +import { expect, test } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; +import { rm } from 'node:fs/promises'; +import { mockKiloApi } from './kilo-api-fixture'; +import { + launchExtensionContext, + seedExtensionAuth, + startFixtureServer, +} from './extension-context-fixture'; +import { + createNewConversation, + delayConversationStoreHydration, + getActiveConversationSelectedTabId, + getSelectedTargetTabLabel, + getTargetTabOptionCount, + getTargetTabOptionLabels, + releaseConversationStoreHydrationUntilReady, + requireSelectedTargetTabId, + requireTwoOptionLabels, + waitForActiveConversationSelectedTabId, +} from './tab-selection-e2e-helpers'; + +const openAuthedSidePanel = async (context: BrowserContext, extensionId: string): Promise => { + const sidePanel = await context.newPage(); + await sidePanel.goto(`chrome-extension://${extensionId}/sidepanel.html`); + await seedExtensionAuth(sidePanel); + await sidePanel.reload(); + await expect(sidePanel.getByLabel('New conversation')).toBeEnabled(); + + return sidePanel; +}; + +const waitForSettledTargetLabel = async (sidePanel: Page): Promise => { + await expect.poll(() => getSelectedTargetTabLabel(sidePanel), { timeout: 10_000 }).not.toBe(''); + + return getSelectedTargetTabLabel(sidePanel); +}; + +/** + * Freeze first conversation on `freezeTitle` (only that fixture tab is open), + * open two candidate tabs, then activate the candidate that differs from both + * the frozen label and the runtime first-listed label. Three-tab geometry + * always yields a non-vacuous activation target for every getTargets() order. + * Fresh browser context per call so legs are independent. + */ +const runCreatePathActiveDefaultLeg = async ({ + candidateTitles, + freezeTitle, +}: { + candidateTitles: readonly [string, string]; + freezeTitle: string; +}): Promise => { + const freezeFixture = await startFixtureServer({ title: freezeTitle }); + const candidateFixtures = await Promise.all( + candidateTitles.map(title => startFixtureServer({ title })) + ); + const { context, extensionId, userDataDir } = await launchExtensionContext(); + + try { + await mockKiloApi(context); + + const freezePage = await context.newPage(); + await freezePage.goto(freezeFixture.url); + + const sidePanel = await openAuthedSidePanel(context, extensionId); + const frozenFirstLabel = await waitForSettledTargetLabel(sidePanel); + const optionLabelsBefore = await getTargetTabOptionLabels(sidePanel); + const [firstListedBefore] = optionLabelsBefore; + + expect(frozenFirstLabel).toBe(freezeTitle); + expect(firstListedBefore).toBe(freezeTitle); + + const pagesByTitle = new Map([[freezeTitle, freezePage]]); + const candidatePages = await Promise.all( + candidateTitles.map(async (title, index) => { + const page = await context.newPage(); + await page.goto(candidateFixtures[index]!.url); + + return [title, page] as const; + }) + ); + + for (const [title, page] of candidatePages) { + pagesByTitle.set(title, page); + } + + await expect.poll(() => getTargetTabOptionCount(sidePanel), { timeout: 10_000 }).toBe(3); + + const optionLabels = await getTargetTabOptionLabels(sidePanel); + const [firstListed] = optionLabels; + expect(firstListed).toBeDefined(); + expect(optionLabels).toEqual( + expect.arrayContaining([freezeTitle, candidateTitles[0], candidateTitles[1]]) + ); + + // Three tabs: at least one label differs from both frozen and first-listed, so + // Both inheritance and first-listed regression guards always have teeth. + const activateTitle = candidateTitles.find( + title => title !== frozenFirstLabel && title !== firstListed + ); + + if (activateTitle === undefined) { + throw new Error( + `No non-vacuous activation target among ${candidateTitles.join(', ')} ` + + `(frozen=${frozenFirstLabel}, firstListed=${firstListed}).` + ); + } + + const activatePage = pagesByTitle.get(activateTitle); + + if (activatePage === undefined) { + throw new Error(`Missing page for activation target ${activateTitle}`); + } + + await activatePage.bringToFront(); + await createNewConversation(sidePanel); + + await expect + .poll(() => getSelectedTargetTabLabel(sidePanel), { timeout: 10_000 }) + .toBe(activateTitle); + + const seededLabel = await getSelectedTargetTabLabel(sidePanel); + expect(seededLabel).toBe(activateTitle); + expect(seededLabel).not.toBe(frozenFirstLabel); + expect(seededLabel).not.toBe(firstListed); + } finally { + await context.close(); + await freezeFixture.close(); + await Promise.all(candidateFixtures.map(fixture => fixture.close())); + await rm(userDataDir, { force: true, recursive: true }); + } +}; + +test('create-path defaults to the activated content tab for each freeze fixture', async () => { + // Leg 1: freeze Alpha; candidates Beta/Gamma — activate the non-vacuous one. + await runCreatePathActiveDefaultLeg({ + candidateTitles: ['Default tab Beta', 'Default tab Gamma'], + freezeTitle: 'Default tab Alpha', + }); + // Leg 2: freeze Beta; candidates Alpha/Gamma (covers the other freeze fixture). + await runCreatePathActiveDefaultLeg({ + candidateTitles: ['Default tab Alpha', 'Default tab Gamma'], + freezeTitle: 'Default tab Beta', + }); +}); + +test('manual target-tab pick survives poll cycles', async () => { + const firstFixture = await startFixtureServer({ title: 'Persist tab Alpha' }); + const secondFixture = await startFixtureServer({ title: 'Persist tab Beta' }); + const { context, extensionId, userDataDir } = await launchExtensionContext(); + + try { + await mockKiloApi(context); + + const pageAlpha = await context.newPage(); + await pageAlpha.goto(firstFixture.url); + const pageBeta = await context.newPage(); + await pageBeta.goto(secondFixture.url); + + const sidePanel = await openAuthedSidePanel(context, extensionId); + await waitForSettledTargetLabel(sidePanel); + + const { otherLabel } = await requireTwoOptionLabels(sidePanel); + + await pageAlpha.bringToFront(); + await createNewConversation(sidePanel); + await waitForSettledTargetLabel(sidePanel); + + await sidePanel.getByLabel('Target tab').selectOption({ label: otherLabel }); + await expect.poll(() => getSelectedTargetTabLabel(sidePanel)).toBe(otherLabel); + + // R2: selection stays across at least one full 2s poll cycle (wall time >2s). + const startedAt = Date.now(); + const labelsSeen: string[] = []; + await expect + .poll(async () => { + labelsSeen.push(await getSelectedTargetTabLabel(sidePanel)); + + return Date.now() - startedAt; + }) + .toBeGreaterThan(2000); + expect(labelsSeen.length).toBeGreaterThan(0); + expect(new Set(labelsSeen)).toEqual(new Set([otherLabel])); + } finally { + await context.close(); + await firstFixture.close(); + await secondFixture.close(); + await rm(userDataDir, { force: true, recursive: true }); + } +}); + +test('single fixture tab is the first-conversation default', async () => { + const fixture = await startFixtureServer({ title: 'Only fixture tab' }); + const { context, extensionId, userDataDir } = await launchExtensionContext(); + + try { + await mockKiloApi(context); + + const page = await context.newPage(); + await page.goto(fixture.url); + + const sidePanel = await openAuthedSidePanel(context, extensionId); + + // Harness panel tab is active/non-inspectable; fallback is the sole fixture tab. + await expect + .poll(() => getSelectedTargetTabLabel(sidePanel), { timeout: 10_000 }) + .toBe('Only fixture tab'); + } finally { + await context.close(); + await fixture.close(); + await rm(userDataDir, { force: true, recursive: true }); + } +}); + +test('hydration race preserves a manual pick and freeze survives reload', async () => { + test.setTimeout(60_000); + + const firstFixture = await startFixtureServer({ title: 'Hydration tab Alpha' }); + const secondFixture = await startFixtureServer({ title: 'Hydration tab Beta' }); + const { context, extensionId, userDataDir } = await launchExtensionContext(); + + try { + await mockKiloApi(context); + + const pageAlpha = await context.newPage(); + await pageAlpha.goto(firstFixture.url); + const pageBeta = await context.newPage(); + await pageBeta.goto(secondFixture.url); + + const sidePanel = await openAuthedSidePanel(context, extensionId); + await waitForSettledTargetLabel(sidePanel); + + const { otherLabel: pickedLabel } = await requireTwoOptionLabels(sidePanel); + + await sidePanel.getByLabel('Target tab').selectOption({ label: pickedLabel }); + await expect.poll(() => getSelectedTargetTabLabel(sidePanel)).toBe(pickedLabel); + + const pickedTabId = await requireSelectedTargetTabId(sidePanel); + + // PRECONDITION: pick must be persisted before reload/hydration delay. + await waitForActiveConversationSelectedTabId(sidePanel, pickedTabId); + + await delayConversationStoreHydration(sidePanel); + await sidePanel.reload(); + + // Avoid storage reads while hydration is held — they match the delay filter and + // Consume the one-shot hold (deadlock with app hydration). Write-gate sanity: + // Storage writes are gated on isLoaded and are proven post-release below. + await releaseConversationStoreHydrationUntilReady(sidePanel); + + // A7(b) POST-RELEASE: selector and storage still show the picked tab. + await expect + .poll(() => getSelectedTargetTabLabel(sidePanel), { timeout: 10_000 }) + .toBe(pickedLabel); + await expect + .poll(() => getActiveConversationSelectedTabId(sidePanel), { timeout: 10_000 }) + .toBe(pickedTabId); + + // Freeze leg: reload with a DIFFERENT content tab active. Init script re-arms hold. + const pagesByLabel = new Map([ + ['Hydration tab Alpha', pageAlpha], + ['Hydration tab Beta', pageBeta], + ]); + const otherLabel = + pickedLabel === 'Hydration tab Alpha' ? 'Hydration tab Beta' : 'Hydration tab Alpha'; + const otherPage = pagesByLabel.get(otherLabel); + + if (otherPage === undefined) { + throw new Error(`Missing page for ${otherLabel}`); + } + + await otherPage.bringToFront(); + await sidePanel.reload(); + await releaseConversationStoreHydrationUntilReady(sidePanel); + + await expect + .poll(() => getSelectedTargetTabLabel(sidePanel), { timeout: 10_000 }) + .toBe(pickedLabel); + await expect + .poll(() => getActiveConversationSelectedTabId(sidePanel), { timeout: 10_000 }) + .toBe(pickedTabId); + } finally { + await context.close(); + await firstFixture.close(); + await secondFixture.close(); + await rm(userDataDir, { force: true, recursive: true }); + } +}); + +test('empty inspectable list does not wipe a stored selectedTabId', async () => { + test.setTimeout(45_000); + + const firstFixture = await startFixtureServer({ title: 'Wipe-guard tab Alpha' }); + const secondFixture = await startFixtureServer({ title: 'Wipe-guard tab Beta' }); + const { context, extensionId, userDataDir } = await launchExtensionContext(); + + try { + await mockKiloApi(context); + + const pageAlpha = await context.newPage(); + await pageAlpha.goto(firstFixture.url); + const pageBeta = await context.newPage(); + await pageBeta.goto(secondFixture.url); + + const sidePanel = await openAuthedSidePanel(context, extensionId); + await waitForSettledTargetLabel(sidePanel); + + const { otherLabel: pickedLabel } = await requireTwoOptionLabels(sidePanel); + + await sidePanel.getByLabel('Target tab').selectOption({ label: pickedLabel }); + await expect.poll(() => getSelectedTargetTabLabel(sidePanel)).toBe(pickedLabel); + + const pickedTabId = await requireSelectedTargetTabId(sidePanel); + + await waitForActiveConversationSelectedTabId(sidePanel, pickedTabId); + + await pageAlpha.close(); + await pageBeta.close(); + + // Reach empty UI first, then hold across >4s (two poll cycles) via storage polling. + await expect + .poll(() => getSelectedTargetTabLabel(sidePanel), { timeout: 10_000 }) + .toBe('No tab selected'); + + const startedAt = Date.now(); + const labelsSeen: string[] = []; + const storedIdsSeen: (number | undefined)[] = []; + await expect + .poll( + async () => { + labelsSeen.push(await getSelectedTargetTabLabel(sidePanel)); + storedIdsSeen.push(await getActiveConversationSelectedTabId(sidePanel)); + + return Date.now() - startedAt; + }, + { timeout: 12_000 } + ) + .toBeGreaterThan(4000); + + // A7(a): stored pick must survive empty list; selector shows empty state. + expect(new Set(labelsSeen)).toEqual(new Set(['No tab selected'])); + expect(new Set(storedIdsSeen)).toEqual(new Set([pickedTabId])); + await expect.poll(() => getSelectedTargetTabLabel(sidePanel)).toBe('No tab selected'); + await expect.poll(() => getActiveConversationSelectedTabId(sidePanel)).toBe(pickedTabId); + } finally { + await context.close(); + await firstFixture.close(); + await secondFixture.close(); + await rm(userDataDir, { force: true, recursive: true }); + } +}); diff --git a/apps/extension/tests/e2e/tab-selection-e2e-helpers.ts b/apps/extension/tests/e2e/tab-selection-e2e-helpers.ts new file mode 100644 index 0000000000..ba0dea7712 --- /dev/null +++ b/apps/extension/tests/e2e/tab-selection-e2e-helpers.ts @@ -0,0 +1,204 @@ +/* eslint-disable import/no-nodejs-modules, promise/avoid-new */ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +export const getSelectedTargetTabLabel = (sidePanel: Page): Promise => + sidePanel.locator('select[aria-label="Target tab"]').evaluate(element => { + if (!(element instanceof HTMLSelectElement)) { + throw new Error('Target tab select was not found.'); + } + + return element.selectedOptions[0]?.textContent?.trim() ?? ''; + }); + +export const getTargetTabOptionLabels = (sidePanel: Page): Promise => + sidePanel.locator('select[aria-label="Target tab"]').evaluate(element => { + if (!(element instanceof HTMLSelectElement)) { + throw new Error('Target tab select was not found.'); + } + + return [...element.options] + .map(option => option.textContent?.trim() ?? '') + .filter(label => label !== '' && label !== 'No tab selected'); + }); + +export const getTargetTabOptionCount = async (sidePanel: Page): Promise => { + const labels = await getTargetTabOptionLabels(sidePanel); + + return labels.length; +}; + +export const getSelectedTargetTabId = (sidePanel: Page): Promise => + sidePanel.locator('select[aria-label="Target tab"]').evaluate(element => { + if (!(element instanceof HTMLSelectElement)) { + throw new Error('Target tab select was not found.'); + } + + if (element.value === '') { + return; + } + + const value = Number(element.value); + + return Number.isInteger(value) ? value : undefined; + }); + +export const getExtensionStorage = (page: Page, keys: string[]): Promise> => + page.evaluate(storageKeys => { + const storage = ( + globalThis as typeof globalThis & { + chrome?: { + storage?: { + local?: { + get: (keys: string[]) => Promise>; + }; + }; + }; + } + ).chrome?.storage?.local; + + if (storage === undefined) { + throw new Error('Extension runtime storage is unavailable.'); + } + + return storage.get(storageKeys); + }, keys); + +// eslint-disable-next-line typescript-eslint/consistent-type-definitions -- AGENTS.md prefers type +type StoredConversationShape = { + readonly id?: string; + readonly selectedTabId?: number; +}; + +// eslint-disable-next-line typescript-eslint/consistent-type-definitions -- AGENTS.md prefers type +type StoredConversationsShape = { + readonly activeConversationId?: string; + readonly conversations?: StoredConversationShape[]; +}; + +const isStoredConversationsShape = (value: unknown): value is StoredConversationsShape => { + if (value === null || typeof value !== 'object') { + return false; + } + + return 'conversations' in value || 'activeConversationId' in value; +}; + +export const getActiveConversationSelectedTabId = async ( + page: Page +): Promise => { + const storage = await getExtensionStorage(page, ['kiloAgentConversations']); + const storeValue = storage['kiloAgentConversations']; + + if (!isStoredConversationsShape(storeValue) || storeValue.conversations === undefined) { + return; + } + + const activeId = storeValue.activeConversationId; + const conversation = storeValue.conversations.find(item => item.id === activeId); + + return conversation?.selectedTabId; +}; + +export const waitForActiveConversationSelectedTabId = async ( + page: Page, + expectedTabId: number +): Promise => { + await expect + .poll(() => getActiveConversationSelectedTabId(page), { timeout: 10_000 }) + .toBe(expectedTabId); +}; + +/** + * Probe (A9) established that Playwright locator.click() on "New conversation" + * does not re-activate the panel tab, so the create-time active-tab sample sees + * the content tab that was bringToFront()'d. UI click is the production recipe. + */ +export const createNewConversation = async (sidePanel: Page): Promise => { + await sidePanel.getByLabel('New conversation').click(); +}; + +export const delayConversationStoreHydration = (sidePanel: Page): Promise => + sidePanel.addInitScript(() => { + const pageGlobal = globalThis as typeof globalThis & { + __resolveKiloConversationStoreHydration?: () => void; + browser?: { + storage?: { + local?: { + get: (keys: unknown) => Promise; + }; + }; + }; + }; + const storageLocal = pageGlobal.browser?.storage?.local; + + if (storageLocal === undefined) { + return; + } + + const originalGet = storageLocal.get.bind(storageLocal); + let isDelayed = false; + + storageLocal.get = async keys => { + if (!isDelayed && JSON.stringify(keys).includes('kiloAgentConversations')) { + isDelayed = true; + const { promise, resolve } = Promise.withResolvers(); + + pageGlobal.__resolveKiloConversationStoreHydration = resolve; + await promise; + } + + return originalGet(keys); + }; + }); + +export const releaseConversationStoreHydration = (sidePanel: Page): Promise => + sidePanel.evaluate(() => { + ( + globalThis as typeof globalThis & { + __resolveKiloConversationStoreHydration?: () => void; + } + ).__resolveKiloConversationStoreHydration?.(); + }); + +/** Release hydration hold; re-try until the panel is interactive (init script re-arms each load). */ +export const releaseConversationStoreHydrationUntilReady = async ( + sidePanel: Page +): Promise => { + await expect + .poll( + async () => { + await releaseConversationStoreHydration(sidePanel); + + return sidePanel.getByLabel('New conversation').isEnabled(); + }, + { timeout: 15_000 } + ) + .toBe(true); +}; + +export const requireTwoOptionLabels = async ( + sidePanel: Page +): Promise<{ firstListed: string; otherLabel: string; optionLabels: string[] }> => { + await expect.poll(() => getTargetTabOptionCount(sidePanel), { timeout: 10_000 }).toBe(2); + + const optionLabels = await getTargetTabOptionLabels(sidePanel); + const [firstListed, ...rest] = optionLabels; + const otherLabel = rest.find(label => label !== firstListed); + + if (firstListed === undefined || otherLabel === undefined) { + throw new Error('Expected two distinct inspectable tab options.'); + } + + return { firstListed, optionLabels, otherLabel }; +}; + +export const requireSelectedTargetTabId = async (sidePanel: Page): Promise => { + const tabId = await getSelectedTargetTabId(sidePanel); + + if (tabId === undefined) { + throw new Error('Selected target tab id was missing from the select value.'); + } + + return tabId; +}; From e3482d8e00ba8d66960e85fbd78d26facc76f480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 18:39:09 +0200 Subject: [PATCH 3/4] test(extension): Firefox E2E non-inheritance for target tab default Rewrite the Firefox mirror of the inverted inheritance scenario under R3 and rename it for Chrome parity. The A9 probe confirmed that Selenium's switchTo() re-activates the panel tab, so the create-time active-tab sample sees the non-inspectable panel: the scenario asserts the deterministic first-listed default instead of the active-tab default (harness-limited), with first-listed read dynamically. --- .../tests/e2e/firefox-selenium-e2e.ts | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/extension/tests/e2e/firefox-selenium-e2e.ts b/apps/extension/tests/e2e/firefox-selenium-e2e.ts index 00e4cbe586..3442b235b7 100644 --- a/apps/extension/tests/e2e/firefox-selenium-e2e.ts +++ b/apps/extension/tests/e2e/firefox-selenium-e2e.ts @@ -23,7 +23,7 @@ const waitMs = 15_000; const chromeWorkflowNames = [ 'conversation automatically continues through another eval request', - 'new conversation inherits the selected target tab', + 'new conversation does not inherit the selected target tab', 'conversation tabs can run in parallel', 'conversation tabs persist across side panel reloads', 'closing a conversation removes only that tab', @@ -945,18 +945,46 @@ const scenarios: FirefoxScenario[] = [ ), }, { - name: 'new conversation inherits the selected target tab', + // R3 non-inheritance without activation (A9 probe FAIL / harness-limited). + name: 'new conversation does not inherit the selected target tab', run: context => withSession(context.api, {}, async session => { await session.openTargetPage('First target tab'); await session.openTargetPage('Second target tab'); await openAuthenticatedPanel(session); await waitForModel(session.driver); - await setSelectByText(session.driver, 'Target tab', 'Second target tab'); - await waitForTargetTab(session.driver, 'Second target tab'); + + await waitUntil( + session.driver, + async () => { + const optionsText = await getSelectOptionsText(session.driver, 'Target tab'); + + return ( + optionsText.includes('First target tab') && optionsText.includes('Second target tab') + ); + }, + 'Timed out waiting for both target tab options' + ); + + const optionsText = await getSelectOptionsText(session.driver, 'Target tab'); + const optionLabels = optionsText + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0 && line !== 'No tab selected'); + const [firstListed] = optionLabels; + const nonFirstListed = optionLabels.find(label => label !== firstListed); + + assert.ok(firstListed !== undefined, 'expected a first-listed target tab option'); + assert.ok(nonFirstListed !== undefined, 'expected a non-first-listed target tab option'); + + // Conv1 picks non-first-listed so inheritance would differ from first-listed. + await setSelectByText(session.driver, 'Target tab', nonFirstListed); + await waitForTargetTab(session.driver, nonFirstListed); await clickButtonByLabel(session.driver, 'New conversation'); - await waitForTargetTab(session.driver, 'Second target tab'); + // New conversation must not inherit conv1's pick; defaults to first-listed. + await waitForTargetTab(session.driver, firstListed); + assert.notEqual(firstListed, nonFirstListed); }), }, { From 94d09d9a400964c071d19d2b9556aa5b359dd07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 19:27:08 +0200 Subject: [PATCH 4/4] fix(extension): re-resolve selectedTabId at persist-effect apply time Prevent a stale fallback write from clobbering a manual target-tab pick when the create path leaves selectedTabId unset (R3) and React batches the effect update after the user pick. Found by CI e2e-chrome on the merged head (conversation controls stay tied to the selected conversation). --- .../sidepanel/agent-chat-panel.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx b/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx index 0918df7e9a..76ee9a0929 100644 --- a/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx +++ b/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx @@ -358,11 +358,29 @@ export const AgentChatPanel = ({ return; } - setConversationStore(currentStore => - updateStoredConversationSettings(currentStore, activeConversationId, { - selectedTabId: nextSelectedTabId, - }) - ); + setConversationStore(currentStore => { + const currentConversation = currentStore.conversations.find( + item => item.id === activeConversationId + ); + + if (currentConversation === undefined) { + return currentStore; + } + + const applyTimeSelectedTabId = getSelectedInspectableTabId({ + activeTabId, + inspectableTabs, + selectedTabId: currentConversation.selectedTabId, + }); + + if (currentConversation.selectedTabId === applyTimeSelectedTabId) { + return currentStore; + } + + return updateStoredConversationSettings(currentStore, activeConversationId, { + selectedTabId: applyTimeSelectedTabId, + }); + }); }, [ activeConversation.selectedTabId, activeConversationId,