From f459163bd5ee415e0077fa0c988f9f1b6ebb5cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:00:11 +0200 Subject: [PATCH 1/4] extension: add agent memory store and Add-to-memory context menu capture --- apps/extension/entrypoints/background.ts | 103 +++++++- .../sidepanel/latest-only-refresh.test.ts | 87 +++++++ .../sidepanel/latest-only-refresh.ts | 43 ++++ .../sidepanel/use-agent-memories.ts | 114 +++++++++ .../src/shared/agent-memories-storage.test.ts | 168 ++++++++++++ .../src/shared/agent-memories-storage.ts | 144 +++++++++++ .../src/shared/agent-memories.test.ts | 239 ++++++++++++++++++ apps/extension/src/shared/agent-memories.ts | 185 ++++++++++++++ apps/extension/src/shared/side-panel.test.ts | 146 ++++++++++- apps/extension/src/shared/side-panel.ts | 85 +++++++ .../src/shared/tab-context-sanitize.test.ts | 26 ++ .../src/shared/tab-context-sanitize.ts | 15 ++ .../tests/e2e/firefox-extension.test.ts | 1 + apps/extension/tests/e2e/sidebar.test.ts | 1 + apps/extension/wxt.config.ts | 4 +- 15 files changed, 1355 insertions(+), 6 deletions(-) create mode 100644 apps/extension/entrypoints/sidepanel/latest-only-refresh.test.ts create mode 100644 apps/extension/entrypoints/sidepanel/latest-only-refresh.ts create mode 100644 apps/extension/entrypoints/sidepanel/use-agent-memories.ts create mode 100644 apps/extension/src/shared/agent-memories-storage.test.ts create mode 100644 apps/extension/src/shared/agent-memories-storage.ts create mode 100644 apps/extension/src/shared/agent-memories.test.ts create mode 100644 apps/extension/src/shared/agent-memories.ts create mode 100644 apps/extension/src/shared/tab-context-sanitize.test.ts create mode 100644 apps/extension/src/shared/tab-context-sanitize.ts diff --git a/apps/extension/entrypoints/background.ts b/apps/extension/entrypoints/background.ts index ebee716219..a55db46546 100644 --- a/apps/extension/entrypoints/background.ts +++ b/apps/extension/entrypoints/background.ts @@ -1,4 +1,19 @@ -import { enableActionClickSidePanel } from '@/src/shared/side-panel'; +import { storage } from '#imports'; +import { buildPendingMemoryDraft } from '@/src/shared/agent-memories'; +import { savePendingAgentMemoryDraft } from '@/src/shared/agent-memories-storage'; +import { + ADD_TO_MEMORY_MENU_ID, + enableActionClickSidePanel, + openSidePanelInWindow, + registerAddToMemoryMenu, +} from '@/src/shared/side-panel'; +import type { + NativeContextMenusApi, + NativeContextMenusOnClickData, + NativeContextMenusTab, + NativeSidePanelOpenApi, + NativeSidebarActionApi, +} from '@/src/shared/side-panel'; import { EVAL_TAB_MESSAGE, LIST_INSPECTABLE_TABS_MESSAGE, @@ -22,6 +37,9 @@ import type { interface ChromeRuntimeApi { readonly id?: string; + readonly onInstalled?: { + readonly addListener: (listener: () => void) => void; + }; readonly onMessage?: { readonly addListener: ( listener: ( @@ -154,21 +172,102 @@ const handleTabDebuggerRequest = async ({ } }; +const handleAddToMemoryClick = ( + info: NativeContextMenusOnClickData, + tab: NativeContextMenusTab | undefined, + { + sidePanelOpen, + sidebarAction, + }: { + sidePanelOpen?: NativeSidePanelOpenApi | undefined; + sidebarAction?: NativeSidebarActionApi | undefined; + } +): void => { + if (info.menuItemId !== ADD_TO_MEMORY_MENU_ID) { + return; + } + + const draft = buildPendingMemoryDraft({ + now: Date.now(), + pageTitle: tab?.title ?? '', + pageUrl: info.pageUrl ?? tab?.url ?? '', + selectionText: info.selectionText, + }); + + if (draft === undefined) { + return; + } + + const windowId = tab?.windowId; + if (windowId !== undefined) { + // User-gesture contract: open synchronously before any await. + try { + const openResult = openSidePanelInWindow({ + sidePanelOpen, + sidebarAction, + windowId, + }); + // Fire-and-forget: must not await before storage save, and open failures are non-fatal. + // eslint-disable-next-line promise/prefer-await-to-then, promise/prefer-await-to-callbacks -- user-gesture open must not await + void Promise.resolve(openResult).catch((error: unknown) => { + console.warn('Failed to open side panel for Add to memory:', error); + }); + } catch (error) { + console.warn('Failed to open side panel for Add to memory:', error); + } + } + + // eslint-disable-next-line promise/prefer-await-to-then, promise/prefer-await-to-callbacks -- keep open/save non-blocking in the SW click path + void savePendingAgentMemoryDraft(storage, draft).catch((error: unknown) => { + console.warn('Failed to save pending agent memory draft:', error); + }); +}; + export default defineBackground(() => { const chromeApi = ( globalThis as typeof globalThis & { chrome?: { + contextMenus?: NativeContextMenusApi; debugger?: ChromeDebuggerApi; runtime?: ChromeRuntimeApi; scripting?: BrowserScriptingApi; - sidePanel?: Parameters[0]; + sidePanel?: Parameters[0] & NativeSidePanelOpenApi; + sidebarAction?: NativeSidebarActionApi; tabs?: BrowserTabsApi; }; } ).chrome; + const browserGlobal = ( + globalThis as typeof globalThis & { + browser?: { + contextMenus?: NativeContextMenusApi; + runtime?: ChromeRuntimeApi; + sidePanel?: NativeSidePanelOpenApi; + sidebarAction?: NativeSidebarActionApi; + }; + } + ).browser; + + const menusApi: NativeContextMenusApi | undefined = + browserGlobal?.contextMenus ?? chromeApi?.contextMenus; + const sidePanelOpen: NativeSidePanelOpenApi | undefined = + chromeApi?.sidePanel ?? browserGlobal?.sidePanel; + const sidebarAction: NativeSidebarActionApi | undefined = + browserGlobal?.sidebarAction ?? chromeApi?.sidebarAction; + void enableActionClickSidePanel(chromeApi?.sidePanel); + void registerAddToMemoryMenu(menusApi); + const runtimeApi = browserGlobal?.runtime ?? chromeApi?.runtime; + runtimeApi?.onInstalled?.addListener(() => { + void registerAddToMemoryMenu(menusApi); + }); + + menusApi?.onClicked.addListener((info, tab) => { + handleAddToMemoryClick(info, tab, { sidePanelOpen, sidebarAction }); + }); + chromeApi?.runtime?.onMessage?.addListener((message, sender, sendResponse) => { if (!isTrustedExtensionSender(sender, chromeApi?.runtime?.id)) { return; diff --git a/apps/extension/entrypoints/sidepanel/latest-only-refresh.test.ts b/apps/extension/entrypoints/sidepanel/latest-only-refresh.test.ts new file mode 100644 index 0000000000..c62d5dc511 --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/latest-only-refresh.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { createLatestOnlyRefresh } from './latest-only-refresh'; + +describe('latest-only refresh control', () => { + it('marks sequential runs as fresh', async () => { + const refresh = createLatestOnlyRefresh(); + + await expect(refresh.run(() => Promise.resolve('first'))).resolves.toStrictEqual({ + status: 'applied', + value: 'first', + }); + await expect(refresh.run(() => Promise.resolve('second'))).resolves.toStrictEqual({ + status: 'applied', + value: 'second', + }); + expect(refresh.isLatest(2)).toBe(true); + expect(refresh.isLatest(1)).toBe(false); + }); + + it('discards out-of-order resolutions so only the latest wins', async () => { + const refresh = createLatestOnlyRefresh(); + const resolvers: ((value: string) => void)[] = []; + + // eslint-disable-next-line promise/avoid-new -- controlled fixture for out-of-order resolution + const firstPromise = new Promise(resolve => { + resolvers[0] = resolve; + }); + // eslint-disable-next-line promise/avoid-new -- controlled fixture for out-of-order resolution + const secondPromise = new Promise(resolve => { + resolvers[1] = resolve; + }); + + const firstRun = refresh.run(() => firstPromise); + const secondRun = refresh.run(() => secondPromise); + + resolvers[0]?.('stale'); + resolvers[1]?.('fresh'); + + await expect(firstRun).resolves.toStrictEqual({ status: 'stale' }); + await expect(secondRun).resolves.toStrictEqual({ status: 'applied', value: 'fresh' }); + }); + + it('discards out-of-order rejections so a stale failure does not surface', async () => { + const refresh = createLatestOnlyRefresh(); + const resolvers: { + rejectFirst?: (reason?: unknown) => void; + resolveSecond?: (value: string) => void; + } = {}; + + // eslint-disable-next-line promise/avoid-new -- controlled fixture for stale rejection + const firstPromise = new Promise((_resolve, reject) => { + resolvers.rejectFirst = reject; + }); + // eslint-disable-next-line promise/avoid-new -- controlled fixture for newer success + const secondPromise = new Promise(resolve => { + resolvers.resolveSecond = resolve; + }); + + const firstRun = refresh.run(() => firstPromise); + const secondRun = refresh.run(() => secondPromise); + + resolvers.resolveSecond?.('fresh'); + resolvers.rejectFirst?.(new Error('stale failure')); + + await expect(firstRun).resolves.toStrictEqual({ status: 'stale' }); + await expect(secondRun).resolves.toStrictEqual({ status: 'applied', value: 'fresh' }); + }); + + it('surfaces failure only when the rejecting generation is still latest', async () => { + const refresh = createLatestOnlyRefresh(); + const error = new Error('latest failure'); + + await expect(refresh.run(() => Promise.reject(error))).resolves.toStrictEqual({ + error, + status: 'failed', + }); + }); + + it('exposes begin/isLatest for callers that apply results themselves', () => { + const refresh = createLatestOnlyRefresh(); + const tokenA = refresh.begin(); + const tokenB = refresh.begin(); + + expect(refresh.isLatest(tokenA)).toBe(false); + expect(refresh.isLatest(tokenB)).toBe(true); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/latest-only-refresh.ts b/apps/extension/entrypoints/sidepanel/latest-only-refresh.ts new file mode 100644 index 0000000000..ae596a4132 --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/latest-only-refresh.ts @@ -0,0 +1,43 @@ +export type LatestOnlyRefreshResult = + | { status: 'applied'; value: TValue } + | { error: unknown; status: 'failed' } + | { status: 'stale' }; + +export interface LatestOnlyRefresh { + /** + * Runs `work` under a monotonic generation token. Resolves a discriminated + * result: `'applied'` when this invocation is still latest and succeeded, + * `'failed'` when still latest and rejected, or `'stale'` when a newer + * generation has already begun (success and failure alike are discarded). + */ + run: (work: () => Promise) => Promise>; + /** True when `token` is still the most recent generation. */ + isLatest: (token: number) => boolean; + /** Begins a generation and returns its token without running work. */ + begin: () => number; +} + +export const createLatestOnlyRefresh = (): LatestOnlyRefresh => { + let latestToken = 0; + + const begin = (): number => { + latestToken += 1; + return latestToken; + }; + + const isLatest = (token: number): boolean => token === latestToken; + + const run = async ( + work: () => Promise + ): Promise> => { + const token = begin(); + try { + const value = await work(); + return isLatest(token) ? { status: 'applied', value } : { status: 'stale' }; + } catch (error) { + return isLatest(token) ? { error, status: 'failed' } : { status: 'stale' }; + } + }; + + return { begin, isLatest, run }; +}; diff --git a/apps/extension/entrypoints/sidepanel/use-agent-memories.ts b/apps/extension/entrypoints/sidepanel/use-agent-memories.ts new file mode 100644 index 0000000000..3ebe3c4998 --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/use-agent-memories.ts @@ -0,0 +1,114 @@ +import { storage } from '#imports'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { AgentMemory, PendingAgentMemoryDraft } from '@/src/shared/agent-memories'; +import { + AGENT_MEMORIES_STORAGE_KEY, + PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY, + loadAgentMemories, + loadPendingAgentMemoryDraft, +} from '@/src/shared/agent-memories-storage'; +import { createLatestOnlyRefresh } from './latest-only-refresh'; + +const INITIAL_LOAD_RETRY_MS = 1000; + +const waitMs = (ms: number): Promise => + // eslint-disable-next-line promise/avoid-new -- timer bridge for initial-load retry + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +export interface UseAgentMemoriesResult { + readonly memories: AgentMemory[]; + readonly pendingDraft: PendingAgentMemoryDraft | undefined; + readonly isLoaded: boolean; + readonly loadError: boolean; + readonly reload: () => void; +} + +export const useAgentMemories = (): UseAgentMemoriesResult => { + const [memories, setMemories] = useState([]); + const [pendingDraft, setPendingDraft] = useState(); + const [isLoaded, setIsLoaded] = useState(false); + const [loadError, setLoadError] = useState(false); + const refreshControlRef = useRef(createLatestOnlyRefresh()); + const initialRetryUsedRef = useRef(false); + + const refresh = useCallback(async (): Promise<'applied' | 'failed' | 'stale'> => { + const result = await refreshControlRef.current.run(async () => { + const [nextMemories, nextDraft] = await Promise.all([ + loadAgentMemories(storage), + loadPendingAgentMemoryDraft(storage), + ]); + return { memories: nextMemories, pendingDraft: nextDraft }; + }); + + if (result.status === 'stale') { + return 'stale'; + } + + if (result.status === 'failed') { + return 'failed'; + } + + setMemories(result.value.memories); + setPendingDraft(result.value.pendingDraft); + setLoadError(false); + setIsLoaded(true); + return 'applied'; + }, []); + + const refreshWithFailureHandling = useCallback( + async ({ isInitialLoad }: { isInitialLoad: boolean }): Promise => { + const outcome = await refresh(); + + if (outcome === 'applied' || outcome === 'stale') { + // Stale failures and superseded successes leave prior state untouched. + return; + } + + // Latest generation failed. + if (isInitialLoad && !initialRetryUsedRef.current) { + initialRetryUsedRef.current = true; + await waitMs(INITIAL_LOAD_RETRY_MS); + const retryOutcome = await refresh(); + if (retryOutcome === 'applied' || retryOutcome === 'stale') { + return; + } + } + + setLoadError(true); + setIsLoaded(true); + }, + [refresh] + ); + + const reload = useCallback(() => { + setIsLoaded(false); + setLoadError(false); + void refreshWithFailureHandling({ isInitialLoad: false }); + }, [refreshWithFailureHandling]); + + useEffect(() => { + void refreshWithFailureHandling({ isInitialLoad: true }); + + const unwatchMemories = storage.watch(AGENT_MEMORIES_STORAGE_KEY, () => { + void refreshWithFailureHandling({ isInitialLoad: false }); + }); + const unwatchDraft = storage.watch(PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY, () => { + void refreshWithFailureHandling({ isInitialLoad: false }); + }); + + return () => { + unwatchMemories(); + unwatchDraft(); + }; + }, [refreshWithFailureHandling]); + + return { + isLoaded, + loadError, + memories, + pendingDraft, + reload, + }; +}; diff --git a/apps/extension/src/shared/agent-memories-storage.test.ts b/apps/extension/src/shared/agent-memories-storage.test.ts new file mode 100644 index 0000000000..f51e3f17fa --- /dev/null +++ b/apps/extension/src/shared/agent-memories-storage.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_MEMORY_COUNT, MAX_MEMORY_NOTE_LENGTH, agentMemoryInputSchema } from './agent-memories'; +import type { AgentMemory, PendingAgentMemoryDraft } from './agent-memories'; +import { + AGENT_MEMORIES_STORAGE_KEY, + AgentMemoryStoreFullError, + PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY, + addAgentMemory, + clearPendingAgentMemoryDraft, + deleteAgentMemory, + loadAgentMemories, + loadPendingAgentMemoryDraft, + saveAgentMemories, + savePendingAgentMemoryDraft, +} from './agent-memories-storage'; +import type { AgentMemoriesStorageArea } from './agent-memories-storage'; + +const createStorage = (): AgentMemoriesStorageArea & { + values: Map; +} => { + const values = new Map(); + + return { + getItem: key => values.get(key), + removeItem: key => { + values.delete(key); + }, + setItem: (key, value) => { + values.set(key, value); + }, + values, + }; +}; + +const baseInput = { + createdAt: 1_700_000_000_000, + pageTitle: 'Example', + pageUrl: 'https://example.com/path', + text: 'selected text', +}; + +describe('agent memories storage', () => { + it('loads an empty list for missing or malformed storage and drops invalid entries', async () => { + const storage = createStorage(); + await expect(loadAgentMemories(storage)).resolves.toStrictEqual([]); + + storage.values.set(AGENT_MEMORIES_STORAGE_KEY, { wrong: true }); + await expect(loadAgentMemories(storage)).resolves.toStrictEqual([]); + + const valid: AgentMemory = { + ...baseInput, + id: 'keep-me', + }; + storage.values.set(AGENT_MEMORIES_STORAGE_KEY, [ + valid, + { id: '', text: 'bad' }, + { ...valid, id: 'note-too-long', note: 'n'.repeat(MAX_MEMORY_NOTE_LENGTH + 1) }, + ]); + await expect(loadAgentMemories(storage)).resolves.toStrictEqual([valid]); + }); + + it('assigns id, copies createdAt, and trims blank notes', async () => { + const storage = createStorage(); + const saved = await addAgentMemory(storage, { + ...baseInput, + note: ' keep me ', + }); + + expect(saved.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + expect(saved.createdAt).toBe(baseInput.createdAt); + expect(saved.note).toBe('keep me'); + + const blankNote = await addAgentMemory(storage, { + ...baseInput, + createdAt: baseInput.createdAt + 1, + note: ' ', + text: 'second', + }); + expect(blankNote).not.toHaveProperty('note'); + }); + + it('enforces the note length cap at the schema boundary', async () => { + const storage = createStorage(); + + expect(() => + agentMemoryInputSchema.parse({ + ...baseInput, + note: 'n'.repeat(MAX_MEMORY_NOTE_LENGTH + 1), + }) + ).toThrow(/too_big|max/i); + + const atCap = await addAgentMemory(storage, { + ...baseInput, + note: 'n'.repeat(MAX_MEMORY_NOTE_LENGTH), + text: 'third', + }); + expect(atCap.note).toHaveLength(MAX_MEMORY_NOTE_LENGTH); + + await expect( + addAgentMemory(storage, { + ...baseInput, + note: 'n'.repeat(MAX_MEMORY_NOTE_LENGTH + 1), + text: 'fail', + }) + ).rejects.toThrow(/too_big|max/i); + }); + + it('throws AgentMemoryStoreFullError at the 200-memory cap', async () => { + const storage = createStorage(); + const full: AgentMemory[] = Array.from({ length: MAX_MEMORY_COUNT }, (_unused, index) => ({ + ...baseInput, + createdAt: index, + id: `id-${index}`, + text: `text ${index}`, + })); + await saveAgentMemories(storage, full); + + await expect(addAgentMemory(storage, baseInput)).rejects.toBeInstanceOf( + AgentMemoryStoreFullError + ); + await expect(addAgentMemory(storage, baseInput)).rejects.toMatchObject({ + name: 'AgentMemoryStoreFullError', + }); + }); + + it('deletes by id and round-trips pending drafts', async () => { + const storage = createStorage(); + const first = await addAgentMemory(storage, baseInput); + const second = await addAgentMemory(storage, { + ...baseInput, + createdAt: baseInput.createdAt + 1, + text: 'second', + }); + + await deleteAgentMemory(storage, first.id); + await expect(loadAgentMemories(storage)).resolves.toStrictEqual([second]); + + const draft: PendingAgentMemoryDraft = { + createdAt: 99, + pageTitle: 'Draft page', + pageUrl: 'https://example.com/draft', + text: 'pending selection', + truncated: true, + }; + await savePendingAgentMemoryDraft(storage, draft); + await expect(loadPendingAgentMemoryDraft(storage)).resolves.toStrictEqual(draft); + + const replacement: PendingAgentMemoryDraft = { + createdAt: 100, + pageTitle: 'Next', + pageUrl: 'https://example.com/next', + text: 'replacement', + }; + await savePendingAgentMemoryDraft(storage, replacement); + await expect(loadPendingAgentMemoryDraft(storage)).resolves.toStrictEqual(replacement); + + await clearPendingAgentMemoryDraft(storage); + await expect(loadPendingAgentMemoryDraft(storage)).resolves.toBeUndefined(); + }); + + it('returns undefined and clears an invalid pending draft', async () => { + const storage = createStorage(); + storage.values.set(PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY, { bad: true }); + + await expect(loadPendingAgentMemoryDraft(storage)).resolves.toBeUndefined(); + expect(storage.values.has(PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY)).toBe(false); + }); +}); diff --git a/apps/extension/src/shared/agent-memories-storage.ts b/apps/extension/src/shared/agent-memories-storage.ts new file mode 100644 index 0000000000..2bcf8e0f67 --- /dev/null +++ b/apps/extension/src/shared/agent-memories-storage.ts @@ -0,0 +1,144 @@ +import type { z } from 'zod'; +import { + MAX_MEMORY_COUNT, + agentMemoryInputSchema, + agentMemorySchema, + pendingAgentMemoryDraftSchema, + storedAgentMemoriesSchema, +} from './agent-memories'; +import type { AgentMemory, AgentMemoryInput, PendingAgentMemoryDraft } from './agent-memories'; + +export const AGENT_MEMORIES_STORAGE_KEY = 'local:kiloAgentMemories'; +export const PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY = 'local:kiloPendingAgentMemoryDraft'; + +type MaybePromise = Promise | Value; + +type AgentMemoriesStorageKey = + | typeof AGENT_MEMORIES_STORAGE_KEY + | typeof PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY; + +export interface AgentMemoriesStorageArea { + getItem(key: AgentMemoriesStorageKey): MaybePromise; + setItem(key: AgentMemoriesStorageKey, value: unknown): MaybePromise; + removeItem(key: AgentMemoriesStorageKey): MaybePromise; +} + +export class AgentMemoryStoreFullError extends Error { + constructor(message = 'Agent memory store is full.') { + super(message); + this.name = 'AgentMemoryStoreFullError'; + } +} + +const toAgentMemory = (value: z.infer): AgentMemory => ({ + createdAt: value.createdAt, + id: value.id, + pageTitle: value.pageTitle, + pageUrl: value.pageUrl, + text: value.text, + ...(value.note === undefined ? {} : { note: value.note }), + ...(value.truncated === undefined ? {} : { truncated: value.truncated }), +}); + +const toPendingDraft = ( + value: z.infer +): PendingAgentMemoryDraft => ({ + createdAt: value.createdAt, + pageTitle: value.pageTitle, + pageUrl: value.pageUrl, + text: value.text, + ...(value.truncated === undefined ? {} : { truncated: value.truncated }), +}); + +export const normalizeAgentMemories = (value: unknown): AgentMemory[] => { + const parsed = storedAgentMemoriesSchema.safeParse(value); + if (!parsed.success) { + return []; + } + + return parsed.data.flatMap(entry => { + const memory = agentMemorySchema.safeParse(entry); + return memory.success ? [toAgentMemory(memory.data)] : []; + }); +}; + +export const loadAgentMemories = async ( + storageArea: AgentMemoriesStorageArea +): Promise => + normalizeAgentMemories(await storageArea.getItem(AGENT_MEMORIES_STORAGE_KEY)); + +export const saveAgentMemories = async ( + storageArea: AgentMemoriesStorageArea, + memories: readonly AgentMemory[] +): Promise => { + await storageArea.setItem(AGENT_MEMORIES_STORAGE_KEY, normalizeAgentMemories(memories)); +}; + +export const addAgentMemory = async ( + storageArea: AgentMemoriesStorageArea, + input: AgentMemoryInput +): Promise => { + const parsedInput = agentMemoryInputSchema.parse(input); + const memories = await loadAgentMemories(storageArea); + + if (memories.length >= MAX_MEMORY_COUNT) { + throw new AgentMemoryStoreFullError(); + } + + const trimmedNote = parsedInput.note?.trim(); + const candidate: AgentMemory = { + createdAt: parsedInput.createdAt, + id: crypto.randomUUID(), + pageTitle: parsedInput.pageTitle, + pageUrl: parsedInput.pageUrl, + text: parsedInput.text, + ...(parsedInput.truncated === undefined ? {} : { truncated: parsedInput.truncated }), + ...(trimmedNote === undefined || trimmedNote.length === 0 ? {} : { note: trimmedNote }), + }; + + const memory = toAgentMemory(agentMemorySchema.parse(candidate)); + await saveAgentMemories(storageArea, [...memories, memory]); + return memory; +}; + +export const deleteAgentMemory = async ( + storageArea: AgentMemoriesStorageArea, + id: string +): Promise => { + const memories = await loadAgentMemories(storageArea); + await saveAgentMemories( + storageArea, + memories.filter(memory => memory.id !== id) + ); +}; + +export const savePendingAgentMemoryDraft = async ( + storageArea: AgentMemoriesStorageArea, + draft: PendingAgentMemoryDraft +): Promise => { + const parsed = toPendingDraft(pendingAgentMemoryDraftSchema.parse(draft)); + await storageArea.setItem(PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY, parsed); +}; + +export const loadPendingAgentMemoryDraft = async ( + storageArea: AgentMemoriesStorageArea +): Promise => { + const value = await storageArea.getItem(PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY); + if (value === null || value === undefined) { + return undefined; + } + + const parsed = pendingAgentMemoryDraftSchema.safeParse(value); + if (!parsed.success) { + await storageArea.removeItem(PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY); + return undefined; + } + + return toPendingDraft(parsed.data); +}; + +export const clearPendingAgentMemoryDraft = async ( + storageArea: AgentMemoriesStorageArea +): Promise => { + await storageArea.removeItem(PENDING_AGENT_MEMORY_DRAFT_STORAGE_KEY); +}; diff --git a/apps/extension/src/shared/agent-memories.test.ts b/apps/extension/src/shared/agent-memories.test.ts new file mode 100644 index 0000000000..4795530fe6 --- /dev/null +++ b/apps/extension/src/shared/agent-memories.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest'; +import { + MEMORY_INDEX_ENTRY_COUNT, + MEMORY_SEARCH_RESULT_COUNT, + MAX_MEMORY_TEXT_LENGTH, + buildPendingMemoryDraft, + formatAgentMemoryIndex, + searchAgentMemories, + toAgentMemorySnippet, +} from './agent-memories'; +import type { AgentMemory } from './agent-memories'; + +const memory = ( + overrides: Partial & Pick +): AgentMemory => ({ + createdAt: 1_700_000_000_000, + pageTitle: 'Example', + pageUrl: 'https://example.com/path', + ...overrides, +}); + +describe('pending memory draft builder', () => { + it('returns undefined for empty or whitespace selections', () => { + expect( + buildPendingMemoryDraft({ + now: 100, + pageTitle: 'Title', + pageUrl: 'https://example.com', + selectionText: '', + }) + ).toBeUndefined(); + expect( + buildPendingMemoryDraft({ + now: 100, + pageTitle: 'Title', + pageUrl: 'https://example.com', + selectionText: ' \n\t ', + }) + ).toBeUndefined(); + expect( + buildPendingMemoryDraft({ + now: 100, + pageTitle: 'Title', + pageUrl: 'https://example.com', + selectionText: undefined, + }) + ).toBeUndefined(); + }); + + it('trims text, sanitizes URL, stamps createdAt, and flags truncation', () => { + const long = `${'a'.repeat(MAX_MEMORY_TEXT_LENGTH)}EXTRA`; + expect( + buildPendingMemoryDraft({ + now: 42, + pageTitle: 'Docs', + pageUrl: 'https://example.com/path?q=1#hash', + selectionText: ` ${long} `, + }) + ).toStrictEqual({ + createdAt: 42, + pageTitle: 'Docs', + pageUrl: 'https://example.com/path', + text: 'a'.repeat(MAX_MEMORY_TEXT_LENGTH), + truncated: true, + }); + }); + + it('keeps empty pageUrl as empty and maps invalid URLs to the sanitizer fallback', () => { + expect( + buildPendingMemoryDraft({ + now: 1, + pageTitle: 'T', + pageUrl: '', + selectionText: 'hello', + }) + ).toMatchObject({ pageUrl: '', text: 'hello' }); + expect( + buildPendingMemoryDraft({ + now: 1, + pageTitle: 'T', + pageUrl: 'not-a-url', + selectionText: 'hello', + }) + ).toMatchObject({ pageUrl: '[invalid URL]', text: 'hello' }); + }); + + it('omits truncated when under the cap', () => { + expect( + buildPendingMemoryDraft({ + now: 9, + pageTitle: 'T', + pageUrl: 'https://example.com', + selectionText: 'short', + }) + ).toStrictEqual({ + createdAt: 9, + pageTitle: 'T', + pageUrl: 'https://example.com/', + text: 'short', + }); + }); +}); + +describe('agent memory search', () => { + const memories = [ + memory({ + createdAt: 30, + id: 'a', + note: 'alpha note', + pageTitle: 'Alpha Title', + pageUrl: 'https://alpha.example/path', + text: 'first body', + }), + memory({ + createdAt: 20, + id: 'b', + pageTitle: 'Beta', + pageUrl: 'https://beta.example', + text: 'second body with alpha token', + }), + memory({ + createdAt: 10, + id: 'c', + pageTitle: 'Gamma', + pageUrl: 'https://gamma.example', + text: 'unrelated', + }), + ]; + + it('requires every token and ranks by createdAt desc, capped at 10', () => { + expect(searchAgentMemories(memories, 'alpha body').map(item => item.id)).toStrictEqual([ + 'a', + 'b', + ]); + expect(searchAgentMemories(memories, 'beta.example').map(item => item.id)).toStrictEqual(['b']); + + const many = Array.from({ length: MEMORY_SEARCH_RESULT_COUNT + 5 }, (_unused, index) => + memory({ + createdAt: index, + id: `m-${index}`, + text: `shared token ${index}`, + }) + ); + expect(searchAgentMemories(many, 'shared').map(item => item.id)).toHaveLength( + MEMORY_SEARCH_RESULT_COUNT + ); + expect(searchAgentMemories(many, 'shared')[0]?.id).toBe(`m-${MEMORY_SEARCH_RESULT_COUNT + 4}`); + }); + + it('returns no matches for empty or whitespace queries (no tokens)', () => { + expect(searchAgentMemories(memories, '')).toStrictEqual([]); + expect(searchAgentMemories(memories, ' ')).toStrictEqual([]); + }); +}); + +describe('agent memory index formatting', () => { + it('returns undefined for an empty store', () => { + expect(formatAgentMemoryIndex([])).toBeUndefined(); + }); + + it('formats newest entries with escaped previews, domain, and UTC date', () => { + const index = formatAgentMemoryIndex([ + memory({ + createdAt: Date.UTC(2026, 0, 2), + id: 'old', + pageTitle: 'Old', + text: 'older text', + }), + memory({ + createdAt: Date.UTC(2026, 0, 5), + id: 'new', + note: 'note with & more', + pageTitle: 'New', + pageUrl: 'https://docs.example.com:8443/path?q=1', + text: 'body ignored when note present', + }), + ]); + + expect(index).toBe( + [ + '', + '- [new] note with <tag> & more (docs.example.com, 2026-01-05)', + '- [old] older text (example.com, 2026-01-02)', + '', + ].join('\n') + ); + }); + + it('omits domain for empty, invalid, and file URLs', () => { + const createdAt = Date.UTC(2026, 5, 1); + expect( + formatAgentMemoryIndex([ + memory({ createdAt, id: 'empty', pageUrl: '', text: 'a' }), + memory({ createdAt: createdAt + 1, id: 'bad', pageUrl: '[invalid URL]', text: 'b' }), + memory({ createdAt: createdAt + 2, id: 'file', pageUrl: 'file:///tmp/x', text: 'c' }), + ]) + ).toBe( + [ + '', + '- [file] c (2026-06-01)', + '- [bad] b (2026-06-01)', + '- [empty] a (2026-06-01)', + '', + ].join('\n') + ); + }); + + it('caps listed entries at 20 and appends the remaining count', () => { + const memories = Array.from({ length: MEMORY_INDEX_ENTRY_COUNT + 7 }, (_unused, index) => + memory({ + createdAt: index, + id: `id-${index}`, + text: `text ${index}`, + }) + ); + const index = formatAgentMemoryIndex(memories); + expect(index).toContain(`count="${MEMORY_INDEX_ENTRY_COUNT + 7}"`); + expect(index).toContain('(7 more memories — use search_memories to find them.)'); + expect(index?.split('\n').filter(line => line.startsWith('- ['))).toHaveLength( + MEMORY_INDEX_ENTRY_COUNT + ); + expect(index).toContain('- [id-26]'); + expect(index).not.toContain('- [id-0]'); + }); + + it('truncates long previews to about 80 characters', () => { + const long = 'word '.repeat(40).trim(); + const index = formatAgentMemoryIndex([memory({ id: 'long', text: long })]); + expect(index).toMatch(/^- \[long\] .{80} \(example\.com, \d{4}-\d{2}-\d{2}\)$/m); + }); +}); + +describe('agent memory snippets', () => { + it('single-lines and truncates text to about 200 characters', () => { + const long = `${'x'.repeat(250)}\nmore`; + expect(toAgentMemorySnippet(memory({ id: 's', text: long }))).toBe('x'.repeat(200)); + expect(toAgentMemorySnippet(memory({ id: 's', text: ' hello\nworld ' }))).toBe('hello world'); + }); +}); diff --git a/apps/extension/src/shared/agent-memories.ts b/apps/extension/src/shared/agent-memories.ts new file mode 100644 index 0000000000..bf0d746394 --- /dev/null +++ b/apps/extension/src/shared/agent-memories.ts @@ -0,0 +1,185 @@ +import { z } from 'zod'; +import { sanitizeTabContextText, sanitizeTabContextUrl } from './tab-context-sanitize'; + +export const MAX_MEMORY_TEXT_LENGTH = 8000; +export const MAX_MEMORY_NOTE_LENGTH = 200; +export const MAX_MEMORY_COUNT = 200; +export const MEMORY_INDEX_ENTRY_COUNT = 20; +export const MEMORY_SEARCH_RESULT_COUNT = 10; + +const MEMORY_INDEX_PREVIEW_LENGTH = 80; +const MEMORY_SNIPPET_LENGTH = 200; + +export interface AgentMemory { + id: string; + text: string; + note?: string | undefined; + pageTitle: string; + pageUrl: string; + createdAt: number; + truncated?: boolean | undefined; +} + +export type AgentMemoryInput = Omit; + +export interface PendingAgentMemoryDraft { + text: string; + pageTitle: string; + pageUrl: string; + createdAt: number; + truncated?: boolean | undefined; +} + +export const agentMemorySchema = z + .object({ + createdAt: z.number(), + id: z.string().min(1), + note: z.string().max(MAX_MEMORY_NOTE_LENGTH).optional(), + pageTitle: z.string(), + pageUrl: z.string(), + text: z.string(), + truncated: z.boolean().optional(), + }) + .strip(); + +export const agentMemoryInputSchema = z + .object({ + createdAt: z.number(), + note: z.string().max(MAX_MEMORY_NOTE_LENGTH).optional(), + pageTitle: z.string(), + pageUrl: z.string(), + text: z.string(), + truncated: z.boolean().optional(), + }) + .strip(); + +export const storedAgentMemoriesSchema = z.array(z.unknown()); + +export const pendingAgentMemoryDraftSchema = z + .object({ + createdAt: z.number(), + pageTitle: z.string(), + pageUrl: z.string(), + text: z.string(), + truncated: z.boolean().optional(), + }) + .strip(); + +const collapseWhitespace = (value: string): string => value.trim().replaceAll(/\s+/g, ' '); + +const singleLinePreview = (value: string, maxLength: number): string => { + const collapsed = collapseWhitespace(value); + if (collapsed.length <= maxLength) { + return collapsed; + } + + return collapsed.slice(0, maxLength); +}; + +const memorySearchCorpus = (memory: AgentMemory): string => + `${memory.text} ${memory.note ?? ''} ${memory.pageTitle} ${memory.pageUrl}`.toLowerCase(); + +const sortByCreatedAtDesc = ( + items: readonly TItem[] +): TItem[] => [...items].toSorted((left, right) => right.createdAt - left.createdAt); + +const formatMemoryDomain = (pageUrl: string): string | undefined => { + if (pageUrl === '') { + return undefined; + } + + try { + const parsed = new URL(pageUrl); + if (parsed.protocol === 'file:') { + return undefined; + } + + return parsed.hostname; + } catch { + return undefined; + } +}; + +const formatUtcDate = (createdAt: number): string => new Date(createdAt).toISOString().slice(0, 10); + +export const buildPendingMemoryDraft = ({ + selectionText, + pageTitle, + pageUrl, + now, +}: { + selectionText: string | undefined; + pageTitle: string; + pageUrl: string; + now: number; +}): PendingAgentMemoryDraft | undefined => { + const trimmed = (selectionText ?? '').trim(); + if (trimmed.length === 0) { + return undefined; + } + + const truncated = trimmed.length > MAX_MEMORY_TEXT_LENGTH; + const text = truncated ? trimmed.slice(0, MAX_MEMORY_TEXT_LENGTH) : trimmed; + + return { + createdAt: now, + pageTitle, + pageUrl: pageUrl === '' ? '' : sanitizeTabContextUrl(pageUrl), + text, + ...(truncated ? { truncated: true } : {}), + }; +}; + +/** + * Empty/whitespace queries produce no tokens and therefore no matches. + * Callers that need "show all" must not call this with an empty query. + */ +export const searchAgentMemories = ( + memories: readonly AgentMemory[], + query: string +): AgentMemory[] => { + const tokens = collapseWhitespace(query.toLowerCase()) + .split(' ') + .filter(token => token.length > 0); + + if (tokens.length === 0) { + return []; + } + + const matches = memories.filter(memory => { + const corpus = memorySearchCorpus(memory); + return tokens.every(token => corpus.includes(token)); + }); + + return sortByCreatedAtDesc(matches).slice(0, MEMORY_SEARCH_RESULT_COUNT); +}; + +export const formatAgentMemoryIndex = (memories: readonly AgentMemory[]): string | undefined => { + if (memories.length === 0) { + return undefined; + } + + const newest = sortByCreatedAtDesc(memories).slice(0, MEMORY_INDEX_ENTRY_COUNT); + const lines = newest.map(memory => { + const previewSource = + memory.note !== undefined && memory.note.length > 0 ? memory.note : memory.text; + const preview = sanitizeTabContextText( + singleLinePreview(previewSource, MEMORY_INDEX_PREVIEW_LENGTH) + ); + const domain = formatMemoryDomain(memory.pageUrl); + const date = formatUtcDate(memory.createdAt); + const suffix = domain === undefined ? `(${date})` : `(${domain}, ${date})`; + + return `- [${memory.id}] ${preview} ${suffix}`; + }); + + const remaining = memories.length - MEMORY_INDEX_ENTRY_COUNT; + if (remaining > 0) { + lines.push(`(${remaining} more memories — use search_memories to find them.)`); + } + + return `\n${lines.join('\n')}\n`; +}; + +export const toAgentMemorySnippet = (memory: AgentMemory): string => + singleLinePreview(memory.text, MEMORY_SNIPPET_LENGTH); diff --git a/apps/extension/src/shared/side-panel.test.ts b/apps/extension/src/shared/side-panel.test.ts index e53954a550..08a4dd5808 100644 --- a/apps/extension/src/shared/side-panel.test.ts +++ b/apps/extension/src/shared/side-panel.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest'; -import { enableActionClickSidePanel } from './side-panel'; +import { describe, expect, it, vi } from 'vitest'; +import { + ADD_TO_MEMORY_MENU_ID, + enableActionClickSidePanel, + openSidePanelInWindow, + registerAddToMemoryMenu, +} from './side-panel'; +import type { NativeContextMenusApi } from './side-panel'; describe('side panel behavior', () => { it('opens the native side panel from the extension action click', async () => { @@ -18,3 +24,139 @@ describe('side panel behavior', () => { await expect(enableActionClickSidePanel()).resolves.toBeUndefined(); }); }); + +describe('add-to-memory context menu registration', () => { + it('registers the selection menu idempotently via remove-then-create', async () => { + const create = vi.fn(); + // eslint-disable-next-line require-await -- async remove fake for sequential await path + const remove = vi.fn(async () => {}); + const menusApi: NativeContextMenusApi = { + create, + onClicked: { addListener: vi.fn() }, + remove, + }; + + await registerAddToMemoryMenu(menusApi); + await registerAddToMemoryMenu(menusApi); + + expect(remove).toHaveBeenCalledTimes(2); + expect(remove).toHaveBeenCalledWith(ADD_TO_MEMORY_MENU_ID); + expect(create).toHaveBeenCalledTimes(2); + expect(create).toHaveBeenCalledWith({ + contexts: ['selection'], + id: ADD_TO_MEMORY_MENU_ID, + title: 'Add to memory', + }); + }); + + it('awaits remove before create so a deferred remove cannot delete a new item', async () => { + const order: string[] = []; + const settleBox: { current: (() => void) | undefined } = { current: undefined }; + // eslint-disable-next-line promise/avoid-new -- deferred remove to prove create waits + const removePending = new Promise(resolve => { + settleBox.current = resolve; + }); + + const create = vi.fn(() => { + order.push('create'); + }); + const remove = vi.fn(async () => { + order.push('remove-start'); + await removePending; + order.push('remove-settled'); + }); + const menusApi: NativeContextMenusApi = { + create, + onClicked: { addListener: vi.fn() }, + remove, + }; + + const registration = registerAddToMemoryMenu(menusApi); + + // Remove has started; create must not run until remove settles. + expect(order).toStrictEqual(['remove-start']); + expect(create).not.toHaveBeenCalled(); + + settleBox.current?.(); + await registration; + + expect(order).toStrictEqual(['remove-start', 'remove-settled', 'create']); + expect(create).toHaveBeenCalledWith({ + contexts: ['selection'], + id: ADD_TO_MEMORY_MENU_ID, + title: 'Add to memory', + }); + }); + + it('still creates after remove rejects (item may not exist yet)', async () => { + const create = vi.fn(); + // eslint-disable-next-line require-await -- async remove rejection fake + const remove = vi.fn(async () => { + throw new Error('no such menu item'); + }); + const menusApi: NativeContextMenusApi = { + create, + onClicked: { addListener: vi.fn() }, + remove, + }; + + await expect(registerAddToMemoryMenu(menusApi)).resolves.toBeUndefined(); + expect(create).toHaveBeenCalledWith({ + contexts: ['selection'], + id: ADD_TO_MEMORY_MENU_ID, + title: 'Add to memory', + }); + }); + + it('swallows create errors when the menu id already exists', async () => { + const menusApi: NativeContextMenusApi = { + create: vi.fn(() => { + throw new Error('duplicate id'); + }), + onClicked: { addListener: vi.fn() }, + }; + + await expect(registerAddToMemoryMenu(menusApi)).resolves.toBeUndefined(); + }); + + it('no-ops when the menus API is unavailable', async () => { + await expect(registerAddToMemoryMenu()).resolves.toBeUndefined(); + }); +}); + +describe('side panel open helpers', () => { + it('prefers sidePanel.open with the tab window id', async () => { + const open = vi.fn(); + const sidebarOpen = vi.fn(); + + await openSidePanelInWindow({ + sidePanelOpen: { open }, + sidebarAction: { open: sidebarOpen }, + windowId: 7, + }); + + expect(open).toHaveBeenCalledWith({ windowId: 7 }); + expect(sidebarOpen).not.toHaveBeenCalled(); + }); + + it('falls back to sidebarAction.open', async () => { + const sidebarOpen = vi.fn(); + + await openSidePanelInWindow({ + sidebarAction: { open: sidebarOpen }, + windowId: 3, + }); + + // Oxlint vitest prefer-called-once vs prefer-called-times conflict on count=1. + // eslint-disable-next-line vitest/prefer-called-once -- matches package call-count style + expect(sidebarOpen).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when no open API is available', () => { + expect( + openSidePanelInWindow({ + windowId: 1, + }) + ).toBeUndefined(); + }); +}); diff --git a/apps/extension/src/shared/side-panel.ts b/apps/extension/src/shared/side-panel.ts index 56aa5046b8..459d407eb7 100644 --- a/apps/extension/src/shared/side-panel.ts +++ b/apps/extension/src/shared/side-panel.ts @@ -5,3 +5,88 @@ export interface NativeSidePanelApi { export const enableActionClickSidePanel = async (sidePanel?: NativeSidePanelApi): Promise => { await sidePanel?.setPanelBehavior({ openPanelOnActionClick: true }); }; + +export const ADD_TO_MEMORY_MENU_ID = 'kilo-add-to-memory'; + +export interface NativeContextMenusCreateOptions { + id: string; + title: string; + contexts: ['selection']; +} + +export interface NativeContextMenusOnClickData { + menuItemId: string | number; + selectionText?: string | undefined; + pageUrl?: string | undefined; +} + +export interface NativeContextMenusTab { + title?: string | undefined; + url?: string | undefined; + windowId?: number | undefined; +} + +export type NativeContextMenusClickListener = ( + info: NativeContextMenusOnClickData, + tab?: NativeContextMenusTab +) => void; + +export interface NativeContextMenusApi { + create(options: NativeContextMenusCreateOptions): unknown; + remove?(menuItemId: string): Promise | void; + onClicked: { + addListener(listener: NativeContextMenusClickListener): void; + }; +} + +export interface NativeSidePanelOpenApi { + open(options: { windowId: number }): Promise | void; +} + +export interface NativeSidebarActionApi { + open(): Promise | void; +} + +export const registerAddToMemoryMenu = async (menusApi?: NativeContextMenusApi): Promise => { + if (menusApi === undefined) { + return; + } + + const options: NativeContextMenusCreateOptions = { + contexts: ['selection'], + id: ADD_TO_MEMORY_MENU_ID, + title: 'Add to memory', + }; + + if (typeof menusApi.remove === 'function') { + try { + await menusApi.remove(ADD_TO_MEMORY_MENU_ID); + } catch { + // Item may not exist yet on a fresh service-worker start. + } + } + + try { + menusApi.create(options); + } catch { + // Swallow duplicate-id errors when remove is unavailable or races. + } +}; + +export const openSidePanelInWindow = ({ + sidePanelOpen, + sidebarAction, + windowId, +}: { + sidePanelOpen?: NativeSidePanelOpenApi | undefined; + sidebarAction?: NativeSidebarActionApi | undefined; + windowId: number; +}): Promise | void => { + if (sidePanelOpen !== undefined) { + return sidePanelOpen.open({ windowId }); + } + + if (sidebarAction !== undefined) { + return sidebarAction.open(); + } +}; diff --git a/apps/extension/src/shared/tab-context-sanitize.test.ts b/apps/extension/src/shared/tab-context-sanitize.test.ts new file mode 100644 index 0000000000..3ad4040ed3 --- /dev/null +++ b/apps/extension/src/shared/tab-context-sanitize.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeTabContextText, sanitizeTabContextUrl } from './tab-context-sanitize'; + +describe('tab context text sanitizing', () => { + it('escapes ampersand before angle brackets so entities are not double-escaped', () => { + expect(sanitizeTabContextText('a & b < c > d')).toBe('a & b < c > d'); + expect(sanitizeTabContextText('<already>')).toBe('&lt;already&gt;'); + }); + + it('leaves plain text unchanged', () => { + expect(sanitizeTabContextText('plain title')).toBe('plain title'); + }); +}); + +describe('tab context URL sanitizing', () => { + it('strips query and hash while preserving origin and path', () => { + expect(sanitizeTabContextUrl('https://example.com/path?q=1#section')).toBe( + 'https://example.com/path' + ); + }); + + it('returns the invalid-URL fallback when parsing fails', () => { + expect(sanitizeTabContextUrl('not a url')).toBe('[invalid URL]'); + expect(sanitizeTabContextUrl('')).toBe('[invalid URL]'); + }); +}); diff --git a/apps/extension/src/shared/tab-context-sanitize.ts b/apps/extension/src/shared/tab-context-sanitize.ts new file mode 100644 index 0000000000..b9e760a4ef --- /dev/null +++ b/apps/extension/src/shared/tab-context-sanitize.ts @@ -0,0 +1,15 @@ +export const sanitizeTabContextText = (text: string): string => + text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + +export const sanitizeTabContextUrl = (url: string): string => { + try { + const parsedUrl = new URL(url); + + parsedUrl.search = ''; + parsedUrl.hash = ''; + + return parsedUrl.toString(); + } catch { + return '[invalid URL]'; + } +}; diff --git a/apps/extension/tests/e2e/firefox-extension.test.ts b/apps/extension/tests/e2e/firefox-extension.test.ts index 852af08d9e..6765066636 100644 --- a/apps/extension/tests/e2e/firefox-extension.test.ts +++ b/apps/extension/tests/e2e/firefox-extension.test.ts @@ -74,6 +74,7 @@ test('firefox build installs as a running add-on without invalid manifest warnin sidebar_action: sidebarAction, } = manifest; + expect(permissions).toContain('contextMenus'); expect(permissions).toContain('storage'); expect(permissions).toContain('scripting'); expect(permissions).toContain('tabs'); diff --git a/apps/extension/tests/e2e/sidebar.test.ts b/apps/extension/tests/e2e/sidebar.test.ts index 9ae5faa6d5..a5d0a316dc 100644 --- a/apps/extension/tests/e2e/sidebar.test.ts +++ b/apps/extension/tests/e2e/sidebar.test.ts @@ -72,6 +72,7 @@ test('native side panel is outside the page DOM', async () => { expect(manifest.side_panel?.default_path).toBe('sidepanel.html'); expect(manifest.host_permissions).toContain('file:///*'); expect(manifest.host_permissions).toContain('https://app.kilo.ai/*'); + expect(manifest.permissions).toContain('contextMenus'); expect(manifest.permissions).toContain('debugger'); expect(manifest.permissions).toContain('sidePanel'); expect(manifest.action?.default_popup).toBeUndefined(); diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 3d9ca95da4..0e3b192f3d 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -26,8 +26,8 @@ export default defineConfig({ name: 'Kilo Code', permissions: browser === 'firefox' - ? ['identity', 'scripting', 'storage', 'tabs'] - : ['debugger', 'identity', 'scripting', 'storage'], + ? ['contextMenus', 'identity', 'scripting', 'storage', 'tabs'] + : ['contextMenus', 'debugger', 'identity', 'scripting', 'storage'], }), modules: ['@wxt-dev/module-react'], vite: () => ({ From bdb4ec9661a331dd6dffb4051b2299e2e6546957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:00:24 +0200 Subject: [PATCH 2/4] extension: add memory save card and settings memories section --- .../entrypoints/sidepanel/auth-shell.tsx | 7 +- .../entrypoints/sidepanel/auth-views.tsx | 2 + .../sidepanel/memory-settings-state.test.ts | 124 +++++++++ .../sidepanel/memory-settings-state.ts | 77 ++++++ .../entrypoints/sidepanel/memory-settings.tsx | 74 ++++++ .../pending-memory-save-card-state.test.ts | 227 +++++++++++++++++ .../pending-memory-save-card-state.ts | 99 ++++++++ .../sidepanel/pending-memory-save-card.tsx | 235 ++++++++++++++++++ .../sidepanel/settings-dialog-state.test.ts | 10 + .../sidepanel/settings-dialog-state.ts | 3 + 10 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 apps/extension/entrypoints/sidepanel/memory-settings-state.test.ts create mode 100644 apps/extension/entrypoints/sidepanel/memory-settings-state.ts create mode 100644 apps/extension/entrypoints/sidepanel/memory-settings.tsx create mode 100644 apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.test.ts create mode 100644 apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts create mode 100644 apps/extension/entrypoints/sidepanel/pending-memory-save-card.tsx create mode 100644 apps/extension/entrypoints/sidepanel/settings-dialog-state.test.ts create mode 100644 apps/extension/entrypoints/sidepanel/settings-dialog-state.ts diff --git a/apps/extension/entrypoints/sidepanel/auth-shell.tsx b/apps/extension/entrypoints/sidepanel/auth-shell.tsx index 38bb118636..75cacf98d6 100644 --- a/apps/extension/entrypoints/sidepanel/auth-shell.tsx +++ b/apps/extension/entrypoints/sidepanel/auth-shell.tsx @@ -1,11 +1,13 @@ -import { useState } from 'react'; +import { useAtom } from 'jotai'; import type { JSX, ReactNode } from 'react'; import { Settings, X } from 'lucide-react'; import type { StoredAuth } from '@/src/shared/auth'; import type { KiloOrganizationOption } from '@/src/shared/kilo-api-client'; import { KiloLogo } from '@/src/shared/kilo-logo'; +import { MemorySettings } from './memory-settings'; import { OrganizationCreditAccountSelect } from './organization-credit-account'; import { RemoteMcpSettings } from './remote-mcp-settings'; +import { settingsDialogOpenAtom } from './settings-dialog-state'; const emptyOrganizationOptions: KiloOrganizationOption[] = []; @@ -43,7 +45,7 @@ const HeaderActions = ({ organizationOptions: KiloOrganizationOption[]; selectedOrganizationId: string; }): JSX.Element => { - const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useAtom(settingsDialogOpenAtom); return (
@@ -82,6 +84,7 @@ const HeaderActions = ({

Signed in

{auth.userEmail ?? 'Kilo user'}

+ ( @@ -147,6 +148,7 @@ export const SignedInView = ({ organizationOptions={organizationOptions} selectedOrganizationId={selectedOrganizationId} > + = {}): AgentMemory => ({ + createdAt: 1_700_000_000_000, + id: 'mem-1', + pageTitle: 'Example', + pageUrl: 'https://example.com/path', + text: 'Stored text about widgets', + ...overrides, +}); + +describe('memory source domain formatting', () => { + it('returns hostname for http(s) URLs', () => { + expect(formatMemorySourceDomain('https://docs.example.com/a?q=1')).toBe('docs.example.com'); + }); + + it('omits empty, invalid, and file URLs', () => { + expect(formatMemorySourceDomain('')).toBeUndefined(); + expect(formatMemorySourceDomain('[invalid URL]')).toBeUndefined(); + expect(formatMemorySourceDomain('not a url')).toBeUndefined(); + expect(formatMemorySourceDomain('file:///tmp/x')).toBeUndefined(); + }); +}); + +describe('memory list date formatting', () => { + it('formats UTC YYYY-MM-DD', () => { + expect(formatMemoryListDate(Date.UTC(2024, 0, 15, 12, 0, 0))).toBe('2024-01-15'); + }); +}); + +describe('memory settings list item mapping', () => { + it('prefers note for preview and builds a unique delete label', () => { + const item = toMemorySettingsListItem( + memory({ note: 'Remember this', text: 'Longer body text' }) + ); + expect(item.preview).toBe('Remember this'); + expect(item.deleteAriaLabel).toBe('Delete memory "Remember this"'); + expect(item.domain).toBe('example.com'); + }); + + it('falls back to text when note is absent', () => { + expect(toMemorySettingsListItem(memory()).preview).toBe('Stored text about widgets'); + }); +}); + +describe('memories settings view selection', () => { + it('shows loading while not loaded', () => { + expect( + deriveMemoriesSettingsView({ + isLoaded: false, + loadError: false, + memories: [memory()], + }) + ).toStrictEqual({ kind: 'loading' }); + }); + + it('shows load error after load fails', () => { + expect( + deriveMemoriesSettingsView({ + isLoaded: true, + loadError: true, + memories: [], + }) + ).toStrictEqual({ kind: 'loadError' }); + }); + + it('shows empty when loaded with zero memories', () => { + expect( + deriveMemoriesSettingsView({ + isLoaded: true, + loadError: false, + memories: [], + }) + ).toStrictEqual({ kind: 'empty' }); + }); + + it('lists memories newest-first', () => { + const older = memory({ createdAt: 100, id: 'old', text: 'Older' }); + const newer = memory({ createdAt: 200, id: 'new', text: 'Newer' }); + const view = deriveMemoriesSettingsView({ + isLoaded: true, + loadError: false, + memories: [older, newer], + }); + + expect(view).toStrictEqual({ + items: [ + { + dateLabel: formatMemoryListDate(200), + deleteAriaLabel: 'Delete memory "Newer"', + domain: 'example.com', + id: 'new', + preview: 'Newer', + }, + { + dateLabel: formatMemoryListDate(100), + deleteAriaLabel: 'Delete memory "Older"', + domain: 'example.com', + id: 'old', + preview: 'Older', + }, + ], + kind: 'list', + }); + }); + + it('does not flash empty during loadError when prior memories exist', () => { + // LoadError wins after isLoaded — list is not shown until reload succeeds. + expect( + deriveMemoriesSettingsView({ + isLoaded: true, + loadError: true, + memories: [memory()], + }) + ).toStrictEqual({ kind: 'loadError' }); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/memory-settings-state.ts b/apps/extension/entrypoints/sidepanel/memory-settings-state.ts new file mode 100644 index 0000000000..81bdf73c2b --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/memory-settings-state.ts @@ -0,0 +1,77 @@ +import type { AgentMemory } from '@/src/shared/agent-memories'; +import { buildMemoryPreviewLabel } from './pending-memory-save-card-state'; + +export type MemoriesSettingsView = + | { kind: 'loading' } + | { kind: 'loadError' } + | { kind: 'empty' } + | { kind: 'list'; items: readonly MemorySettingsListItem[] }; + +export interface MemorySettingsListItem { + id: string; + preview: string; + domain: string | undefined; + dateLabel: string; + deleteAriaLabel: string; +} + +const sortByCreatedAtDesc = (memories: readonly AgentMemory[]): AgentMemory[] => + [...memories].toSorted((left, right) => right.createdAt - left.createdAt); + +export const formatMemorySourceDomain = (pageUrl: string): string | undefined => { + if (pageUrl === '') { + return undefined; + } + + try { + const parsed = new URL(pageUrl); + if (parsed.protocol === 'file:') { + return undefined; + } + + return parsed.hostname === '' ? undefined : parsed.hostname; + } catch { + return undefined; + } +}; + +export const formatMemoryListDate = (createdAt: number): string => + new Date(createdAt).toISOString().slice(0, 10); + +export const toMemorySettingsListItem = (memory: AgentMemory): MemorySettingsListItem => { + const preview = buildMemoryPreviewLabel(memory); + return { + dateLabel: formatMemoryListDate(memory.createdAt), + deleteAriaLabel: `Delete memory "${preview}"`, + domain: formatMemorySourceDomain(memory.pageUrl), + id: memory.id, + preview, + }; +}; + +export const deriveMemoriesSettingsView = ({ + isLoaded, + loadError, + memories, +}: { + isLoaded: boolean; + loadError: boolean; + memories: readonly AgentMemory[]; +}): MemoriesSettingsView => { + if (!isLoaded) { + return { kind: 'loading' }; + } + + if (loadError) { + return { kind: 'loadError' }; + } + + if (memories.length === 0) { + return { kind: 'empty' }; + } + + return { + items: sortByCreatedAtDesc(memories).map(entry => toMemorySettingsListItem(entry)), + kind: 'list', + }; +}; diff --git a/apps/extension/entrypoints/sidepanel/memory-settings.tsx b/apps/extension/entrypoints/sidepanel/memory-settings.tsx new file mode 100644 index 0000000000..37047f19cd --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/memory-settings.tsx @@ -0,0 +1,74 @@ +import { storage } from '#imports'; +import { Trash2 } from 'lucide-react'; +import type { JSX } from 'react'; +import { deleteAgentMemory } from '@/src/shared/agent-memories-storage'; +import { deriveMemoriesSettingsView } from './memory-settings-state'; +import { useAgentMemories } from './use-agent-memories'; + +const EMPTY_MESSAGE = + 'No memories yet. Highlight text on any page, right-click, and choose Add to memory.'; +const LOAD_ERROR_MESSAGE = "Couldn't load memories. Try again."; + +const secondaryButtonClass = + 'h-8 rounded-md border border-zinc-700 px-3 text-sm font-medium text-zinc-200 transition hover:border-zinc-600 hover:bg-zinc-900 focus:outline-none focus:ring-2 focus:ring-[#EDFF00] focus:ring-offset-2 focus:ring-offset-zinc-950'; + +export const MemorySettings = (): JSX.Element => { + const { isLoaded, loadError, memories, reload } = useAgentMemories(); + const view = deriveMemoriesSettingsView({ isLoaded, loadError, memories }); + + return ( +
+

Memories

+ + {view.kind === 'loading' ?

Loading…

: null} + + {view.kind === 'loadError' ? ( +
+

{LOAD_ERROR_MESSAGE}

+
+ +
+
+ ) : null} + + {view.kind === 'empty' ?

{EMPTY_MESSAGE}

: null} + + {view.kind === 'list' ? ( +
    + {view.items.map(item => ( +
  • +
    +

    + {item.preview} +

    +

    + {item.domain === undefined + ? item.dateLabel + : `${item.domain} · ${item.dateLabel}`} +

    +
    + +
  • + ))} +
+ ) : null} +
+ ); +}; diff --git a/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.test.ts b/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.test.ts new file mode 100644 index 0000000000..9539545bc3 --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentMemory, PendingAgentMemoryDraft } from '@/src/shared/agent-memories'; +import { MAX_MEMORY_COUNT, MAX_MEMORY_NOTE_LENGTH } from '@/src/shared/agent-memories'; +import { AgentMemoryStoreFullError } from '@/src/shared/agent-memories-storage'; +import { + buildDeleteMemoryAriaLabel, + buildDraftSelectionPreview, + buildMemoryPreviewLabel, + classifySaveError, + deriveNoteCharacterCount, + deriveSaveCardState, +} from './pending-memory-save-card-state'; + +const draft = (overrides: Partial = {}): PendingAgentMemoryDraft => ({ + createdAt: 1_700_000_000_000, + pageTitle: 'Example', + pageUrl: 'https://example.com/page', + text: 'Selected text', + ...overrides, +}); + +const memory = (overrides: Partial = {}): AgentMemory => ({ + createdAt: 1_700_000_000_000, + id: 'mem-1', + pageTitle: 'Example', + pageUrl: 'https://example.com/page', + text: 'Stored text', + ...overrides, +}); + +const baseInput = { + isLoaded: true, + loadError: false, + memories: [] as AgentMemory[], + pendingDraft: undefined as PendingAgentMemoryDraft | undefined, + saveError: undefined as string | undefined, + savedConfirmation: false, +}; + +const fullMemories = (): AgentMemory[] => + Array.from({ length: MAX_MEMORY_COUNT }, (_unused, index) => + memory({ createdAt: index, id: `mem-${index}` }) + ); + +describe('save card state machine', () => { + it('hides while not loaded (branch 1)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + isLoaded: false, + pendingDraft: draft(), + }) + ).toStrictEqual({ kind: 'hidden' }); + }); + + it('hides when there is no draft and no confirmation (branch 2)', () => { + expect(deriveSaveCardState(baseInput)).toStrictEqual({ kind: 'hidden' }); + }); + + it('stays hidden on loadError with no known draft (branch 2 silent-by-design)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + loadError: true, + }) + ).toStrictEqual({ kind: 'hidden' }); + }); + + it('shows load error when draft is known (branch 3)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + loadError: true, + pendingDraft: draft(), + }) + ).toStrictEqual({ kind: 'loadError' }); + }); + + it('shows full when store is at max and draft exists (branch 4)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + memories: fullMemories(), + pendingDraft: draft(), + }) + ).toStrictEqual({ kind: 'full' }); + }); + + it('full wins over saveError (priority conflict)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + memories: fullMemories(), + pendingDraft: draft(), + saveError: "Couldn't save memory. Try again.", + }) + ).toStrictEqual({ kind: 'full' }); + }); + + it('does not show full while loadError is set even if count is max', () => { + expect( + deriveSaveCardState({ + ...baseInput, + loadError: true, + memories: fullMemories(), + pendingDraft: draft(), + }) + ).toStrictEqual({ kind: 'loadError' }); + }); + + it('shows save error when draft and saveError are set (branch 5)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + pendingDraft: draft(), + saveError: "Couldn't save memory. Try again.", + }) + ).toStrictEqual({ + kind: 'saveError', + message: "Couldn't save memory. Try again.", + }); + }); + + it('shows draft form when draft exists (branch 6)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + pendingDraft: draft(), + }) + ).toStrictEqual({ kind: 'draft' }); + }); + + it('shows confirmation when savedConfirmation is true and no draft (branch 7)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + savedConfirmation: true, + }) + ).toStrictEqual({ kind: 'confirmation' }); + }); + + it('loadError + confirmation yields confirmation (no draft → branches 1–2 miss; 3 needs draft)', () => { + expect( + deriveSaveCardState({ + ...baseInput, + loadError: true, + savedConfirmation: true, + }) + ).toStrictEqual({ kind: 'confirmation' }); + }); + + it('draft wins over confirmation when both are present', () => { + expect( + deriveSaveCardState({ + ...baseInput, + pendingDraft: draft(), + savedConfirmation: true, + }) + ).toStrictEqual({ kind: 'draft' }); + }); + + it('encodes new-draft-wins after effect clears savedConfirmation', () => { + expect( + deriveSaveCardState({ + ...baseInput, + pendingDraft: draft({ createdAt: 2 }), + // Effect clears confirmation when a new draft arrives. + savedConfirmation: false, + }) + ).toStrictEqual({ kind: 'draft' }); + }); +}); + +describe('save error classification', () => { + it('classifies AgentMemoryStoreFullError as full', () => { + expect(classifySaveError(new AgentMemoryStoreFullError())).toBe('full'); + }); + + it('classifies other errors as retryable', () => { + expect(classifySaveError(new Error('quota'))).toBe('retryable'); + expect(classifySaveError('string error')).toBe('retryable'); + expect(classifySaveError(null)).toBe('retryable'); + }); +}); + +describe('draft selection preview', () => { + it('returns the first three lines', () => { + expect(buildDraftSelectionPreview('a\nb\nc\nd\ne')).toBe('a\nb\nc'); + }); + + it('returns the full text when fewer than three lines', () => { + expect(buildDraftSelectionPreview('only one')).toBe('only one'); + }); +}); + +describe('memory preview label', () => { + it('prefers note over text', () => { + expect(buildMemoryPreviewLabel({ note: 'My note', text: 'Body text' })).toBe('My note'); + }); + + it('falls back to text when note is missing or blank', () => { + expect(buildMemoryPreviewLabel({ text: 'Body text' })).toBe('Body text'); + expect(buildMemoryPreviewLabel({ note: ' ', text: 'Body text' })).toBe('Body text'); + }); + + it('truncates to ~40 characters', () => { + const long = 'x'.repeat(50); + expect(buildMemoryPreviewLabel({ text: long })).toBe('x'.repeat(40)); + }); +}); + +describe('delete memory aria label', () => { + it('uses the preview inside the Delete memory label', () => { + expect(buildDeleteMemoryAriaLabel({ note: 'Alpha', text: 'Body' })).toBe( + 'Delete memory "Alpha"' + ); + }); +}); + +describe('note character count', () => { + it('reports length against the note max', () => { + expect(deriveNoteCharacterCount('hi')).toStrictEqual({ + count: 2, + max: MAX_MEMORY_NOTE_LENGTH, + }); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts b/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts new file mode 100644 index 0000000000..d6b0c3e09d --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts @@ -0,0 +1,99 @@ +import type { AgentMemory, PendingAgentMemoryDraft } from '@/src/shared/agent-memories'; +import { MAX_MEMORY_COUNT, MAX_MEMORY_NOTE_LENGTH } from '@/src/shared/agent-memories'; +import { AgentMemoryStoreFullError } from '@/src/shared/agent-memories-storage'; + +export const MEMORY_DELETE_PREVIEW_LENGTH = 40; +export const MEMORY_DRAFT_PREVIEW_LINE_COUNT = 3; + +export type SaveCardView = + | { kind: 'hidden' } + | { kind: 'loadError' } + | { kind: 'full' } + | { kind: 'saveError'; message: string } + | { kind: 'draft' } + | { kind: 'confirmation' }; + +export type SaveErrorClassification = 'full' | 'retryable'; + +const collapseWhitespace = (value: string): string => value.trim().replaceAll(/\s+/g, ' '); + +/** First ~3 lines of selection text for the draft card preview. */ +export const buildDraftSelectionPreview = (text: string): string => { + const lines = text.split(/\r?\n/); + return lines.slice(0, MEMORY_DRAFT_PREVIEW_LINE_COUNT).join('\n'); +}; + +/** First ~40 chars of note-or-text for delete accessible names and list rows. */ +export const buildMemoryPreviewLabel = (memory: { + note?: string | undefined; + text: string; +}): string => { + const source = + memory.note !== undefined && memory.note.trim().length > 0 ? memory.note : memory.text; + const collapsed = collapseWhitespace(source); + if (collapsed.length <= MEMORY_DELETE_PREVIEW_LENGTH) { + return collapsed; + } + + return collapsed.slice(0, MEMORY_DELETE_PREVIEW_LENGTH); +}; + +export const buildDeleteMemoryAriaLabel = (memory: { + note?: string | undefined; + text: string; +}): string => `Delete memory "${buildMemoryPreviewLabel(memory)}"`; + +export const deriveNoteCharacterCount = ( + note: string +): { + count: number; + max: number; +} => ({ + count: note.length, + max: MAX_MEMORY_NOTE_LENGTH, +}); + +export const classifySaveError = (error: unknown): SaveErrorClassification => + error instanceof AgentMemoryStoreFullError ? 'full' : 'retryable'; + +export const deriveSaveCardState = ({ + isLoaded, + loadError, + memories, + pendingDraft, + savedConfirmation, + saveError, +}: { + isLoaded: boolean; + loadError: boolean; + memories: readonly AgentMemory[]; + pendingDraft: PendingAgentMemoryDraft | undefined; + savedConfirmation: boolean; + saveError: string | undefined; +}): SaveCardView => { + if (!isLoaded) { + return { kind: 'hidden' }; + } + + if (pendingDraft === undefined && !savedConfirmation) { + return { kind: 'hidden' }; + } + + if (loadError && pendingDraft !== undefined) { + return { kind: 'loadError' }; + } + + if (!loadError && pendingDraft !== undefined && memories.length >= MAX_MEMORY_COUNT) { + return { kind: 'full' }; + } + + if (pendingDraft !== undefined && saveError !== undefined) { + return { kind: 'saveError', message: saveError }; + } + + if (pendingDraft !== undefined) { + return { kind: 'draft' }; + } + + return { kind: 'confirmation' }; +}; diff --git a/apps/extension/entrypoints/sidepanel/pending-memory-save-card.tsx b/apps/extension/entrypoints/sidepanel/pending-memory-save-card.tsx new file mode 100644 index 0000000000..1d1d49f05a --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/pending-memory-save-card.tsx @@ -0,0 +1,235 @@ +import { storage } from '#imports'; +import { useSetAtom } from 'jotai'; +import { useEffect, useRef, useState } from 'react'; +import type { JSX } from 'react'; +import { MAX_MEMORY_NOTE_LENGTH } from '@/src/shared/agent-memories'; +import { addAgentMemory, clearPendingAgentMemoryDraft } from '@/src/shared/agent-memories-storage'; +import { + buildDraftSelectionPreview, + classifySaveError, + deriveNoteCharacterCount, + deriveSaveCardState, +} from './pending-memory-save-card-state'; +import { settingsDialogOpenAtom } from './settings-dialog-state'; +import { useAgentMemories } from './use-agent-memories'; + +const SAVE_ERROR_MESSAGE = "Couldn't save memory. Try again."; +const LOAD_ERROR_MESSAGE = "Couldn't load memories. Try again."; +const FULL_MESSAGE = 'Memory is full. Delete memories to save new ones.'; +const CONFIRMATION_MESSAGE = 'Saved to memory'; +const TRUNCATION_NOTICE = 'Selection truncated to 8,000 characters'; + +const secondaryButtonClass = + 'h-8 rounded-md border border-zinc-700 px-3 text-sm font-medium text-zinc-200 transition hover:border-zinc-600 hover:bg-zinc-900 focus:outline-none focus:ring-2 focus:ring-[#EDFF00] focus:ring-offset-2 focus:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50'; + +const primaryButtonClass = + 'h-8 rounded-md bg-[#EDFF00] px-3 text-sm font-semibold text-zinc-950 transition hover:bg-[#d9ea00] focus:outline-none focus:ring-2 focus:ring-[#EDFF00] focus:ring-offset-2 focus:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:bg-zinc-800 disabled:text-zinc-500'; + +export const PendingMemorySaveCard = (): JSX.Element | null => { + const { isLoaded, loadError, memories, pendingDraft, reload } = useAgentMemories(); + const setSettingsOpen = useSetAtom(settingsDialogOpenAtom); + const [savedConfirmation, setSavedConfirmation] = useState(false); + const [saveError, setSaveError] = useState(); + const [note, setNote] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const lastDraftKeyRef = useRef(null); + + useEffect(() => { + if (pendingDraft === undefined) { + lastDraftKeyRef.current = null; + return; + } + + const draftKey = `${pendingDraft.createdAt}:${pendingDraft.text}`; + if (lastDraftKeyRef.current !== null && lastDraftKeyRef.current !== draftKey) { + setSavedConfirmation(false); + setSaveError(undefined); + setNote(''); + } else if (lastDraftKeyRef.current === null) { + // Fresh draft while confirmation may still be showing from a prior save. + setSavedConfirmation(false); + setSaveError(undefined); + } + + lastDraftKeyRef.current = draftKey; + }, [pendingDraft]); + + const view = deriveSaveCardState({ + isLoaded, + loadError, + memories, + pendingDraft, + saveError, + savedConfirmation, + }); + + if (view.kind === 'hidden') { + return null; + } + + const handleCancel = (): void => { + void clearPendingAgentMemoryDraft(storage); + setSavedConfirmation(false); + setSaveError(undefined); + setNote(''); + }; + + const handleDone = (): void => { + setSavedConfirmation(false); + setSaveError(undefined); + setNote(''); + }; + + const handleSave = async (): Promise => { + if (pendingDraft === undefined || isSaving) { + return; + } + + setIsSaving(true); + try { + const trimmedNote = note.trim(); + await addAgentMemory(storage, { + createdAt: pendingDraft.createdAt, + pageTitle: pendingDraft.pageTitle, + pageUrl: pendingDraft.pageUrl, + text: pendingDraft.text, + ...(pendingDraft.truncated === undefined ? {} : { truncated: pendingDraft.truncated }), + ...(trimmedNote.length === 0 ? {} : { note: trimmedNote }), + }); + await clearPendingAgentMemoryDraft(storage); + setSaveError(undefined); + setSavedConfirmation(true); + setNote(''); + } catch (error) { + if (classifySaveError(error) === 'full') { + // Reactive full view is already correct — do not set saveError. + return; + } + + setSaveError(SAVE_ERROR_MESSAGE); + } finally { + setIsSaving(false); + } + }; + + const noteCount = deriveNoteCharacterCount(note); + + return ( +
+
+ {view.kind === 'confirmation' ? ( +
+

{CONFIRMATION_MESSAGE}

+
+ +
+
+ ) : null} + + {view.kind === 'loadError' ? ( +
+

{LOAD_ERROR_MESSAGE}

+
+ +
+
+ ) : null} + + {view.kind === 'full' || view.kind === 'saveError' || view.kind === 'draft' ? ( +
+ {view.kind === 'full' ? ( +
+

{FULL_MESSAGE}

+
+ +
+
+ ) : null} + + {view.kind === 'saveError' ? ( +
+

{view.message}

+
+ +
+
+ ) : null} + + {pendingDraft === undefined ? null : ( + <> +
+

Selection

+

+ {buildDraftSelectionPreview(pendingDraft.text)} +

+ {pendingDraft.truncated === true ? ( +

{TRUNCATION_NOTICE}

+ ) : null} +
+ +
+ +