From 6f357b0aea3884221e709c2f5fb048ff04469471 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 10 Aug 2026 02:08:41 -0600 Subject: [PATCH] Split brains-pane.tsx into sidebar, detail pane, and collection view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brains-pane.tsx held seven components in one 704-line file. Split along the existing component boundaries — every component moved wholesale, so no state or effect changed owner and the rendered output is identical. - brains-pane.tsx (123): BrainsListContent, the sidebar project list - brains-detail-pane.tsx (276): BrainsDetailPane, BrainsOverview, BrainProjectDetail, CollectionPill - brains-collection-view.tsx (300): BrainCollectionView, DeleteBrainDialog, plus a shared DeleteTarget type that was previously spelled out twice - brains-utils.ts (3): repoBasename, used by the sidebar and the detail header automations-pane.tsx is the sole consumer; its import block now pulls BrainsDetailPane from the new file. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/app/automations-pane.tsx | 6 +- .../components/app/brains-collection-view.tsx | 300 +++++++++ .../src/components/app/brains-detail-pane.tsx | 276 ++++++++ apps/web/src/components/app/brains-pane.tsx | 589 +----------------- apps/web/src/components/app/brains-utils.ts | 3 + 5 files changed, 585 insertions(+), 589 deletions(-) create mode 100644 apps/web/src/components/app/brains-collection-view.tsx create mode 100644 apps/web/src/components/app/brains-detail-pane.tsx create mode 100644 apps/web/src/components/app/brains-utils.ts diff --git a/apps/web/src/components/app/automations-pane.tsx b/apps/web/src/components/app/automations-pane.tsx index 3f957972..e943bcc9 100644 --- a/apps/web/src/components/app/automations-pane.tsx +++ b/apps/web/src/components/app/automations-pane.tsx @@ -6,10 +6,8 @@ import { motion } from "framer-motion"; import { JobsProvider } from "@/components/app/jobs-context"; import { JobListContent } from "@/components/app/jobs-list-content"; import { JobDetailPane } from "@/components/app/jobs-detail-pane"; -import { - BrainsListContent, - BrainsDetailPane, -} from "@/components/app/brains-pane"; +import { BrainsListContent } from "@/components/app/brains-pane"; +import { BrainsDetailPane } from "@/components/app/brains-detail-pane"; import { CreateTemplateDialog } from "@/components/app/automations-create-dialog"; import { LaunchTemplateDialog } from "@/components/app/automations-launch-dialog"; import { TemplateDetailPane } from "@/components/app/automations-template-detail"; diff --git a/apps/web/src/components/app/brains-collection-view.tsx b/apps/web/src/components/app/brains-collection-view.tsx new file mode 100644 index 00000000..9d184fca --- /dev/null +++ b/apps/web/src/components/app/brains-collection-view.tsx @@ -0,0 +1,300 @@ +import { useState } from "react"; +import { Brain, Database, List, Radio, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + useBrainObjects, + useBrainLists, + useBrainEvents, + useBrainActions, + type BrainObject, + type BrainList, + type BrainEvent, +} from "@/hooks/use-brain"; +import { + CollapsibleSection, + ObjectCard, + ListCard, + EventCard, +} from "@/components/app/brain-cards"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type DeleteTarget = + | { type: "object"; object: BrainObject } + | { type: "list"; list: BrainList } + | { type: "event"; event: BrainEvent } + | { type: "collection"; collection: string }; + +export function BrainCollectionView({ + repoRoot, + collection, + search, + onCollectionCleared, +}: { + repoRoot: string; + collection: string | null; + search: string; + onCollectionCleared: () => void; +}): JSX.Element { + const objectFilters = collection ? { collection } : { limit: 100 }; + const listFilters = collection ? { collection } : { limit: 100 }; + const eventFilters = collection ? { collection, limit: 100 } : { limit: 100 }; + + const { data: objects = [], isLoading: objectsLoading } = useBrainObjects( + repoRoot, + objectFilters + ); + const { data: lists = [], isLoading: listsLoading } = useBrainLists( + repoRoot, + listFilters + ); + const { data: events = [], isLoading: eventsLoading } = useBrainEvents( + repoRoot, + eventFilters + ); + const { deleteObject, deleteList, deleteEvent, deleteCollection } = + useBrainActions(); + const [deleteTarget, setDeleteTarget] = useState(null); + + const isLoading = objectsLoading || listsLoading || eventsLoading; + + const lowerSearch = search.toLowerCase(); + const filteredObjects = search + ? objects.filter( + (o) => + o.name.toLowerCase().includes(lowerSearch) || + o.collection.toLowerCase().includes(lowerSearch) + ) + : objects; + const filteredLists = search + ? lists.filter( + (l) => + l.name.toLowerCase().includes(lowerSearch) || + l.collection.toLowerCase().includes(lowerSearch) + ) + : lists; + const filteredEvents = search + ? events.filter( + (e) => + e.kind.toLowerCase().includes(lowerSearch) || + (e.subject?.toLowerCase().includes(lowerSearch) ?? false) || + e.collection.toLowerCase().includes(lowerSearch) + ) + : events; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if ( + filteredObjects.length === 0 && + filteredLists.length === 0 && + filteredEvents.length === 0 + ) { + return ( +
+
+ +
+ {search + ? "No brain data matches your filter." + : "No brain data in this collection yet."} +
+
+
+ ); + } + + const isCapped = + !collection && + (objects.length >= 100 || lists.length >= 100 || events.length >= 100); + + const confirmDelete = async () => { + if (!deleteTarget) return; + try { + if (deleteTarget.type === "object") { + await deleteObject.mutateAsync({ + repoRoot, + collection: deleteTarget.object.collection, + name: deleteTarget.object.name, + }); + toast.success("Object deleted."); + } else if (deleteTarget.type === "list") { + await deleteList.mutateAsync({ + repoRoot, + collection: deleteTarget.list.collection, + name: deleteTarget.list.name, + }); + toast.success("List deleted."); + } else if (deleteTarget.type === "event") { + await deleteEvent.mutateAsync({ repoRoot, id: deleteTarget.event.id }); + toast.success("Event deleted."); + } else { + const result = await deleteCollection.mutateAsync({ + repoRoot, + collection: deleteTarget.collection, + }); + toast.success( + `Deleted ${result.objects + result.lists + result.events} entries from ${deleteTarget.collection}.` + ); + onCollectionCleared(); + } + setDeleteTarget(null); + } catch { + toast.error("Could not delete brain data."); + } + }; + + const deleting = + deleteObject.isPending || + deleteList.isPending || + deleteEvent.isPending || + deleteCollection.isPending; + + return ( +
+
+ {isCapped && !search ? ( +
+ Showing the first 100 items per section. Select a collection to see + all entries. +
+ ) : null} + + {collection ? ( +
+ +
+ ) : null} + + + {filteredObjects.map((obj) => ( + setDeleteTarget({ type: "object", object: obj })} + /> + ))} + + + + {filteredLists.map((list) => ( + setDeleteTarget({ type: "list", list })} + /> + ))} + + + + {filteredEvents.map((event) => ( + setDeleteTarget({ type: "event", event })} + /> + ))} + +
+ !open && setDeleteTarget(null)} + onConfirm={() => void confirmDelete()} + deleting={deleting} + /> +
+ ); +} + +function DeleteBrainDialog({ + target, + onOpenChange, + onConfirm, + deleting, +}: { + target: DeleteTarget | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + deleting: boolean; +}): JSX.Element { + const title = + target?.type === "object" + ? `Delete ${target.object.name}?` + : target?.type === "list" + ? `Delete ${target.list.name}?` + : target?.type === "event" + ? `Delete ${target.event.kind} event?` + : `Clear ${target?.collection ?? "collection"}?`; + const description = + target?.type === "collection" + ? `This permanently deletes all objects, lists, and events in “${target.collection}” for this project.` + : "This permanently deletes shared brain data for this project."; + + return ( + + + + {title} + {description} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/app/brains-detail-pane.tsx b/apps/web/src/components/app/brains-detail-pane.tsx new file mode 100644 index 00000000..c58a13f3 --- /dev/null +++ b/apps/web/src/components/app/brains-detail-pane.tsx @@ -0,0 +1,276 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Brain, Search, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { useBrainCollections, useBrainActions } from "@/hooks/use-brain"; +import { BrainCollectionView } from "@/components/app/brains-collection-view"; +import { repoBasename } from "@/components/app/brains-utils"; +import { decodeRepoRoot } from "@/lib/brain-encoding"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export function BrainsDetailPane(): JSX.Element { + const navigate = useNavigate(); + const { encodedRepoRoot, collection } = useParams<{ + encodedRepoRoot?: string; + collection?: string; + }>(); + + if (!encodedRepoRoot) { + return ; + } + + let repoRoot: string; + try { + repoRoot = decodeRepoRoot(encodedRepoRoot); + } catch { + navigate("/automations/brains", { replace: true }); + return ; + } + + return ( + + ); +} + +function BrainsOverview(): JSX.Element { + return ( +
+
+ +
Brain Explorer
+
+ Select a project to inspect its shared brain memory — objects, lists, + and events organized by collection. +
+
+
+ ); +} + +function BrainProjectDetail({ + repoRoot, + encodedRepoRoot, + selectedCollection, +}: { + repoRoot: string; + encodedRepoRoot: string; + selectedCollection: string | null; +}): JSX.Element { + const navigate = useNavigate(); + const { data: collections = [], isLoading: collectionsLoading } = + useBrainCollections(repoRoot); + const [search, setSearch] = useState(""); + const [projectDeleteOpen, setProjectDeleteOpen] = useState(false); + const { deleteProject } = useBrainActions(); + + const confirmProjectDelete = async () => { + try { + const result = await deleteProject.mutateAsync({ repoRoot }); + toast.success( + `Deleted ${result.objects + result.lists + result.events} entries from this project.` + ); + setProjectDeleteOpen(false); + navigate("/automations/brains", { replace: true }); + } catch { + toast.error("Could not delete project brain data."); + } + }; + + return ( +
+
+
+
+

+ {repoBasename(repoRoot)} +

+
+ {repoRoot} +
+
+ +
+
+ +
+
+ + Collections + + + {/* Mobile: dropdown */} +
+ +
+ + {/* Desktop: pills */} +
+ navigate(`/automations/brains/${encodedRepoRoot}`)} + /> + {collectionsLoading + ? null + : collections.map((col) => ( + + navigate( + `/automations/brains/${encodedRepoRoot}/${encodeURIComponent(col.collection)}` + ) + } + /> + ))} +
+ +
+ + setSearch(e.target.value)} + placeholder="Filter..." + className="h-8 w-32 pl-8 text-xs md:w-40" + /> +
+
+
+ + + navigate(`/automations/brains/${encodedRepoRoot}`, { replace: true }) + } + /> + + + + Clear this project? + + This permanently deletes every object, list, and event in all + collections for this project. + + +
+ + +
+
+
+
+ ); +} + +function CollectionPill({ + label, + count, + active, + onClick, +}: { + label: string; + count?: number; + active: boolean; + onClick: () => void; +}): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/components/app/brains-pane.tsx b/apps/web/src/components/app/brains-pane.tsx index cba101e1..bd94d42e 100644 --- a/apps/web/src/components/app/brains-pane.tsx +++ b/apps/web/src/components/app/brains-pane.tsx @@ -1,59 +1,12 @@ -import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { - Activity, - Brain, - Database, - List, - Radio, - Search, - Trash2, -} from "lucide-react"; -import { toast } from "sonner"; +import { Activity, Database, List, Radio } from "lucide-react"; -import { - useBrainProjects, - useBrainCollections, - useBrainObjects, - useBrainLists, - useBrainEvents, - useBrainActions, - type BrainObject, - type BrainList, - type BrainEvent, -} from "@/hooks/use-brain"; -import { - CollapsibleSection, - ObjectCard, - ListCard, - EventCard, -} from "@/components/app/brain-cards"; -import { decodeRepoRoot, encodeRepoRoot } from "@/lib/brain-encoding"; +import { useBrainProjects } from "@/hooks/use-brain"; +import { repoBasename } from "@/components/app/brains-utils"; +import { encodeRepoRoot } from "@/lib/brain-encoding"; import { shortPath } from "@/lib/format"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; -function repoBasename(repoRoot: string): string { - return repoRoot.split("/").filter(Boolean).pop() ?? repoRoot; -} - -// ── Sidebar ───────────────────────────────────────────────────── - export function BrainsListContent({ onItemSelect, }: { @@ -168,537 +121,3 @@ export function BrainsListContent({
); } - -// ── Detail Pane ────────────────────────────────────────────────── - -export function BrainsDetailPane(): JSX.Element { - const navigate = useNavigate(); - const { encodedRepoRoot, collection } = useParams<{ - encodedRepoRoot?: string; - collection?: string; - }>(); - - if (!encodedRepoRoot) { - return ; - } - - let repoRoot: string; - try { - repoRoot = decodeRepoRoot(encodedRepoRoot); - } catch { - navigate("/automations/brains", { replace: true }); - return ; - } - - return ( - - ); -} - -function BrainsOverview(): JSX.Element { - return ( -
-
- -
Brain Explorer
-
- Select a project to inspect its shared brain memory — objects, lists, - and events organized by collection. -
-
-
- ); -} - -// ── Project Detail ─────────────────────────────────────────────── - -function BrainProjectDetail({ - repoRoot, - encodedRepoRoot, - selectedCollection, -}: { - repoRoot: string; - encodedRepoRoot: string; - selectedCollection: string | null; -}): JSX.Element { - const navigate = useNavigate(); - const { data: collections = [], isLoading: collectionsLoading } = - useBrainCollections(repoRoot); - const [search, setSearch] = useState(""); - const [projectDeleteOpen, setProjectDeleteOpen] = useState(false); - const { deleteProject } = useBrainActions(); - - const confirmProjectDelete = async () => { - try { - const result = await deleteProject.mutateAsync({ repoRoot }); - toast.success( - `Deleted ${result.objects + result.lists + result.events} entries from this project.` - ); - setProjectDeleteOpen(false); - navigate("/automations/brains", { replace: true }); - } catch { - toast.error("Could not delete project brain data."); - } - }; - - return ( -
-
-
-
-

