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
72 changes: 70 additions & 2 deletions apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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(),
Expand All @@ -14,7 +14,11 @@ vi.mock('#imports', () => ({
}));

// eslint-disable-next-line import/first
import { formatSelectedTabSystemEnvironment, formatSystemEnvironment } from './agent-chat-panel';
import {
formatSelectedTabSystemEnvironment,
formatSystemEnvironment,
getSelectedInspectableTabId,
} from './agent-chat-panel';

describe('selected tab context formatting', () => {
it('redacts URL query and hash data and escapes page-controlled title text', () => {
Expand All @@ -33,6 +37,70 @@ describe('selected tab context formatting', () => {
});
});

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);
});
});

describe('system environment builder', () => {
it('returns undefined without a selected tab even when memories exist', () => {
expect(
Expand Down
99 changes: 88 additions & 11 deletions apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -49,7 +49,7 @@ import { ContextDonut } from './context-donut';
import { runDangerousLlmTurn, runSafeLlmTurn } from './agent-turn-runners';
import { AUTO_COMPACT_RATIO, getContextRatio } from '@/src/shared/context-usage';
import { addSessionCost } from '@/src/shared/session-cost';
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';
Expand Down Expand Up @@ -78,17 +78,23 @@ 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 => {
if (selectedTabId !== undefined && inspectableTabs.some(tab => tab.id === selectedTabId)) {
return selectedTabId;
}

if (activeTabId !== undefined && inspectableTabs.some(tab => tab.id === activeTabId)) {
return activeTabId;
}

return inspectableTabs[0]?.id;
};

Expand Down Expand Up @@ -145,17 +151,24 @@ export const AgentChatPanel = ({
const runStatesRef = useRef(new Map<string, ConversationRunState>());
const runTokenRef = useRef(0);
const [remoteMcpToolWarning, setRemoteMcpToolWarning] = useState<string>();
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,
});
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),
Expand Down Expand Up @@ -348,7 +361,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,
});
Expand All @@ -357,15 +379,36 @@ 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,
activeTabId,
inspectableTabs,
isConversationStoreLoaded,
pendingCreateDefaultConversationId,
setConversationStore,
]);

Expand Down Expand Up @@ -454,6 +497,7 @@ export const AgentChatPanel = ({
const runThinkingOptions = runSelectedModel?.variants ?? [];
const runThinkingEffort = conversation.thinkingEffort ?? runThinkingOptions[0] ?? '';
const runSelectedTabId = getSelectedInspectableTabId({
activeTabId,
inspectableTabs,
selectedTabId: conversation.selectedTabId,
});
Expand Down Expand Up @@ -596,6 +640,7 @@ export const AgentChatPanel = ({
const conversation = getActiveStoredConversation(conversationStoreRef.current);
const conversationModel = conversation.model ?? modelOptions[0]?.id ?? '';
const conversationSelectedTabId = getSelectedInspectableTabId({
activeTabId,
inspectableTabs,
selectedTabId: conversation.selectedTabId,
});
Expand Down Expand Up @@ -624,14 +669,13 @@ export const AgentChatPanel = ({
};

const createConversation = (): void => {
if (!isConversationStoreLoaded) {
if (!isConversationStoreLoaded || isCreateDefaultInFlightRef.current) {
Comment thread
iscekic marked this conversation as resolved.
return;
}

const settings = {
mode,
model,
...(selectedTabId === undefined ? {} : { selectedTabId }),
thinkingEffort,
};

Expand All @@ -641,6 +685,39 @@ export const AgentChatPanel = ({
settings
);
setConversationStore(conversationStoreRef.current);

const newConversationId = conversationStoreRef.current.activeConversationId;

isCreateDefaultInFlightRef.current = true;
setPendingCreateDefaultConversationId(newConversationId);

void (async (): Promise<void> => {
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 => {
Expand Down
46 changes: 46 additions & 0 deletions apps/extension/entrypoints/sidepanel/use-tab-debugger.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading