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
9 changes: 9 additions & 0 deletions apps/extension/entrypoints/sidepanel/agent-chat-panel.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
41 changes: 33 additions & 8 deletions apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,7 @@ import { sanitizeTabContextText, sanitizeTabContextUrl } from '@/src/shared/tab-
const apiBaseUrl = getKiloApiBaseUrl();
const fetchFromWindow = (input: string, init?: RequestInit): Promise<Response> =>
fetch(input, init);
const createDefaultConversationEvents = (): AgentConversationEvent[] => [
createAssistantMessage('Pick a tab and ask Kilo to inspect it.'),
];
const emptyDefaultConversationEvents = (): AgentConversationEvent[] => [];

interface ConversationRunState {
readonly abort: AbortController;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -690,7 +688,7 @@ export const AgentChatPanel = ({

conversationStoreRef.current = createNextStoredConversation(
conversationStoreRef.current,
createDefaultConversationEvents(),
emptyDefaultConversationEvents(),
settings
);
setConversationStore(conversationStoreRef.current);
Expand Down Expand Up @@ -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<string>();
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]
);
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions apps/extension/entrypoints/sidepanel/auth-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -134,7 +134,7 @@ const AnalyticsSettingsRow = ({ userEmail }: { userEmail: string | undefined }):
<span
aria-hidden="true"
className={`absolute top-0.5 size-3.5 rounded-full transition ${
state.checked ? 'left-4 bg-foreground' : 'left-0.5 bg-foreground-muted'
state.checked ? 'left-4 bg-brand-primary-foreground' : 'left-0.5 bg-foreground-muted'
}`}
/>
</button>
Expand Down
4 changes: 4 additions & 0 deletions apps/extension/entrypoints/sidepanel/conversation-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CSSProperties, JSX } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { getConversationScrollKey } from '@/src/shared/agent-conversation';
import type { GroupedConversationItem } from '@/src/shared/agent-conversation';
import { LEGACY_CONVERSATION_GREETING } from '@/src/shared/agent-conversation-tabs';
import { AgentConversationItemView } from './agent-conversation-events';

