From 8d51fbff7a91cdaf8d800737f4b47280a9b000c1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 1 Jul 2026 16:17:47 -0700 Subject: [PATCH] fix: repair memory-list project filter + add type-ahead project dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memory list page threw a Next.js server-side exception whenever a project filter was applied. The filter built a raw Drizzle `sql` fragment that interpolated a JS string[] into `ANY(${accessibleIds}::uuid[])`, which doesn't bind as a Postgres array literal — the same array-binding bug class already fixed in #1 (commits 3019446, b3f7e60) for memory.list/snippet.list. The search path avoided it by using the `pg` tag; the plain list path did not. Fix: resolve the typed project key to a single project id from the user's accessible set (owned ∪ shared, owned winning on key collision to match project.identify and the search path), then filter with a plain `eq(memories.projectId, resolvedId)` — fully parameterized, no raw array interpolation. Returns empty when the key matches no readable project. This also makes the list view's collision semantics consistent with the search view. Feature: replace the plain "Project key…" text input with a type-ahead combobox (_project-combobox.tsx) populated with the user's accessible project keys, narrowing as they type; selecting a suggestion applies the filter immediately. Free-typed keys still submit. getAccessibleProjects is hoisted to the page and reused for both the dropdown options and the list WHERE clause (no extra query on the list path). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../(authed)/memories/_project-combobox.tsx | 151 ++++++++++++++++++ apps/web/app/(authed)/memories/page.tsx | 51 +++--- 2 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 apps/web/app/(authed)/memories/_project-combobox.tsx diff --git a/apps/web/app/(authed)/memories/_project-combobox.tsx b/apps/web/app/(authed)/memories/_project-combobox.tsx new file mode 100644 index 0000000..b3aee28 --- /dev/null +++ b/apps/web/app/(authed)/memories/_project-combobox.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; + +const field = + "block w-full rounded-md bg-surface-1 border border-border " + + "text-fg placeholder:text-fg-subtle " + + "focus:border-accent-400 focus:outline-none " + + "disabled:opacity-50 transition-colors h-9 px-3 text-sm"; + +/** + * Type-ahead project filter. A text input whose value submits as `name` + * (default "project") via the enclosing GET form, plus a dropdown of the + * projects the user can read that narrows as they type. Selecting a + * suggestion fills the box and submits the form so the filter applies + * immediately; free text is still allowed (the input value is what + * submits), so an arbitrary key keeps working even if it isn't listed. + */ +export function ProjectCombobox({ + name = "project", + defaultValue = "", + options, + className = "", +}: { + name?: string; + defaultValue?: string; + options: string[]; + className?: string; +}) { + const [value, setValue] = useState(defaultValue); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const rootRef = useRef(null); + const inputRef = useRef(null); + const listId = useId(); + + // Case-insensitive substring match. An empty box shows the full list so + // the control doubles as a "browse my projects" dropdown. + const q = value.trim().toLowerCase(); + const matches = q + ? options.filter((o) => o.toLowerCase().includes(q)) + : options; + + // Close when focus/click leaves the widget. + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + function commit(next: string) { + setValue(next); + setOpen(false); + // Write the DOM value synchronously before submitting: setValue only + // schedules a re-render (React batches it), so the input's serialized + // value would still be the pre-selection text when requestSubmit reads + // it. The upcoming render sets the same value, so there's no flicker. + if (inputRef.current) inputRef.current.value = next; + // requestSubmit fires a real submit (unlike form.submit()). + inputRef.current?.form?.requestSubmit(); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (!open) { + setOpen(true); + setActive(0); + } else { + setActive((i) => Math.min(i + 1, matches.length - 1)); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + // Only intercept Enter to pick a highlighted suggestion; otherwise + // let it fall through and submit the form with the typed value. + if (open && matches[active]) { + e.preventDefault(); + commit(matches[active]); + } + } else if (e.key === "Escape") { + if (open) { + e.preventDefault(); + setOpen(false); + } + } + } + + const showList = open && matches.length > 0; + + return ( +
+ { + setValue(e.target.value); + setOpen(true); + setActive(0); + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + /> + {showList ? ( +
    + {matches.map((opt, i) => ( +
  • { + e.preventDefault(); + commit(opt); + }} + onMouseEnter={() => setActive(i)} + > + {opt} +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/(authed)/memories/page.tsx b/apps/web/app/(authed)/memories/page.tsx index a55b0b6..aa7c988 100644 --- a/apps/web/app/(authed)/memories/page.tsx +++ b/apps/web/app/(authed)/memories/page.tsx @@ -1,10 +1,15 @@ import Link from "next/link"; -import { and, desc, eq, isNull, inArray, or, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, inArray, or } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; import { memories, projects, projectShares } from "@/lib/db/schema"; import { searchMemories } from "@/lib/memories"; -import { getUserGroupNames, readableProjectIds } from "@/lib/access"; +import { + getAccessibleProjects, + getUserGroupNames, + type AccessibleProject, +} from "@/lib/access"; +import { ProjectCombobox } from "./_project-combobox"; import { Container, PageHeader } from "@/app/_components/ui/container"; import { Card, CardBody } from "@/app/_components/ui/card"; import { Badge } from "@/app/_components/ui/badge"; @@ -50,12 +55,12 @@ async function fetchMemoriesByIds( async function listRecent( userId: string, - groupNames: string[], + accessible: AccessibleProject[], scope?: Scope, project?: string, ): Promise { // Visibility: own rows OR rows in an accessible project. - const accessibleIds = await readableProjectIds(userId, groupNames); + const accessibleIds = accessible.map((p) => p.projectId); const visibility = accessibleIds.length > 0 ? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds)) @@ -63,17 +68,18 @@ async function listRecent( const filters = [visibility!, isNull(memories.deletedAt)]; if (scope) filters.push(eq(memories.scope, scope)); if (project) { - // Project filter — match the project key against any project the - // user can read (owned or shared). When the key matches none of - // those, return empty. - filters.push( - sql`${memories.projectId} IN ( - SELECT id FROM ${projects} - WHERE ${projects.key} = ${project} - AND (${projects.userId} = ${userId} - OR ${projects.id} = ANY(${accessibleIds}::uuid[])) - )`, - ); + // Project filter — resolve the typed key against the projects the + // user can read (owned or shared), owned winning on a key collision + // to match project.identify / the search path. Filtering by the + // resolved id keeps the WHERE clause a plain equality — no raw-SQL + // array binding (the source of the earlier memory.list crash). When + // the key matches no accessible project, return empty. + const matches = accessible.filter((p) => p.projectKey === project); + const resolvedId = + matches.find((p) => p.access === "owner")?.projectId ?? + matches[0]?.projectId; + if (!resolvedId) return []; + filters.push(eq(memories.projectId, resolvedId)); } const rows = await db .select({ @@ -120,6 +126,15 @@ export default async function MemoriesPage({ const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined; const project = params.project?.trim() || undefined; + // Projects the user can read (owned ∪ shared). Reused both to build the + // list-path WHERE clause and to populate the project filter's type-ahead + // suggestions. Keys are deduped (a key can appear once per owned/shared + // project) and sorted for a stable dropdown order. + const accessible = await getAccessibleProjects(userId, groupNames); + const projectKeys = [...new Set(accessible.map((p) => p.projectKey))].sort( + (a, b) => a.localeCompare(b), + ); + let rows: MemoryRow[] = []; let debug: { vec: number; fts: number; tag: number } | null = null; @@ -138,7 +153,7 @@ export default async function MemoriesPage({ }); debug = result.debug; } else { - rows = await listRecent(userId, groupNames, scope, project); + rows = await listRecent(userId, accessible, scope, project); } // Annotate which rows belong to projects that have any active share. @@ -182,10 +197,10 @@ export default async function MemoriesPage({ className="flex-1 min-w-[200px]" /> -