From b416620ac7d75459d9d684875fe02d4aa08fb774 Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 9 Apr 2026 22:11:05 +0700 Subject: [PATCH] feat(vault): modal document view, compact sidebar, non-owner team access UI: - Replace bottom detail panel with modal dialog for all doc views - Redesign sidebar with type icons, compact layout, inline metadata - Always show team select, fix missing load() call for teams hook - Align header heights between sidebar and main panel Backend: - Add TeamIDs filter to VaultListOptions and VaultSearchOptions - Non-owner users now see personal + their teams' docs (was personal-only) - Refactor PG search to use searchTeamFilter for FTS and vector queries - Update both PG and SQLite stores with shared team filter helpers --- internal/http/vault_handlers.go | 44 ++++- internal/store/pg/vault_documents.go | 133 ++++++++----- internal/store/sqlitestore/vault_documents.go | 46 +++-- internal/store/vault_store.go | 2 + ui/web/src/pages/vault/vault-detail-panel.tsx | 180 ------------------ .../pages/vault/vault-document-sidebar.tsx | 97 ++++++---- ui/web/src/pages/vault/vault-page.tsx | 59 +++--- 7 files changed, 228 insertions(+), 333 deletions(-) delete mode 100644 ui/web/src/pages/vault/vault-detail-panel.tsx diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index f54c4fb6..b5fe2a7c 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -54,6 +54,34 @@ func (h *VaultHandler) validateTeamMembership(ctx context.Context, w http.Respon return true } +// userAccessibleTeamIDs returns the team IDs accessible by the current non-owner user. +// Returns nil if no teams are found or team store is unavailable. +func (h *VaultHandler) userAccessibleTeamIDs(ctx context.Context) []string { + userID := store.UserIDFromContext(ctx) + if userID == "" || h.teamAccess == nil { + return nil + } + teams, err := h.teamAccess.ListUserTeams(ctx, userID) + if err != nil || len(teams) == 0 { + return nil + } + ids := make([]string, len(teams)) + for i, t := range teams { + ids[i] = t.ID.String() + } + return ids +} + +// applyNonOwnerTeamScope restricts a non-owner vault list to personal + user's teams. +func (h *VaultHandler) applyNonOwnerTeamScope(ctx context.Context, opts *store.VaultListOptions) { + if ids := h.userAccessibleTeamIDs(ctx); len(ids) > 0 { + opts.TeamIDs = ids + } else { + empty := "" + opts.TeamID = &empty + } +} + func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) { // Cross-agent endpoint (agent_id optional query param). mux.HandleFunc("GET /v1/vault/documents", h.auth(h.handleListAllDocuments)) @@ -107,10 +135,9 @@ func (h *VaultHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Req return } } - // Non-owner without team_id filter: default to personal (NULL team_id) only. + // Non-owner without team_id filter: show personal + user's teams. if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - empty := "" - opts.TeamID = &empty + h.applyNonOwnerTeamScope(r.Context(), &opts) } docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) @@ -141,8 +168,7 @@ func (h *VaultHandler) handleListDocuments(w http.ResponseWriter, r *http.Reques } } if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - empty := "" - opts.TeamID = &empty + h.applyNonOwnerTeamScope(r.Context(), &opts) } docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) @@ -224,8 +250,12 @@ func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) { } searchOpts.TeamID = &body.TeamID } else if !store.IsOwnerRole(r.Context()) { - empty := "" - searchOpts.TeamID = &empty // non-owner: personal only + if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 { + searchOpts.TeamIDs = ids + } else { + empty := "" + searchOpts.TeamID = &empty + } } results, err := h.store.Search(r.Context(), searchOpts) diff --git a/internal/store/pg/vault_documents.go b/internal/store/pg/vault_documents.go index 97d72936..7784766c 100644 --- a/internal/store/pg/vault_documents.go +++ b/internal/store/pg/vault_documents.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" "log/slog" "sort" "time" @@ -13,6 +14,29 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// appendTeamFilter appends the team_id clause to a vault query. +// TeamIDs (personal + listed teams) takes precedence over TeamID (single team / personal-only). +func appendTeamFilter(q string, args []any, p int, teamID *string, teamIDs []string) (string, []any, int) { + if len(teamIDs) > 0 { + ph := make([]string, len(teamIDs)) + for i, id := range teamIDs { + ph[i] = fmt.Sprintf("$%d", p) + args = append(args, mustParseUUID(id)) + p++ + } + q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))" + } else if teamID != nil { + if *teamID != "" { + q += fmt.Sprintf(" AND team_id = $%d", p) + args = append(args, mustParseUUID(*teamID)) + p++ + } else { + q += " AND team_id IS NULL" + } + } + return q, args, p +} + // PGVaultStore implements store.VaultStore backed by PostgreSQL. type PGVaultStore struct { db *sql.DB @@ -177,15 +201,7 @@ func (s *PGVaultStore) ListDocuments(ctx context.Context, tenantID, agentID stri p++ } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, mustParseUUID(*opts.TeamID)) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) @@ -237,15 +253,7 @@ func (s *PGVaultStore) CountDocuments(ctx context.Context, tenantID, agentID str args = append(args, aid) p++ } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, mustParseUUID(*opts.TeamID)) - p++ - } else { - q += " AND team_id IS NULL" - } - } + 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) @@ -281,17 +289,8 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions tid := mustParseUUID(opts.TenantID) aid := mustParseUUID(opts.AgentID) - // Parse team_id filter. - var teamUUID *uuid.UUID - if opts.TeamID != nil { - if *opts.TeamID != "" { - t := mustParseUUID(*opts.TeamID) - teamUUID = &t - } - // opts.TeamID == ptr-to-empty → teamUUID stays nil → filters for team_id IS NULL - } - // opts.TeamID == nil → teamUUID stays nil and useTeamFilter = false (handled below) - useTeamFilter := opts.TeamID != nil + // Build team filter for search sub-queries. + tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs) maxResults := opts.MaxResults if maxResults <= 0 { @@ -299,7 +298,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions } // FTS search - ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2) + ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) if err != nil { return nil, err } @@ -310,7 +309,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions vecs, embErr := s.embProvider.Embed(ctx, []string{opts.Query}) if embErr == nil && len(vecs) > 0 { var vecErr error - vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2) + vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) if vecErr != nil { slog.Debug("vault.vector_search_fallback", "err", vecErr) vecResults = nil @@ -334,7 +333,55 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions return merged, nil } -func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { +// searchTeamFilter holds pre-parsed team filter info for search sub-queries. +type searchTeamFilter struct { + teamIDs []uuid.UUID // personal + these teams (len > 0 = multi-team mode) + teamID *uuid.UUID // single team filter (nil + active = personal-only) + active bool // whether any team filter is applied +} + +func buildSearchTeamFilter(teamID *string, teamIDs []string) searchTeamFilter { + if len(teamIDs) > 0 { + uuids := make([]uuid.UUID, len(teamIDs)) + for i, id := range teamIDs { + uuids[i] = mustParseUUID(id) + } + return searchTeamFilter{teamIDs: uuids, active: true} + } + if teamID != nil { + if *teamID != "" { + t := mustParseUUID(*teamID) + return searchTeamFilter{teamID: &t, active: true} + } + return searchTeamFilter{active: true} // personal-only + } + return searchTeamFilter{} // no filter +} + +// appendSearchTeamClause appends team filter SQL to a search query. +func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, int) { + if !tf.active { + return q, args, p + } + if len(tf.teamIDs) > 0 { + ph := make([]string, len(tf.teamIDs)) + for i, id := range tf.teamIDs { + ph[i] = fmt.Sprintf("$%d", p) + args = append(args, id) + p++ + } + q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))" + } else if tf.teamID != nil { + q += fmt.Sprintf(" AND team_id = $%d", p) + args = append(args, *tf.teamID) + p++ + } else { + q += " AND team_id IS NULL" + } + return q, args, p +} + +func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM vault_documents @@ -342,15 +389,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag args := []any{query, tenantID, agentID} p := 4 - if useTeamFilter { - if teamID != nil { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, *teamID) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = tf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) @@ -373,7 +412,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag return vaultSearchRowsToResults(scanned, "vault"), nil } -func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { +func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { vecStr := vectorToString(embedding) q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at, 1 - (embedding <=> $1) AS score @@ -382,15 +421,7 @@ func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, te args := []any{vecStr, tenantID, agentID} p := 4 - if useTeamFilter { - if teamID != nil { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, *teamID) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = tf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) diff --git a/internal/store/sqlitestore/vault_documents.go b/internal/store/sqlitestore/vault_documents.go index 56b3a891..5b8a5e7a 100644 --- a/internal/store/sqlitestore/vault_documents.go +++ b/internal/store/sqlitestore/vault_documents.go @@ -15,6 +15,25 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// sqliteAppendTeamFilter appends the team_id clause to a vault query. +func sqliteAppendTeamFilter(q string, args []any, teamID *string, teamIDs []string) (string, []any) { + if len(teamIDs) > 0 { + ph := strings.Repeat("?,", len(teamIDs)-1) + "?" + q += " AND (team_id IS NULL OR team_id IN (" + ph + "))" + for _, id := range teamIDs { + args = append(args, id) + } + } else if teamID != nil { + if *teamID != "" { + q += " AND team_id = ?" + args = append(args, *teamID) + } else { + q += " AND team_id IS NULL" + } + } + return q, args +} + // SQLiteVaultStore implements store.VaultStore backed by SQLite. type SQLiteVaultStore struct { db *sql.DB @@ -118,14 +137,7 @@ func (s *SQLiteVaultStore) ListDocuments(ctx context.Context, tenantID, agentID q += " AND agent_id = ?" args = append(args, agentID) } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += " AND scope = ?" args = append(args, opts.Scope) @@ -176,14 +188,7 @@ func (s *SQLiteVaultStore) CountDocuments(ctx context.Context, tenantID, agentID q += " AND agent_id = ?" args = append(args, agentID) } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += " AND scope = ?" args = append(args, opts.Scope) @@ -248,14 +253,7 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')` args := []any{opts.TenantID, opts.AgentID, pattern, pattern} - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) q += " ORDER BY updated_at DESC LIMIT ?" args = append(args, maxResults*2) diff --git a/internal/store/vault_store.go b/internal/store/vault_store.go index d3f6f6d0..9fd570a1 100644 --- a/internal/store/vault_store.go +++ b/internal/store/vault_store.go @@ -55,6 +55,7 @@ type VaultSearchOptions struct { AgentID string TenantID string TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team + TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view) Scope string // empty = all scopes DocTypes []string // empty = all types MaxResults int // default 10 @@ -64,6 +65,7 @@ type VaultSearchOptions struct { // VaultListOptions configures a list query for vault documents. type VaultListOptions struct { TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team + TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view) Scope string // empty = all DocTypes []string // empty = all Limit int diff --git a/ui/web/src/pages/vault/vault-detail-panel.tsx b/ui/web/src/pages/vault/vault-detail-panel.tsx deleted file mode 100644 index 54c4567e..00000000 --- a/ui/web/src/pages/vault/vault-detail-panel.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { X, Pencil, Plus, FileText, Link2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; -import { useVaultLinks, useVaultFileContent, useUpdateDocument, useDeleteDocument } from "./hooks/use-vault"; -import { VaultLinkDialog } from "./vault-link-dialog"; -import { VaultEditControls, DocTypeSelect, ScopeSelect, LinkBadge } from "./vault-detail-edit-section"; -import type { VaultDocument } from "@/types/vault"; - -interface Props { - doc: VaultDocument | null; - open: boolean; - onClose: () => void; - onDeleted?: () => void; -} - -export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) { - const { t } = useTranslation("vault"); - const { outlinks, backlinks } = useVaultLinks(doc?.agent_id ?? "", doc?.id ?? null); - const { content, loading: contentLoading, error: contentError } = useVaultFileContent(open && doc ? doc.path : null); - const { update } = useUpdateDocument(doc?.agent_id ?? "", doc?.id ?? ""); - const { remove } = useDeleteDocument(doc?.agent_id ?? "", doc?.id ?? ""); - - const [editMode, setEditMode] = useState(false); - const [editTitle, setEditTitle] = useState(""); - const [editDocType, setEditDocType] = useState(""); - const [editScope, setEditScope] = useState(""); - const [saving, setSaving] = useState(false); - const [confirmDelete, setConfirmDelete] = useState(false); - const [linkDialogOpen, setLinkDialogOpen] = useState(false); - - // Reset edit state when doc changes - useEffect(() => { setEditMode(false); setConfirmDelete(false); }, [doc?.id]); - - // Escape to close - useEffect(() => { - if (!open) return; - const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [open, onClose]); - - const startEdit = () => { - if (!doc) return; - setEditTitle(doc.title); setEditDocType(doc.doc_type); setEditScope(doc.scope); - setEditMode(true); - }; - - const handleSave = async () => { - setSaving(true); - try { await update({ title: editTitle, doc_type: editDocType, scope: editScope }); setEditMode(false); } - catch { /* toasted */ } - finally { setSaving(false); } - }; - - const handleDelete = async () => { - setSaving(true); - try { await remove(); onDeleted?.(); onClose(); } - catch { /* toasted */ } - finally { setSaving(false); setConfirmDelete(false); } - }; - - if (!doc || !open) return null; - - const hash = doc.content_hash; - const hashDisplay = hash.length > 16 ? `${hash.slice(0, 8)}...${hash.slice(-8)}` : hash; - - return ( - <> -
- {/* Header */} -
- {editMode ? ( - setEditTitle(e.target.value)} - className="h-7 text-sm font-medium flex-1" autoFocus /> - ) : ( - {doc.title || doc.path} - )} - {editMode ? ( - <> - - - - ) : ( - <> - {t(`type.${doc.doc_type}`)} - {t(`scope.${doc.scope}`)} - - )} - {!editMode && ( - - )} - -
- - {/* Edit controls */} - {editMode && ( -
- { setEditMode(false); setConfirmDelete(false); }} - onDeleteRequest={() => setConfirmDelete(true)} onDeleteConfirm={handleDelete} - onDeleteCancel={() => setConfirmDelete(false)} t={t} /> -
- )} - - {/* Content: left = markdown, right = meta */} -
- {/* Markdown preview */} -
- {contentLoading ? ( -
-
-
-
- ) : contentError ? ( -
- {t("detail.fileNotFound")} -
- ) : content ? ( - - ) : ( -
- {t("detail.emptyContent")} -
- )} -
- - {/* Meta column */} -
-
- {doc.path} -
-
SHA: {hashDisplay}
- - {/* Outlinks */} -
-
- - {t("detail.outlinks")} ({outlinks.length}) - -
- {outlinks.length > 0 && ( -
- {outlinks.map((l) => )} -
- )} -
- - {/* Backlinks */} - {backlinks.length > 0 && ( -
- {t("detail.backlinks")} ({backlinks.length}) -
- {backlinks.map((l) => ( - {l.from_doc_id.slice(0, 8)} - ))} -
-
- )} -
-
-
- - {linkDialogOpen && ( - - )} - - ); -} diff --git a/ui/web/src/pages/vault/vault-document-sidebar.tsx b/ui/web/src/pages/vault/vault-document-sidebar.tsx index a66d0d55..380864b1 100644 --- a/ui/web/src/pages/vault/vault-document-sidebar.tsx +++ b/ui/web/src/pages/vault/vault-document-sidebar.tsx @@ -1,19 +1,25 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Link2, ChevronLeft, ChevronRight } from "lucide-react"; +import { + Link2, ChevronLeft, ChevronRight, + FileText, Brain, StickyNote, Sparkles, Clock, Image, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { formatRelativeTime } from "@/lib/format"; import type { VaultDocument } from "@/types/vault"; -const DOC_DOT_COLORS: Record = { - context: "bg-blue-500", - memory: "bg-purple-500", - note: "bg-yellow-500", - skill: "bg-green-500", - episodic: "bg-orange-500", - media: "bg-red-500", +const DOC_TYPE_CONFIG: Record = { + context: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", icon: FileText }, + memory: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-500/10", icon: Brain }, + note: { color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-500/10", icon: StickyNote }, + skill: { color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", icon: Sparkles }, + episodic: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-500/10", icon: Clock }, + media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image }, }; +const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText }; + interface Props { documents: VaultDocument[]; selectedId: string | null; @@ -31,6 +37,8 @@ function DocCard({ doc, selected, linkCount, onClick }: { }) { const { t } = useTranslation("vault"); const ref = useRef(null); + const cfg = DOC_TYPE_CONFIG[doc.doc_type] ?? DEFAULT_CONFIG; + const Icon = cfg.icon; useEffect(() => { if (selected) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); @@ -39,26 +47,40 @@ function DocCard({ doc, selected, linkCount, onClick }: { return (
-
- - + {/* Type icon */} +
+ +
+ + {/* Content */} +
+ {doc.title || doc.path.split("/").pop()} - {linkCount > 0 && ( - - - {linkCount} - - )} -
-
- {t(`type.${doc.doc_type}`)} · {t(`scope.${doc.scope}`)} -
-
- {formatRelativeTime(doc.updated_at)} + +
+ {t(`type.${doc.doc_type}`)} + · + {t(`scope.${doc.scope}`)} + {linkCount > 0 && ( + <> + · + + + {linkCount} + + + )} + · + {formatRelativeTime(doc.updated_at)} +
); @@ -72,24 +94,27 @@ export function VaultDocumentSidebar({ return (
{/* Header */} -
- {t("title")} - {total} +
+ {t("title")} + {total}
{/* Doc list */} -
+
{loading && documents.length === 0 ? ( - Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
+ Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
)) ) : documents.length === 0 ? ( -
- {t("noDocuments")} +
+ + {t("noDocuments")}
) : ( documents.map((doc) => ( @@ -110,7 +135,7 @@ export function VaultDocumentSidebar({ - {page + 1} / {totalPages} + {page + 1} / {totalPages} diff --git a/ui/web/src/pages/vault/vault-page.tsx b/ui/web/src/pages/vault/vault-page.tsx index 01eb29bc..d0e8a3ca 100644 --- a/ui/web/src/pages/vault/vault-page.tsx +++ b/ui/web/src/pages/vault/vault-page.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, lazy, Suspense } from "react"; +import { useState, useEffect, useMemo, lazy, Suspense } from "react"; import { useTranslation } from "react-i18next"; import { Search, FileArchive, Plus, PanelLeftOpen } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -8,7 +8,6 @@ import { useTeams } from "@/pages/teams/hooks/use-teams"; import { useIsMobile } from "@/hooks/use-media-query"; import { useVaultDocuments, useVaultGraphData } from "./hooks/use-vault"; import { VaultDocumentSidebar } from "./vault-document-sidebar"; -import { VaultDetailPanel } from "./vault-detail-panel"; import { VaultSearchDialog } from "./vault-search-dialog"; import { VaultCreateDialog } from "./vault-create-dialog"; import type { VaultDocument } from "@/types/vault"; @@ -25,16 +24,17 @@ const PAGE_SIZE = 100; export function VaultPage() { const { t } = useTranslation("vault"); const { agents } = useAgents(); - const { teams } = useTeams(); + const { teams, load: loadTeams } = useTeams(); const isMobile = useIsMobile(); + useEffect(() => { loadTeams(); }, [loadTeams]); + const [selectedAgent, setSelectedAgent] = useState(""); const [selectedTeam, setSelectedTeam] = useState(""); - const [selectedDoc, setSelectedDoc] = useState(null); + const [detailDoc, setDetailDoc] = useState(null); const [selectedDocId, setSelectedDocId] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const [searchResultDoc, setSearchResultDoc] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(false); const [page, setPage] = useState(0); @@ -59,9 +59,9 @@ export function VaultPage() { const handleAgentChange = (v: string) => { setSelectedAgent(v); setPage(0); }; const handleTeamChange = (v: string) => { setSelectedTeam(v); setPage(0); }; - // Sidebar click → select doc + highlight graph node + // Sidebar click → open detail modal + highlight graph node const handleSidebarSelect = (doc: VaultDocument) => { - setSelectedDoc(doc); + setDetailDoc(doc); setSelectedDocId(doc.id); if (isMobile) setSidebarOpen(false); }; @@ -69,16 +69,15 @@ export function VaultPage() { // Graph single-click → highlight only const handleNodeSelect = (docId: string | null) => { setSelectedDocId(docId); - if (!docId) setSelectedDoc(null); }; - // Graph double-click → open detail + highlight + // Graph double-click → open detail modal + highlight const handleNodeDoubleClick = (doc: VaultDocument) => { - setSelectedDoc(doc); + setDetailDoc(doc); setSelectedDocId(doc.id); }; - const handleCloseDetail = () => { setSelectedDoc(null); setSelectedDocId(null); }; + const handleCloseDetail = () => { setDetailDoc(null); }; return (
@@ -109,7 +108,7 @@ export function VaultPage() { {/* Main: header + graph + detail panel */}
{/* Header */} -
+
{isMobile && ( @@ -148,7 +145,7 @@ export function VaultPage() {
- {/* Graph */} + {/* Graph — takes full remaining height */}
}>
- - {/* Detail panel (bottom, collapsible) */} -
- {/* Search dialog — result opens legacy modal */} + {/* Search dialog — result opens detail modal */} {selectedAgent && ( { setDetailDoc(doc); setSelectedDocId(doc.id); }} /> )} {selectedAgent && } - {/* Legacy detail dialog for search results only */} + {/* Detail dialog for all document views */} !open && setSearchResultDoc(null)} - onDeleted={() => setSearchResultDoc(null)} + doc={detailDoc} open={!!detailDoc} + onOpenChange={(open) => { if (!open) handleCloseDetail(); }} + onDeleted={handleCloseDetail} />