"use client"; import dynamic from "next/dynamic"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Archive, Bot, BookOpen, Brain, ClipboardList, ExternalLink, GitCommit, Library, Loader2, MessageSquare, NotebookPen, Pencil, PenLine, RefreshCw, Save, X, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { apiFetch, apiUrl } from "@/lib/api"; import SpaceSectionHeader from "@/components/space/SpaceSectionHeader"; const MarkdownRenderer = dynamic( () => import("@/components/common/MarkdownRenderer"), { ssr: false }, ); // ── Types ──────────────────────────────────────────────────────────── type Layer = "L2" | "L3"; type Surface = | "chat" | "notebook" | "quiz" | "kb" | "book" | "partner" | "cowriter"; const SURFACES: readonly Surface[] = [ "chat", "notebook", "quiz", "kb", "book", "partner", "cowriter", ] as const; type Tab = "L1" | "L2" | "L3"; interface Entity { id: string; label: string; ts: string; content: string; metadata: Record; fingerprint: string; } interface SnapshotResponse { surface: Surface; entities: Entity[]; last_refresh: string | null; pending_changes: ChangeEntryDTO[]; } interface ChangeEntryDTO { ts: string; kind: "added" | "modified" | "removed"; entity_id: string; label: string; prev_fingerprint: string | null; new_fingerprint: string | null; } interface ChangesResponse { surface: Surface; changes: ChangeEntryDTO[]; } interface KbQueryDTO { id: string; ts: string; surface: Surface; kind: string; payload: Record; session_id: string | null; turn_id: string | null; } interface KbQueriesResponse { surface: Surface; events: KbQueryDTO[]; } interface DocOverview { layer: Layer; key: string; exists: boolean; updated_at: string | null; entry_count: number; backlog: number; } interface OverviewResponse { docs: DocOverview[]; backups: string[]; } interface StreamStage { stage: string; count?: number; delta?: string; ops?: unknown[]; report?: { accepted: boolean; reason?: string; results?: unknown[] }; message?: string; // Agentic-loop fields (tool_called / tool_observed / step_done / loop_summary) turn?: number; name?: string; args?: Record; brief?: string; action?: string; turns_used?: number; tools_used?: Record; ops_emitted?: number; summary?: string; } // ── Surface metadata + helpers ─────────────────────────────────────── interface SurfaceMeta { icon: LucideIcon; label: string; } const SURFACE_META: Record = { chat: { icon: MessageSquare, label: "Chat" }, notebook: { icon: NotebookPen, label: "Notebook" }, quiz: { icon: ClipboardList, label: "题库" }, kb: { icon: BookOpen, label: "Knowledge base" }, book: { icon: Library, label: "Book" }, partner: { icon: Bot, label: "Partner" }, cowriter: { icon: PenLine, label: "Co-writer" }, }; const L3_LABELS: Record = { recent: "近期总结", profile: "用户画像", scope: "知识 Scope", preferences: "偏好", }; // Entity refs in L2/L3 docs are written as `:`. // The id portion is intentionally permissive (notebook record_id, doc_id, // book_id, bot name, session_id, "session:question" composites, kb_name). const ENTITY_REF_RE = /\b(chat|notebook|quiz|kb|book|partner|cowriter):[A-Za-z0-9_.\-:]+/g; function entityAnchorId(ref: string): string { // Anchor IDs can't contain ':' cleanly across CSS selectors — flatten // it. We never round-trip from anchor back to ref, so the encoding // can be lossy. return `entity-${ref.replace(/:/g, "__")}`; } function parseEntityAnchor(anchor: string): { surface: Surface; ref: string; } | null { if (!anchor.startsWith("entity-")) return null; const body = anchor.slice("entity-".length); const sep = body.indexOf("__"); if (sep <= 0) return null; const surface = body.slice(0, sep); const rest = body.slice(sep + 2).replace(/__/g, ":"); if (!(SURFACES as readonly string[]).includes(surface)) return null; return { surface: surface as Surface, ref: `${surface}:${rest}` }; } function linkifyEntityRefs(content: string): string { return content.replace( ENTITY_REF_RE, (ref) => `[${ref}](#${entityAnchorId(ref)})`, ); } function labelFor(doc: DocOverview): string { if (doc.layer === "L2") return SURFACE_META[doc.key as Surface]?.label ?? doc.key; return L3_LABELS[doc.key] ?? doc.key; } function formatTimestamp(value: string | null, fallback: string): string { if (!value) return fallback; const d = new Date(value); if (Number.isNaN(d.getTime())) return value; return d.toLocaleString(); } function shorten(s: string, n: number): string { const trimmed = (s || "").replace(/\s+/g, " ").trim(); return trimmed.length > n ? trimmed.slice(0, n - 1) + "…" : trimmed; } function asString(v: unknown): string { if (typeof v === "string") return v; if (typeof v === "number") return String(v); return ""; } function entityDeepLinkUrl(surface: Surface, ent: Entity): string | null { const m = ent.metadata || {}; switch (surface) { case "chat": return `/home/${encodeURIComponent(ent.id)}`; case "cowriter": return `/co-writer/${encodeURIComponent(ent.id)}`; case "notebook": { const nbId = asString(m.notebook_id); return nbId ? `/space/notebooks?notebook=${encodeURIComponent(nbId)}` : "/space/notebooks"; } case "book": return `/book?book=${encodeURIComponent(ent.id)}`; case "partner": { // Partner entity.id is `partnerId:sessionKey`. Deep-link to the partner. const partnerId = asString(m.partner_id) || ent.id.split(":")[0]; return partnerId ? `/partners/${encodeURIComponent(partnerId)}` : "/partners"; } case "quiz": { // Quiz entity.id is `session:question`. Deep-link to the session. const sessionId = asString(m.session_id) || ent.id.split(":")[0]; return sessionId ? `/?session=${encodeURIComponent(sessionId)}` : "/space/questions"; } case "kb": return `/knowledge?kb=${encodeURIComponent(ent.id)}`; } return null; } // ── Main component ────────────────────────────────────────────────── interface MemorySectionProps { forcedTab?: Tab; // when provided, hides the TabStrip and locks the active tab hideHeader?: boolean; // when true, skips the SpaceSectionHeader (parent page renders its own) } export default function MemorySection({ forcedTab, hideHeader = false, }: MemorySectionProps = {}) { const { t, i18n } = useTranslation(); const [tab, setTab] = useState(forcedTab ?? "L2"); const [overview, setOverview] = useState(null); const [selected, setSelected] = useState<{ layer: Layer; key: string; } | null>(null); const [content, setContent] = useState(""); const [editing, setEditing] = useState(false); const [editorValue, setEditorValue] = useState(""); const [stream, setStream] = useState([]); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [toast, setToast] = useState(""); const [l1Surface, setL1Surface] = useState("notebook"); const [l1FocusRef, setL1FocusRef] = useState(null); const [dismissedBackup, setDismissedBackup] = useState(null); useEffect(() => { if (typeof window === "undefined") return; setDismissedBackup( window.localStorage.getItem("dt:memory:banner-dismissed") || null, ); }, []); const latestBackup = overview?.backups?.[overview.backups.length - 1] ?? null; const showArchivedBanner = !!latestBackup && latestBackup !== dismissedBackup; const dismissArchivedBanner = useCallback(() => { if (!latestBackup) return; if (typeof window !== "undefined") { window.localStorage.setItem("dt:memory:banner-dismissed", latestBackup); } setDismissedBackup(latestBackup); }, [latestBackup]); const loadOverview = useCallback(async () => { setLoading(true); try { const res = await apiFetch(apiUrl("/api/v1/memory/overview")); const data = (await res.json()) as OverviewResponse; setOverview(data); } catch (e) { setToast(e instanceof Error ? e.message : "Failed to load overview"); } finally { setLoading(false); } }, []); useEffect(() => { void loadOverview(); }, [loadOverview]); useEffect(() => { if (!toast) return; const id = setTimeout(() => setToast(""), 3500); return () => clearTimeout(id); }, [toast]); const loadDoc = useCallback(async (layer: Layer, key: string) => { setSelected({ layer, key }); setEditing(false); setStream([]); try { const res = await apiFetch(apiUrl(`/api/v1/memory/doc/${layer}/${key}`)); const data = await res.json(); const md = String(data?.content || ""); setContent(md); setEditorValue(md); } catch (e) { setToast(e instanceof Error ? e.message : "Failed to load document"); } }, []); const saveDoc = useCallback(async () => { if (!selected) return; setBusy(true); try { await apiFetch( apiUrl(`/api/v1/memory/doc/${selected.layer}/${selected.key}`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: editorValue }), }, ); setContent(editorValue); setEditing(false); setToast(t("Saved")); void loadOverview(); } catch (e) { setToast(e instanceof Error ? e.message : "Failed to save"); } finally { setBusy(false); } }, [editorValue, loadOverview, selected, t]); const runUpdate = useCallback(async () => { if (!selected) return; if (selected.layer === "L3" && selected.key === "preferences") { setToast( t("Preferences is written by the chat assistant, not consolidated."), ); return; } setBusy(true); setStream([]); try { const res = await apiFetch( apiUrl(`/api/v1/memory/doc/${selected.layer}/${selected.key}/update`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language: i18n.language || "en" }), }, ); const reader = res.body?.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (reader) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let nl = buffer.indexOf("\n\n"); while (nl !== -1) { const chunk = buffer.slice(0, nl); buffer = buffer.slice(nl + 2); const line = chunk .split("\n") .find((l) => l.startsWith("data:")) ?.replace(/^data:\s?/, ""); if (line) { try { const evt = JSON.parse(line) as StreamStage; setStream((prev) => [...prev, evt]); } catch { // ignore malformed chunk } } nl = buffer.indexOf("\n\n"); } } void loadDoc(selected.layer, selected.key); void loadOverview(); } catch (e) { setToast(e instanceof Error ? e.message : "Update failed"); } finally { setBusy(false); } }, [i18n.language, loadDoc, loadOverview, selected, t]); // Clicking a `:` ref inside an L2/L3 doc opens // the L1 tab focused on that entity. const handleEntityLinkClick = useCallback( (e: React.MouseEvent) => { const link = (e.target as HTMLElement | null)?.closest("a"); if (!link) return; const href = link.getAttribute("href") || ""; if (!href.startsWith("#entity-")) return; const parsed = parseEntityAnchor(href.slice(1)); if (!parsed) return; setTab("L1"); setL1Surface(parsed.surface); setL1FocusRef(parsed.ref); }, [], ); const l2Rows = useMemo( () => (overview?.docs || []).filter((d) => d.layer === "L2"), [overview], ); const l3Rows = useMemo( () => (overview?.docs || []).filter((d) => d.layer === "L3"), [overview], ); return (
{!hideHeader && ( {toast} ) : null } /> )} {!forcedTab && showArchivedBanner && latestBackup && (

{t("Your v1 memory was archived")}

{t( "Stored at memory/backup/{{name}}. v2 starts fresh — interact with DeepTutor and click Update on each doc to build memory.", { name: latestBackup }, )}

)} {!forcedTab && ( )} {tab === "L1" && ( { setL1Surface(s); setL1FocusRef(null); }} focusRef={l1FocusRef} onClearFocus={() => setL1FocusRef(null)} onToast={setToast} t={t} /> )} {tab !== "L1" && (
{loading ? (
) : !selected || selected.layer !== tab ? (

{t("Pick a document to view or update")}

) : ( { setEditing((v) => !v); setEditorValue(content); }} onSave={saveDoc} onUpdate={runUpdate} onEntityLinkClick={handleEntityLinkClick} t={t} /> )} {stream.length > 0 && ( setStream([])} t={t} /> )}
)}
); } // ── TabStrip ──────────────────────────────────────────────────────── interface TabStripProps { tab: Tab; onChange: (tab: Tab) => void; l2Count: number; l3Count: number; t: (key: string, opts?: Record) => string; } function TabStrip({ tab, onChange, l2Count, l3Count, t }: TabStripProps) { const tabs: Array<{ key: Tab; label: string; count?: number; hint: string }> = [ { key: "L1", label: t("L1 · Workspace"), hint: t( "Live snapshot of your workspace — one entry per real artifact.", ), }, { key: "L2", label: t("L2 · Per-surface"), count: l2Count, hint: t("Per-surface summaries consolidated from L1 content."), }, { key: "L3", label: t("L3 · Cross-surface"), count: l3Count, hint: t("Cross-surface knowledge consolidated from L2."), }, ]; return (
{tabs.map(({ key, label, count, hint }) => { const active = tab === key; return ( ); })}
); } // ── L1View (snapshot + changes per surface) ───────────────────────── type L1Mode = "snapshot" | "changes" | "queries"; export interface L1ViewProps { surface: Surface; onSurfaceChange: (s: Surface) => void; focusRef: string | null; onClearFocus: () => void; onToast: (msg: string) => void; t: (key: string, opts?: Record) => string; // When true, the surface pill bar is hidden — the parent renders its own // surface picker (e.g. a left rail in the workbench layout). compact?: boolean; } export function L1View({ surface, onSurfaceChange, focusRef, onClearFocus, onToast, t, compact = false, }: L1ViewProps) { const [mode, setMode] = useState("snapshot"); const [snapshot, setSnapshot] = useState(null); const [changes, setChanges] = useState([]); const [kbQueries, setKbQueries] = useState([]); const [loadingSnapshot, setLoadingSnapshot] = useState(false); const [loadingChanges, setLoadingChanges] = useState(false); const [loadingQueries, setLoadingQueries] = useState(false); const [refreshing, setRefreshing] = useState(false); const containerRef = useRef(null); // Default mode when switching surfaces: // - kb: prefer "snapshot" but kb-only "queries" mode is accessible. // - others: snapshot. useEffect(() => { if (mode === "queries" && surface !== "kb") { setMode("snapshot"); } }, [surface, mode]); const loadSnapshot = useCallback(async () => { setLoadingSnapshot(true); try { const res = await apiFetch(apiUrl(`/api/v1/memory/snapshot/${surface}`)); const data = (await res.json()) as SnapshotResponse; setSnapshot(data); } catch (e) { onToast(e instanceof Error ? e.message : "Failed to load snapshot"); } finally { setLoadingSnapshot(false); } }, [surface, onToast]); const loadChanges = useCallback(async () => { setLoadingChanges(true); try { const res = await apiFetch( apiUrl(`/api/v1/memory/snapshot/${surface}/changes`), ); const data = (await res.json()) as ChangesResponse; setChanges(data.changes); } catch (e) { onToast(e instanceof Error ? e.message : "Failed to load changes"); } finally { setLoadingChanges(false); } }, [surface, onToast]); const loadKbQueries = useCallback(async () => { if (surface !== "kb") return; setLoadingQueries(true); try { const res = await apiFetch(apiUrl("/api/v1/memory/trace/kb?limit=200")); const data = (await res.json()) as KbQueriesResponse; setKbQueries(data.events); } catch (e) { onToast(e instanceof Error ? e.message : "Failed to load queries"); } finally { setLoadingQueries(false); } }, [surface, onToast]); useEffect(() => { setSnapshot(null); setChanges([]); setKbQueries([]); void loadSnapshot(); void loadChanges(); if (surface === "kb") void loadKbQueries(); }, [surface, loadSnapshot, loadChanges, loadKbQueries]); // Auto-refetch snapshot when the tab regains focus or becomes visible — // workspace can mutate while the user is in another tab (notebook write, // co-writer edit, etc.) so the snapshot must reflect that without a click. useEffect(() => { const refetch = () => { if (typeof document !== "undefined" && document.hidden) return; void loadSnapshot(); }; window.addEventListener("focus", refetch); document.addEventListener("visibilitychange", refetch); return () => { window.removeEventListener("focus", refetch); document.removeEventListener("visibilitychange", refetch); }; }, [loadSnapshot]); // Auto-scroll to focused entity when snapshot finishes loading. useEffect(() => { if (!focusRef || !containerRef.current) return; if (!snapshot) return; const el = containerRef.current.querySelector( `[data-entity-ref="${focusRef}"]`, ) as HTMLElement | null; if (el) { el.scrollIntoView({ block: "center", behavior: "smooth" }); } }, [focusRef, snapshot]); const pendingByEntity = useMemo(() => { const m = new Map(); for (const c of snapshot?.pending_changes ?? []) { m.set(c.entity_id, c.kind); } return m; }, [snapshot?.pending_changes]); const pendingCount = snapshot?.pending_changes?.length ?? 0; const onRefresh = useCallback(async () => { setRefreshing(true); try { const res = await apiFetch( apiUrl(`/api/v1/memory/snapshot/${surface}/refresh`), { method: "POST" }, ); const data = await res.json(); const newChanges: ChangeEntryDTO[] = data?.changes || []; onToast( newChanges.length > 0 ? t("Refreshed: {{n}} changes", { n: newChanges.length }) : t("Refreshed: no changes"), ); await loadSnapshot(); await loadChanges(); } catch (e) { onToast(e instanceof Error ? e.message : "Refresh failed"); } finally { setRefreshing(false); } }, [surface, loadSnapshot, loadChanges, onToast, t]); return (
{!compact && (
{SURFACES.map((s) => { const meta = SURFACE_META[s]; return ( onSurfaceChange(s)} icon={meta.icon} label={meta.label} /> ); })}
{pendingCount > 0 && ( {t("{{n}} pending", { n: pendingCount })} )}
)}
{compact && (
{pendingCount > 0 && ( {t("{{n}} pending", { n: pendingCount })} )}
)}
{mode === "snapshot" && ( )} {mode === "changes" && ( )} {mode === "queries" && surface === "kb" && ( )}
); } // ── SurfacePill ───────────────────────────────────────────────────── interface SurfacePillProps { active: boolean; onClick: () => void; icon: LucideIcon; label: string; } function SurfacePill({ active, onClick, icon: Icon, label }: SurfacePillProps) { return ( ); } // ── ModeStrip (snapshot / changes / [kb queries]) ─────────────────── interface ModeStripProps { mode: L1Mode; setMode: (m: L1Mode) => void; surface: Surface; t: (key: string, opts?: Record) => string; } function ModeStrip({ mode, setMode, surface, t }: ModeStripProps) { const tabs: Array<{ key: L1Mode; label: string; hidden?: boolean }> = [ { key: "snapshot", label: t("Snapshot") }, { key: "changes", label: t("Changes") }, { key: "queries", label: t("Queries"), hidden: surface !== "kb" }, ]; return (
{tabs .filter((x) => !x.hidden) .map(({ key, label }) => { const active = mode === key; return ( ); })}
); } // ── SnapshotList ───────────────────────────────────────────────────── interface SnapshotListProps { surface: Surface; loading: boolean; snapshot: SnapshotResponse | null; pendingByEntity: Map; focusRef: string | null; onClearFocus: () => void; t: (key: string, opts?: Record) => string; } function SnapshotList({ surface, loading, snapshot, pendingByEntity, focusRef, onClearFocus, t, }: SnapshotListProps) { const entities = snapshot?.entities ?? []; return ( <>
{t("{{n}} entities", { n: entities.length })} {snapshot?.last_refresh && ( <> {" · "} {t("last refresh {{ts}}", { ts: formatTimestamp(snapshot.last_refresh, ""), })} )} {focusRef && ( )}
{loading && entities.length === 0 ? (
) : entities.length === 0 ? (

{t("Nothing in workspace yet.")}

) : (
    {entities.map((ent, idx) => ( ))}
)} ); } // ── EntityRow ─────────────────────────────────────────────────────── interface EntityRowProps { surface: Surface; ent: Entity; focused: boolean; pendingKind: ChangeEntryDTO["kind"] | null; t: (key: string, opts?: Record) => string; } function EntityRow({ surface, ent, focused, pendingKind, t }: EntityRowProps) { const url = entityDeepLinkUrl(surface, ent); const ref = `${surface}:${ent.id}`; const meta = SURFACE_META[surface]; const Icon = meta.icon; const preview = shorten(ent.content, 220); const inner = ( <>
{ent.label} {ent.id} {ent.ts && {formatTimestamp(ent.ts, "")}} {pendingKind && }
{preview && (

{preview}

)}
{url && ( )} ); const focusedRing = focused ? "border-l-[3px] border-l-[var(--primary)] bg-[var(--primary)]/12 ring-1 ring-[var(--primary)]/30" : pendingKind === "added" ? "border-l-[3px] border-l-emerald-500 bg-emerald-500/5 hover:bg-emerald-500/10" : pendingKind === "modified" ? "border-l-[3px] border-l-amber-500 bg-amber-500/5 hover:bg-amber-500/10" : "border-l-[3px] border-l-transparent hover:bg-[var(--muted)]/40"; const rowClass = `group flex items-start gap-3 border-b border-[var(--border)]/50 px-4 py-2.5 transition-colors last:border-0 ${focusedRing}`; return (
  • {url ? ( {inner} ) : (
    {inner}
    )}
  • ); } // ── PendingBadge (row-level pending marker) ───────────────────────── function PendingBadge({ kind, t, }: { kind: ChangeEntryDTO["kind"]; t: (key: string, opts?: Record) => string; }) { const map = { added: { label: t("new"), cls: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", }, modified: { label: t("modified"), cls: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", }, removed: { label: t("removed"), cls: "border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-300", }, } as const; const cfg = map[kind]; return ( {cfg.label} ); } // ── ChangesList (git-log style) ───────────────────────────────────── interface ChangesListProps { loading: boolean; changes: ChangeEntryDTO[]; pending: ChangeEntryDTO[]; t: (key: string, opts?: Record) => string; } function ChangesList({ loading, changes, pending, t }: ChangesListProps) { if (loading && changes.length === 0 && pending.length === 0) { return (
    ); } const hasAny = changes.length > 0 || pending.length > 0; if (!hasAny) { return (

    {t("No changes recorded yet. Run Refresh to capture the baseline.")}

    ); } return (
    {pending.length > 0 && (
    {t("Pending — {{n}} change(s) since last refresh", { n: pending.length, })} {t("Click Refresh to commit")}
      {pending.map((c, i) => ( ))}
    )} {changes.length > 0 && (
      {changes.map((c, i) => ( ))}
    )}
    ); } function ChangeRow({ c }: { c: ChangeEntryDTO }) { return (
  • {c.label || c.entity_id} {c.entity_id} {formatTimestamp(c.ts, "")}
  • ); } function ChangeGlyph({ kind }: { kind: ChangeEntryDTO["kind"] }) { const map = { added: { ch: "+", bg: "bg-emerald-500/15", fg: "text-emerald-600" }, modified: { ch: "~", bg: "bg-amber-500/15", fg: "text-amber-600" }, removed: { ch: "−", bg: "bg-rose-500/15", fg: "text-rose-600" }, } as const; const cfg = map[kind]; return ( {cfg.ch} ); } // ── KbQueriesList (event-driven, only for KB) ─────────────────────── interface KbQueriesListProps { loading: boolean; queries: KbQueryDTO[]; t: (key: string, opts?: Record) => string; } function KbQueriesList({ loading, queries, t }: KbQueriesListProps) { if (loading && queries.length === 0) { return (
    ); } if (queries.length === 0) { return (

    {t("No RAG queries recorded yet.")}

    ); } return (
      {queries.map((q) => { const kb = asString(q.payload?.kb_name) || "?"; const query = asString(q.payload?.query); return (
    1. {kb} {formatTimestamp(q.ts, "")}

      {query}

    2. ); })}
    ); } // ── DocList / DocPane / StreamPanel (mostly unchanged) ────────────── interface DocListProps { title: string; rows: DocOverview[]; selected: { layer: Layer; key: string } | null; onSelect: (layer: Layer, key: string) => void; } function DocList({ title, rows, selected, onSelect }: DocListProps) { const { t } = useTranslation(); return (
    {title}
      {rows.map((row) => { const isActive = selected?.layer === row.layer && selected.key === row.key; return (
    • ); })}
    ); } interface DocPaneProps { selected: { layer: Layer; key: string }; content: string; editing: boolean; editorValue: string; busy: boolean; onEditValue: (v: string) => void; onEditToggle: () => void; onSave: () => void; onUpdate: () => void; onEntityLinkClick: (e: React.MouseEvent) => void; t: (key: string, opts?: Record) => string; } function DocPane({ selected, content, editing, editorValue, busy, onEditValue, onEditToggle, onSave, onUpdate, onEntityLinkClick, t, }: DocPaneProps) { const isPrefs = selected.layer === "L3" && selected.key === "preferences"; const renderedContent = useMemo(() => linkifyEntityRefs(content), [content]); return (
    {selected.layer} ·{" "} {labelFor({ layer: selected.layer, key: selected.key, } as DocOverview)}
    {editing && ( )} {!isPrefs && ( )}
    {editing ? (