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
This commit is contained in:
viettranx committed 2026-04-09 22:11:05 +07:00
1 parent 99da7edd01
commit b416620ac7
7 files changed
+228 -333

No files matched your search

+37 -7
View File
@@ -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)
+82 -51
View File
@@ -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)
+22 -24
View File
@@ -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)
+2
View File
@@ -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
@@ -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 (
<>
<div className="border-t bg-background flex flex-col" style={{ height: 240 }}>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b shrink-0">
{editMode ? (
<Input value={editTitle} onChange={(e) => setEditTitle(e.target.value)}
className="h-7 text-sm font-medium flex-1" autoFocus />
) : (
<span className="text-sm font-medium truncate flex-1">{doc.title || doc.path}</span>
)}
{editMode ? (
<>
<DocTypeSelect value={editDocType} onChange={setEditDocType} t={t} />
<ScopeSelect value={editScope} onChange={setEditScope} t={t} />
</>
) : (
<>
<Badge variant="secondary" className="text-2xs px-1.5 py-0">{t(`type.${doc.doc_type}`)}</Badge>
<Badge variant="outline" className="text-2xs px-1.5 py-0">{t(`scope.${doc.scope}`)}</Badge>
</>
)}
{!editMode && (
<Button variant="ghost" size="xs" className="h-6 w-6 p-0" onClick={startEdit}>
<Pencil className="h-3 w-3" />
</Button>
)}
<Button variant="ghost" size="xs" className="h-6 w-6 p-0" onClick={onClose}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* Edit controls */}
{editMode && (
<div className="px-3 py-1 border-b shrink-0">
<VaultEditControls saving={saving} confirmDelete={confirmDelete}
onSave={handleSave} onCancel={() => { setEditMode(false); setConfirmDelete(false); }}
onDeleteRequest={() => setConfirmDelete(true)} onDeleteConfirm={handleDelete}
onDeleteCancel={() => setConfirmDelete(false)} t={t} />
</div>
)}
{/* Content: left = markdown, right = meta */}
<div className="flex-1 min-h-0 flex overflow-hidden">
{/* Markdown preview */}
<div className="flex-1 overflow-y-auto px-3 py-2 border-r">
{contentLoading ? (
<div className="space-y-2">
<div className="h-3 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
</div>
) : contentError ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileText className="h-3.5 w-3.5" /> {t("detail.fileNotFound")}
</div>
) : content ? (
<MarkdownRenderer content={content}
className="text-xs [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:text-xs [&_h2]:font-semibold [&_p]:text-xs [&_li]:text-xs [&_code]:text-2xs [&_pre]:overflow-x-auto [&_img]:max-w-full" />
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileText className="h-3.5 w-3.5" /> {t("detail.emptyContent")}
</div>
)}
</div>
{/* Meta column */}
<div className="w-[220px] shrink-0 overflow-y-auto px-3 py-2 space-y-2 text-xs">
<div className="font-mono text-2xs text-muted-foreground break-all" dir="rtl" title={doc.path}>
{doc.path}
</div>
<div className="font-mono text-2xs text-muted-foreground">SHA: {hashDisplay}</div>
{/* Outlinks */}
<div className="space-y-1">
<div className="flex items-center gap-1 text-muted-foreground">
<Link2 className="h-3 w-3" />
<span>{t("detail.outlinks")} ({outlinks.length})</span>
<Button variant="ghost" size="xs" className="h-5 px-1 ml-auto" onClick={() => setLinkDialogOpen(true)}>
<Plus className="h-2.5 w-2.5" />
</Button>
</div>
{outlinks.length > 0 && (
<div className="flex flex-wrap gap-1">
{outlinks.map((l) => <LinkBadge key={l.id} link={l} agentId={doc.agent_id} t={t} />)}
</div>
)}
</div>
{/* Backlinks */}
{backlinks.length > 0 && (
<div className="space-y-1">
<span className="text-muted-foreground">{t("detail.backlinks")} ({backlinks.length})</span>
<div className="flex flex-wrap gap-1">
{backlinks.map((l) => (
<Badge key={l.id} variant="secondary" className="text-2xs">{l.from_doc_id.slice(0, 8)}</Badge>
))}
</div>
</div>
)}
</div>
</div>
</div>
{linkDialogOpen && (
<VaultLinkDialog agentId={doc.agent_id} fromDoc={doc}
open={linkDialogOpen} onOpenChange={setLinkDialogOpen} />
)}
</>
);
}
@@ -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<string, string> = {
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<string, { color: string; bg: string; icon: typeof FileText }> = {
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<HTMLDivElement>(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 (
<div
ref={ref}
className={`px-3 py-2 cursor-pointer border-b transition-colors ${selected ? "bg-accent/50" : "hover:bg-muted/50"}`}
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="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full shrink-0 ${DOC_DOT_COLORS[doc.doc_type] ?? "bg-gray-400"}`} />
<span className="text-sm font-medium truncate flex-1">
{/* Type icon */}
<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>
{/* Content */}
<div className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium leading-snug">
{doc.title || doc.path.split("/").pop()}
</span>
{linkCount > 0 && (
<span className="flex items-center gap-0.5 text-2xs text-muted-foreground shrink-0">
<Link2 className="h-3 w-3" />
{linkCount}
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5 pl-4">
{t(`type.${doc.doc_type}`)} · {t(`scope.${doc.scope}`)}
</div>
<div className="text-2xs text-muted-foreground pl-4">
{formatRelativeTime(doc.updated_at)}
<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>
</div>
</div>
</div>
);
@@ -72,24 +94,27 @@ export function VaultDocumentSidebar({
return (
<div className="flex h-full flex-col border-r bg-background">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-medium">{t("title")}</span>
<span className="text-2xs text-muted-foreground">{total}</span>
<div className="flex h-10 items-center justify-between px-3 border-b shrink-0">
<span className="text-sm font-semibold">{t("title")}</span>
<Badge variant="secondary" className="text-2xs tabular-nums">{total}</Badge>
</div>
{/* Doc list */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto py-1">
{loading && documents.length === 0 ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-3 py-2 border-b space-y-1.5">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
<div className="h-2.5 w-1/3 animate-pulse rounded bg-muted" />
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>
))
) : documents.length === 0 ? (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
{t("noDocuments")}
<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("noDocuments")}</span>
</div>
) : (
documents.map((doc) => (
@@ -110,7 +135,7 @@ export function VaultDocumentSidebar({
<Button variant="ghost" size="xs" disabled={page === 0} onClick={() => onPageChange(page - 1)}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span>{page + 1} / {totalPages}</span>
<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>
+24 -35
View File
@@ -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<VaultDocument | null>(null);
const [detailDoc, setDetailDoc] = useState<VaultDocument | null>(null);
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [searchResultDoc, setSearchResultDoc] = useState<VaultDocument | null>(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 (
<div className="relative flex h-full overflow-hidden">
@@ -109,7 +108,7 @@ export function VaultPage() {
{/* Main: header + graph + detail panel */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b flex-wrap shrink-0">
<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)}>
<PanelLeftOpen className="h-4 w-4" />
@@ -120,17 +119,15 @@ export function VaultPage() {
{/* 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-8">
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-7">
<option value="">{t("allAgents")}</option>
{(agents ?? []).map((a) => <option key={a.id} value={a.id}>{a.display_name || a.agent_key}</option>)}
</select>
{(teams ?? []).length > 0 && (
<select value={selectedTeam} onChange={(e) => handleTeamChange(e.target.value)}
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-8">
<option value="">{t("allTeams", "All teams")}</option>
{(teams ?? []).map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
</select>
)}
<select value={selectedTeam} onChange={(e) => handleTeamChange(e.target.value)}
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-7">
<option value="">{t("allTeams", "All teams")}</option>
{(teams ?? []).map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
</select>
<Button size="sm" variant="outline" onClick={() => setSearchOpen(true)} disabled={!selectedAgent}>
<Search className="h-3.5 w-3.5" />
</Button>
@@ -148,7 +145,7 @@ export function VaultPage() {
</TooltipProvider>
</div>
{/* Graph */}
{/* Graph — takes full remaining height */}
<div className="flex-1 min-h-0 relative">
<Suspense fallback={<div className="h-full animate-pulse bg-muted" />}>
<VaultGraphView
@@ -160,31 +157,23 @@ export function VaultPage() {
/>
</Suspense>
</div>
{/* Detail panel (bottom, collapsible) */}
<VaultDetailPanel
doc={selectedDoc}
open={!!selectedDoc}
onClose={handleCloseDetail}
onDeleted={handleCloseDetail}
/>
</div>
{/* Search dialog — result opens legacy modal */}
{/* Search dialog — result opens detail modal */}
{selectedAgent && (
<VaultSearchDialog
agentId={selectedAgent} open={searchOpen} onOpenChange={setSearchOpen}
onSelectResult={setSearchResultDoc}
onSelectResult={(doc) => { setDetailDoc(doc); setSelectedDocId(doc.id); }}
/>
)}
{selectedAgent && <VaultCreateDialog agentId={selectedAgent} open={createOpen} onOpenChange={setCreateOpen} />}
{/* Legacy detail dialog for search results only */}
{/* Detail dialog for all document views */}
<Suspense fallback={null}>
<VaultDetailDialog
doc={searchResultDoc} open={!!searchResultDoc}
onOpenChange={(open) => !open && setSearchResultDoc(null)}
onDeleted={() => setSearchResultDoc(null)}
doc={detailDoc} open={!!detailDoc}
onOpenChange={(open) => { if (!open) handleCloseDetail(); }}
onDeleted={handleCloseDetail}
/>
</Suspense>
</div>