From f6d275b0cd4c6dbecf74f08c6bb24315ab2d2f15 Mon Sep 17 00:00:00 2001 From: huyz <739820174@qq.com> Date: Fri, 9 Oct 2026 00:25:06 +0800 Subject: [PATCH 1/7] feat(notes): unify workspace vault and Mem synchronization (#574) --- CHANGELOG.md | 2 + apps/desktop/packaging/runtime-layout.cjs | 1 + apps/desktop/renderer/src/App.tsx | 12 + apps/desktop/renderer/src/docs/DocViewer.tsx | 5 +- apps/desktop/renderer/src/docs/DocsPanel.tsx | 134 +++-- .../renderer/src/docs/LiveMarkdownEditor.tsx | 10 +- .../renderer/src/markdown/Markdown.tsx | 56 +- .../renderer/src/memory/MemoryModule.tsx | 60 +-- .../renderer/src/memory/VaultModule.tsx | 349 +++++++++++++ .../renderer/src/memory/vault-workspace.css | 9 + apps/desktop/renderer/src/owb.d.ts | 1 + .../src/settings/ConfigurationSettings.tsx | 2 +- .../src/settings/ServiceConnections.tsx | 4 +- .../renderer/src/turns/TurnNoteAction.tsx | 59 +++ .../desktop/renderer/src/turns/TurnThread.tsx | 3 + apps/desktop/renderer/src/turns/turn-note.css | 4 + apps/desktop/renderer/src/vault/bridge.ts | 26 + apps/desktop/renderer/test/App.test.tsx | 84 ++- .../renderer/test/memory-module.test.tsx | 352 +------------ .../renderer/test/turn-note-action.test.tsx | 39 ++ .../renderer/test/vault-module.test.tsx | 256 ++++++++++ apps/desktop/src/main.js | 3 + apps/desktop/src/preload.js | 1 + apps/desktop/src/vault-ipc.cjs | 79 +++ apps/desktop/test/vault-ipc.test.cjs | 25 + apps/server/src/routes/turns.ts | 5 + apps/server/src/server.ts | 4 + apps/server/src/vault/attachments.ts | 480 ++++++++++++++++++ apps/server/src/vault/context.ts | 76 +++ apps/server/src/vault/extensions.ts | 35 ++ apps/server/src/vault/from-turn.ts | 44 ++ apps/server/src/vault/history.ts | 82 +++ apps/server/src/vault/receipts.ts | 76 +++ apps/server/src/vault/routes.ts | 68 +++ apps/server/src/vault/store.ts | 445 ++++++++++++++++ apps/server/src/vault/sync-plan.ts | 109 ++++ apps/server/src/vault/sync.ts | 128 +++++ .../test/review-context-lifecycle.test.ts | 2 +- apps/server/test/turns.test.ts | 4 +- apps/server/test/vault-attachments.test.ts | 289 +++++++++++ apps/server/test/vault-context.test.ts | 73 +++ apps/server/test/vault-receipts.test.ts | 55 ++ apps/server/test/vault-sync-edge.test.ts | 132 +++++ apps/server/test/vault-sync-plan.test.ts | 42 ++ apps/server/test/vault.test.ts | 224 ++++++++ docs/acceptance/workspace-vault-validation.md | 53 ++ docs/workspace-vault.md | 78 +++ packages/shared/src/api.ts | 15 + packages/shared/src/errors.ts | 7 + packages/shared/src/index.ts | 2 + packages/shared/src/vault-sync.ts | 24 + packages/shared/src/vault.ts | 61 +++ packages/ui/src/locales/en.ts | 2 + packages/ui/src/locales/vault.ts | 137 +++++ packages/ui/src/locales/zh.ts | 2 + 55 files changed, 3868 insertions(+), 462 deletions(-) create mode 100644 apps/desktop/renderer/src/memory/VaultModule.tsx create mode 100644 apps/desktop/renderer/src/memory/vault-workspace.css create mode 100644 apps/desktop/renderer/src/turns/TurnNoteAction.tsx create mode 100644 apps/desktop/renderer/src/turns/turn-note.css create mode 100644 apps/desktop/renderer/src/vault/bridge.ts create mode 100644 apps/desktop/renderer/test/turn-note-action.test.tsx create mode 100644 apps/desktop/renderer/test/vault-module.test.tsx create mode 100644 apps/desktop/src/vault-ipc.cjs create mode 100644 apps/desktop/test/vault-ipc.test.cjs create mode 100644 apps/server/src/vault/attachments.ts create mode 100644 apps/server/src/vault/context.ts create mode 100644 apps/server/src/vault/extensions.ts create mode 100644 apps/server/src/vault/from-turn.ts create mode 100644 apps/server/src/vault/history.ts create mode 100644 apps/server/src/vault/receipts.ts create mode 100644 apps/server/src/vault/routes.ts create mode 100644 apps/server/src/vault/store.ts create mode 100644 apps/server/src/vault/sync-plan.ts create mode 100644 apps/server/src/vault/sync.ts create mode 100644 apps/server/test/vault-attachments.test.ts create mode 100644 apps/server/test/vault-context.test.ts create mode 100644 apps/server/test/vault-receipts.test.ts create mode 100644 apps/server/test/vault-sync-edge.test.ts create mode 100644 apps/server/test/vault-sync-plan.test.ts create mode 100644 apps/server/test/vault.test.ts create mode 100644 docs/acceptance/workspace-vault-validation.md create mode 100644 docs/workspace-vault.md create mode 100644 packages/shared/src/vault-sync.ts create mode 100644 packages/shared/src/vault.ts create mode 100644 packages/ui/src/locales/vault.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 40240cda..c1b7202c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added +- #574:新增工作区级 Markdown 笔记库、跨岗位引用、会话结论沉淀与实际上下文出处;支持中文路径、全文搜索、授权图片附件、手动 Mem 双向同步、冲突副本和基于版本校验的历史恢复。 + - 项目任务新增持久化人工验收:逐项核对验收标准,绑定原会话、执行和产出摘要版本;接受后任务完成,拒绝保留原因并回到待验收。验收与任务状态原子保存,过期计划和产出拒绝裁决,网络重试保留幂等记录。 - 文档支持目录树及键盘导航,会话记忆准确继续原会话;关系图默认提供员工、来源、资源与证据的逐步浏览。 - #549:按已确认设计更新收件箱审批工作台,提供请求列表、风险与岗位筛选、裁决操作、执行证据与异常详情、可追溯审计和运行时间线;断线保留只读快照,不确定的审批重试先核验权限,如实展示未观测用量和实际预算比例。 diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index fb17efea..fea95fa0 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -20,6 +20,7 @@ const DESKTOP_RUNTIME_FILES = [ "src/wsl-bootstrap.cjs", "src/docs-ipc.cjs", "src/drive-ipc.cjs", + "src/vault-ipc.cjs", "src/experiments-ipc.cjs", "src/goal-ipc.cjs", "src/group-ipc.cjs", diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx index f3ad5f5b..a9d6f272 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -2266,6 +2266,14 @@ function AppInner({ } setActiveModule("conversation"); }} + onOpenSource={(source) => { + if (!source.positionId || !source.sessionId) return; + const samePosition = selectedIdRef.current === source.positionId; + selectedSessions.current[JSON.stringify([workspacePathRef.current, source.positionId])] = source.sessionId; + selectPosition(source.positionId); + if (samePosition) { selectedSessionIdRef.current = source.sessionId; setSelectedSessionId(source.sessionId); } + setSessionFocusTurnId(source.turnId ?? null); setActiveModule("conversation"); + }} workspaceOpen={workspaceInfo?.open === true} positions={positions} selectedPositionId={selectedId} @@ -2416,6 +2424,10 @@ function AppInner({ cancelling={turnCancelling} focusTurnId={sessionFocusTurnId} onOpenResource={(positionId, path) => { + if (path.startsWith("vault://notes/")) { + setResourceRequest({ positionId, path, nonce: Date.now() }); + setMemorySource("docs"); setActiveModule("docs"); return; + } if (!/^knowledge\//i.test(path) && path !== "SKILL.md") { const workspacePath = workspacePathRef.current; const relativePath = workspaceResourcePath(workspacePath, path); diff --git a/apps/desktop/renderer/src/docs/DocViewer.tsx b/apps/desktop/renderer/src/docs/DocViewer.tsx index e827452b..c0d24b81 100644 --- a/apps/desktop/renderer/src/docs/DocViewer.tsx +++ b/apps/desktop/renderer/src/docs/DocViewer.tsx @@ -2,6 +2,7 @@ import { useId, type ReactNode } from "react"; import { Tag } from "antd"; import { useT } from "@roleweave/ui"; import { Markdown, markdownHeadings } from "../markdown/Markdown"; +import type { ImageResourceLoader } from "../markdown/Markdown"; import { splitFrontmatter } from "./frontmatter"; function stripExtension(filename: string): string { @@ -21,6 +22,7 @@ export interface DocViewerProps { editor?: ReactNode; showEditor?: boolean; onNavigateDoc?: (target: string, kind: "wikilink" | "relative") => void; + imageResourceLoader?: ImageResourceLoader; } const META_LABEL_KEYS: Record = { name: "docs.metaName", @@ -37,6 +39,7 @@ export function DocViewer({ editor, showEditor = true, onNavigateDoc, + imageResourceLoader, }: DocViewerProps) { const t = useT(); const headingPrefix = `document-${useId().replaceAll(":", "")}`; @@ -127,7 +130,7 @@ export function DocViewer({ )}
{isMarkdown ? ( - + ) : extension === "txt" ? (
{source}
) : ( diff --git a/apps/desktop/renderer/src/docs/DocsPanel.tsx b/apps/desktop/renderer/src/docs/DocsPanel.tsx index 2157c752..85d32233 100644 --- a/apps/desktop/renderer/src/docs/DocsPanel.tsx +++ b/apps/desktop/renderer/src/docs/DocsPanel.tsx @@ -32,6 +32,7 @@ import type { import { DocViewer } from "./DocViewer"; import { LiveMarkdownEditor } from "./LiveMarkdownEditor"; import { DocumentFileTree } from "./DocumentFileTree"; +import type { ImageResourceLoader } from "../markdown/Markdown"; /** * Document routing surface (#35 S2/S4, DS-35-001 rev-1 §3/§5): routes a @@ -41,6 +42,19 @@ import { DocumentFileTree } from "./DocumentFileTree"; * reference shape stays the frozen doc-ref.v1alpha1. */ export interface DocsPanelProps { + /** A workspace vault reuses the editor without treating a position as its owner. */ + mode?: "position" | "vault"; + storageScope?: string; + searchDocs?(scope: string, query: string, options?: { archived?: boolean }): Promise; + canMutate?(path: string): boolean; + fileReference?(entry: DocsFileEntry): string; + documentActions?(path: string): ReactNode; + imageResourceLoader?: ImageResourceLoader; + onDirtyChange?(dirty: boolean): void; + onSelectPath?(path: string): void; + onNavigateReference?(uri: string): void; + resolveUnlistedNote?(path: string, target: string, kind: "wikilink" | "relative"): Promise; + insertRequest?: { path: string; text: string; nonce: number } | null; knowledgeFirst?: boolean; toolbar?: ReactNode; positionId: string | null; @@ -80,6 +94,12 @@ interface NoteSession { const noteSessions = new Map(); const noteWriteQueues = new Map>(); +const DOCUMENT_CACHE_CHANGED = "owb:document-cache-changed"; +export function documentScopeDirty(scope: string): boolean { + const prefix = `${scope}\u0000`; + return [...noteSessions].some(([key, session]) => key.startsWith(prefix) && session.draft !== session.savedContent) + || [...noteWriteQueues.keys()].some((key) => key.startsWith(prefix)); +} function sessionKey(positionId: string, path: string): string { return `${positionId}\u0000${path}`; @@ -97,12 +117,13 @@ function enqueueNoteWrite( noteWriteQueues.set(key, queued); const remove = () => { if (noteWriteQueues.get(key) === queued) noteWriteQueues.delete(key); + window.dispatchEvent(new Event(DOCUMENT_CACHE_CHANGED)); }; void queued.then(remove, remove); return queued; } -function resolveNotePath(currentPath: string, target: string, kind: "wikilink" | "relative"): string | null { +function resolveNotePath(currentPath: string, target: string, kind: "wikilink" | "relative", vault = false): string | null { let decoded: string; try { decoded = decodeURIComponent(target.split(/[?#]/, 1)[0] ?? "").trim(); @@ -117,19 +138,31 @@ function resolveNotePath(currentPath: string, target: string, kind: "wikilink" | for (const segment of candidate.split("/")) { if (segment === "." || segment === "") continue; if (segment === "..") { - if (segments.length <= 1) return null; + if (segments.length <= (vault ? 0 : 1)) return null; segments.pop(); continue; } - if (!/^(?!\.)[A-Za-z0-9._-]+$/.test(segment)) return null; + if (vault ? !/^(?!\.)[^<>:"|?*\\\u0000-\u001f]+$/u.test(segment) : !/^(?!\.)[A-Za-z0-9._-]+$/.test(segment)) return null; segments.push(segment); } const resolved = segments.join("/"); - return resolved.startsWith("knowledge/") && /\.(md|markdown)$/i.test(resolved) ? resolved : null; + return (vault || resolved.startsWith("knowledge/")) && /\.(md|markdown)$/i.test(resolved) ? resolved : null; } export function DocsPanel({ - positionId, + positionId: ownerId, + mode = "position", + storageScope, + searchDocs, + canMutate, + fileReference, + documentActions, + imageResourceLoader, + onDirtyChange, + onSelectPath, + onNavigateReference, + resolveUnlistedNote, + insertRequest, listDocs, readDoc, writeDoc, @@ -143,6 +176,8 @@ export function DocsPanel({ toolbar, requestedPath, }: DocsPanelProps) { + const positionId = storageScope ?? ownerId; + const vault = mode === "vault"; const t = useT(); const [messageApi, messageHolder] = message.useMessage(); const [files, setFiles] = useState([]); @@ -153,12 +188,12 @@ export function DocsPanel({ const [reading, setReading] = useState(false); const [readError, setReadError] = useState(null); const [query, setQuery] = useState(""); - const [fileScope, setFileScope] = useState("knowledge"); + const [fileScope, setFileScope] = useState(vault ? "all" : "knowledge"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const [savedContent, setSavedContent] = useState(""); const [baseVersion, setBaseVersion] = useState(""); - const [editorMode, setEditorMode] = useState("live"); + const [editorMode, setEditorMode] = useState(vault ? "reading" : "live"); const [externalConflict, setExternalConflict] = useState(null); const [compareOpen, setCompareOpen] = useState(false); const [missingNote, setMissingNote] = useState(null); @@ -184,12 +219,18 @@ export function DocsPanel({ const editorRef = useRef(null); const visibleFiles = files.filter( (file) => - (!knowledgeFirst || + (vault || !knowledgeFirst || fileScope === "all" || /\.(md|markdown|txt)$/i.test(file.path) || file.path.startsWith("knowledge/")) && - file.path.toLowerCase().includes(query.toLowerCase()), + (searchDocs || file.path.toLowerCase().includes(query.toLowerCase())), ); + const searchQuery = searchDocs ? query : ""; + const [searchTerm, setSearchTerm] = useState(""); + useEffect(() => { + const timer = window.setTimeout(() => setSearchTerm(searchQuery), 180); + return () => window.clearTimeout(timer); + }, [searchQuery]); useEffect(() => { readVersion.current += 1; @@ -235,7 +276,9 @@ export function DocsPanel({ let cancelled = false; setListing(true); setListError(null); - const listed = archivedView ? listDocs(positionId, { archived: true }) : listDocs(positionId); + const options = archivedView ? { archived: true } : undefined; + const listed = searchDocs && searchTerm.trim() ? searchDocs(positionId, searchTerm.trim(), options) + : archivedView ? listDocs(positionId, { archived: true }) : listDocs(positionId); listed .then((response) => { if (!cancelled) setFiles(response.files); @@ -250,11 +293,12 @@ export function DocsPanel({ return () => { cancelled = true; }; - }, [positionId, listDocs, reloadToken, listRetry, archivedView]); + }, [positionId, listDocs, searchDocs, searchTerm, reloadToken, listRetry, archivedView]); const openFile = useCallback( (path: string) => { if (positionId === null) return; + onSelectPath?.(path); setSelected(path); setDoc(null); setReadError(null); @@ -281,7 +325,7 @@ export function DocsPanel({ draft: response.content, savedContent: response.content, baseVersion: response.version, - mode: cached?.mode ?? "live", + mode: cached?.mode ?? (vault ? "reading" : "live"), selectionStart: cached?.selectionStart ?? 0, selectionEnd: cached?.selectionEnd ?? 0, editorScrollTop: cached?.editorScrollTop ?? 0, @@ -305,7 +349,7 @@ export function DocsPanel({ if (version === readVersion.current) setReading(false); }); }, - [positionId, readDoc, archivedView], + [positionId, readDoc, archivedView, vault, onSelectPath], ); useLayoutEffect(() => { @@ -338,11 +382,16 @@ export function DocsPanel({ const refreshList = useCallback(() => setListRetry((value) => value + 1), []); - const mutableSelected = selected !== null && isKnowledgeFile(selected); + const mutableSelected = selected !== null && (canMutate ? canMutate(selected) : vault || isKnowledgeFile(selected)); const liveEditable = Boolean( doc && mutableSelected && !archivedView && writeDoc && isMarkdownFile(doc.path), ); const dirty = liveEditable && (draft !== savedContent || composing); + useEffect(() => { + const refresh = () => onDirtyChange?.(dirty || editing || saving || Boolean(positionId && documentScopeDirty(positionId))); + refresh(); window.addEventListener(DOCUMENT_CACHE_CHANGED, refresh); + return () => window.removeEventListener(DOCUMENT_CACHE_CHANGED, refresh); + }, [dirty, editing, saving, positionId, onDirtyChange]); const updateComposition = (active: boolean) => { composingRef.current = active; @@ -355,6 +404,7 @@ export function DocsPanel({ const key = sessionKey(positionId, selected); const session = noteSessions.get(key)!; noteSessions.set(key, { ...session, draft: value }); + window.dispatchEvent(new Event(DOCUMENT_CACHE_CHANGED)); }; const saveEdit = useCallback( @@ -425,7 +475,7 @@ export function DocsPanel({ reading ) return; const listed = files.find((file) => file.path === selected); - if (!listed || listed.modifiedAt <= baseVersion) return; + if (!listed || listed.modifiedAt <= (vault ? doc.modifiedAt : baseVersion)) return; const checkKey = `${positionId}:${selected}:${listed.modifiedAt}`; if (externalCheck.current === checkKey) return; externalCheck.current = checkKey; @@ -453,7 +503,15 @@ export function DocsPanel({ setSavedContent(response.content); setBaseVersion(response.version); }); - }, [archivedView, baseVersion, dirty, doc, editorMode, files, positionId, readDoc, reading, selected]); + }, [archivedView, baseVersion, dirty, doc, editorMode, files, positionId, readDoc, reading, selected, vault]); + + const lastInsert = useRef(); + useEffect(() => { + if (!insertRequest || insertRequest.path !== selected || !liveEditable || composing || reading + || lastInsert.current === insertRequest.nonce) return; + lastInsert.current = insertRequest.nonce; + updateDraft(`${draft.replace(/\s+$/, "")}\n\n${insertRequest.text}\n`); + }, [insertRequest, selected, liveEditable, composing, reading, draft]); const keepMine = () => { if (externalConflict === null || positionId === null || selected === null) return; @@ -496,8 +554,9 @@ export function DocsPanel({ messageApi.error(t("docs.nameRequired")); return; } - const parent = selected.includes("/") ? selected.slice(0, selected.lastIndexOf("/") + 1) : "knowledge/"; - const nextPath = trimmed.includes("/") ? trimmed : `${parent}${trimmed}`; + const parent = selected.includes("/") ? selected.slice(0, selected.lastIndexOf("/") + 1) : vault ? "" : "knowledge/"; + let nextPath = trimmed.includes("/") ? trimmed : `${parent}${trimmed}`; + if (vault && !/\.(md|markdown)$/i.test(nextPath)) nextPath += ".md"; const epoch = readVersion.current; const actingPosition = positionId; const actingArchived = archivedView; @@ -590,10 +649,22 @@ export function DocsPanel({ const navigateDoc = (target: string, kind: "wikilink" | "relative") => { if (selected === null) return; - const resolved = resolveNotePath(selected, target, kind); + if (vault && target.startsWith("vault://notes/")) { onNavigateReference?.(target); return; } + let resolved = resolveNotePath(selected, target, kind, vault); if (resolved === null) return; + if (vault && !files.some((file) => file.path === resolved) && kind === "wikilink") { + const name = resolved.slice(resolved.lastIndexOf("/") + 1); + const matches = files.filter((file) => file.path === name || file.path.endsWith(`/${name}`)); + if (matches.length === 1) resolved = matches[0]!.path; + } if (files.some((file) => file.path === resolved)) openFile(resolved); - else setMissingNote(resolved); + else if (vault && resolveUnlistedNote) { + const epoch = readVersion.current; + const missingPath = resolved; + void resolveUnlistedNote(resolved, target, kind).then((found) => { + if (!found && epoch === readVersion.current) setMissingNote(missingPath); + }).catch((failure) => { if (epoch === readVersion.current) messageApi.error(failure instanceof Error ? failure.message : t("docs.readFail")); }); + } else setMissingNote(resolved); }; const createMissingNote = async () => { @@ -614,7 +685,7 @@ export function DocsPanel({ const copyRef = async (entry: DocsFileEntry) => { if (positionId === null) return; - const ref = JSON.stringify({ + const ref = fileReference ? fileReference(entry) : JSON.stringify({ uri: formatDocRefUri(positionId, entry.path), version: entry.modifiedAt, }); @@ -664,7 +735,7 @@ export function DocsPanel({ }; const modifier = /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘" : "Ctrl+"; const documentState = saving ? "docs.saving" : liveEditable ? dirty ? "docs.unsaved" : "docs.saved" - : editing ? "docs.unsaved" : !mutableSelected ? "docs.boundReadOnly" : "reading.preview"; + : editing ? "docs.unsaved" : !mutableSelected ? vault ? "vault.legacyReadOnly" : "docs.boundReadOnly" : "reading.preview"; const documentMenu: NonNullable = []; if (doc) { if (liveEditable) { @@ -713,7 +784,7 @@ export function DocsPanel({ void copyRef({ path: doc.path, modifiedAt: doc.version, size: doc.size, kind: "file" }); } }); documentMenu.push({ type: "divider" }, { key: "shortcuts", label: t("docs.shortcuts"), onClick: () => setShortcutsOpen(true) }); - if (mutableSelected && deleteDoc) { + if (mutableSelected && deleteDoc && (!vault || archivedView)) { documentMenu.push({ type: "divider" }, { key: "delete", label: t("docs.delete"), danger: true, disabled: operationBlocked, onClick: () => setDeleteOpen(true) }); } @@ -755,7 +826,7 @@ export function DocsPanel({ ]; return ( -
+
{messageHolder} {positionId === null ? ( @@ -767,8 +838,8 @@ export function DocsPanel({ } - aria-label={t("memory.search")} - placeholder={t("memory.search")} + aria-label={t(vault ? "vault.search" : "memory.search")} + placeholder={t(vault ? "vault.search" : "memory.search")} value={query} onChange={(event) => setQuery(event.target.value)} /> @@ -783,8 +854,8 @@ export function DocsPanel({ value={fileScope} onChange={setFileScope} options={[ - { value: "knowledge", label: t("memory.knowledge") }, - { value: "all", label: t("memory.allFiles") }, + ...(!vault ? [{ value: "knowledge", label: t("memory.knowledge") }] : []), + { value: "all", label: t(vault ? "vault.allNotes" : "memory.allFiles") }, ...(lifecycleEnabled ? [{ value: "archived", label: t("docs.archived") }] : []), @@ -830,14 +901,14 @@ export function DocsPanel({ {query ? t("reading.docs.searchNone", { query }) - : t("docs.empty")} + : t(vault ? "vault.empty" : "docs.empty")} {query ? ( ) : ( - {t("docs.emptyHint")} + {t(vault ? "vault.emptyHint" : "docs.emptyHint")} )}
)} @@ -988,6 +1059,7 @@ export function DocsPanel({ {saving ?