diff --git a/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts b/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts index e09f6b2e84..34d436affe 100644 --- a/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts +++ b/apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { LEGACY_CONVERSATION_GREETING } from '@/src/shared/agent-conversation-tabs'; // 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 @@ -101,6 +102,14 @@ describe('inspectable tab selection resolution', () => { }); }); +describe('transcript empty-state copy', () => { + it('uses the shared legacy greeting string as the empty-state hint', () => { + // ConversationList renders LEGACY_CONVERSATION_GREETING when items.length === 0; + // The constant is the single source for both migration strip and empty UI. + expect(LEGACY_CONVERSATION_GREETING).toBe('Pick a tab and ask Kilo to inspect it.'); + }); +}); + describe('system environment builder', () => { it('returns undefined without a selected tab even when memories exist', () => { expect( diff --git a/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx b/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx index 1d0896eead..d99a4525a5 100644 --- a/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx +++ b/apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx @@ -74,9 +74,7 @@ import { sanitizeTabContextText, sanitizeTabContextUrl } from '@/src/shared/tab- const apiBaseUrl = getKiloApiBaseUrl(); const fetchFromWindow = (input: string, init?: RequestInit): Promise => fetch(input, init); -const createDefaultConversationEvents = (): AgentConversationEvent[] => [ - createAssistantMessage('Pick a tab and ask Kilo to inspect it.'), -]; +const emptyDefaultConversationEvents = (): AgentConversationEvent[] => []; interface ConversationRunState { readonly abort: AbortController; @@ -146,7 +144,7 @@ export const AgentChatPanel = ({ }): JSX.Element => { const store = useStore(); const [conversationStore, setConversationStore, isConversationStoreLoaded] = - useStoredAgentConversations(createDefaultConversationEvents); + useStoredAgentConversations(emptyDefaultConversationEvents); const { memories } = useAgentMemories(); const runningConversationIds = useAtomValue(runningConversationIdsAtom); const setRunningConversationIds = useSetAtom(runningConversationIdsAtom); @@ -690,7 +688,7 @@ export const AgentChatPanel = ({ conversationStoreRef.current = createNextStoredConversation( conversationStoreRef.current, - createDefaultConversationEvents(), + emptyDefaultConversationEvents(), settings ); setConversationStore(conversationStoreRef.current); @@ -762,9 +760,36 @@ export const AgentChatPanel = ({ } abortConversationRun(conversationId); - setConversationStore(currentStore => - closeStoredConversationTab(currentStore, conversationId, createDefaultConversationEvents()) + const currentStore = conversationStoreRef.current; + const closedConversation = currentStore.conversations.find( + conversation => conversation.id === conversationId + ); + const wasEmpty = + closedConversation !== undefined && isStoredConversationEmpty(closedConversation); + const nextStore = closeStoredConversationTab( + currentStore, + conversationId, + emptyDefaultConversationEvents() ); + conversationStoreRef.current = nextStore; + setConversationStore(nextStore); + + // Evict outside the state updater (StrictMode may double-invoke updaters). + // Empty closed tabs are deleted: always free their atoms, including when ensureOpen + // Recreates a fallback with the same id so drafts do not survive onto the fresh tab. + // Non-empty closed tabs keep drafts for History reopen. + const idsToEvict = new Set(); + if (wasEmpty) { + idsToEvict.add(conversationId); + } + for (const conversation of currentStore.conversations) { + if (!nextStore.conversations.some(next => next.id === conversation.id)) { + idsToEvict.add(conversation.id); + } + } + for (const id of idsToEvict) { + evictConversationAtoms(id); + } }, [abortConversationRun, isConversationStoreLoaded, setConversationStore] ); @@ -784,7 +809,7 @@ export const AgentChatPanel = ({ abortConversationRun(conversationId); setConversationStore(currentStore => - deleteStoredConversation(currentStore, conversationId, createDefaultConversationEvents()) + deleteStoredConversation(currentStore, conversationId, emptyDefaultConversationEvents()) ); // Free per-conversation atoms; a deleted conversation can never be reopened. evictConversationAtoms(conversationId); diff --git a/apps/extension/entrypoints/sidepanel/auth-shell.tsx b/apps/extension/entrypoints/sidepanel/auth-shell.tsx index 0130149568..e9a1149159 100644 --- a/apps/extension/entrypoints/sidepanel/auth-shell.tsx +++ b/apps/extension/entrypoints/sidepanel/auth-shell.tsx @@ -123,7 +123,7 @@ const AnalyticsSettingsRow = ({ userEmail }: { userEmail: string | undefined }): aria-label="Share usage analytics" className={`relative mt-0.5 h-5 w-9 shrink-0 rounded-full border transition outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring ring-offset-2 ring-offset-surface-background disabled:cursor-not-allowed disabled:bg-surface-selected ${ state.checked - ? 'border-border-strong bg-surface-selected' + ? 'border-brand-primary bg-brand-primary' : 'border-border bg-surface-overlay' }`} disabled={!interactive} @@ -134,7 +134,7 @@ const AnalyticsSettingsRow = ({ userEmail }: { userEmail: string | undefined }):