diff --git a/internal/http/vault_handler_tree.go b/internal/http/vault_handler_tree.go new file mode 100644 index 00000000..0b78a328 --- /dev/null +++ b/internal/http/vault_handler_tree.go @@ -0,0 +1,56 @@ +package http + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleVaultTree returns immediate children (files + virtual folders) under +// a given path prefix for lazy-loading the vault sidebar tree. +func (h *VaultHandler) handleVaultTree(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + path := r.URL.Query().Get("path") + + if strings.Contains(path, "..") || strings.HasPrefix(path, "/") { + slog.Warn("security.vault_tree_traversal", "path", path) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + opts := store.VaultTreeOptions{ + Path: path, + Scope: r.URL.Query().Get("scope"), + DocTypes: splitCSV(r.URL.Query().Get("doc_type")), + } + if teamID := r.URL.Query().Get("team_id"); teamID != "" { + opts.TeamID = &teamID + } + + if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { + if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 { + opts.TeamIDs = ids + } else { + empty := "" + opts.TeamID = &empty + } + } + if opts.TeamID != nil && *opts.TeamID != "" { + if !h.validateTeamMembership(r.Context(), w, *opts.TeamID) { + return + } + } + + entries, err := h.store.ListTreeEntries(r.Context(), tenantID.String(), opts) + if err != nil { + slog.Warn("vault.tree failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tree"}) + return + } + if entries == nil { + entries = []store.VaultTreeEntry{} + } + writeJSON(w, http.StatusOK, map[string]any{"entries": entries}) +} diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index ac060399..1dbf172b 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -119,6 +119,7 @@ func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/vault/links/batch", h.auth(h.handleBatchGetLinks)) mux.HandleFunc("POST /v1/vault/upload", h.auth(h.handleUpload)) mux.HandleFunc("POST /v1/vault/rescan", h.auth(h.handleRescan)) + mux.HandleFunc("GET /v1/vault/tree", h.auth(h.handleVaultTree)) mux.HandleFunc("POST /v1/vault/search", h.auth(h.handleSearchAll)) mux.HandleFunc("GET /v1/vault/enrichment/status", h.auth(h.handleEnrichmentStatus)) // Per-agent endpoints (backward compat — same handlers, agentID from path). diff --git a/internal/store/pg/vault_documents.go b/internal/store/pg/vault_documents.go index fe5a453b..c128ea8a 100644 --- a/internal/store/pg/vault_documents.go +++ b/internal/store/pg/vault_documents.go @@ -674,3 +674,128 @@ func (s *PGVaultStore) mergeResults(fts, vec []store.VaultSearchResult, ftsW, ve return results } +// ListTreeEntries returns immediate children (files + virtual folders) under the given path prefix. +func (s *PGVaultStore) ListTreeEntries(ctx context.Context, tenantID string, opts store.VaultTreeOptions) ([]store.VaultTreeEntry, error) { + tid, err := parseUUID(tenantID) + if err != nil { + return nil, fmt.Errorf("vault tree: tenant: %w", err) + } + prefix := opts.Path + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + fileQ := `SELECT id, path, title, doc_type, scope, updated_at FROM vault_documents WHERE tenant_id = $1` + fileArgs := []any{tid} + fp := 2 + if prefix == "" { + fileQ += " AND path NOT LIKE '%/%'" + } else { + fileQ += fmt.Sprintf(" AND path LIKE $%d AND path NOT LIKE $%d", fp, fp+1) + fileArgs = append(fileArgs, prefix+"%", prefix+"%/%") + fp += 2 + } + fileQ, fileArgs, fp = appendTreeFilters(fileQ, fileArgs, fp, opts) + fileQ += " ORDER BY path" + + deepQ := `SELECT DISTINCT path FROM vault_documents WHERE tenant_id = $1` + deepArgs := []any{tid} + dp := 2 + if prefix == "" { + deepQ += " AND path LIKE '%/%'" + } else { + deepQ += fmt.Sprintf(" AND path LIKE $%d", dp) + deepArgs = append(deepArgs, prefix+"%/%") + dp++ + } + deepQ, deepArgs, _ = appendTreeFilters(deepQ, deepArgs, dp, opts) + deepQ += " LIMIT 50000" + + fileRows, err := s.db.QueryContext(ctx, fileQ, fileArgs...) + if err != nil { + return nil, fmt.Errorf("vault tree files: %w", err) + } + defer fileRows.Close() + var entries []store.VaultTreeEntry + for fileRows.Next() { + var id, path, title, docType, scope string + var updatedAt time.Time + if err := fileRows.Scan(&id, &path, &title, &docType, &scope, &updatedAt); err != nil { + return nil, fmt.Errorf("vault tree scan: %w", err) + } + name := path + if idx := strings.LastIndex(path, "/"); idx >= 0 { + name = path[idx+1:] + } + ua := updatedAt + entries = append(entries, store.VaultTreeEntry{ + Name: name, Path: path, DocID: id, DocType: docType, Scope: scope, Title: title, UpdatedAt: &ua, + }) + } + if err := fileRows.Err(); err != nil { + return nil, fmt.Errorf("vault tree files: %w", err) + } + + deepRows, err := s.db.QueryContext(ctx, deepQ, deepArgs...) + if err != nil { + return nil, fmt.Errorf("vault tree deep: %w", err) + } + defer deepRows.Close() + var deepPaths []string + for deepRows.Next() { + var p string + if err := deepRows.Scan(&p); err != nil { + return nil, fmt.Errorf("vault tree deep scan: %w", err) + } + deepPaths = append(deepPaths, p) + } + if err := deepRows.Err(); err != nil { + return nil, fmt.Errorf("vault tree deep: %w", err) + } + + for _, fname := range extractFolderNames(prefix, deepPaths) { + entries = append(entries, store.VaultTreeEntry{ + Name: fname, Path: prefix + fname, IsDir: true, HasChildren: true, + }) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].IsDir != entries[j].IsDir { + return entries[i].IsDir + } + return entries[i].Name < entries[j].Name + }) + return entries, nil +} + +func appendTreeFilters(q string, args []any, p int, opts store.VaultTreeOptions) (string, []any, int) { + q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs) + if opts.Scope != "" { + q += fmt.Sprintf(" AND scope = $%d", p) + args = append(args, opts.Scope) + p++ + } + if len(opts.DocTypes) > 0 { + q += fmt.Sprintf(" AND doc_type = ANY($%d)", p) + args = append(args, pqStringArray(opts.DocTypes)) + p++ + } + return q, args, p +} + +func extractFolderNames(prefix string, deepPaths []string) []string { + seen := make(map[string]bool) + var folders []string + for _, p := range deepPaths { + rest := strings.TrimPrefix(p, prefix) + if idx := strings.Index(rest, "/"); idx > 0 { + seg := rest[:idx] + if !seen[seg] { + seen[seg] = true + folders = append(folders, seg) + } + } + } + sort.Strings(folders) + return folders +} + diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index 61c0c0f3..ec61a1f9 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 16 +const SchemaVersion = 17 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -435,6 +435,9 @@ CREATE INDEX IF NOT EXISTS idx_vault_links_source CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation ON vault_documents(json_extract(metadata, '$.delegation_id')) WHERE json_extract(metadata, '$.delegation_id') IS NOT NULL;`, + + // Version 16 → 17: path prefix index for vault tree lazy-load queries. + 16: `CREATE INDEX IF NOT EXISTS idx_vault_docs_path_prefix ON vault_documents(tenant_id, path);`, } // backfillV16 populates base_name / path_basename for rows that existed diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 4064d720..d054b5f7 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1542,6 +1542,7 @@ CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_ CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash); CREATE INDEX IF NOT EXISTS idx_vault_docs_team ON vault_documents(team_id); CREATE INDEX IF NOT EXISTS idx_vault_docs_basename ON vault_documents(tenant_id, path_basename); +CREATE INDEX IF NOT EXISTS idx_vault_docs_path_prefix ON vault_documents(tenant_id, path); CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation ON vault_documents(json_extract(metadata, '$.delegation_id')) WHERE json_extract(metadata, '$.delegation_id') IS NOT NULL; diff --git a/internal/store/sqlitestore/vault_documents.go b/internal/store/sqlitestore/vault_documents.go index 31148fe9..02757814 100644 --- a/internal/store/sqlitestore/vault_documents.go +++ b/internal/store/sqlitestore/vault_documents.go @@ -382,6 +382,109 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt return results, nil } +// ListTreeEntries returns immediate children (files + virtual folders) under the given path prefix. +func (s *SQLiteVaultStore) ListTreeEntries(ctx context.Context, tenantID string, opts store.VaultTreeOptions) ([]store.VaultTreeEntry, error) { + prefix := opts.Path + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + fileQ := `SELECT id, path, title, doc_type, scope, updated_at FROM vault_documents WHERE tenant_id = ?` + fileArgs := []any{tenantID} + if prefix == "" { + fileQ += " AND path NOT LIKE '%/%'" + } else { + fileQ += " AND path LIKE ? AND path NOT LIKE ?" + fileArgs = append(fileArgs, prefix+"%", prefix+"%/%") + } + fileQ, fileArgs = sqliteAppendTreeFilters(fileQ, fileArgs, opts) + fileQ += " ORDER BY path" + + deepQ := `SELECT DISTINCT path FROM vault_documents WHERE tenant_id = ?` + deepArgs := []any{tenantID} + if prefix == "" { + deepQ += " AND path LIKE '%/%'" + } else { + deepQ += " AND path LIKE ?" + deepArgs = append(deepArgs, prefix+"%/%") + } + deepQ, deepArgs = sqliteAppendTreeFilters(deepQ, deepArgs, opts) + deepQ += " LIMIT 50000" + + fileRows, err := s.db.QueryContext(ctx, fileQ, fileArgs...) + if err != nil { + return nil, fmt.Errorf("vault tree files: %w", err) + } + defer fileRows.Close() + var entries []store.VaultTreeEntry + for fileRows.Next() { + var id, path, title, docType, scope string + var ua sqliteTime + if err := fileRows.Scan(&id, &path, &title, &docType, &scope, &ua); err != nil { + return nil, fmt.Errorf("vault tree scan: %w", err) + } + name := path + if idx := strings.LastIndex(path, "/"); idx >= 0 { + name = path[idx+1:] + } + t := ua.Time + entries = append(entries, store.VaultTreeEntry{ + Name: name, Path: path, DocID: id, DocType: docType, Scope: scope, Title: title, UpdatedAt: &t, + }) + } + if err := fileRows.Err(); err != nil { + return nil, fmt.Errorf("vault tree files: %w", err) + } + + deepRows, err := s.db.QueryContext(ctx, deepQ, deepArgs...) + if err != nil { + return nil, fmt.Errorf("vault tree deep: %w", err) + } + defer deepRows.Close() + seen := make(map[string]bool) + for deepRows.Next() { + var p string + if err := deepRows.Scan(&p); err != nil { + return nil, fmt.Errorf("vault tree deep scan: %w", err) + } + rest := strings.TrimPrefix(p, prefix) + if idx := strings.Index(rest, "/"); idx > 0 { + seg := rest[:idx] + if !seen[seg] { + seen[seg] = true + entries = append(entries, store.VaultTreeEntry{ + Name: seg, Path: prefix + seg, IsDir: true, HasChildren: true, + }) + } + } + } + if err := deepRows.Err(); err != nil { + return nil, fmt.Errorf("vault tree deep: %w", err) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].IsDir != entries[j].IsDir { + return entries[i].IsDir + } + return entries[i].Name < entries[j].Name + }) + return entries, nil +} + +func sqliteAppendTreeFilters(q string, args []any, opts store.VaultTreeOptions) (string, []any) { + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) + if opts.Scope != "" { + q += " AND scope = ?" + args = append(args, opts.Scope) + } + if len(opts.DocTypes) > 0 { + ph := strings.Repeat("?,", len(opts.DocTypes)-1) + "?" + q += " AND doc_type IN (" + ph + ")" + for _, dt := range opts.DocTypes { + args = append(args, dt) + } + } + return q, args +} + // --- scan helpers --- func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) { @@ -409,7 +512,7 @@ func scanVaultDocRow(rows *sql.Rows) (*store.VaultDocument, error) { var agentID *string ca, ua := &sqliteTime{}, &sqliteTime{} err := rows.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope, - &doc.Path, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua) + &doc.Path, &doc.PathBasename, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua) if err != nil { return nil, err } diff --git a/internal/store/vault_store.go b/internal/store/vault_store.go index be4ba77c..48336ff5 100644 --- a/internal/store/vault_store.go +++ b/internal/store/vault_store.go @@ -74,6 +74,28 @@ type VaultListOptions struct { Offset int } +// VaultTreeEntry represents a file or virtual folder in the vault tree. +type VaultTreeEntry struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"isDir"` + HasChildren bool `json:"hasChildren,omitempty"` + DocID string `json:"docId,omitempty"` + DocType string `json:"docType,omitempty"` + Scope string `json:"scope,omitempty"` + Title string `json:"title,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// VaultTreeOptions configures a vault tree listing query. +type VaultTreeOptions struct { + Path string + TeamID *string + TeamIDs []string + Scope string + DocTypes []string +} + // VaultStore manages the Knowledge Vault document registry and links. type VaultStore interface { // Document CRUD @@ -85,6 +107,9 @@ type VaultStore interface { CountDocuments(ctx context.Context, tenantID, agentID string, opts VaultListOptions) (int, error) UpdateHash(ctx context.Context, tenantID, id, newHash string) error + // ListTreeEntries returns immediate children (files + virtual folders) under the given path prefix. + ListTreeEntries(ctx context.Context, tenantID string, opts VaultTreeOptions) ([]VaultTreeEntry, error) + // GetDocumentsByIDs returns documents matching the given IDs with tenant isolation. GetDocumentsByIDs(ctx context.Context, tenantID string, docIDs []string) ([]VaultDocument, error) // GetDocumentByBasename finds a document by path basename (case-insensitive). diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 42e3e2e5..d73908cc 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 48 +const RequiredSchemaVersion uint = 49 diff --git a/migrations/000049_vault_path_prefix_index.down.sql b/migrations/000049_vault_path_prefix_index.down.sql new file mode 100644 index 00000000..1b424b4b --- /dev/null +++ b/migrations/000049_vault_path_prefix_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_vault_docs_path_prefix; diff --git a/migrations/000049_vault_path_prefix_index.up.sql b/migrations/000049_vault_path_prefix_index.up.sql new file mode 100644 index 00000000..ad90fa7d --- /dev/null +++ b/migrations/000049_vault_path_prefix_index.up.sql @@ -0,0 +1,2 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_vault_docs_path_prefix + ON vault_documents (tenant_id, path text_pattern_ops); diff --git a/ui/web/src/pages/vault/components/vault-tree.tsx b/ui/web/src/pages/vault/components/vault-tree.tsx new file mode 100644 index 00000000..10b2faaa --- /dev/null +++ b/ui/web/src/pages/vault/components/vault-tree.tsx @@ -0,0 +1,155 @@ +import { useState, useCallback } from "react"; +import { + ChevronRight, Folder, FolderOpen, Loader2, + FileText, Brain, StickyNote, Sparkles, Clock, Image, FileType, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { formatRelativeTime } from "@/lib/format"; +import type { TreeNode } from "@/lib/file-helpers"; +import type { VaultTreeEntry } from "../hooks/use-vault-tree"; + +const DOC_TYPE_ICONS: Record = { + context: { icon: FileText, color: "text-blue-600 dark:text-blue-400" }, + memory: { icon: Brain, color: "text-purple-600 dark:text-purple-400" }, + note: { icon: StickyNote, color: "text-amber-600 dark:text-amber-400" }, + skill: { icon: Sparkles, color: "text-emerald-600 dark:text-emerald-400" }, + episodic: { icon: Clock, color: "text-orange-600 dark:text-orange-400" }, + media: { icon: Image, color: "text-rose-600 dark:text-rose-400" }, + document: { icon: FileType, color: "text-cyan-600 dark:text-cyan-400" }, +}; +const DEFAULT_ICON = { icon: FileText, color: "text-muted-foreground" }; + +const SCOPE_DOT: Record = { + personal: "bg-blue-400", + team: "bg-green-400", + shared: "bg-amber-400", +}; + +/** Truncate filename in the middle: "very-long-file-name.md" → "very-lo…me.md" */ +function truncateMiddle(s: string, max = 28): string { + if (s.length <= max) return s; + const keep = Math.floor((max - 1) / 2); + return s.slice(0, keep) + "…" + s.slice(s.length - keep); +} + +export interface VaultTreeProps { + tree: TreeNode[]; + meta: Map; + loading: boolean; + activePath: string | null; + onSelect: (path: string) => void; + onLoadMore: (path: string) => void; +} + +function VaultTreeNode({ + node, depth, meta, activePath, onSelect, onLoadMore, +}: { + node: TreeNode; depth: number; meta: Map; + activePath: string | null; onSelect: (path: string) => void; onLoadMore: (path: string) => void; +}) { + const [expanded, setExpanded] = useState(depth === 0); + const entry = meta.get(node.path); + + const handleToggle = useCallback(() => { + const willExpand = !expanded; + setExpanded(willExpand); + if (willExpand && node.isDir && node.hasChildren && node.children.length === 0 && !node.loading) { + onLoadMore(node.path); + } + }, [expanded, node.isDir, node.hasChildren, node.children.length, node.loading, node.path, onLoadMore]); + + // --- Folder node --- + if (node.isDir) { + return ( +
+
+ + {expanded + ? + : } + {node.name} + {node.loading && } + {!node.loading && node.children.length > 0 && ( + + {node.children.length} + + )} +
+ {expanded && node.children.map((child) => ( + + ))} + {expanded && node.hasChildren && node.children.length === 0 && !node.loading && ( +
onLoadMore(node.path)}> + Load more +
+ )} +
+ ); + } + + // --- File node (compact single-line) --- + const isActive = activePath === node.path; + const docType = entry?.docType ?? ""; + const { icon: Icon, color } = DOC_TYPE_ICONS[docType] ?? DEFAULT_ICON; + const title = entry?.title || node.name; + const scopeDot = entry?.scope ? SCOPE_DOT[entry.scope] : null; + const relTime = entry?.updatedAt ? formatRelativeTime(entry.updatedAt) : null; + + return ( + + + +
onSelect(node.path)} + > + + {truncateMiddle(title)} + {scopeDot && } +
+
+ +

