Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1523 lines
49 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
brief?: string;
action?: string;
turns_used?: number;
tools_used?: Record<string, number>;
ops_emitted?: number;
summary?: string;
}
// ── Surface metadata + helpers ───────────────────────────────────────
interface SurfaceMeta {
icon: LucideIcon;
label: string;
}
const SURFACE_META: Record<Surface, SurfaceMeta> = {
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<string, string> = {
recent: "近期总结",
profile: "用户画像",
scope: "知识 Scope",
preferences: "偏好",
};
// Entity refs in L2/L3 docs are written as `<surface>:<entity_id>`.
// 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<Tab>(forcedTab ?? "L2");
const [overview, setOverview] = useState<OverviewResponse | null>(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<StreamStage[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState("");
const [l1Surface, setL1Surface] = useState<Surface>("notebook");
const [l1FocusRef, setL1FocusRef] = useState<string | null>(null);
const [dismissedBackup, setDismissedBackup] = useState<string | null>(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 `<surface>:<entity_id>` ref inside an L2/L3 doc opens
// the L1 tab focused on that entity.
const handleEntityLinkClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
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 (
<div className="space-y-6">
{!hideHeader && (
<SpaceSectionHeader
icon={Brain}
title={t("Memory")}
description={t(
"L1 mirrors your workspace, L2 summarises per-surface content, L3 is cross-surface knowledge.",
)}
meta={
toast ? (
<span className="rounded-full border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-2 py-0.5 text-[10.5px] font-medium text-[var(--primary)]">
{toast}
</span>
) : null
}
/>
)}
{!forcedTab && showArchivedBanner && latestBackup && (
<div className="relative flex items-start gap-2 rounded-xl border border-[var(--border)] bg-[var(--muted)] px-4 py-3 pr-10 text-[13px]">
<Archive className="mt-0.5 h-4 w-4 shrink-0 text-[var(--muted-foreground)]" />
<div>
<p className="font-medium text-[var(--foreground)]">
{t("Your v1 memory was archived")}
</p>
<p className="mt-0.5 text-[var(--muted-foreground)]">
{t(
"Stored at memory/backup/{{name}}. v2 starts fresh — interact with DeepTutor and click Update on each doc to build memory.",
{ name: latestBackup },
)}
</p>
</div>
<button
type="button"
onClick={dismissArchivedBanner}
aria-label={t("Dismiss")}
className="absolute right-2 top-2 rounded-md p-1.5 text-[var(--muted-foreground)] transition hover:bg-[var(--background)] hover:text-[var(--foreground)]"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{!forcedTab && (
<TabStrip
tab={tab}
onChange={setTab}
l2Count={l2Rows.length}
l3Count={l3Rows.length}
t={t}
/>
)}
{tab === "L1" && (
<L1View
surface={l1Surface}
onSurfaceChange={(s) => {
setL1Surface(s);
setL1FocusRef(null);
}}
focusRef={l1FocusRef}
onClearFocus={() => setL1FocusRef(null)}
onToast={setToast}
t={t}
/>
)}
{tab !== "L1" && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px,1fr]">
<div className="space-y-4">
<DocList
title={t(
tab === "L2" ? "L2 · Per-surface" : "L3 · Cross-surface",
)}
rows={tab === "L2" ? l2Rows : l3Rows}
selected={selected}
onSelect={loadDoc}
/>
</div>
<div className="space-y-4">
{loading ? (
<div className="flex min-h-[300px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-[var(--muted-foreground)]" />
</div>
) : !selected || selected.layer !== tab ? (
<div className="flex min-h-[300px] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border)] text-center">
<Brain className="mb-3 h-5 w-5 text-[var(--muted-foreground)]" />
<p className="text-[14px] text-[var(--foreground)]">
{t("Pick a document to view or update")}
</p>
</div>
) : (
<DocPane
selected={selected}
content={content}
editing={editing}
editorValue={editorValue}
busy={busy}
onEditValue={setEditorValue}
onEditToggle={() => {
setEditing((v) => !v);
setEditorValue(content);
}}
onSave={saveDoc}
onUpdate={runUpdate}
onEntityLinkClick={handleEntityLinkClick}
t={t}
/>
)}
{stream.length > 0 && (
<StreamPanel
stages={stream}
onDismiss={() => setStream([])}
t={t}
/>
)}
</div>
</div>
)}
</div>
);
}
// ── TabStrip ────────────────────────────────────────────────────────
interface TabStripProps {
tab: Tab;
onChange: (tab: Tab) => void;
l2Count: number;
l3Count: number;
t: (key: string, opts?: Record<string, unknown>) => 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 (
<div className="border-b border-[var(--border)]">
<div className="flex gap-1">
{tabs.map(({ key, label, count, hint }) => {
const active = tab === key;
return (
<button
key={key}
onClick={() => onChange(key)}
title={hint}
className={`relative px-4 py-2 text-[13px] font-medium transition-colors ${
active
? "text-[var(--foreground)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{label}
{typeof count === "number" && (
<span className="ml-2 rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-normal text-[var(--muted-foreground)]">
{count}
</span>
)}
{active && (
<span
aria-hidden
className="absolute -bottom-px left-0 right-0 h-[2px] bg-[var(--foreground)]"
/>
)}
</button>
);
})}
</div>
</div>
);
}
// ── 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, unknown>) => 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<L1Mode>("snapshot");
const [snapshot, setSnapshot] = useState<SnapshotResponse | null>(null);
const [changes, setChanges] = useState<ChangeEntryDTO[]>([]);
const [kbQueries, setKbQueries] = useState<KbQueryDTO[]>([]);
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
const [loadingChanges, setLoadingChanges] = useState(false);
const [loadingQueries, setLoadingQueries] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const containerRef = useRef<HTMLDivElement>(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<string, ChangeEntryDTO["kind"]>();
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 (
<div className="space-y-3" ref={containerRef}>
{!compact && (
<div className="flex flex-wrap items-center gap-2">
{SURFACES.map((s) => {
const meta = SURFACE_META[s];
return (
<SurfacePill
key={s}
active={surface === s}
onClick={() => onSurfaceChange(s)}
icon={meta.icon}
label={meta.label}
/>
);
})}
<div className="ml-auto flex items-center gap-2">
{pendingCount > 0 && (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-300"
title={t(
"Workspace changed since last refresh. Click Refresh to commit these to the changes log.",
)}
>
{t("{{n}} pending", { n: pendingCount })}
</span>
)}
<button
onClick={() => void onRefresh()}
disabled={refreshing}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--card)] px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
title={t("Re-scan workspace and record any changes")}
>
{refreshing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{t("Refresh")}
</button>
</div>
</div>
)}
<div className="flex items-center justify-between gap-2">
<ModeStrip mode={mode} setMode={setMode} surface={surface} t={t} />
{compact && (
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-300"
title={t(
"Workspace changed since last refresh. Click Refresh to commit these to the changes log.",
)}
>
{t("{{n}} pending", { n: pendingCount })}
</span>
)}
<button
onClick={() => void onRefresh()}
disabled={refreshing}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--card)] px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
title={t("Re-scan workspace and record any changes")}
>
{refreshing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{t("Refresh")}
</button>
</div>
)}
</div>
{mode === "snapshot" && (
<SnapshotList
surface={surface}
loading={loadingSnapshot}
snapshot={snapshot}
pendingByEntity={pendingByEntity}
focusRef={focusRef}
onClearFocus={onClearFocus}
t={t}
/>
)}
{mode === "changes" && (
<ChangesList
loading={loadingChanges}
changes={changes}
pending={snapshot?.pending_changes ?? []}
t={t}
/>
)}
{mode === "queries" && surface === "kb" && (
<KbQueriesList loading={loadingQueries} queries={kbQueries} t={t} />
)}
</div>
);
}
// ── SurfacePill ─────────────────────────────────────────────────────
interface SurfacePillProps {
active: boolean;
onClick: () => void;
icon: LucideIcon;
label: string;
}
function SurfacePill({ active, onClick, icon: Icon, label }: SurfacePillProps) {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] transition-colors ${
active
? "border-[var(--primary)]/40 bg-[var(--primary)]/10 text-[var(--primary)]"
: "border-[var(--border)] text-[var(--muted-foreground)] hover:bg-[var(--muted)]/50 hover:text-[var(--foreground)]"
}`}
>
<Icon className="h-3 w-3" />
<span>{label}</span>
</button>
);
}
// ── ModeStrip (snapshot / changes / [kb queries]) ───────────────────
interface ModeStripProps {
mode: L1Mode;
setMode: (m: L1Mode) => void;
surface: Surface;
t: (key: string, opts?: Record<string, unknown>) => 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 (
<div className="inline-flex items-center gap-0.5 rounded-md border border-[var(--border)] bg-[var(--card)] p-0.5 text-[12px]">
{tabs
.filter((x) => !x.hidden)
.map(({ key, label }) => {
const active = mode === key;
return (
<button
key={key}
onClick={() => setMode(key)}
className={`rounded px-2.5 py-1 transition-colors ${
active
? "bg-[var(--muted)] text-[var(--foreground)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{label}
</button>
);
})}
</div>
);
}
// ── SnapshotList ─────────────────────────────────────────────────────
interface SnapshotListProps {
surface: Surface;
loading: boolean;
snapshot: SnapshotResponse | null;
pendingByEntity: Map<string, ChangeEntryDTO["kind"]>;
focusRef: string | null;
onClearFocus: () => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}
function SnapshotList({
surface,
loading,
snapshot,
pendingByEntity,
focusRef,
onClearFocus,
t,
}: SnapshotListProps) {
const entities = snapshot?.entities ?? [];
return (
<>
<div className="flex items-baseline justify-between text-[11.5px] text-[var(--muted-foreground)]">
<span>
{t("{{n}} entities", { n: entities.length })}
{snapshot?.last_refresh && (
<>
{" · "}
{t("last refresh {{ts}}", {
ts: formatTimestamp(snapshot.last_refresh, ""),
})}
</>
)}
</span>
{focusRef && (
<button
onClick={onClearFocus}
className="text-[var(--primary)] hover:underline"
>
{t("Clear focus")}
</button>
)}
</div>
{loading && entities.length === 0 ? (
<div className="flex items-center justify-center rounded-xl border border-[var(--border)] py-12">
<Loader2 className="h-4 w-4 animate-spin text-[var(--muted-foreground)]" />
</div>
) : entities.length === 0 ? (
<p className="rounded-xl border border-[var(--border)] px-4 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
{t("Nothing in workspace yet.")}
</p>
) : (
<ol className="rounded-xl border border-[var(--border)]">
{entities.map((ent, idx) => (
<EntityRow
key={`${ent.id}#${idx}`}
surface={surface}
ent={ent}
focused={focusRef === `${surface}:${ent.id}`}
pendingKind={pendingByEntity.get(ent.id) ?? null}
t={t}
/>
))}
</ol>
)}
</>
);
}
// ── EntityRow ───────────────────────────────────────────────────────
interface EntityRowProps {
surface: Surface;
ent: Entity;
focused: boolean;
pendingKind: ChangeEntryDTO["kind"] | null;
t: (key: string, opts?: Record<string, unknown>) => 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 = (
<>
<span
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded ${
focused
? "bg-[var(--primary)]/25 text-[var(--primary)]"
: "bg-[var(--muted)] text-[var(--muted-foreground)]"
}`}
>
<Icon className="h-3 w-3" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 text-[11px] text-[var(--muted-foreground)]">
<span className="truncate text-[13px] font-medium text-[var(--foreground)]">
{ent.label}
</span>
<span className="font-mono opacity-70">{ent.id}</span>
{ent.ts && <span>{formatTimestamp(ent.ts, "")}</span>}
{pendingKind && <PendingBadge kind={pendingKind} t={t} />}
</div>
{preview && (
<p className="mt-1 line-clamp-2 text-[12px] text-[var(--muted-foreground)]/90">
{preview}
</p>
)}
</div>
{url && (
<ExternalLink className="mt-1 h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)] group-hover:text-[var(--primary)]" />
)}
</>
);
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 (
<li
id={entityAnchorId(ref)}
data-entity-ref={ref}
title={t("Open in {{label}}", { label: meta.label })}
>
{url ? (
<Link href={url} className={rowClass}>
{inner}
</Link>
) : (
<div className={rowClass}>{inner}</div>
)}
</li>
);
}
// ── PendingBadge (row-level pending marker) ─────────────────────────
function PendingBadge({
kind,
t,
}: {
kind: ChangeEntryDTO["kind"];
t: (key: string, opts?: Record<string, unknown>) => 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 (
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0 text-[10px] font-medium ${cfg.cls}`}
title={t("Pending — not yet committed to changes log")}
>
{cfg.label}
</span>
);
}
// ── ChangesList (git-log style) ─────────────────────────────────────
interface ChangesListProps {
loading: boolean;
changes: ChangeEntryDTO[];
pending: ChangeEntryDTO[];
t: (key: string, opts?: Record<string, unknown>) => string;
}
function ChangesList({ loading, changes, pending, t }: ChangesListProps) {
if (loading && changes.length === 0 && pending.length === 0) {
return (
<div className="flex items-center justify-center rounded-xl border border-[var(--border)] py-12">
<Loader2 className="h-4 w-4 animate-spin text-[var(--muted-foreground)]" />
</div>
);
}
const hasAny = changes.length > 0 || pending.length > 0;
if (!hasAny) {
return (
<p className="rounded-xl border border-[var(--border)] px-4 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
{t("No changes recorded yet. Run Refresh to capture the baseline.")}
</p>
);
}
return (
<div className="space-y-2">
{pending.length > 0 && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/5">
<div className="flex items-center justify-between border-b border-amber-500/30 px-4 py-1.5 text-[11px] font-medium text-amber-700 dark:text-amber-300">
<span>
{t("Pending — {{n}} change(s) since last refresh", {
n: pending.length,
})}
</span>
<span className="opacity-70">{t("Click Refresh to commit")}</span>
</div>
<ol>
{pending.map((c, i) => (
<ChangeRow key={`pending-${c.entity_id}-${i}`} c={c} />
))}
</ol>
</div>
)}
{changes.length > 0 && (
<ol className="rounded-xl border border-[var(--border)]">
{changes.map((c, i) => (
<ChangeRow key={`${c.ts}-${c.entity_id}-${i}`} c={c} />
))}
</ol>
)}
</div>
);
}
function ChangeRow({ c }: { c: ChangeEntryDTO }) {
return (
<li className="flex items-start gap-3 border-b border-[var(--border)]/50 px-4 py-2 last:border-0">
<ChangeGlyph kind={c.kind} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 text-[11px] text-[var(--muted-foreground)]">
<span className="font-medium text-[var(--foreground)]">
{c.label || c.entity_id}
</span>
<span className="font-mono opacity-70">{c.entity_id}</span>
<span>{formatTimestamp(c.ts, "")}</span>
</div>
</div>
</li>
);
}
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 (
<span
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded font-mono text-[12px] font-bold ${cfg.bg} ${cfg.fg}`}
>
{cfg.ch}
</span>
);
}
// ── KbQueriesList (event-driven, only for KB) ───────────────────────
interface KbQueriesListProps {
loading: boolean;
queries: KbQueryDTO[];
t: (key: string, opts?: Record<string, unknown>) => string;
}
function KbQueriesList({ loading, queries, t }: KbQueriesListProps) {
if (loading && queries.length === 0) {
return (
<div className="flex items-center justify-center rounded-xl border border-[var(--border)] py-12">
<Loader2 className="h-4 w-4 animate-spin text-[var(--muted-foreground)]" />
</div>
);
}
if (queries.length === 0) {
return (
<p className="rounded-xl border border-[var(--border)] px-4 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
{t("No RAG queries recorded yet.")}
</p>
);
}
return (
<ol className="rounded-xl border border-[var(--border)]">
{queries.map((q) => {
const kb = asString(q.payload?.kb_name) || "?";
const query = asString(q.payload?.query);
return (
<li
key={q.id}
className="flex items-start gap-3 border-b border-[var(--border)]/50 px-4 py-2 last:border-0"
>
<GitCommit className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)]" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 text-[11px] text-[var(--muted-foreground)]">
<span className="font-medium text-[var(--foreground)]">
{kb}
</span>
<span>{formatTimestamp(q.ts, "")}</span>
</div>
<p className="mt-0.5 truncate text-[12.5px] text-[var(--foreground)]">
{query}
</p>
</div>
</li>
);
})}
</ol>
);
}
// ── 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 (
<div className="rounded-xl border border-[var(--border)]">
<div className="border-b border-[var(--border)] px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">
{title}
</div>
<ul>
{rows.map((row) => {
const isActive =
selected?.layer === row.layer && selected.key === row.key;
return (
<li
key={`${row.layer}-${row.key}`}
className={`flex items-center justify-between border-b border-[var(--border)]/50 px-4 py-2 text-[13px] last:border-0 ${
isActive ? "bg-[var(--muted)]" : "hover:bg-[var(--muted)]/50"
}`}
>
<button
onClick={() => onSelect(row.layer, row.key)}
className="flex-1 text-left"
>
<span className="font-medium text-[var(--foreground)]">
{labelFor(row)}
</span>
<span className="ml-2 text-[11px] text-[var(--muted-foreground)]">
{row.entry_count} ·{" "}
{formatTimestamp(row.updated_at, t("not built"))}
</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
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<HTMLDivElement>) => void;
t: (key: string, opts?: Record<string, unknown>) => 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 (
<div className="rounded-xl border border-[var(--border)]">
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-2">
<span className="text-[14px] font-medium text-[var(--foreground)]">
{selected.layer} ·{" "}
{labelFor({
layer: selected.layer,
key: selected.key,
} as DocOverview)}
</span>
<div className="flex items-center gap-2">
<button
onClick={onEditToggle}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Pencil className="h-3 w-3" />
{editing ? t("Cancel") : t("Edit")}
</button>
{editing && (
<button
onClick={onSave}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--primary)]/40 bg-[var(--primary)]/10 px-2.5 py-1 text-[12px] font-medium text-[var(--primary)] disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
{t("Save")}
</button>
)}
{!isPrefs && (
<button
onClick={onUpdate}
disabled={busy || editing}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--primary)]/40 bg-[var(--primary)]/10 px-2.5 py-1 text-[12px] font-medium text-[var(--primary)] disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{t("Update")}
</button>
)}
</div>
</div>
<div className="px-5 py-4">
{editing ? (
<textarea
value={editorValue}
onChange={(e) => onEditValue(e.target.value)}
spellCheck={false}
className="min-h-[420px] w-full resize-none rounded-lg border border-[var(--border)] bg-transparent p-3 font-mono text-[13px] leading-6 outline-none focus:border-[var(--ring)]"
/>
) : content.trim() ? (
<div onClick={onEntityLinkClick}>
<MarkdownRenderer
content={renderedContent}
variant="prose"
className="text-[14px]"
/>
</div>
) : (
<p className="text-[13px] text-[var(--muted-foreground)]">
{isPrefs
? t(
"Preferences are written when you explicitly tell the chat assistant your preferences (style, language, format).",
)
: t(
"Empty. Click Update to consolidate from the current snapshot.",
)}
</p>
)}
</div>
</div>
);
}
interface StreamPanelProps {
stages: StreamStage[];
onDismiss: () => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}
function StreamPanel({ stages, onDismiss, t }: StreamPanelProps) {
return (
<div className="rounded-xl border border-[var(--border)]">
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-2">
<span className="text-[12px] font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Update progress")}
</span>
<button
onClick={onDismiss}
className="rounded p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
>
<X className="h-3 w-3" />
</button>
</div>
<ol className="space-y-2 px-4 py-3 text-[12px]">
{stages.map((s, i) => (
<li
key={i}
className="rounded bg-[var(--muted)]/50 px-3 py-2 font-mono"
>
<span className="font-semibold text-[var(--foreground)]">
{s.stage}
{typeof s.turn === "number" ? ` · t${s.turn}` : ""}
{s.name ? ` · ${s.name}` : ""}
</span>
{typeof s.count === "number" && (
<span className="ml-2 text-[var(--muted-foreground)]">
count={s.count}
</span>
)}
{s.delta && (
<div className="mt-1 whitespace-pre-wrap text-[var(--muted-foreground)]">
{s.delta}
</div>
)}
{s.brief && (
<div className="mt-1 text-[var(--muted-foreground)]">
{s.brief}
</div>
)}
{s.args && Object.keys(s.args).length > 0 && (
<div className="mt-1 text-[var(--muted-foreground)]">
args: {JSON.stringify(s.args)}
</div>
)}
{s.ops && (
<div className="mt-1 text-[var(--muted-foreground)]">
ops: {s.ops.length}
</div>
)}
{typeof s.ops_emitted === "number" && (
<div className="mt-1 text-[var(--muted-foreground)]">
ops_emitted={s.ops_emitted} · turns={s.turns_used ?? "?"}
{s.tools_used
? ` · ${Object.entries(s.tools_used)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}`
: ""}
</div>
)}
{s.report && (
<div className="mt-1 text-[var(--muted-foreground)]">
accepted={String(s.report.accepted)}
{s.report.reason ? ` · ${s.report.reason}` : ""}
</div>
)}
{s.message && (
<div className="mt-1 text-[var(--muted-foreground)]">
{s.message}
</div>
)}
{s.summary && (
<div className="mt-1 text-[var(--muted-foreground)]">
{s.summary}
</div>
)}
</li>
))}
</ol>
</div>
);
}