- {repoBasename(repoRoot)} -

-
- {repoRoot} -
-
- -
-
- -
-
- - Collections - - - {/* Mobile: dropdown */} -
- -
- - {/* Desktop: pills */} -
- navigate(`/automations/brains/${encodedRepoRoot}`)} - /> - {collectionsLoading - ? null - : collections.map((col) => ( - - navigate( - `/automations/brains/${encodedRepoRoot}/${encodeURIComponent(col.collection)}` - ) - } - /> - ))} -
- -
- - setSearch(e.target.value)} - placeholder="Filter..." - className="h-8 w-32 pl-8 text-xs md:w-40" - /> -
-
-
- - - navigate(`/automations/brains/${encodedRepoRoot}`, { replace: true }) - } - /> - - - - Clear this project? - - This permanently deletes every object, list, and event in all - collections for this project. - - -
- - -
-
-
-
- ); -} - -function CollectionPill({ - label, - count, - active, - onClick, -}: { - label: string; - count?: number; - active: boolean; - onClick: () => void; -}): JSX.Element { - return ( - - ); -} - -// ── Collection View ────────────────────────────────────────────── - -function BrainCollectionView({ - repoRoot, - collection, - search, - onCollectionCleared, -}: { - repoRoot: string; - collection: string | null; - search: string; - onCollectionCleared: () => void; -}): JSX.Element { - const objectFilters = collection ? { collection } : { limit: 100 }; - const listFilters = collection ? { collection } : { limit: 100 }; - const eventFilters = collection ? { collection, limit: 100 } : { limit: 100 }; - - const { data: objects = [], isLoading: objectsLoading } = useBrainObjects( - repoRoot, - objectFilters - ); - const { data: lists = [], isLoading: listsLoading } = useBrainLists( - repoRoot, - listFilters - ); - const { data: events = [], isLoading: eventsLoading } = useBrainEvents( - repoRoot, - eventFilters - ); - const { deleteObject, deleteList, deleteEvent, deleteCollection } = - useBrainActions(); - const [deleteTarget, setDeleteTarget] = useState< - | { type: "object"; object: BrainObject } - | { type: "list"; list: BrainList } - | { type: "event"; event: BrainEvent } - | { type: "collection"; collection: string } - | null - >(null); - - const isLoading = objectsLoading || listsLoading || eventsLoading; - - const lowerSearch = search.toLowerCase(); - const filteredObjects = search - ? objects.filter( - (o) => - o.name.toLowerCase().includes(lowerSearch) || - o.collection.toLowerCase().includes(lowerSearch) - ) - : objects; - const filteredLists = search - ? lists.filter( - (l) => - l.name.toLowerCase().includes(lowerSearch) || - l.collection.toLowerCase().includes(lowerSearch) - ) - : lists; - const filteredEvents = search - ? events.filter( - (e) => - e.kind.toLowerCase().includes(lowerSearch) || - (e.subject?.toLowerCase().includes(lowerSearch) ?? false) || - e.collection.toLowerCase().includes(lowerSearch) - ) - : events; - - if (isLoading) { - return ( -
-
-
- ); - } - - if ( - filteredObjects.length === 0 && - filteredLists.length === 0 && - filteredEvents.length === 0 - ) { - return ( -
-
- -
- {search - ? "No brain data matches your filter." - : "No brain data in this collection yet."} -
-
-
- ); - } - - const isCapped = - !collection && - (objects.length >= 100 || lists.length >= 100 || events.length >= 100); - - const confirmDelete = async () => { - if (!deleteTarget) return; - try { - if (deleteTarget.type === "object") { - await deleteObject.mutateAsync({ - repoRoot, - collection: deleteTarget.object.collection, - name: deleteTarget.object.name, - }); - toast.success("Object deleted."); - } else if (deleteTarget.type === "list") { - await deleteList.mutateAsync({ - repoRoot, - collection: deleteTarget.list.collection, - name: deleteTarget.list.name, - }); - toast.success("List deleted."); - } else if (deleteTarget.type === "event") { - await deleteEvent.mutateAsync({ repoRoot, id: deleteTarget.event.id }); - toast.success("Event deleted."); - } else { - const result = await deleteCollection.mutateAsync({ - repoRoot, - collection: deleteTarget.collection, - }); - toast.success( - `Deleted ${result.objects + result.lists + result.events} entries from ${deleteTarget.collection}.` - ); - onCollectionCleared(); - } - setDeleteTarget(null); - } catch { - toast.error("Could not delete brain data."); - } - }; - - const deleting = - deleteObject.isPending || - deleteList.isPending || - deleteEvent.isPending || - deleteCollection.isPending; - - return ( -
-
- {isCapped && !search ? ( -
- Showing the first 100 items per section. Select a collection to see - all entries. -
- ) : null} - - {collection ? ( -
- -
- ) : null} - - - {filteredObjects.map((obj) => ( - setDeleteTarget({ type: "object", object: obj })} - /> - ))} - - - - {filteredLists.map((list) => ( - setDeleteTarget({ type: "list", list })} - /> - ))} - - - - {filteredEvents.map((event) => ( - setDeleteTarget({ type: "event", event })} - /> - ))} - -
- !open && setDeleteTarget(null)} - onConfirm={() => void confirmDelete()} - deleting={deleting} - /> -
- ); -} - -function DeleteBrainDialog({ - target, - onOpenChange, - onConfirm, - deleting, -}: { - target: - | { type: "object"; object: BrainObject } - | { type: "list"; list: BrainList } - | { type: "event"; event: BrainEvent } - | { type: "collection"; collection: string } - | null; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - deleting: boolean; -}): JSX.Element { - const title = - target?.type === "object" - ? `Delete ${target.object.name}?` - : target?.type === "list" - ? `Delete ${target.list.name}?` - : target?.type === "event" - ? `Delete ${target.event.kind} event?` - : `Clear ${target?.collection ?? "collection"}?`; - const description = - target?.type === "collection" - ? `This permanently deletes all objects, lists, and events in “${target.collection}” for this project.` - : "This permanently deletes shared brain data for this project."; - - return ( - - - - {title} - {description} - -
- - -
-
-
- ); -} diff --git a/apps/web/src/components/app/brains-utils.ts b/apps/web/src/components/app/brains-utils.ts new file mode 100644 index 00000000..0e04e87f --- /dev/null +++ b/apps/web/src/components/app/brains-utils.ts @@ -0,0 +1,3 @@ +export function repoBasename(repoRoot: string): string { + return repoRoot.split("/").filter(Boolean).pop() ?? repoRoot; +}