const getConversationItemKey = (item: GroupedConversationItem): string =>
Expand Down Expand Up @@ -280,6 +281,9 @@ export const ConversationList = ({
className="agent-conversation-scrollbar h-full overflow-y-auto px-4 py-4"
ref={listRef}
>
{items.length === 0 ? (
<p className="type-body text-foreground-muted">{LEGACY_CONVERSATION_GREETING}</p>
) : null}
<div className="relative w-full" style={getListSpacerStyle(totalSize)}>
{virtualItems.map(virtualItem => {
const item = items[virtualItem.index];
Expand Down
110 changes: 56 additions & 54 deletions apps/extension/entrypoints/sidepanel/conversation-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,62 +24,64 @@ export const ConversationTabs = ({

return (
<div className="border-b border-border bg-surface-raised">
<div
aria-label="Conversation tabs"
className="agent-conversation-scrollbar flex min-w-0 items-center gap-1 overflow-x-auto px-2 py-2"
role="tablist"
>
{conversations.map(conversation => {
const title = getStoredConversationTitle(conversation);
const isActive = conversation.id === activeConversationId;
const isRunning =
runningConversationIds.includes(conversation.id) ||
compactingConversationIds.includes(conversation.id);
<div className="flex min-w-0 items-center gap-1 px-2 py-2">
<div
aria-label="Conversation tabs"
className="agent-conversation-scrollbar flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
role="tablist"
>
{conversations.map(conversation => {
const title = getStoredConversationTitle(conversation);
const isActive = conversation.id === activeConversationId;
const isRunning =
runningConversationIds.includes(conversation.id) ||
compactingConversationIds.includes(conversation.id);

return (
<div
className={
isActive
? 'flex h-8 max-w-44 shrink-0 items-center rounded-md border border-border-strong bg-surface-selected text-foreground'
: 'flex h-8 max-w-44 shrink-0 items-center rounded-md border border-border text-foreground-muted hover:border-border-strong hover:text-foreground'
}
key={conversation.id}
>
<button
aria-selected={isActive}
className="flex h-full min-w-0 items-center gap-1.5 px-2 text-left text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background disabled:cursor-not-allowed disabled:text-foreground-subtle"
disabled={isDisabled}
onClick={() => {
onSelectConversation(conversation.id);
}}
role="tab"
title={title}
type="button"
>
{isRunning ? (
<span
aria-hidden="true"
className="size-2 shrink-0 animate-pulse rounded-full bg-status-blue-400"
/>
) : null}
<span className="truncate">{title}</span>
</button>
<button
aria-label={`Close ${title}`}
className="mr-1 flex size-6 shrink-0 items-center justify-center rounded-sm text-foreground-muted outline-none transition hover:bg-surface-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background"
disabled={isDisabled}
onClick={() => {
onCloseConversation(conversation.id);
}}
type="button"
return (
<div
className={
isActive
? 'flex h-8 max-w-44 shrink-0 items-center rounded-md border border-border-strong bg-surface-selected text-foreground'
: 'flex h-8 max-w-44 shrink-0 items-center rounded-md border border-border text-foreground-muted hover:border-border-strong hover:text-foreground'
}
key={conversation.id}
>
<span aria-hidden="true" className="text-sm leading-none">
x
</span>
</button>
</div>
);
})}
<button
aria-selected={isActive}
className="flex h-full min-w-0 items-center gap-1.5 px-2 text-left text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background disabled:cursor-not-allowed disabled:text-foreground-subtle"
disabled={isDisabled}
onClick={() => {
onSelectConversation(conversation.id);
}}
role="tab"
title={title}
type="button"
>
{isRunning ? (
<span
aria-hidden="true"
className="size-2 shrink-0 animate-pulse rounded-full bg-status-blue-400"
/>
) : null}
<span className="truncate">{title}</span>
</button>
<button
aria-label={`Close ${title}`}
className="mr-1 flex size-6 shrink-0 items-center justify-center rounded-sm text-foreground-muted outline-none transition hover:bg-surface-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background"
disabled={isDisabled}
onClick={() => {
onCloseConversation(conversation.id);
}}
type="button"
>
<span aria-hidden="true" className="text-sm leading-none">
x
</span>
</button>
</div>
);
})}
</div>
<button
aria-label="New conversation"
className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay text-foreground-on-secondary outline-none transition hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background disabled:cursor-not-allowed disabled:text-foreground-subtle"
Expand Down
32 changes: 28 additions & 4 deletions apps/extension/entrypoints/sidepanel/pending-memory-save-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@ export const PendingMemorySaveCard = (): JSX.Element | null => {
const [note, setNote] = useState('');
const [isSaving, setIsSaving] = useState(false);
const lastDraftKeyRef = useRef<string | null>(null);
const noteTextareaRef = useRef<HTMLTextAreaElement>(null);
const focusedDraftKeyRef = useRef<string | null>(null);

useEffect(() => {
if (pendingDraft === undefined) {
lastDraftKeyRef.current = null;
focusedDraftKeyRef.current = null;
return;
}

Expand All @@ -63,6 +66,24 @@ export const PendingMemorySaveCard = (): JSX.Element | null => {
savedConfirmation,
});

const showsDraftForm =
pendingDraft !== undefined &&
(view.kind === 'draft' || view.kind === 'full' || view.kind === 'saveError');

useEffect(() => {
if (!showsDraftForm || pendingDraft === undefined) {
return;
}

const draftKey = `${pendingDraft.createdAt}:${pendingDraft.text}`;
if (focusedDraftKeyRef.current === draftKey) {
return;
}

focusedDraftKeyRef.current = draftKey;
noteTextareaRef.current?.focus();
}, [pendingDraft, showsDraftForm]);

if (view.kind === 'hidden') {
return null;
}
Expand Down Expand Up @@ -115,11 +136,13 @@ export const PendingMemorySaveCard = (): JSX.Element | null => {
const noteCount = deriveNoteCharacterCount(note);

return (
<section
<div
aria-label="Add to memory"
className="shrink-0 border-b border-border bg-surface-background px-3 py-3"
aria-modal="true"
className="fixed inset-0 z-[25] flex items-center justify-center bg-black/50 p-4"
role="dialog"
>
<div className="rounded-xl border border-border bg-surface-raised p-3">
<div className="w-full max-w-sm rounded-xl border border-border bg-surface-raised p-3 shadow-lg shadow-black/50">
{view.kind === 'confirmation' ? (
<div className="flex flex-col gap-3">
<p className="type-body text-foreground">{CONFIRMATION_MESSAGE}</p>
Expand Down Expand Up @@ -203,6 +226,7 @@ export const PendingMemorySaveCard = (): JSX.Element | null => {
onChange={event => {
setNote(event.target.value);
}}
ref={noteTextareaRef}
value={note}
/>
<p className="type-label text-right text-foreground-muted">
Expand Down Expand Up @@ -230,6 +254,6 @@ export const PendingMemorySaveCard = (): JSX.Element | null => {
</div>
) : null}
</div>
</section>
</div>
);
};
Loading
Loading