feat(vault): tree sidebar with lazy-load folder hierarchy

Replace flat paginated document list with collapsible tree view.
Backend: two-query-per-level approach (files + DISTINCT deeper paths
for virtual folder derivation), path parsing in Go for PG+SQLite
compatibility, text_pattern_ops index for prefix range scans.
Frontend: VaultTree component with doc_type colored icons, compact
single-line nodes, truncate-middle filenames, scope dots, hover
tooltip for date. Sidebar expanded to 384px on large screens.

Also fixes pre-existing SQLite scanVaultDocRow missing path_basename.
This commit is contained in:
viettranx committed 2026-04-12 12:37:10 +07:00
1 parent 31b2662ecf
commit 35ce8cc2c5
14 files changed
+638 -191

No files matched your search

+56
View File
@@ -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})
}
+1
View File
@@ -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).
+125
View File
@@ -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
}
+4 -1
View File
@@ -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
+1
View File
@@ -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;
+104 -1
View File
@@ -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
}
+25
View File
@@ -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).
+1 -1
View File
@@ -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
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_vault_docs_path_prefix;
@@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_vault_docs_path_prefix
ON vault_documents (tenant_id, path text_pattern_ops);
@@ -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<string, { icon: LucideIcon; color: string }> = {
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<string, string> = {
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<string, VaultTreeEntry>;
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<string, VaultTreeEntry>;
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 (
<div>
<div
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm cursor-pointer hover:bg-accent"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleToggle}
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`} />
{expanded
? <FolderOpen className="h-4 w-4 shrink-0 text-yellow-600" />
: <Folder className="h-4 w-4 shrink-0 text-yellow-600" />}
<span className="truncate text-xs">{node.name}</span>
{node.loading && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground ml-auto" />}
{!node.loading && node.children.length > 0 && (
<span className="ml-auto shrink-0 rounded-full bg-muted px-1 text-2xs tabular-nums text-muted-foreground">
{node.children.length}
</span>
)}
</div>
{expanded && node.children.map((child) => (
<VaultTreeNode key={child.path} node={child} depth={depth + 1}
meta={meta} activePath={activePath} onSelect={onSelect} onLoadMore={onLoadMore} />
))}
{expanded && node.hasChildren && node.children.length === 0 && !node.loading && (
<div className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
style={{ paddingLeft: `${(depth + 1) * 16 + 20}px` }}
onClick={() => onLoadMore(node.path)}>
<Loader2 className="h-3 w-3" /><span>Load more</span>
</div>
)}
</div>
);
}
// --- 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 (
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`flex w-full items-center gap-1.5 rounded px-2 py-1 text-left cursor-pointer ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
}`}
style={{ paddingLeft: `${depth * 16 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<Icon className={`h-3.5 w-3.5 shrink-0 ${color}`} />
<span className="truncate text-xs">{truncateMiddle(title)}</span>
{scopeDot && <span className={`ml-auto h-1.5 w-1.5 rounded-full shrink-0 ${scopeDot}`} />}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<p className="font-medium">{title}</p>
{relTime && <p className="text-muted-foreground">{relTime}</p>}
{entry?.scope && <p className="text-muted-foreground capitalize">{entry.scope}</p>}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function VaultTree({ tree, meta, loading, activePath, onSelect, onLoadMore }: VaultTreeProps) {
if (loading && tree.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (tree.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 gap-1 text-muted-foreground">
<FileText className="h-5 w-5" /><span className="text-sm">No documents</span>
</div>
);
}
return (
<div className="flex-1 min-h-0">
{tree.map((node) => (
<VaultTreeNode key={node.path} node={node} depth={0}
meta={meta} activePath={activePath} onSelect={onSelect} onLoadMore={onLoadMore} />
))}
</div>
);
}
@@ -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<TreeNode[]>([]);
const [meta, setMeta] = useState<Map<string, VaultTreeEntry>>(new Map());
const [loading, setLoading] = useState(false);
const buildParams = useCallback(
(extraPath?: string): Record<string, string> => {
const p: Record<string, string> = {};
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<VaultTreeResponse>("/v1/vault/tree", buildParams());
const entries = res.entries ?? [];
const m = new Map<string, VaultTreeEntry>();
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<VaultTreeResponse>("/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 };
}
+47 -139
View File
@@ -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<string, { color: string; bg: string; icon: typeof FileText; dotColor: string }> = {
@@ -19,70 +20,35 @@ const DOC_TYPE_CONFIG: Record<string, { color: string; bg: string; icon: typeof
media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image, dotColor: "bg-rose-500" },
document: { color: "text-cyan-600 dark:text-cyan-400", bg: "bg-cyan-500/10", icon: FileType, dotColor: "bg-cyan-500" },
};
const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText, dotColor: "bg-muted-foreground" };
const DOC_TYPES = ["context", "memory", "note", "skill", "episodic", "media", "document"] as const;
interface Props {
documents: VaultDocument[];
selectedId: string | null;
linkCounts: Map<string, number>;
onSelect: (doc: VaultDocument) => void;
tree: TreeNode[];
meta: Map<string, VaultTreeEntry>;
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<HTMLDivElement>(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 (
<div
ref={ref}
className={`group mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-all ${
selected
? "bg-accent shadow-sm ring-1 ring-accent-foreground/10"
: "hover:bg-muted/60"
}`}
onClick={onClick}
>
<div className="mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-muted/60" onClick={onClick}>
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded ${cfg.bg}`}>
<Icon className={`h-3 w-3 ${cfg.color}`} />
</div>
<div className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium leading-snug">
{doc.title || doc.path.split("/").pop()}
</span>
<span className="block truncate text-xs font-medium leading-snug">{doc.title || doc.path.split("/").pop()}</span>
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-muted-foreground">
<span>{t(`type.${doc.doc_type}`)}</span>
<span>·</span>
<span>{t(`scope.${doc.scope}`)}</span>
{linkCount > 0 && (
<>
<span>·</span>
<span className="flex items-center gap-0.5">
<Link2 className="h-2.5 w-2.5" />
{linkCount}
</span>
</>
)}
<span>·</span>
<span>{formatRelativeTime(doc.updated_at)}</span>
<span>{doc.doc_type}</span><span>·</span><span>{doc.scope}</span>
<span>·</span><span>{formatRelativeTime(doc.updated_at)}</span>
</div>
</div>
</div>
@@ -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<ReturnType<typeof setTimeout>>(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 (
<div className="flex h-full flex-col border-r bg-background">
{/* Filter bar: type chips + search */}
<div className="shrink-0 border-b px-2 py-1.5 space-y-1.5">
{/* Type filter chips — horizontal scroll */}
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none">
<button
onClick={() => onDocTypeChange("")}
<button onClick={() => onDocTypeChange("")}
className={`shrink-0 px-1.5 py-0.5 rounded text-2xs font-medium transition-colors ${
!docType ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"
}`}
>
!docType ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"}`}>
{t("allTypes")}
</button>
{DOC_TYPES.map((dt) => {
const cfg = DOC_TYPE_CONFIG[dt] ?? DEFAULT_CONFIG;
const active = docType === dt;
return (
<button
key={dt}
onClick={() => onDocTypeChange(active ? "" : dt)}
<button key={dt} onClick={() => onDocTypeChange(active ? "" : dt)}
className={`shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-medium transition-colors ${
active ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"
}`}
>
active ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"}`}>
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-primary-foreground" : cfg.dotColor}`} />
{t(`type.${dt}`)}
</button>
);
})}
<Badge variant="secondary" className="text-2xs tabular-nums ml-auto shrink-0">{total}</Badge>
</div>
{/* Search input */}
<div className="relative">
{searching
? <Loader2 className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground animate-spin" />
: <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
}
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
: <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />}
<input ref={inputRef} type="text" value={query} onChange={(e) => 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 && (
<button
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<button onClick={clearSearch} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Doc list */}
<div className="flex-1 overflow-y-auto py-1">
{(loading || searching) && displayDocs.length === 0 ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5">
<div className="h-6 w-6 shrink-0 animate-pulse rounded bg-muted" />
<div className="flex-1 space-y-1">
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-2.5 w-1/2 animate-pulse rounded bg-muted" />
{isSearchMode ? (
(searching && !searchResults) ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5">
<div className="h-6 w-6 shrink-0 animate-pulse rounded bg-muted" />
<div className="flex-1 space-y-1"><div className="h-3.5 w-3/4 animate-pulse rounded bg-muted" /><div className="h-2.5 w-1/2 animate-pulse rounded bg-muted" /></div>
</div>
))
) : searchResults && searchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 gap-1 text-muted-foreground">
<FileText className="h-5 w-5" /><span className="text-sm">{t("noResults")}</span>
</div>
))
) : displayDocs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 gap-1 text-muted-foreground">
<FileText className="h-5 w-5" />
<span className="text-sm">{isSearchMode ? t("noResults") : t("noDocuments")}</span>
</div>
) : (
searchResults?.map((r) => (
<SearchResultCard key={r.document.id} doc={r.document} onClick={() => onSelect(r.document.path)} />
))
)
) : (
displayDocs.map((doc) => (
<DocCard
key={doc.id}
doc={doc}
selected={doc.id === selectedId}
linkCount={linkCounts.get(doc.id) ?? 0}
onClick={() => onSelect(doc)}
/>
))
<VaultTree tree={tree} meta={meta} loading={loading}
activePath={selectedPath} onSelect={onSelect} onLoadMore={onLoadMore} />
)}
</div>
{/* Pagination footer — hidden during search */}
{!isSearchMode && totalPages > 1 && (
<div className="flex items-center justify-center gap-2 px-3 py-1.5 border-t text-xs text-muted-foreground">
<Button variant="ghost" size="xs" disabled={page === 0} onClick={() => onPageChange(page - 1)}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="tabular-nums">{page + 1} / {totalPages}</span>
<Button variant="ghost" size="xs" disabled={page >= totalPages - 1} onClick={() => onPageChange(page + 1)}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
);
}
+36 -49
View File
@@ -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<VaultDocument | null>(null);
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(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<string, number>();
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<VaultDocument>(`/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 (
<div className="relative flex h-full overflow-hidden">
{/* Mobile drawer backdrop */}
{isMobile && sidebarOpen && (
<div className="fixed inset-0 z-40 bg-black/50" onClick={() => setSidebarOpen(false)} />
)}
@@ -98,18 +96,15 @@ export function VaultPage() {
<div className={
isMobile
? `fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] transition-transform duration-200 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`
: "w-80 md:w-[280px] lg:w-80 shrink-0"
: "w-80 md:w-80 lg:w-96 shrink-0"
}>
<VaultDocumentSidebar
documents={documents}
selectedId={selectedDocId}
linkCounts={linkCounts}
onSelect={handleSidebarSelect}
tree={tree}
meta={meta}
selectedPath={selectedPath}
onSelect={handleTreeSelect}
onLoadMore={loadSubtree}
loading={loading}
page={page}
totalPages={totalPages}
total={total}
onPageChange={setPage}
docType={docType}
onDocTypeChange={handleDocTypeChange}
agentId={selectedAgent}
@@ -119,7 +114,6 @@ export function VaultPage() {
{/* Main: header + graph + detail panel */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header */}
<div className="flex h-10 items-center gap-2 px-3 border-b shrink-0">
{isMobile && (
<Button variant="ghost" size="xs" className="h-7 w-7 p-0" onClick={() => setSidebarOpen(true)}>
@@ -129,7 +123,6 @@ export function VaultPage() {
<FileArchive className="h-4 w-4 text-indigo-500 shrink-0" />
<span className="text-sm font-semibold mr-auto">{t("title")}</span>
{/* Filters */}
<select value={selectedAgent} onChange={(e) => handleAgentChange(e.target.value)}
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-7">
<option value="">{t("allAgents")}</option>
@@ -166,14 +159,11 @@ export function VaultPage() {
</TooltipProvider>
</div>
{/* Enrichment progress bar */}
{enrichment && enrichment.total > 0 && (
<div className="px-3 py-1.5 flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${Math.round((enrichment.done / enrichment.total) * 100)}%` }}
/>
<div className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${Math.round((enrichment.done / enrichment.total) * 100)}%` }} />
</div>
<span className="shrink-0">
{enrichment.running
@@ -183,7 +173,6 @@ export function VaultPage() {
</div>
)}
{/* Graph — takes full remaining height */}
<div className="flex-1 min-h-0 relative">
<Suspense fallback={<div className="h-full animate-pulse bg-muted" />}>
<VaultGraphView
@@ -197,7 +186,6 @@ export function VaultPage() {
</div>
</div>
{/* Search dialog — result opens detail modal */}
{selectedAgent && (
<VaultSearchDialog
agentId={selectedAgent} open={searchOpen} onOpenChange={setSearchOpen}
@@ -206,7 +194,6 @@ export function VaultPage() {
)}
<VaultCreateDialog open={createOpen} onOpenChange={setCreateOpen} defaultAgentId={selectedAgent} defaultTeamId={selectedTeam} />
{/* Detail dialog for all document views */}
<Suspense fallback={null}>
<VaultDetailDialog
doc={detailDoc} open={!!detailDoc}