diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 0885b63a26..8536892ebb 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -220,10 +220,11 @@ does not remove the search tool, and searching does not alter the session's pinn Search sits behind the `FactSearchIndex` port (`memory/fact-search.ts`): the session service builds an engine-independent `FactQuery` from authorized partitions and shapes the bounded response, and `LexicalFactIndex` answers it with one escaped-`LIKE` statement ranked in SQL by -`FACT_SEARCH_FIELDS`. A different engine (Postgres full-text search, embeddings) is another adapter; -no migration or new service is needed today. Body matching still scans text; bounded results do not -guarantee constant query cost. The opt-in Node SQLite benchmark records query plans and -rare/broad-query timings at 1,000 and 10,000 facts with representative and maximum-size bodies: +`FACT_SEARCH_WEIGHTS` over `MEMORY_SEARCH_FIELDS` (`memory/search-fields.ts`). A different engine +(Postgres full-text search, embeddings) is another adapter; no migration or new service is needed +today. Body matching still scans text; bounded results do not guarantee constant query cost. The +opt-in Node SQLite benchmark records query plans and rare/broad-query timings at 1,000 and 10,000 +facts with representative and maximum-size bodies: ```bash MEMORY_SEARCH_BENCHMARK=1 MEMORY_SEARCH_BENCHMARK_OUTPUT=/tmp/memory-search-benchmark.json \ diff --git a/packages/control-plane/src/db/lexical-fact-index.ts b/packages/control-plane/src/db/lexical-fact-index.ts index 0091285314..6ed91ec632 100644 --- a/packages/control-plane/src/db/lexical-fact-index.ts +++ b/packages/control-plane/src/db/lexical-fact-index.ts @@ -1,8 +1,6 @@ import { - FACT_SEARCH_FIELDS, type FactHit, type FactQuery, - type FactSearchField, type FactSearchIndex, type FactSearchPartition, } from "../memory/fact-search"; @@ -13,8 +11,10 @@ import { type PartitionColumns, type ScopeDisplayColumns, } from "../memory/partition"; -import { likeContains, LIKE_ESCAPE_CLAUSE } from "./like-pattern"; -import { CURRENT_MEMORY } from "./memory-queries"; +import { likeContains } from "./like-pattern"; +import { FACT_SEARCH_WEIGHTS } from "../memory/fact-search"; +import { MEMORY_SEARCH_FIELDS } from "../memory/search-fields"; +import { CURRENT_MEMORY, revisionFieldMatches, revisionMatchesAllTerms } from "./memory-queries"; import type { SqlDatabase } from "./sql-database"; import { prepareSql, sql, type SqlFragment } from "./sql-fragment"; @@ -25,28 +25,12 @@ interface FactHitRow extends PartitionColumns, ScopeDisplayColumns { description: string; } -const COLUMNS: Record = { - title: sql`lower(r.title)`, - description: sql`lower(r.description)`, - content: sql`lower(r.content)`, -}; -const LIKE_ESCAPE = sql.constant(LIKE_ESCAPE_CLAUSE); - -const matches = (field: FactSearchField, pattern: string) => - sql`${COLUMNS[field]} LIKE ${pattern} ${LIKE_ESCAPE}`; - -/** The term matches at least one searchable field. */ -const matchesAnyField = (pattern: string) => - sql`(${sql.join( - FACT_SEARCH_FIELDS.map(({ field }) => matches(field, pattern)), - " OR " - )})`; - /** The weight of the strongest field the term matches (fields are listed strongest first). */ const strongestFieldWeight = (pattern: string) => sql`(CASE ${sql.join( - FACT_SEARCH_FIELDS.map( - ({ field, weight }) => sql`WHEN ${matches(field, pattern)} THEN ${weight}` + MEMORY_SEARCH_FIELDS.map( + (field) => + sql`WHEN ${revisionFieldMatches(field, pattern)} THEN ${FACT_SEARCH_WEIGHTS[field]}` ), " " )} ELSE 0 END)`; @@ -60,7 +44,7 @@ function searchable({ partition, pinnedIn }: FactSearchPartition): SqlFragment { /** * Portable lexical search: escaped `LIKE` over current active facts, ranked in SQL by - * {@link FACT_SEARCH_FIELDS} in a single statement. Body matching scans text, so response + * {@link FACT_SEARCH_WEIGHTS} in a single statement. Body matching scans text, so response * bounds do not imply constant query cost. */ export class LexicalFactIndex implements FactSearchIndex { @@ -77,7 +61,7 @@ export class LexicalFactIndex implements FactSearchIndex { ${CURRENT_MEMORY} WHERE m.status = 'active' AND m.memory_type = 'fact' AND (${sql.join(query.partitions.map(searchable), " OR ")}) - AND ${sql.join(patterns.map(matchesAnyField), " AND ")} + AND ${revisionMatchesAllTerms(patterns)} ORDER BY ${sql.join(patterns.map(strongestFieldWeight), " + ")} DESC, m.updated_at DESC, m.id LIMIT ${query.limit + 1}` ).all(); diff --git a/packages/control-plane/src/db/memory-queries.ts b/packages/control-plane/src/db/memory-queries.ts index 979fb2f608..06aea33bc3 100644 --- a/packages/control-plane/src/db/memory-queries.ts +++ b/packages/control-plane/src/db/memory-queries.ts @@ -1,4 +1,6 @@ import { partitionPredicate, type MemoryPartition } from "../memory/partition"; +import { MEMORY_SEARCH_FIELDS, type MemorySearchField } from "../memory/search-fields"; +import { LIKE_ESCAPE_CLAUSE } from "./like-pattern"; import { sql, type SqlFragment } from "./sql-fragment"; /** @@ -15,3 +17,25 @@ export function inPartitions(partitions: readonly MemoryPartition[]): SqlFragmen " OR " )})`; } + +const SEARCH_COLUMNS: Record = { + title: sql`lower(r.title)`, + description: sql`lower(r.description)`, + content: sql`lower(r.content)`, +}; +const LIKE_ESCAPE = sql.constant(LIKE_ESCAPE_CLAUSE); + +/** The current revision's `field` matches `pattern`, a lowercase `like-pattern` pattern. */ +export const revisionFieldMatches = (field: MemorySearchField, pattern: string): SqlFragment => + sql`${SEARCH_COLUMNS[field]} LIKE ${pattern} ${LIKE_ESCAPE}`; + +/** The pattern matches at least one searchable field of the current revision. */ +export const revisionMatchesAnyField = (pattern: string): SqlFragment => + sql`(${sql.join( + MEMORY_SEARCH_FIELDS.map((field) => revisionFieldMatches(field, pattern)), + " OR " + )})`; + +/** Every pattern matches some searchable field; no patterns means no filter. */ +export const revisionMatchesAllTerms = (patterns: readonly string[]): SqlFragment => + patterns.length ? sql`(${sql.join(patterns.map(revisionMatchesAnyField), " AND ")})` : sql`1 = 1`; diff --git a/packages/control-plane/src/db/memory-records.ts b/packages/control-plane/src/db/memory-records.ts index d9ecbec059..98527e1824 100644 --- a/packages/control-plane/src/db/memory-records.ts +++ b/packages/control-plane/src/db/memory-records.ts @@ -31,7 +31,8 @@ import { import type { MemoryActor, MemoryCandidate, MemoryRecord } from "../memory/types"; import { MAX_D1_QUERY_PARAMETERS } from "./query-limits"; import type { SqlDatabase, SqlStatement } from "./sql-database"; -import { CURRENT_MEMORY, inPartitions } from "./memory-queries"; +import { likeContains } from "./like-pattern"; +import { CURRENT_MEMORY, inPartitions, revisionMatchesAllTerms } from "./memory-queries"; import { prepareSql, sql, type SqlFragment } from "./sql-fragment"; import { agentWriteGuard, @@ -104,6 +105,8 @@ interface MemoryListOptions { status: MemoryStatus; offset: number; limit: number; + /** Lowercase literal terms (see `memorySearchTerms`); each must match title, description or body. Empty means no filter. */ + terms: readonly string[]; } /** Content for a new record; the partition is resolved and authorized by the caller. */ @@ -139,9 +142,11 @@ export class MemoryRecordStore { /** Read one management page in stable updated-time/ID order. */ async list(partition: MemoryPartition, options: MemoryListOptions): Promise { + // Management search keeps the list's stable order (agent search ranks instead) so paging holds. const result = await prepareSql( this.db, sql`${CURRENT_MEMORY_SELECT} WHERE ${partitionPredicate(partition)} AND m.status = ${options.status} + AND ${revisionMatchesAllTerms(options.terms.map(likeContains))} ORDER BY m.updated_at DESC, m.id LIMIT ${options.limit} OFFSET ${options.offset}` ).all(); return result.results.map(memoryFromRow); diff --git a/packages/control-plane/src/memory/fact-search.ts b/packages/control-plane/src/memory/fact-search.ts index 89a783f360..152aeff8a6 100644 --- a/packages/control-plane/src/memory/fact-search.ts +++ b/packages/control-plane/src/memory/fact-search.ts @@ -5,6 +5,7 @@ import { type MemorySearchResult, } from "@open-inspect/shared/types/memories"; import type { MemoryPartition } from "./partition"; +import type { MemorySearchField } from "./search-fields"; /** A partition to search; `pinnedIn` restricts it to records pinned in that session. */ export interface FactSearchPartition { @@ -33,16 +34,15 @@ export interface FactHit { } /** - * Ranking policy: each term scores its strongest matching field, and a hit's score is the sum - * over terms. Ties fall back to the most recently updated, then ID. Engine adapters must - * implement this ordering; listed strongest first. + * Ranking policy: each term scores its strongest matching field (see MEMORY_SEARCH_FIELDS), and a + * hit's score is the sum over terms. Ties fall back to the most recently updated, then ID. Engine + * adapters must implement this ordering. */ -export const FACT_SEARCH_FIELDS = [ - { field: "title", weight: 5 }, - { field: "description", weight: 3 }, - { field: "content", weight: 1 }, -] as const; -export type FactSearchField = (typeof FACT_SEARCH_FIELDS)[number]["field"]; +export const FACT_SEARCH_WEIGHTS = { + title: 5, + description: 3, + content: 1, +} as const satisfies Record; /** Searches current active facts. Implementations decide how; the query decides what. */ export interface FactSearchIndex { diff --git a/packages/control-plane/src/memory/search-fields.ts b/packages/control-plane/src/memory/search-fields.ts new file mode 100644 index 0000000000..b756e7e255 --- /dev/null +++ b/packages/control-plane/src/memory/search-fields.ts @@ -0,0 +1,6 @@ +/** + * The current-revision fields a memory text search matches, strongest signal first. Shared by + * agent fact search (which ranks by them) and the management list (which only filters). + */ +export const MEMORY_SEARCH_FIELDS = ["title", "description", "content"] as const; +export type MemorySearchField = (typeof MEMORY_SEARCH_FIELDS)[number]; diff --git a/packages/control-plane/src/routes/memories.ts b/packages/control-plane/src/routes/memories.ts index a8f324f41d..ad60a9c27a 100644 --- a/packages/control-plane/src/routes/memories.ts +++ b/packages/control-plane/src/routes/memories.ts @@ -8,7 +8,9 @@ import { memoryActionBodySchemas, memoryPreferencesSchema, memoryPreviewSchema, + memoryListSearchSchema, memoryScopeFromSearchParams, + memorySearchTerms, memoryStatusSchema, reviseMemorySchema, type MemoryAction, @@ -88,6 +90,8 @@ async function list(request: Request, env: Env, _params: object, ctx: UserRouteC }); if (!pagination.success) return error("Invalid memory pagination", 400); const { offset, limit } = pagination.data; + const search = memoryListSearchSchema.safeParse(query.get("search") ?? ""); + if (!search.success) return error("Invalid memory search", 400); const access = await createMemoryManagementPolicy(ctx, env).authorizeScope(scope, "read"); if (access.kind === "denied") return memoryDenialResponse(access.denial); const store = new MemoryRecordStore(ctx.db); @@ -96,6 +100,7 @@ async function list(request: Request, env: Env, _params: object, ctx: UserRouteC status: status.data, offset, limit: limit + 1, + terms: memorySearchTerms(search.data), }); const page = records.slice(0, limit); const supersededBy = await store.supersededByIds(page.map((record) => record.id)); diff --git a/packages/control-plane/test/integration/memories-routes.test.ts b/packages/control-plane/test/integration/memories-routes.test.ts index 094df63c5b..bbf5f27485 100644 --- a/packages/control-plane/test/integration/memories-routes.test.ts +++ b/packages/control-plane/test/integration/memories-routes.test.ts @@ -80,6 +80,43 @@ describe("memory HTTP lifecycle and session boundaries", () => { ); expect((await request("/memories?limit=10000")).status).toBe(400); }); + it("filters management records by literal terms across title, description and body", async () => { + const byTitle = await createMemory({ ...content, title: "Rspec retries" }); + const byDescription = await createMemory({ + ...content, + description: "Flaky RSPEC suites in CI", + }); + const byBody = await createMemory({ ...content, content: "Run rspec with --seed" }); + const wildcard = await createMemory({ ...content, title: "Coverage at 100%_done" }); + await createMemory(); + const ids = async (search: string, extra = "") => { + const response = await request(`/memories?search=${encodeURIComponent(search)}${extra}`); + expect(response.status).toBe(200); + const body = (await response.json()) as { memories: { id: string }[]; nextOffset: unknown }; + return { ids: body.memories.map((memory) => memory.id).sort(), nextOffset: body.nextOffset }; + }; + + expect((await ids("RSpec")).ids).toEqual([byTitle.id, byDescription.id, byBody.id].sort()); + // Every term must match somewhere in the record. + expect((await ids("rspec seed")).ids).toEqual([byBody.id]); + // `%` and `_` match themselves, not any text. + expect((await ids("100%_")).ids).toEqual([wildcard.id]); + expect((await ids("0%")).ids).toEqual([wildcard.id]); + // Search combines with paging and status. + const firstPage = await ids("rspec", "&limit=2"); + expect(firstPage.ids).toHaveLength(2); + expect(firstPage.nextOffset).toBe(2); + expect((await ids("rspec", "&status=archived")).ids).toEqual([]); + // A blank search is no filter; oversized searches are rejected. + expect((await ids(" ")).ids).toHaveLength(5); + expect((await request(`/memories?search=${"x".repeat(257)}`)).status).toBe(400); + // Nonblank searches share agent search's minimum length. + expect((await request("/memories?search=a")).status).toBe(400); + expect((await request("/memories?search=%20a%20")).status).toBe(400); + expect( + (await request(`/memories?search=${encodeURIComponent("a b c d e f g h i")}`)).status + ).toBe(400); + }); it("keeps personal management owner-only, including other administrators", async () => { const record = await createMemory(); expect((await request(`/memories/${record.id}`, "GET", undefined, OTHER)).status).toBe(404); diff --git a/packages/control-plane/test/integration/memories.test.ts b/packages/control-plane/test/integration/memories.test.ts index c9124dc972..90f82aec98 100644 --- a/packages/control-plane/test/integration/memories.test.ts +++ b/packages/control-plane/test/integration/memories.test.ts @@ -34,7 +34,7 @@ function memory(content: Partial = {}, extra: Partial ...extra, }; } -const page = { status: "active" as const, offset: 0, limit: 50 }; +const page = { status: "active" as const, offset: 0, limit: 50, terms: [] }; describe("memory persistence", () => { beforeEach(async () => { diff --git a/packages/shared/src/types/memories.test.ts b/packages/shared/src/types/memories.test.ts index 0da31c8792..1b087a4ae7 100644 --- a/packages/shared/src/types/memories.test.ts +++ b/packages/shared/src/types/memories.test.ts @@ -4,6 +4,8 @@ import { allowedMemoryActions, memoryScopeFromSearchParams, memoryScopeToSearchParams, + memoryListSearchFromInput, + memoryListSearchSchema, memorySearchSchema, MEMORY_TRANSITIONS, sandboxMemoryWriteSchema, @@ -209,3 +211,32 @@ describe("memory lifecycle table", () => { }); }); }); + +describe("management list search", () => { + it("allows blank as no filter and applies agent search's bounds otherwise", () => { + const parse = (query: string) => memoryListSearchSchema.safeParse(query); + expect(parse(" ")).toMatchObject({ success: true, data: "" }); + expect(parse(" ab ")).toMatchObject({ success: true, data: "ab" }); + expect(parse("a").error?.issues[0].message).toBe("Use at least 2 characters."); + expect(parse("x".repeat(257)).error?.issues[0].message).toBe("Use at most 256 characters."); + expect(parse("a1 b2 c3 d4 e5 f6 g7 h8 i9").error?.issues[0].message).toBe( + "Use at most 8 search terms." + ); + expect(parse("a1 b2 c3 d4 e5 f6 g7 h8").success).toBe(true); + }); + + it("treats typing under the minimum as no search yet and explains over-limit queries", () => { + expect(memoryListSearchFromInput("")).toEqual({ search: "" }); + expect(memoryListSearchFromInput(" r ")).toEqual({ search: "" }); + expect(memoryListSearchFromInput(" rspec ")).toEqual({ search: "rspec" }); + expect(memoryListSearchFromInput("a1 b2 c3 d4 e5 f6 g7 h8 i9")).toEqual({ + problem: "Use at most 8 search terms.", + }); + }); + + it("gives agent search the same limits", () => { + expect(memorySearchSchema.safeParse({ query: "a" }).error?.issues[0].message).toBe( + "Use at least 2 characters." + ); + }); +}); diff --git a/packages/shared/src/types/memories.ts b/packages/shared/src/types/memories.ts index 2592b3c06b..0c4d389220 100644 --- a/packages/shared/src/types/memories.ts +++ b/packages/shared/src/types/memories.ts @@ -388,6 +388,35 @@ export function memorySearchTerms(query: string): string[] { return [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; } +/** A literal search query: one set of bounds for agent search and the management list. */ +export const memorySearchQuerySchema = z + .string() + .trim() + .min(MEMORY_SEARCH_LIMITS.queryMin, `Use at least ${MEMORY_SEARCH_LIMITS.queryMin} characters.`) + .max(MEMORY_SEARCH_LIMITS.query, `Use at most ${MEMORY_SEARCH_LIMITS.query} characters.`) + .refine( + (query) => memorySearchTerms(query).length <= MEMORY_SEARCH_LIMITS.terms, + `Use at most ${MEMORY_SEARCH_LIMITS.terms} search terms.` + ); + +/** Management-list text filter (`GET /memories?search=`): blank means no filter. */ +export const memoryListSearchSchema = z + .string() + .trim() + .pipe(z.literal("").or(memorySearchQuerySchema)); + +/** + * What a search box applies for the text typed so far. Under the minimum length is not a search + * yet (no filter, so the list stays put while typing); a query over a limit is explained instead + * of sent, since the server would reject it. + */ +export function memoryListSearchFromInput(raw: string): { search: string } | { problem: string } { + const trimmed = raw.trim(); + if (trimmed.length < MEMORY_SEARCH_LIMITS.queryMin) return { search: "" }; + const parsed = memorySearchQuerySchema.safeParse(trimmed); + return parsed.success ? { search: parsed.data } : { problem: parsed.error.issues[0].message }; +} + /** Optional `repoOwner`/`repoName` selector shared by agent search and write inputs. */ const repositorySelectorShape = { repoOwner: repositoryPairInputSchema.shape.repoOwner @@ -410,16 +439,7 @@ function checkRepositorySelector( /** Repository filters select session members; environment and personal identity are server-derived. */ export const memorySearchSchema = z .object({ - query: z - .string() - .trim() - .min(MEMORY_SEARCH_LIMITS.queryMin) - .max(MEMORY_SEARCH_LIMITS.query) - .refine( - (query) => memorySearchTerms(query).length <= MEMORY_SEARCH_LIMITS.terms, - "Too many search terms" - ) - .describe("Short literal keywords; every term must match"), + query: memorySearchQuerySchema.describe("Short literal keywords; every term must match"), scopeType: memoryScopeTypeSchema .optional() .describe("Restrict to one scope type; omit to search every permitted session scope"), diff --git a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx index e3dc968512..0eb563ba55 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx @@ -9,8 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PlusIcon, SearchIcon } from "@/components/ui/icons"; import { useAutomationScope } from "@/hooks/use-automation-scope"; - -const SEARCH_DEBOUNCE_MS = 300; +import { SEARCH_DEBOUNCE_MS } from "@/lib/search"; export default function AutomationsPage() { return ( diff --git a/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx index cf3bf7c0c1..ddab4e2a39 100644 --- a/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx @@ -24,8 +24,8 @@ import { parseSessionDiscoveryQuery, type SessionDiscoveryQuery, } from "@/lib/session-discovery"; +import { SEARCH_DEBOUNCE_MS } from "@/lib/search"; -const SEARCH_DEBOUNCE_MS = 300; const MANAGE_ARCHIVED_SESSIONS_HREF = "/settings?tab=data-controls"; export default function SessionsPage() { diff --git a/packages/web/src/components/settings/memories-settings/index.test.tsx b/packages/web/src/components/settings/memories-settings/index.test.tsx index 252ab89ac8..8b9346b9a2 100644 --- a/packages/web/src/components/settings/memories-settings/index.test.tsx +++ b/packages/web/src/components/settings/memories-settings/index.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { MemoryDto, MemoryRevision } from "@open-inspect/shared/types/memories"; import { MemoriesSettings, SharedMemoriesSettings } from "."; @@ -18,8 +18,23 @@ const mocks = vi.hoisted(() => ({ nextOffset: null as number | null, collection: vi.fn(), canManageOwn: true, + replace: vi.fn(), + environments: [] as { id: string; name: string }[], })); -vi.mock("next/navigation", () => ({ useSearchParams: () => mocks.params })); +// Like the real router, replace() updates the URL and re-renders readers of useSearchParams. +const urlListeners = new Set<() => void>(); +vi.mock("next/navigation", async () => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + urlListeners.add(listener); + return () => urlListeners.delete(listener); + }; + return { + useSearchParams: () => useSyncExternalStore(subscribe, () => mocks.params), + usePathname: () => "/settings", + useRouter: () => ({ replace: mocks.replace }), + }; +}); vi.mock("@/hooks/use-memories", () => ({ createMemory: mocks.createMemory, reviseMemory: mocks.reviseMemory, @@ -51,12 +66,15 @@ vi.mock("@/hooks/use-current-user-authorization", () => ({ })); vi.mock("@/hooks/use-repos", () => ({ useRepos: () => ({ - repos: [{ id: 1, owner: "acme", name: "web", fullName: "acme/web" }], + repos: [ + { id: 1, owner: "acme", name: "web", fullName: "acme/web" }, + { id: 2, owner: "acme", name: "api", fullName: "acme/api" }, + ], loading: false, }), })); vi.mock("@/hooks/use-environments", () => ({ - useEnvironments: () => ({ environments: [], loading: false }), + useEnvironments: () => ({ environments: mocks.environments, loading: false }), })); const record: MemoryDto = { @@ -101,12 +119,17 @@ describe("memory management", () => { cleanup(); vi.clearAllMocks(); mocks.params = new URLSearchParams(); + mocks.replace.mockImplementation((href: string) => { + mocks.params = new URLSearchParams(href.split("?")[1] ?? ""); + act(() => urlListeners.forEach((listener) => listener())); + }); mocks.records = []; mocks.focused = undefined; mocks.revisions = []; mocks.canCreate = true; mocks.nextOffset = null; mocks.canManageOwn = true; + mocks.environments = []; for (const mutation of [mocks.createMemory, mocks.reviseMemory, mocks.applyMemoryAction]) { mutation.mockResolvedValue(record); } @@ -130,9 +153,9 @@ describe("memory management", () => { mocks.nextOffset = 50; render(); fireEvent.click(screen.getByText("Next page")); - expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 50); + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 50, ""); fireEvent.click(screen.getByRole("radio", { name: "Archived" })); - expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "archived", 0); + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "archived", 0, ""); }); it("validates a new record and creates it in the collection's scope", async () => { @@ -262,7 +285,119 @@ describe("memory management", () => { expect(mocks.collection).toHaveBeenLastCalledWith( { type: "repository", repoOwner: "acme", repoName: "web" }, "active", - 0 + 0, + "" + ); + }); + + it("filters by a debounced search kept in the URL and returns to the first page", async () => { + mocks.nextOffset = 50; + render(); + fireEvent.click(screen.getByText("Next page")); + fireEvent.change(screen.getByRole("searchbox", { name: "Search memories" }), { + target: { value: " rspec " }, + }); + await waitFor(() => + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, "rspec") + ); + expect(mocks.replace).toHaveBeenLastCalledWith("/settings?search=rspec", { scroll: false }); + expect(screen.getByText("No active memories match “rspec”.")).toBeTruthy(); + }); + + it("returns to the first page when a search is cleared", async () => { + mocks.nextOffset = 50; + render(); + fireEvent.click(screen.getByText("Next page")); + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 50, ""); + const box = screen.getByRole("searchbox", { name: "Search memories" }); + fireEvent.change(box, { target: { value: "rspec" } }); + await waitFor(() => + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, "rspec") + ); + fireEvent.change(box, { target: { value: "" } }); + await waitFor(() => + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, "") + ); + }); + + it("follows a search changed by navigation instead of restoring the old one", async () => { + mocks.params = new URLSearchParams({ search: "docker" }); + const { rerender } = render(); + mocks.params = new URLSearchParams({ search: "rspec" }); + rerender(); + await waitFor(() => + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, "rspec") + ); + expect( + (screen.getByRole("searchbox", { name: "Search memories" }) as HTMLInputElement).value + ).toBe("rspec"); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(mocks.replace).not.toHaveBeenCalled(); + }); + + it("explains a search with too many terms instead of requesting it", async () => { + render(); + const box = screen.getByRole("searchbox", { name: "Search memories" }); + fireEvent.change(box, { target: { value: "rspec" } }); + await waitFor(() => + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, "rspec") + ); + fireEvent.change(box, { target: { value: "a1 b2 c3 d4 e5 f6 g7 h8 i9" } }); + expect(await screen.findByText("Use at most 8 search terms.")).toBeTruthy(); + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, "rspec"); + expect(mocks.collection).not.toHaveBeenCalledWith( + { type: "personal" }, + "active", + 0, + "a1 b2 c3 d4 e5 f6 g7 h8 i9" + ); + }); + + it("does not filter on a single character", async () => { + render(); + fireEvent.change(screen.getByRole("searchbox", { name: "Search memories" }), { + target: { value: "r" }, + }); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, ""); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("keeps a one-character edit after the URL echo", async () => { + mocks.params = new URLSearchParams({ search: "rspec" }); + render(); + const box = screen.getByRole("searchbox", { name: "Search memories" }) as HTMLInputElement; + fireEvent.change(box, { target: { value: "r" } }); + await waitFor(() => + expect(mocks.replace).toHaveBeenLastCalledWith("/settings", { scroll: false }) + ); + expect(box.value).toBe("r"); + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, ""); + }); + + it("starts from a search already in the URL", () => { + mocks.params = new URLSearchParams({ search: "docker" }); + render(); + expect(mocks.collection).toHaveBeenLastCalledWith({ type: "personal" }, "active", 0, "docker"); + expect( + (screen.getByRole("searchbox", { name: "Search memories" }) as HTMLInputElement).value + ).toBe("docker"); + }); + + it("narrows the scope picker as you type and opens the chosen scope", async () => { + mocks.environments = [{ id: "env_taskiq", name: "TaskIQ Factory" }]; + render(); + fireEvent.click(screen.getByRole("button", { name: /Memory scope/ })); + fireEvent.change(screen.getByPlaceholderText("Search repositories and environments..."), { + target: { value: "taskiq" }, + }); + expect(screen.queryByText("acme/web")).toBeNull(); + fireEvent.click(screen.getByText("TaskIQ Factory")); + expect(mocks.collection).toHaveBeenLastCalledWith( + { type: "environment", environmentId: "env_taskiq" }, + "active", + 0, + "" ); }); }); diff --git a/packages/web/src/components/settings/memories-settings/index.tsx b/packages/web/src/components/settings/memories-settings/index.tsx index 061dc1088b..be16d96d81 100644 --- a/packages/web/src/components/settings/memories-settings/index.tsx +++ b/packages/web/src/components/settings/memories-settings/index.tsx @@ -14,12 +14,11 @@ import { useEnvironments } from "@/hooks/use-environments"; import { errorMessage, PERSONAL_MEMORY_DISCLOSURE } from "@/lib/memories"; import { Switch } from "@/components/ui/switch"; import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@/components/ui/select"; + Combobox, + FIELD_COMBOBOX_TRIGGER_CLASS, + type ComboboxGroup, +} from "@/components/ui/combobox"; +import { ChevronDownIcon } from "@/components/ui/icons"; import { MemoryCollection } from "./memory-collection"; /** Persist the account-wide default and disclose the audience of included personal context. */ @@ -97,16 +96,34 @@ export function SharedMemoriesSettings() { const [selection, setSelection] = useState(null); const linkedScope = memoryScopeFromSearchParams(params); const scope = selection ?? (linkedScope?.type === "personal" ? null : linkedScope); - const options: { scope: MemoryScope; label: string }[] = [ - ...repos.map((repo) => ({ - scope: { type: "repository", repoOwner: repo.owner, repoName: repo.name } as const, - label: repo.fullName, - })), - ...environments.map((environment) => ({ - scope: { type: "environment", environmentId: environment.id } as const, - label: `Environment: ${environment.name}`, - })), - ]; + // Searchable and grouped, like the repository picker in Secrets: deployments can have many repos. + const scopeOptions = new Map(); + const group = (category: string, entries: { scope: MemoryScope; label: string }[]) => ({ + category, + options: entries.map((entry) => { + const key = memoryScopeDisplayKey(entry.scope); + scopeOptions.set(key, entry); + return { value: key, label: entry.label }; + }), + }); + const groups: ComboboxGroup[] = [ + group( + "Repositories", + repos.map((repo) => ({ + scope: { type: "repository", repoOwner: repo.owner, repoName: repo.name }, + label: repo.fullName, + })) + ), + group( + "Environments", + environments.map((environment) => ({ + scope: { type: "environment", environmentId: environment.id }, + label: environment.name, + })) + ), + ].filter((entry) => entry.options.length > 0); + const selectedKey = scope ? memoryScopeDisplayKey(scope) : ""; + const selectedLabel = scopeOptions.get(selectedKey)?.label; return (
@@ -116,28 +133,32 @@ export function SharedMemoriesSettings() { sessions load them.

- +
+ + setSelection(scopeOptions.get(key)?.scope ?? null)} + items={groups} + searchable + searchPlaceholder="Search repositories and environments..." + direction="down" + dropdownWidth="w-full max-w-sm" + triggerClassName={FIELD_COMBOBOX_TRIGGER_CLASS} + > + + {selectedLabel ?? "Choose a repository or environment"} + + + +
{(reposLoading || environmentsLoading) &&

Loading scopes…

} {(reposError || environmentsError) && (

diff --git a/packages/web/src/components/settings/memories-settings/memory-collection.tsx b/packages/web/src/components/settings/memories-settings/memory-collection.tsx index de7b649a30..84320e2c26 100644 --- a/packages/web/src/components/settings/memories-settings/memory-collection.tsx +++ b/packages/web/src/components/settings/memories-settings/memory-collection.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { + MEMORY_SEARCH_LIMITS, MEMORY_STATUSES, type MemoryContent, type MemoryDto, @@ -10,10 +11,13 @@ import { import { applyMemoryAction, createMemory, reviseMemory } from "@/hooks/use-memories"; import { errorMessage, MEMORY_STATUS_LABELS } from "@/lib/memories"; import { Button } from "@/components/ui/button"; +import { SearchIcon } from "@/components/ui/icons"; +import { Input } from "@/components/ui/input"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { MemoryCard } from "./memory-card"; import { MemoryEditor } from "./memory-editor"; import { useMemoryCollection } from "./use-memory-collection"; +import { useMemorySearch } from "./use-memory-search"; /** * The one open panel in a collection. Card-local panels key on the record ID so actions use the @@ -34,7 +38,8 @@ const STATUS_OPTIONS = MEMORY_STATUSES.map((value) => ({ /** Manage a paginated scope using server capabilities and revision-fenced mutations. */ export function MemoryCollection({ scope }: { scope: MemoryScope }) { - const collection = useMemoryCollection(scope); + const search = useMemorySearch(); + const collection = useMemoryCollection(scope, search.search); const [panel, setPanel] = useState(() => collection.focusedId ? { kind: "history", memoryId: collection.focusedId } : NO_PANEL ); @@ -89,6 +94,28 @@ export function MemoryCollection({ scope }: { scope: MemoryScope }) { )} +

+
+ {search.problem && ( + + )}