diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 64ab0092c85..537ca6dc8ee 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -354,6 +354,7 @@ export type { MemoryResolvedBinding, MemoryRecordHandle, MemoryRecord, + MemoryRecordCountResult, MemoryOperation, MemoryExtractionJob, MemoryRefreshJobSourceCounts, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 65882818083..6cc74102514 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -147,6 +147,7 @@ export type { MemoryResolvedBinding, MemoryRecordHandle, MemoryRecord, + MemoryRecordCountResult, MemoryOperation, MemoryExtractionJob, MemoryRefreshJobSourceCounts, diff --git a/packages/shared/src/types/memory.ts b/packages/shared/src/types/memory.ts index ecd0f9fc517..94793bc135b 100644 --- a/packages/shared/src/types/memory.ts +++ b/packages/shared/src/types/memory.ts @@ -234,6 +234,10 @@ export interface MemoryRecord { updatedAt: Date; } +export interface MemoryRecordCountResult { + count: number; +} + export interface MemoryOperation { id: string; companyId: string; diff --git a/packages/shared/src/validators/memory.ts b/packages/shared/src/validators/memory.ts index 84af8fb165a..679f6f2081f 100644 --- a/packages/shared/src/validators/memory.ts +++ b/packages/shared/src/validators/memory.ts @@ -300,6 +300,7 @@ export const memoryListRecordsQuerySchema = z includeExpired: z.coerce.boolean().optional().default(false), includeSuperseded: z.coerce.boolean().optional().default(false), limit: z.coerce.number().int().positive().max(200).optional().default(50), + count: z.enum(["only"]).optional(), }) .strict(); diff --git a/server/src/__tests__/memory-routes.test.ts b/server/src/__tests__/memory-routes.test.ts index e659067a84d..bc24ee6b7c2 100644 --- a/server/src/__tests__/memory-routes.test.ts +++ b/server/src/__tests__/memory-routes.test.ts @@ -27,6 +27,7 @@ const mockMemoryService = vi.hoisted(() => ({ review: vi.fn(), sweepRetention: vi.fn(), listRecords: vi.fn(), + countRecords: vi.fn(), getRecord: vi.fn(), listOperations: vi.fn(), listExtractionJobs: vi.fn(), @@ -245,6 +246,35 @@ describe("memory routes", () => { expect(mockLogActivity).toHaveBeenCalledOnce(); }); + it("routes count-only record queries through memory service", async () => { + mockMemoryService.countRecords.mockResolvedValue({ count: 152 }); + const app = createApp({ + type: "board", + userId: "board-user", + source: "session", + companyIds: [companyA], + isInstanceAdmin: false, + }); + + const res = await request(app) + .get(`/api/companies/${companyA}/memory/records`) + .query({ count: "only", reviewState: "pending" }) + .set("Origin", "http://localhost:3100"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ count: 152 }); + expect(mockMemoryService.countRecords).toHaveBeenCalledWith( + companyA, + expect.objectContaining({ + count: "only", + reviewState: "pending", + includeRevoked: false, + }), + expect.objectContaining({ actorType: "user", userId: "board-user" }), + ); + expect(mockMemoryService.listRecords).not.toHaveBeenCalled(); + }); + it("sets project memory overrides through the owning project company", async () => { const projectId = "77777777-7777-4777-8777-777777777777"; mockMemoryService.setProjectOverride.mockResolvedValue({ diff --git a/server/src/routes/memory.ts b/server/src/routes/memory.ts index f8fec3eb753..663db98d6db 100644 --- a/server/src/routes/memory.ts +++ b/server/src/routes/memory.ts @@ -268,6 +268,10 @@ export function memoryRoutes( const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const parsed = memoryListRecordsQuerySchema.parse(req.query); + if (parsed.count === "only") { + res.json(await memory.countRecords(companyId, parsed, actorInfoFromReq(req))); + return; + } res.json(await memory.listRecords(companyId, parsed, actorInfoFromReq(req))); }); diff --git a/server/src/services/memory.ts b/server/src/services/memory.ts index 191ecca7d6b..90877f9a930 100644 --- a/server/src/services/memory.ts +++ b/server/src/services/memory.ts @@ -40,6 +40,7 @@ import type { MemoryQuery, MemoryQueryResult, MemoryRecord, + MemoryRecordCountResult, MemoryRefreshJob, MemoryRefreshJobResult, MemoryRefreshJobSourceCounts, @@ -1121,7 +1122,7 @@ export function memoryService( }); } - async function listLocalBasic(companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) { + function buildLocalBasicConditions(companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) { const conditions = [eq(memoryLocalRecords.companyId, companyId)]; if (filters.bindingId) conditions.push(eq(memoryLocalRecords.bindingId, filters.bindingId)); if (filters.providerKey) conditions.push(eq(memoryLocalRecords.providerKey, filters.providerKey)); @@ -1181,6 +1182,11 @@ export function memoryService( ); } + return conditions; + } + + async function listLocalBasic(companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) { + const conditions = buildLocalBasicConditions(companyId, filters, actor); const rows = await db .select() .from(memoryLocalRecords) @@ -1190,6 +1196,19 @@ export function memoryService( return rows.map((row) => mapRecord(row)); } + async function countLocalBasic( + companyId: string, + filters: MemoryListRecordsQuery, + actor: ActorInfo, + ): Promise { + const conditions = buildLocalBasicConditions(companyId, filters, actor); + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(memoryLocalRecords) + .where(and(...conditions)); + return { count: Number(row?.count ?? 0) }; + } + async function logOperation(input: { id?: string; companyId: string; @@ -2675,6 +2694,9 @@ export function memoryService( listRecords: async (companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) => listLocalBasic(companyId, filters, actor), + countRecords: async (companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) => + countLocalBasic(companyId, filters, actor), + getRecord: async (companyId: string, recordId: string, actor?: ActorInfo) => { const row = await db .select() diff --git a/ui/src/api/memory.ts b/ui/src/api/memory.ts index 273ea3054b6..2ba13a4e8e1 100644 --- a/ui/src/api/memory.ts +++ b/ui/src/api/memory.ts @@ -9,6 +9,7 @@ import type { MemoryOperation, MemoryProviderDescriptor, MemoryRecord, + MemoryRecordCountResult, MemoryRefreshJobResult, MemoryRetentionSweep, MemoryRetentionSweepResult, @@ -60,6 +61,10 @@ export const memoryApi = { api.get( `/companies/${encodeURIComponent(companyId)}/memory/records${buildQueryString(filters)}`, ), + countRecords: (companyId: string, filters?: Partial) => + api.get( + `/companies/${encodeURIComponent(companyId)}/memory/records${buildQueryString({ ...filters, count: "only" })}`, + ), listOperations: (companyId: string, filters?: MemoryListOperationsQuery) => api.get( `/companies/${encodeURIComponent(companyId)}/memory/operations${buildQueryString(filters)}`, diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 8c45b05ae1e..e54630aa90e 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -38,6 +38,8 @@ export const queryKeys = { projectBinding: (projectId: string) => ["memory", "project-binding", projectId] as const, records: (companyId: string, filters?: Record) => ["memory", companyId, "records", filters ?? {}] as const, + recordCount: (companyId: string, filters?: Record) => + ["memory", companyId, "records", "count", filters ?? {}] as const, operations: (companyId: string, filters?: Record) => ["memory", companyId, "operations", filters ?? {}] as const, }, diff --git a/ui/src/pages/WorkMemories.tsx b/ui/src/pages/WorkMemories.tsx index 14a9c30fbd2..ca7703d6a1d 100644 --- a/ui/src/pages/WorkMemories.tsx +++ b/ui/src/pages/WorkMemories.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link } from "@/lib/router"; +import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; +import { Link, useSearchParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { MEMORY_RETENTION_STATES, @@ -19,14 +19,31 @@ import { type MemorySourceKind, type Project, } from "@paperclipai/shared"; -import { Check, Clipboard, Database, RotateCcw, SlidersHorizontal, Trash2, X } from "lucide-react"; +import { AlertTriangle, Check, ChevronDown, Clipboard, Database, RotateCcw, SlidersHorizontal, Trash2, X } from "lucide-react"; import { agentsApi } from "../api/agents"; import { issuesApi } from "../api/issues"; import { memoryApi } from "../api/memory"; import { projectsApi } from "../api/projects"; import { EmptyState } from "../components/EmptyState"; +import { + getMemoryRecordTitle, + labelizeMemoryValue, + MemoryRecordRow, + memoryRecordSourceHref, + memoryRecordSourceLabel, +} from "../components/MemoryRecordRow"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Sheet, SheetContent, @@ -34,12 +51,14 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { StatusBadge } from "../components/StatusBadge"; import { Textarea } from "@/components/ui/textarea"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; -import { agentUrl, cn, formatDateTime, issueUrl, projectUrl, relativeTime } from "../lib/utils"; +import { agentUrl, cn, formatDateTime, projectUrl, relativeTime } from "../lib/utils"; type MemoryFilters = { q: string; @@ -75,48 +94,24 @@ const DEFAULT_FILTERS: MemoryFilters = { includeSuperseded: false, }; +const SELECT_ALL_VALUE = "__all__"; +const REVIEW_TABS = ["pending", "accepted", "rejected", "revoked"] as const; +type ReviewTab = (typeof REVIEW_TABS)[number]; + function labelize(value: string) { - return value.replace(/_/g, " "); + return labelizeMemoryValue(value); } -function summarizeRecord(record: MemoryRecord) { - const text = (record.summary ?? record.content).replace(/\s+/g, " ").trim(); - return text.length > 360 ? `${text.slice(0, 357)}...` : text; -} - -function recordTitle(record: MemoryRecord) { - return record.title ?? record.citation?.sourceTitle ?? labelize(record.source?.kind ?? "memory"); -} - -function badgeTone(record: MemoryRecord) { - if (record.revokedAt || record.retentionState === "revoked") return "bg-zinc-500/10 text-zinc-700 dark:text-zinc-300"; - if (record.reviewState === "accepted") return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - if (record.reviewState === "rejected") return "bg-rose-500/10 text-rose-700 dark:text-rose-300"; - return "bg-amber-500/10 text-amber-800 dark:text-amber-300"; +function memoryReviewStatus(record: MemoryRecord) { + return record.revokedAt || record.retentionState === "revoked" ? "revoked" : record.reviewState; } function sourceLabel(record: MemoryRecord, issuesById: Map) { - const issueId = record.source?.issueId ?? record.scope.issueId ?? null; - const issue = issueId ? issuesById.get(issueId) : null; - if (issue) return issue.identifier ?? issue.title; - if (record.source?.runId ?? record.scope.runId) return `run ${(record.source?.runId ?? record.scope.runId)!.slice(0, 8)}`; - if (record.source?.externalRef) return record.source.externalRef; - return labelize(record.source?.kind ?? record.scopeType); + return memoryRecordSourceLabel(record, issuesById); } function sourceHref(record: MemoryRecord, issuesById: Map) { - const issueId = record.source?.issueId ?? record.scope.issueId ?? null; - const issue = issueId ? issuesById.get(issueId) : null; - if (issue) { - const base = issueUrl(issue); - if (record.source?.commentId) return `${base}#comment-${record.source.commentId}`; - if (record.source?.documentKey) return `${base}#document-${record.source.documentKey}`; - return base; - } - const runId = record.source?.runId ?? record.scope.runId ?? null; - const agentId = record.scope.agentId ?? null; - if (runId && agentId) return `/agents/${agentId}/runs/${runId}`; - return null; + return memoryRecordSourceHref(record, issuesById); } function buildRecordFilters(filters: MemoryFilters): Partial { @@ -140,6 +135,30 @@ function buildRecordFilters(filters: MemoryFilters): Partial { + const base = buildRecordFilters({ + ...filters, + reviewState: "", + retentionState: filters.retentionState === "revoked" ? "" : filters.retentionState, + }); + delete base.limit; + + if (reviewTab === "revoked") { + return { + ...base, + reviewState: undefined, + retentionState: "revoked", + includeRevoked: true, + }; + } + + return { + ...base, + reviewState: reviewTab, + includeRevoked: false, + }; +} + function detailOperationFilters(record: MemoryRecord | null): MemoryListOperationsQuery | undefined { if (!record) return undefined; if (record.scope.runId ?? record.source?.runId) { @@ -164,28 +183,24 @@ function FilterSelect({ options: Array<{ value: string; label: string }>; }) { return ( - - ); -} - -function ReviewBadge({ record }: { record: MemoryRecord }) { - return ( - - {record.revokedAt ? "revoked" : record.reviewState} - + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + ); } @@ -313,12 +328,58 @@ function MemoryDetailSheet({ record.source?.documentKey ? `document:${record.source.documentKey}` : null, record.source?.runId ? `run:${record.source.runId}` : null, ].filter(Boolean).join(" "); + const canSaveCorrection = canMutate && Boolean(correctionContent.trim()) && Boolean(correctionReason.trim()) && !correctRecord.isPending; + + const copySourceReference = () => { + void navigator.clipboard.writeText(sourceReference); + pushToast({ title: "Source reference copied", tone: "success" }); + }; + + const handleSheetKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement; + const isEditable = Boolean(target.closest("input, textarea, select, [contenteditable=true]")); + + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + if (canSaveCorrection) { + event.preventDefault(); + correctRecord.mutate(); + } + return; + } + + if (isEditable) return; + + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "c") { + const selection = window.getSelection()?.toString(); + if (!selection) { + event.preventDefault(); + copySourceReference(); + } + return; + } + + if (!canMutate || reviewRecord.isPending || event.metaKey || event.ctrlKey || event.altKey) return; + + const key = event.key.toLowerCase(); + if (key === "a") { + event.preventDefault(); + reviewRecord.mutate("accepted"); + } + if (key === "r") { + event.preventDefault(); + reviewRecord.mutate("rejected"); + } + if (key === "p") { + event.preventDefault(); + reviewRecord.mutate("pending"); + } + }; return ( { if (!next) onClose(); }}> - + - {recordTitle(record)} + {getMemoryRecordTitle(record)} {record.providerKey} · {record.sensitivityLabel} · {record.retentionState} @@ -326,7 +387,7 @@ function MemoryDetailSheet({
- + {record.scopeType} @@ -390,10 +451,7 @@ function MemoryDetailSheet({
)}
+ +
+ Shortcuts: A accept · R reject · P pending · Cmd+C copy reference · Cmd+Enter save correction +
@@ -511,13 +573,23 @@ function MemoryDetailSheet({ export function WorkMemories() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const [filters, setFilters] = useState(DEFAULT_FILTERS); + const { pushToast } = useToast(); + const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const searchAgentId = searchParams.get("agentId") ?? ""; + const [filters, setFilters] = useState(() => ({ ...DEFAULT_FILTERS, agentId: searchAgentId })); + const [filtersOpen, setFiltersOpen] = useState(false); const [selectedRecordId, setSelectedRecordId] = useState(null); + const [selectedRecordIds, setSelectedRecordIds] = useState>(() => new Set()); useEffect(() => { setBreadcrumbs([{ label: "Memories" }]); }, [setBreadcrumbs]); + useEffect(() => { + setFilters((current) => current.agentId === searchAgentId ? current : { ...current, agentId: searchAgentId }); + }, [searchAgentId]); + const providersQuery = useQuery({ queryKey: selectedCompanyId ? queryKeys.memory.providers(selectedCompanyId) : ["memory", "providers", "none"], queryFn: () => memoryApi.providers(selectedCompanyId!), @@ -554,21 +626,97 @@ export function WorkMemories() { queryFn: () => memoryApi.listRecords(selectedCompanyId!, recordFilters), enabled: !!selectedCompanyId, }); + const countFilters = useMemo( + () => Object.fromEntries(REVIEW_TABS.map((tab) => [tab, buildCountFilters(filters, tab)])) as Record>, + [filters], + ); + const pendingCountQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.memory.recordCount(selectedCompanyId, countFilters.pending) : ["memory", "records", "count", "pending", "none"], + queryFn: () => memoryApi.countRecords(selectedCompanyId!, countFilters.pending), + enabled: !!selectedCompanyId, + }); + const acceptedCountQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.memory.recordCount(selectedCompanyId, countFilters.accepted) : ["memory", "records", "count", "accepted", "none"], + queryFn: () => memoryApi.countRecords(selectedCompanyId!, countFilters.accepted), + enabled: !!selectedCompanyId, + }); + const rejectedCountQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.memory.recordCount(selectedCompanyId, countFilters.rejected) : ["memory", "records", "count", "rejected", "none"], + queryFn: () => memoryApi.countRecords(selectedCompanyId!, countFilters.rejected), + enabled: !!selectedCompanyId, + }); + const revokedCountQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.memory.recordCount(selectedCompanyId, countFilters.revoked) : ["memory", "records", "count", "revoked", "none"], + queryFn: () => memoryApi.countRecords(selectedCompanyId!, countFilters.revoked), + enabled: !!selectedCompanyId, + }); const agentsById = useMemo(() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent])), [agentsQuery.data]); const projectsById = useMemo(() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project])), [projectsQuery.data]); const issuesById = useMemo(() => new Map((issuesQuery.data ?? []).map((issue) => [issue.id, issue])), [issuesQuery.data]); - const selectedRecord = (recordsQuery.data ?? []).find((record) => record.id === selectedRecordId) ?? null; + const records = recordsQuery.data ?? []; + const selectedRecord = records.find((record) => record.id === selectedRecordId) ?? null; - const counts = useMemo(() => { - const records = recordsQuery.data ?? []; + const counts = { + pending: pendingCountQuery.data?.count ?? 0, + accepted: acceptedCountQuery.data?.count ?? 0, + rejected: rejectedCountQuery.data?.count ?? 0, + revoked: revokedCountQuery.data?.count ?? 0, + }; + const countsLoading = pendingCountQuery.isLoading || acceptedCountQuery.isLoading || rejectedCountQuery.isLoading || revokedCountQuery.isLoading; + + const selectedVisibleRecords = useMemo( + () => records.filter((record) => selectedRecordIds.has(record.id)), + [records, selectedRecordIds], + ); + const allVisibleSelected = records.length > 0 && records.every((record) => selectedRecordIds.has(record.id)); + const someVisibleSelected = selectedVisibleRecords.length > 0 && !allVisibleSelected; + + const bulkReview = useMutation({ + mutationFn: async (reviewState: "pending" | "accepted" | "rejected") => { + if (!selectedCompanyId) throw new Error("No company selected"); + const recordIds = Array.from(selectedRecordIds); + await Promise.all( + recordIds.map((recordId) => + memoryApi.reviewRecord(selectedCompanyId, recordId, { + reviewState, + note: null, + }), + ), + ); + return recordIds.length; + }, + onSuccess: async (count) => { + setSelectedRecordIds(new Set()); + await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); + pushToast({ title: `${count} memories reviewed`, tone: "success" }); + }, + onError: (error) => { + pushToast({ + title: "Failed to review memories", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); + + useEffect(() => { + const visibleIds = new Set(records.map((record) => record.id)); + setSelectedRecordIds((current) => { + const next = new Set(Array.from(current).filter((id) => visibleIds.has(id))); + return next.size === current.size ? current : next; + }); + }, [records]); + + const visibleCounts = useMemo(() => { return { total: records.length, pending: records.filter((record) => record.reviewState === "pending").length, accepted: records.filter((record) => record.reviewState === "accepted").length, rejected: records.filter((record) => record.reviewState === "rejected").length, + revoked: records.filter((record) => memoryReviewStatus(record) === "revoked").length, }; - }, [recordsQuery.data]); + }, [records]); if (!selectedCompanyId) { return ; @@ -579,33 +727,192 @@ export function WorkMemories() { ...(providersQuery.data ?? []).map((provider) => ({ value: provider.key, label: provider.displayName })), ]; const bindingById = new Map((bindingsQuery.data ?? []).map((binding) => [binding.id, binding])); - + const activeReviewTab: ReviewTab | null = + filters.retentionState === "revoked" && filters.includeRevoked + ? "revoked" + : filters.reviewState || null; + const activeFilterChips = [ + activeReviewTab + ? { + key: "review", + label: `Review: ${labelize(activeReviewTab)}`, + onClear: () => + setFilters((current) => ({ + ...current, + reviewState: "", + retentionState: activeReviewTab === "revoked" ? "" : current.retentionState, + includeRevoked: activeReviewTab === "revoked" ? false : current.includeRevoked, + })), + } + : null, + filters.q.trim() + ? { key: "q", label: `Search: ${filters.q.trim()}`, onClear: () => setFilters((current) => ({ ...current, q: "" })) } + : null, + filters.providerKey + ? { + key: "provider", + label: `Provider: ${providerOptions.find((option) => option.value === filters.providerKey)?.label ?? filters.providerKey}`, + onClear: () => setFilters((current) => ({ ...current, providerKey: "" })), + } + : null, + filters.projectId + ? { + key: "project", + label: `Project: ${projectsById.get(filters.projectId)?.name ?? filters.projectId}`, + onClear: () => setFilters((current) => ({ ...current, projectId: "" })), + } + : null, + filters.agentId + ? { + key: "agent", + label: `Agent: ${agentsById.get(filters.agentId)?.name ?? filters.agentId}`, + onClear: () => setFilters((current) => ({ ...current, agentId: "" })), + } + : null, + filters.issueId + ? { + key: "task", + label: `Task: ${issuesById.get(filters.issueId)?.identifier ?? filters.issueId.slice(0, 8)}`, + onClear: () => setFilters((current) => ({ ...current, issueId: "" })), + } + : null, + filters.runId.trim() + ? { + key: "run", + label: `Run: ${filters.runId.trim().slice(0, 8)}`, + onClear: () => setFilters((current) => ({ ...current, runId: "" })), + } + : null, + filters.sourceKind + ? { + key: "source", + label: `Source: ${labelize(filters.sourceKind)}`, + onClear: () => setFilters((current) => ({ ...current, sourceKind: "" })), + } + : null, + filters.sensitivityLabel + ? { + key: "sensitivity", + label: `Sensitivity: ${filters.sensitivityLabel}`, + onClear: () => setFilters((current) => ({ ...current, sensitivityLabel: "" })), + } + : null, + filters.retentionState && activeReviewTab !== "revoked" + ? { + key: "retention", + label: `Retention: ${filters.retentionState}`, + onClear: () => setFilters((current) => ({ ...current, retentionState: "" })), + } + : null, + filters.scopeType + ? { + key: "scope", + label: `Scope: ${filters.scopeType}`, + onClear: () => setFilters((current) => ({ ...current, scopeType: "" })), + } + : null, + filters.includeRevoked && activeReviewTab !== "revoked" + ? { + key: "include-revoked", + label: "Including revoked", + onClear: () => setFilters((current) => ({ ...current, includeRevoked: false })), + } + : null, + filters.includeExpired + ? { + key: "include-expired", + label: "Including expired", + onClear: () => setFilters((current) => ({ ...current, includeExpired: false })), + } + : null, + filters.includeSuperseded + ? { + key: "include-superseded", + label: "Including superseded", + onClear: () => setFilters((current) => ({ ...current, includeSuperseded: false })), + } + : null, + ].filter(Boolean) as Array<{ key: string; label: string; onClear: () => void }>; + const selectReviewTab = (tab: ReviewTab) => { + setFilters((current) => ({ + ...current, + reviewState: tab === "revoked" ? "" : tab, + retentionState: tab === "revoked" ? "revoked" : current.retentionState === "revoked" ? "" : current.retentionState, + includeRevoked: tab === "revoked" ? true : false, + })); + setSelectedRecordIds(new Set()); + }; + const toggleRecordSelection = (recordId: string, checked: boolean) => { + setSelectedRecordIds((current) => { + const next = new Set(current); + if (checked) next.add(recordId); + else next.delete(recordId); + return next; + }); + }; + const toggleAllVisible = (checked: boolean) => { + setSelectedRecordIds((current) => { + const next = new Set(current); + if (checked) { + records.forEach((record) => next.add(record.id)); + } else { + records.forEach((record) => next.delete(record.id)); + } + return next; + }); + }; return (
-

Memories

+

Memories

-
- {counts.total} visible - {counts.pending} pending - {counts.accepted} accepted - {counts.rejected} rejected +
+ {activeFilterChips.length === 0 ? ( + No filters applied + ) : ( + activeFilterChips.map((chip) => ( + + )) + )}
- +
+ +
-
-
- - Filters -
+ + + + +
-
- - - +
+
Include records
+
+ + + +
+ + + +
+ {REVIEW_TABS.map((tab) => ( + + ))} + + Showing {visibleCounts.total} records from this page +
{recordsQuery.isLoading ? ( -
Loading memories...
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ +
+ + + +
+
+ + +
+
+ + +
+
+ ))} +
) : recordsQuery.error ? ( -
- {recordsQuery.error.message} -
- ) : (recordsQuery.data ?? []).length === 0 ? ( -
- No memory records match the current filters. -
+ void recordsQuery.refetch()} + tone="destructive" + /> + ) : records.length === 0 ? ( + ) : (
+
+
+ toggleAllVisible(checked === true)} + aria-label="Select all visible memories" + /> +
+
Memory
+
Source
+
Context
+
- {(recordsQuery.data ?? []).map((record) => { - const sourceUrl = sourceHref(record, issuesById); - const agent = record.scope.agentId ? agentsById.get(record.scope.agentId) : null; - const project = record.scope.projectId ? projectsById.get(record.scope.projectId) : null; - const binding = bindingById.get(record.bindingId); - return ( - - ); - })} + {records.map((record) => ( + setSelectedRecordId(record.id)} + selected={selectedRecordIds.has(record.id)} + onSelectedChange={(checked) => toggleRecordSelection(record.id, checked)} + /> + ))}
)} + {selectedRecordIds.size > 0 ? ( +
+ {selectedRecordIds.size} selected + + + +
+ ) : null} +