{title}

+ {relTime &&

{relTime}

} + {entry?.scope &&

{entry.scope}

} +
+
+
+ ); +} + +export function VaultTree({ tree, meta, loading, activePath, onSelect, onLoadMore }: VaultTreeProps) { + if (loading && tree.length === 0) { + return ( +
+ +
+ ); + } + if (tree.length === 0) { + return ( +
+ No documents +
+ ); + } + return ( +
+ {tree.map((node) => ( + + ))} +
+ ); +} diff --git a/ui/web/src/pages/vault/hooks/use-vault-tree.ts b/ui/web/src/pages/vault/hooks/use-vault-tree.ts new file mode 100644 index 00000000..f50774fe --- /dev/null +++ b/ui/web/src/pages/vault/hooks/use-vault-tree.ts @@ -0,0 +1,80 @@ +import { useState, useCallback } from "react"; +import { useHttp } from "@/hooks/use-ws"; +import { buildTree, mergeSubtree, setNodeLoading, type TreeNode } from "@/lib/file-helpers"; + +export interface VaultTreeEntry { + name: string; + path: string; + isDir: boolean; + hasChildren?: boolean; + docId?: string; + docType?: string; + scope?: string; + title?: string; + updatedAt?: string; +} + +interface VaultTreeResponse { entries: VaultTreeEntry[] } + +interface VaultTreeFilter { + scope?: string; + doc_type?: string; + team_id?: string; +} + +function toTreeInputs(entries: VaultTreeEntry[]) { + return entries.map((e) => ({ + path: e.path, name: e.name, isDir: e.isDir, size: 0, hasChildren: e.hasChildren, + })); +} + +export function useVaultTree(filter: VaultTreeFilter = {}) { + const http = useHttp(); + const [tree, setTree] = useState([]); + const [meta, setMeta] = useState>(new Map()); + const [loading, setLoading] = useState(false); + + const buildParams = useCallback( + (extraPath?: string): Record => { + const p: Record = {}; + if (extraPath) p.path = extraPath; + if (filter.scope) p.scope = filter.scope; + if (filter.doc_type) p.doc_type = filter.doc_type; + if (filter.team_id) p.team_id = filter.team_id; + return p; + }, + [filter.scope, filter.doc_type, filter.team_id], + ); + + const loadRoot = useCallback(async () => { + setLoading(true); + try { + const res = await http.get("/v1/vault/tree", buildParams()); + const entries = res.entries ?? []; + const m = new Map(); + for (const e of entries) m.set(e.path, e); + setMeta(m); + setTree(buildTree(toTreeInputs(entries))); + } finally { + setLoading(false); + } + }, [http, buildParams]); + + const loadSubtree = useCallback(async (path: string) => { + setTree((prev) => setNodeLoading(prev, path, true)); + try { + const res = await http.get("/v1/vault/tree", buildParams(path)); + const entries = res.entries ?? []; + setMeta((prev) => { + const next = new Map(prev); + for (const e of entries) next.set(e.path, e); + return next; + }); + setTree((prev) => mergeSubtree(prev, path, toTreeInputs(entries))); + } catch { + setTree((prev) => setNodeLoading(prev, path, false)); + } + }, [http, buildParams]); + + return { tree, meta, loading, loadRoot, loadSubtree }; +} diff --git a/ui/web/src/pages/vault/vault-document-sidebar.tsx b/ui/web/src/pages/vault/vault-document-sidebar.tsx index de74cd34..6cca2498 100644 --- a/ui/web/src/pages/vault/vault-document-sidebar.tsx +++ b/ui/web/src/pages/vault/vault-document-sidebar.tsx @@ -1,13 +1,14 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { - Link2, ChevronLeft, ChevronRight, Search, X, Loader2, + Search, X, Loader2, FileText, Brain, StickyNote, Sparkles, Clock, Image, FileType, } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { formatRelativeTime } from "@/lib/format"; +import type { TreeNode } from "@/lib/file-helpers"; import type { VaultDocument, VaultSearchResult } from "@/types/vault"; +import { VaultTree } from "./components/vault-tree"; +import type { VaultTreeEntry } from "./hooks/use-vault-tree"; import { useVaultSearchAll } from "./hooks/use-vault"; const DOC_TYPE_CONFIG: Record = { @@ -19,70 +20,35 @@ const DOC_TYPE_CONFIG: Record; - onSelect: (doc: VaultDocument) => void; + tree: TreeNode[]; + meta: Map; + selectedPath: string | null; + onSelect: (path: string) => void; + onLoadMore: (path: string) => void; loading: boolean; - page: number; - totalPages: number; - total: number; - onPageChange: (page: number) => void; docType: string; onDocTypeChange: (type: string) => void; agentId: string; teamId: string; } -function DocCard({ doc, selected, linkCount, onClick }: { - doc: VaultDocument; selected: boolean; linkCount: number; onClick: () => void; -}) { - const { t } = useTranslation("vault"); - const ref = useRef(null); +function SearchResultCard({ doc, onClick }: { doc: VaultDocument; onClick: () => void }) { const cfg = DOC_TYPE_CONFIG[doc.doc_type] ?? DEFAULT_CONFIG; const Icon = cfg.icon; - - useEffect(() => { - if (selected) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, [selected]); - return ( -
+
- - {doc.title || doc.path.split("/").pop()} - + {doc.title || doc.path.split("/").pop()}
- {t(`type.${doc.doc_type}`)} - · - {t(`scope.${doc.scope}`)} - {linkCount > 0 && ( - <> - · - - - {linkCount} - - - )} - · - {formatRelativeTime(doc.updated_at)} + {doc.doc_type}·{doc.scope} + ·{formatRelativeTime(doc.updated_at)}
@@ -90,9 +56,8 @@ function DocCard({ doc, selected, linkCount, onClick }: { } export function VaultDocumentSidebar({ - documents, selectedId, linkCounts, onSelect, loading, - page, totalPages, total, onPageChange, - docType, onDocTypeChange, agentId, teamId, + tree, meta, selectedPath, onSelect, onLoadMore, + loading, docType, onDocTypeChange, agentId, teamId, }: Props) { const { t } = useTranslation("vault"); const [query, setQuery] = useState(""); @@ -102,12 +67,11 @@ export function VaultDocumentSidebar({ const debounceRef = useRef>(null); const { search } = useVaultSearchAll(); + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []); + const doSearch = useCallback( async (q: string) => { - if (!q.trim()) { - setSearchResults(null); - return; - } + if (!q.trim()) { setSearchResults(null); return; } setSearching(true); try { const results = await search(q, { @@ -117,11 +81,7 @@ export function VaultDocumentSidebar({ maxResults: 30, }); setSearchResults(results); - } catch { - setSearchResults([]); - } finally { - setSearching(false); - } + } catch { setSearchResults([]); } finally { setSearching(false); } }, [search, agentId, docType, teamId], ); @@ -129,125 +89,73 @@ export function VaultDocumentSidebar({ const handleQueryChange = (value: string) => { setQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); - if (!value.trim()) { - setSearchResults(null); - return; - } + if (!value.trim()) { setSearchResults(null); return; } debounceRef.current = setTimeout(() => doSearch(value), 300); }; - const clearSearch = () => { - setQuery(""); - setSearchResults(null); - inputRef.current?.focus(); - }; - - // Show search results or regular document list + const clearSearch = () => { setQuery(""); setSearchResults(null); inputRef.current?.focus(); }; const isSearchMode = query.trim().length > 0; - const displayDocs = isSearchMode && searchResults - ? searchResults.map((r) => r.document) - : documents; return (
- {/* Filter bar: type chips + search */}
- {/* Type filter chips — horizontal scroll */}
- {DOC_TYPES.map((dt) => { const cfg = DOC_TYPE_CONFIG[dt] ?? DEFAULT_CONFIG; const active = docType === dt; return ( - ); })} - {total}
- - {/* Search input */}
{searching ? - : - } - handleQueryChange(e.target.value)} + : } + handleQueryChange(e.target.value)} placeholder={t("searchPlaceholder")} - className="w-full rounded-md border bg-background pl-7 pr-7 py-1 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" - /> + className="w-full rounded-md border bg-background pl-7 pr-7 py-1 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" /> {query && ( - )}
- {/* Doc list */}
- {(loading || searching) && displayDocs.length === 0 ? ( - Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
+ {isSearchMode ? ( + (searching && !searchResults) ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+ )) + ) : searchResults && searchResults.length === 0 ? ( +
+ {t("noResults")}
- )) - ) : displayDocs.length === 0 ? ( -
- - {isSearchMode ? t("noResults") : t("noDocuments")} -
+ ) : ( + searchResults?.map((r) => ( + onSelect(r.document.path)} /> + )) + ) ) : ( - displayDocs.map((doc) => ( - onSelect(doc)} - /> - )) + )}
- - {/* Pagination footer — hidden during search */} - {!isSearchMode && totalPages > 1 && ( -
- - {page + 1} / {totalPages} - -
- )}
); } diff --git a/ui/web/src/pages/vault/vault-page.tsx b/ui/web/src/pages/vault/vault-page.tsx index 057a67aa..ea9f6891 100644 --- a/ui/web/src/pages/vault/vault-page.tsx +++ b/ui/web/src/pages/vault/vault-page.tsx @@ -3,10 +3,12 @@ import { useTranslation } from "react-i18next"; import { Search, FileArchive, Plus, PanelLeftOpen, FolderSync, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useHttp } from "@/hooks/use-ws"; import { useAgents } from "@/pages/agents/hooks/use-agents"; import { useTeams } from "@/pages/teams/hooks/use-teams"; import { useIsMobile } from "@/hooks/use-media-query"; -import { useVaultDocuments, useVaultGraphData, useRescanWorkspace } from "./hooks/use-vault"; +import { useVaultGraphData, useRescanWorkspace } from "./hooks/use-vault"; +import { useVaultTree } from "./hooks/use-vault-tree"; import { useEnrichmentProgress } from "./hooks/use-enrichment-progress"; import { VaultDocumentSidebar } from "./vault-document-sidebar"; import { VaultSearchDialog } from "./vault-search-dialog"; @@ -20,10 +22,9 @@ const VaultDetailDialog = lazy(() => import("./vault-detail-dialog").then((m) => ({ default: m.VaultDetailDialog })), ); -const PAGE_SIZE = 100; - export function VaultPage() { const { t } = useTranslation("vault"); + const http = useHttp(); const { agents } = useAgents(); const { teams, load: loadTeams } = useTeams(); const isMobile = useIsMobile(); @@ -35,44 +36,42 @@ export function VaultPage() { const [docType, setDocType] = useState(""); const [detailDoc, setDetailDoc] = useState(null); const [selectedDocId, setSelectedDocId] = useState(null); + const [selectedPath, setSelectedPath] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); - const [page, setPage] = useState(0); const { rescan, isPending: rescanPending } = useRescanWorkspace(); const enrichment = useEnrichmentProgress(); const enriching = enrichment?.running ?? false; - const { documents, total, loading } = useVaultDocuments(selectedAgent, { - teamId: selectedTeam || undefined, - docType: docType || undefined, - limit: PAGE_SIZE, - offset: page * PAGE_SIZE, - }); - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const treeFilter = useMemo(() => ({ + doc_type: docType || undefined, + team_id: selectedTeam || undefined, + }), [docType, selectedTeam]); + const { tree, meta, loading, loadRoot, loadSubtree } = useVaultTree(treeFilter); - // Link counts from graph data (client-side computation) - const { links } = useVaultGraphData(selectedAgent, { teamId: selectedTeam || undefined }); - const linkCounts = useMemo(() => { - const counts = new Map(); - for (const l of links) { - counts.set(l.from_doc_id, (counts.get(l.from_doc_id) ?? 0) + 1); - counts.set(l.to_doc_id, (counts.get(l.to_doc_id) ?? 0) + 1); - } - return counts; - }, [links]); + useEffect(() => { loadRoot(); }, [loadRoot]); - const handleAgentChange = (v: string) => { setSelectedAgent(v); setPage(0); }; - const handleTeamChange = (v: string) => { setSelectedTeam(v); setPage(0); }; - const handleDocTypeChange = (v: string) => { setDocType(v); setPage(0); }; + // Graph data for the graph view panel + useVaultGraphData(selectedAgent, { teamId: selectedTeam || undefined }); - // Sidebar click → open detail modal + highlight graph node - const handleSidebarSelect = (doc: VaultDocument) => { - setDetailDoc(doc); - setSelectedDocId(doc.id); + const handleAgentChange = (v: string) => { setSelectedAgent(v); }; + const handleTeamChange = (v: string) => { setSelectedTeam(v); }; + const handleDocTypeChange = (v: string) => { setDocType(v); }; + + // Tree file click → fetch full doc for detail modal + const handleTreeSelect = useCallback(async (path: string) => { + setSelectedPath(path); + const entry = meta.get(path); + if (!entry?.docId) return; + setSelectedDocId(entry.docId); + try { + const doc = await http.get(`/v1/vault/documents/${entry.docId}`); + setDetailDoc(doc); + } catch { /* http layer handles toast */ } if (isMobile) setSidebarOpen(false); - }; + }, [meta, http, isMobile]); // Graph single-click → highlight only const handleNodeSelect = useCallback((docId: string | null) => { @@ -89,7 +88,6 @@ export function VaultPage() { return (
- {/* Mobile drawer backdrop */} {isMobile && sidebarOpen && (
setSidebarOpen(false)} /> )} @@ -98,18 +96,15 @@ export function VaultPage() {
- {/* Header */}
{isMobile && (