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
640 lines
22 KiB
TypeScript
640 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ArrowLeft,
|
|
BookOpen,
|
|
Bot,
|
|
ClipboardList,
|
|
FileText,
|
|
Hash,
|
|
Layers,
|
|
Library,
|
|
Loader2,
|
|
MessageSquare,
|
|
Network,
|
|
NotebookPen,
|
|
PenLine,
|
|
Pencil,
|
|
Save,
|
|
Workflow,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { apiFetch, apiUrl } from "@/lib/api";
|
|
import MemoryRunPanel from "@/components/memory/MemoryRunPanel";
|
|
|
|
const MarkdownRenderer = dynamic(
|
|
() => import("@/components/common/MarkdownRenderer"),
|
|
{ ssr: false },
|
|
);
|
|
|
|
type Layer = "L2" | "L3";
|
|
|
|
// Each bullet ends with an HTML comment carrying the entry id
|
|
// (``<!--m_01HZK...-->``). The parser round-trips it but the rendered
|
|
// view should not show it as text — and we want clicking a same-doc
|
|
// m_xxx footnote to scroll the user back to the citing bullet. Convert
|
|
// the comment to an inline span with an ``id`` so:
|
|
// * the rendered output is visually empty (zero-width span)
|
|
// * ``#m_xxx`` hash navigation works within the doc
|
|
// Requires ``allowHtml`` on the markdown renderer (we set it below).
|
|
const _ENTRY_ANCHOR_RE = /\s*<!--\s*(m_[0-9A-HJKMNP-TV-Z]{26})\s*-->/g;
|
|
|
|
// Rewrite footnote definitions so the ref text becomes a markdown link.
|
|
//
|
|
// Four link targets, picked by (ref shape, current layer):
|
|
// * ``surface:id`` → ``/memory/l1?ref=surface:id`` (L2 → L1 hop)
|
|
// * ``m_<ULID>`` in L2 → ``#m_<ULID>`` (same-doc scroll; anchor
|
|
// span above provides the target)
|
|
// * ``m_<ULID>`` in L3 → ``/memory/resolve?id=m_<ULID>``
|
|
// (legacy pre-pivot doc; resolver page redirects to the right L2)
|
|
// * bare ``<surface>`` → ``/memory/l2/<surface>`` (L3 → L2 hop;
|
|
// new design, L3 cites L2 files not L2 entries)
|
|
//
|
|
// The label regex is intentionally loose so this hits BOTH layouts —
|
|
// the new consolidated ``[^1]:`` and the legacy entry-keyed
|
|
// ``[^m_xxx]:`` — so docs that pre-date the merge step still get
|
|
// clickable footnotes until the next mode pass migrates them.
|
|
const _FOOTNOTE_DEF_LINKIFY_SURFACE_REF_RE =
|
|
/^(\[\^[^\]]+\]:\s*)([a-z][a-z0-9_-]*):([A-Za-z0-9_-]+)\s*$/gm;
|
|
const _FOOTNOTE_DEF_LINKIFY_ENTRY_RE =
|
|
/^(\[\^[^\]]+\]:\s*)(m_[0-9A-HJKMNP-TV-Z]{26})\s*$/gm;
|
|
// Bare surface name — keep tight (whitelist) so a typo can't accidentally
|
|
// turn into an L2 hub link.
|
|
const _L3_SURFACES = new Set([
|
|
"chat",
|
|
"notebook",
|
|
"quiz",
|
|
"kb",
|
|
"book",
|
|
"partner",
|
|
"cowriter",
|
|
]);
|
|
const _FOOTNOTE_DEF_LINKIFY_BARE_RE =
|
|
/^(\[\^[^\]]+\]:\s*)([a-z][a-z0-9_-]*)\s*$/gm;
|
|
|
|
function prepareDocForRender(md: string, layer: Layer): string {
|
|
// Anchor injection MUST come before linkify so that ``m_xxx`` text on
|
|
// a footnote-definition line is matched as a ref, not chewed up by the
|
|
// anchor regex (which only matches inside HTML comments).
|
|
const withAnchors = md.replace(
|
|
_ENTRY_ANCHOR_RE,
|
|
' <span id="$1" class="memory-entry-anchor"></span>',
|
|
);
|
|
// ``surface:id`` must be tried before bare-surface because the bare
|
|
// regex would otherwise match the surface prefix and leave ``:id``
|
|
// dangling.
|
|
const withSurfaceLinks = withAnchors.replace(
|
|
_FOOTNOTE_DEF_LINKIFY_SURFACE_REF_RE,
|
|
(_match, prefix: string, surface: string, entityId: string) => {
|
|
const ref = `${surface}:${entityId}`;
|
|
const url = `/memory/l1?ref=${encodeURIComponent(ref)}`;
|
|
return `${prefix}[${ref}](${url})`;
|
|
},
|
|
);
|
|
const withEntryLinks = withSurfaceLinks.replace(
|
|
_FOOTNOTE_DEF_LINKIFY_ENTRY_RE,
|
|
(_match, prefix: string, entryId: string) => {
|
|
// L2: entry id refers to a bullet in *this* doc → local anchor.
|
|
// L3: entry id refers to a bullet in some L2 doc → resolver page
|
|
// does the surface lookup, then redirects.
|
|
const href =
|
|
layer === "L2"
|
|
? `#${entryId}`
|
|
: `/memory/resolve?id=${encodeURIComponent(entryId)}`;
|
|
return `${prefix}[${entryId}](${href})`;
|
|
},
|
|
);
|
|
// Bare surface name — only meaningful for L3 refs (new design).
|
|
// Whitelist guards against linkifying arbitrary words that happen to
|
|
// end up alone on a footnote definition line.
|
|
return withEntryLinks.replace(
|
|
_FOOTNOTE_DEF_LINKIFY_BARE_RE,
|
|
(match, prefix: string, name: string) => {
|
|
if (!_L3_SURFACES.has(name)) return match;
|
|
return `${prefix}[${name}](/memory/l2/${name})`;
|
|
},
|
|
);
|
|
}
|
|
|
|
interface DocResponse {
|
|
layer: Layer;
|
|
key: string;
|
|
content: string;
|
|
}
|
|
|
|
interface LineRowDTO {
|
|
number: number;
|
|
kind: "title" | "blank" | "section" | "bullet";
|
|
text: string;
|
|
entry_id: string | null;
|
|
section: string | null;
|
|
}
|
|
|
|
interface DocOverview {
|
|
layer: Layer;
|
|
key: string;
|
|
exists: boolean;
|
|
updated_at: string | null;
|
|
entry_count: number;
|
|
backlog: number;
|
|
}
|
|
|
|
interface NavEntry {
|
|
key: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
}
|
|
|
|
const L2_NAV: NavEntry[] = [
|
|
{ key: "chat", icon: MessageSquare, label: "Chat" },
|
|
{ key: "notebook", icon: NotebookPen, label: "Notebook" },
|
|
{ key: "quiz", icon: ClipboardList, label: "Quiz" },
|
|
{ key: "kb", icon: BookOpen, label: "Knowledge base" },
|
|
{ key: "book", icon: Library, label: "Book" },
|
|
{ key: "partner", icon: Bot, label: "Partner" },
|
|
{ key: "cowriter", icon: PenLine, label: "Co-writer" },
|
|
];
|
|
|
|
const L3_NAV: NavEntry[] = [
|
|
{ key: "recent", icon: Network, label: "Recent summary" },
|
|
{ key: "profile", icon: Network, label: "User profile" },
|
|
{ key: "scope", icon: Network, label: "Knowledge scope" },
|
|
];
|
|
|
|
type ViewMode = "plain" | "lines";
|
|
|
|
export interface MemoryWorkbenchProps {
|
|
layer: Layer;
|
|
initialKey?: string;
|
|
/**
|
|
* Entry id to scroll into view + briefly highlight after the markdown
|
|
* renders. Set by the deep-link contract (``?focus=m_xxx``) when the
|
|
* user lands here from an L3 footnote click.
|
|
*/
|
|
initialFocus?: string;
|
|
}
|
|
|
|
export default function MemoryWorkbench({
|
|
layer,
|
|
initialKey,
|
|
initialFocus,
|
|
}: MemoryWorkbenchProps) {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const nav = layer === "L2" ? L2_NAV : L3_NAV;
|
|
const [docKey, setDocKey] = useState<string>(initialKey || nav[0].key);
|
|
|
|
useEffect(() => {
|
|
if (initialKey) setDocKey(initialKey);
|
|
}, [initialKey]);
|
|
|
|
const [overview, setOverview] = useState<Record<string, DocOverview>>({});
|
|
const [content, setContent] = useState("");
|
|
const [lines, setLines] = useState<LineRowDTO[]>([]);
|
|
const [view, setView] = useState<ViewMode>("plain");
|
|
const [editing, setEditing] = useState(false);
|
|
const [editorValue, setEditorValue] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [toast, setToast] = useState("");
|
|
// Focus is one-shot — once we've scrolled-to + flashed the anchor we
|
|
// don't want subsequent content reloads (e.g. after a Run) to keep
|
|
// re-scrolling. ``initialFocus`` seeds it; the effect clears it.
|
|
const [pendingFocus, setPendingFocus] = useState<string | null>(
|
|
initialFocus || null,
|
|
);
|
|
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (initialFocus) setPendingFocus(initialFocus);
|
|
}, [initialFocus]);
|
|
|
|
const loadOverview = useCallback(async () => {
|
|
const res = await apiFetch(apiUrl("/api/v1/memory/overview"));
|
|
const data = await res.json();
|
|
const map: Record<string, DocOverview> = {};
|
|
for (const d of data.docs || []) {
|
|
if (d.layer === layer) map[d.key] = d;
|
|
}
|
|
setOverview(map);
|
|
}, [layer]);
|
|
|
|
const loadDoc = useCallback(async () => {
|
|
const res = await apiFetch(apiUrl(`/api/v1/memory/doc/${layer}/${docKey}`));
|
|
const data = (await res.json()) as DocResponse;
|
|
setContent(data?.content || "");
|
|
setEditorValue(data?.content || "");
|
|
}, [layer, docKey]);
|
|
|
|
const loadLines = useCallback(async () => {
|
|
const res = await apiFetch(
|
|
apiUrl(`/api/v1/memory/doc/${layer}/${docKey}/lines`),
|
|
);
|
|
const data = (await res.json()) as { lines: LineRowDTO[] };
|
|
setLines(data?.lines || []);
|
|
}, [layer, docKey]);
|
|
|
|
useEffect(() => {
|
|
void loadOverview();
|
|
}, [loadOverview]);
|
|
useEffect(() => {
|
|
void loadDoc();
|
|
void loadLines();
|
|
}, [loadDoc, loadLines]);
|
|
useEffect(() => {
|
|
if (!toast) return;
|
|
const id = setTimeout(() => setToast(""), 2200);
|
|
return () => clearTimeout(id);
|
|
}, [toast]);
|
|
|
|
// Scroll-to + flash the focused entry once the markdown is in the
|
|
// DOM. Markdown renders via a dynamic import → we can't fire on
|
|
// ``content`` change alone; wait a frame so the anchor span exists.
|
|
// ``editing`` mode hides the rendered preview, so skip then.
|
|
useEffect(() => {
|
|
if (!pendingFocus || editing) return;
|
|
if (!content || !previewRef.current) return;
|
|
const anchorId = pendingFocus.startsWith("m_")
|
|
? pendingFocus
|
|
: `m_${pendingFocus}`;
|
|
let cancelled = false;
|
|
const id = window.setTimeout(() => {
|
|
if (cancelled) return;
|
|
const span = document.getElementById(anchorId);
|
|
// Highlight the bullet, not the zero-width anchor span.
|
|
const li = (span?.closest("li") as HTMLElement | null) ?? span;
|
|
if (!li) return;
|
|
li.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
const prev = {
|
|
outline: li.style.outline,
|
|
outlineOffset: li.style.outlineOffset,
|
|
borderRadius: li.style.borderRadius,
|
|
transition: li.style.transition,
|
|
};
|
|
li.style.outline = "2px solid var(--primary)";
|
|
li.style.outlineOffset = "4px";
|
|
li.style.borderRadius = "6px";
|
|
li.style.transition = "outline-color 1.4s ease-out";
|
|
const clear = window.setTimeout(() => {
|
|
li.style.outline = prev.outline;
|
|
li.style.outlineOffset = prev.outlineOffset;
|
|
li.style.borderRadius = prev.borderRadius;
|
|
li.style.transition = prev.transition;
|
|
}, 1800);
|
|
// Consume the focus token so reloads don't re-trigger.
|
|
setPendingFocus(null);
|
|
return () => window.clearTimeout(clear);
|
|
}, 80);
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(id);
|
|
};
|
|
}, [pendingFocus, content, editing]);
|
|
|
|
const selectDoc = useCallback(
|
|
(next: string) => {
|
|
if (next === docKey) return;
|
|
setDocKey(next);
|
|
// Reflect selection in the URL so refresh + share keep state.
|
|
router.replace(
|
|
layer === "L2" ? `/memory/l2/${next}` : `/memory/l3/${next}`,
|
|
);
|
|
},
|
|
[docKey, layer, router],
|
|
);
|
|
|
|
const saveDoc = useCallback(async () => {
|
|
setSaving(true);
|
|
try {
|
|
await apiFetch(apiUrl(`/api/v1/memory/doc/${layer}/${docKey}`), {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content: editorValue }),
|
|
});
|
|
setContent(editorValue);
|
|
setEditing(false);
|
|
setToast(t("Saved"));
|
|
void loadLines();
|
|
} catch (e) {
|
|
setToast(e instanceof Error ? e.message : t("Save failed"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [editorValue, layer, docKey, t, loadLines]);
|
|
|
|
const nicelabel = useMemo(() => {
|
|
const entry = nav.find((n) => n.key === docKey);
|
|
return entry ? t(entry.label) : docKey;
|
|
}, [docKey, nav, t]);
|
|
|
|
const handleRunComplete = useCallback(() => {
|
|
void loadDoc();
|
|
void loadLines();
|
|
void loadOverview();
|
|
}, [loadDoc, loadLines, loadOverview]);
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col gap-3 px-6 py-4 md:px-10">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<Breadcrumb layer={layer} label={nicelabel} t={t} />
|
|
<LayerSwitcher current={layer} t={t} />
|
|
</div>
|
|
|
|
<div className="grid min-h-0 flex-1 grid-cols-[180px_minmax(0,1fr)_360px] gap-4">
|
|
{/* ── Left rail ── */}
|
|
<aside className="min-h-0 overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--card)] p-2">
|
|
<ul className="space-y-0.5">
|
|
{nav.map(({ key, icon: Icon, label }) => {
|
|
const doc = overview[key];
|
|
const active = key === docKey;
|
|
return (
|
|
<li key={key}>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectDoc(key)}
|
|
className={
|
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] text-left transition " +
|
|
(active
|
|
? "bg-[var(--muted)] text-[var(--foreground)]"
|
|
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]/60 hover:text-[var(--foreground)]")
|
|
}
|
|
>
|
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1 truncate">{t(label)}</span>
|
|
{doc?.exists && (
|
|
<span className="rounded-full bg-[var(--background)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
|
|
{doc.entry_count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</aside>
|
|
|
|
{/* ── Center: preview ── */}
|
|
<section className="flex min-h-0 flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<ViewSwitch view={view} setView={setView} t={t} />
|
|
{!editing ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[12px] transition hover:bg-[var(--muted)]"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
{t("Edit raw")}
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditorValue(content);
|
|
setEditing(false);
|
|
}}
|
|
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-1 text-[12px]"
|
|
>
|
|
{t("Cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveDoc()}
|
|
disabled={saving}
|
|
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--primary)] px-3 py-1 text-[12px] text-[var(--primary-foreground)] disabled:opacity-50"
|
|
>
|
|
{saving ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Save className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("Save")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
ref={previewRef}
|
|
className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5"
|
|
>
|
|
{editing ? (
|
|
<textarea
|
|
value={editorValue}
|
|
onChange={(e) => setEditorValue(e.target.value)}
|
|
className="h-[60vh] w-full resize-none rounded-md border border-[var(--border)] bg-[var(--background)] p-3 font-mono text-[12.5px] outline-none focus:border-[var(--primary)]"
|
|
/>
|
|
) : view === "lines" ? (
|
|
<LineNumberedView lines={lines} t={t} />
|
|
) : content.trim().length > 0 ? (
|
|
// The wrapper hides the default footnote backref arrow
|
|
// (``data-footnote-backref``). Without this the rendered
|
|
// doc has two clickable elements per footnote: the L1
|
|
// text link we inject (correct) and the ↩ icon
|
|
// remark-gfm auto-generates that jumps back to the
|
|
// citation marker (confusing — users expect every link
|
|
// in the footnote to go to L1).
|
|
<div className="memory-doc-content [&_.data-footnote-backref]:hidden">
|
|
<MarkdownRenderer
|
|
content={prepareDocForRender(content, layer)}
|
|
variant="prose"
|
|
className="text-[14px]"
|
|
allowHtml
|
|
/>
|
|
</div>
|
|
) : (
|
|
<EmptyState t={t} />
|
|
)}
|
|
</div>
|
|
{toast && (
|
|
<div className="self-start rounded-md border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-3 py-1 text-[11.5px] text-[var(--primary)]">
|
|
{toast}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Right: LLM work area ── */}
|
|
<aside className="min-h-0">
|
|
<MemoryRunPanel
|
|
layer={layer}
|
|
docKey={docKey}
|
|
onRunComplete={handleRunComplete}
|
|
onDocUpdated={handleRunComplete}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Sub-components ──────────────────────────────────────────────────
|
|
|
|
function Breadcrumb({
|
|
layer,
|
|
label,
|
|
t,
|
|
}: {
|
|
layer: Layer;
|
|
label: string;
|
|
t: (k: string) => string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-[12.5px]">
|
|
<Link
|
|
href="/memory"
|
|
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
{t("Memory")}
|
|
</Link>
|
|
<span className="text-[var(--muted-foreground)]">/</span>
|
|
<span className="inline-flex items-center gap-1.5 text-[var(--foreground)]">
|
|
{layer === "L2" ? (
|
|
<Workflow className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Network className="h-3.5 w-3.5" />
|
|
)}
|
|
{layer === "L2"
|
|
? t("L2 · Per-surface summaries")
|
|
: t("L3 · Cross-surface knowledge")}
|
|
</span>
|
|
<span className="text-[var(--muted-foreground)]">/</span>
|
|
<span className="text-[var(--foreground)]">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LayerSwitcher({
|
|
current,
|
|
t,
|
|
}: {
|
|
current: Layer;
|
|
t: (k: string) => string;
|
|
}) {
|
|
// L1 page is a single workbench (no per-key route); L2/L3 hubs land
|
|
// on /memory/l2 and /memory/l3 which list the surfaces / slots.
|
|
const entries: {
|
|
key: "L1" | Layer;
|
|
href: string;
|
|
icon: LucideIcon;
|
|
label: string;
|
|
}[] = [
|
|
{ key: "L1", href: "/memory/l1", icon: Layers, label: t("L1") },
|
|
{ key: "L2", href: "/memory/l2", icon: Workflow, label: t("L2") },
|
|
{ key: "L3", href: "/memory/l3", icon: Network, label: t("L3") },
|
|
];
|
|
return (
|
|
<nav className="flex items-center gap-0.5 rounded-full border border-[var(--border)] bg-[var(--card)] p-0.5">
|
|
{entries.map(({ key, href, icon: Icon, label }) => {
|
|
const active = key === current;
|
|
if (active) {
|
|
return (
|
|
<span
|
|
key={key}
|
|
className="inline-flex items-center gap-1 rounded-full bg-[var(--muted)] px-2.5 py-0.5 text-[11.5px] font-medium text-[var(--foreground)]"
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<Link
|
|
key={key}
|
|
href={href}
|
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]/60 hover:text-[var(--foreground)]"
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
{label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function ViewSwitch({
|
|
view,
|
|
setView,
|
|
t,
|
|
}: {
|
|
view: ViewMode;
|
|
setView: (v: ViewMode) => void;
|
|
t: (k: string) => string;
|
|
}) {
|
|
const items: { key: ViewMode; label: string; icon: LucideIcon }[] = [
|
|
{ key: "plain", label: t("Rendered"), icon: FileText },
|
|
{ key: "lines", label: t("Line numbers"), icon: Hash },
|
|
];
|
|
return (
|
|
<div className="inline-flex items-center gap-1 rounded-lg border border-[var(--border)] bg-[var(--background)] p-1">
|
|
{items.map(({ key, label, icon: Icon }) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setView(key)}
|
|
className={
|
|
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11.5px] transition " +
|
|
(view === key
|
|
? "bg-[var(--muted)] text-[var(--foreground)]"
|
|
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]/60")
|
|
}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LineNumberedView({
|
|
lines,
|
|
t,
|
|
}: {
|
|
lines: LineRowDTO[];
|
|
t: (k: string) => string;
|
|
}) {
|
|
if (lines.length === 0) return <EmptyState t={t} />;
|
|
const width = Math.max(2, String(lines[lines.length - 1].number).length);
|
|
return (
|
|
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed text-[var(--foreground)]">
|
|
{lines.map((line) => {
|
|
const num = String(line.number).padStart(width, " ");
|
|
const muted = line.kind === "blank" || line.kind === "title";
|
|
return (
|
|
<div
|
|
key={line.number}
|
|
className={
|
|
muted
|
|
? "text-[var(--muted-foreground)]"
|
|
: "text-[var(--foreground)]"
|
|
}
|
|
>
|
|
<span className="select-none pr-3 text-[var(--muted-foreground)]">
|
|
{num}:
|
|
</span>
|
|
{line.text || " "}
|
|
</div>
|
|
);
|
|
})}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ t }: { t: (k: string) => string }) {
|
|
return (
|
|
<div className="grid h-[300px] place-items-center text-center text-[13px] text-[var(--muted-foreground)]">
|
|
<div className="max-w-sm space-y-2">
|
|
<Workflow className="mx-auto h-6 w-6 opacity-60" />
|
|
<p>{t("Empty. Click Update to extract facts from your traces.")}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|