From b3204bece90c7c2af38c6f32036f906946bebac9 Mon Sep 17 00:00:00 2001 From: jlightner Date: Mon, 30 Mar 2026 03:02:31 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Version=20switcher=20on=20technique=20p?= =?UTF-8?q?ages=20=E2=80=94=20view=20historical=20snapshots=20with=20pipel?= =?UTF-8?q?ine=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version dropdown appears when version_count > 0 (hidden until first re-run) - Selecting a historical version overlays snapshot content (title, summary, body, chains, plugins) - Key moments and related links always show live data (not versioned) - Pipeline metadata block shows model, capture time, and prompt file hashes (truncated) - Cyan banner when viewing historical version with "Back to current" button - fetchTechniqueVersion API function for single version detail --- frontend/src/App.css | 129 ++++++++++++ frontend/src/api/public-client.ts | 16 ++ frontend/src/pages/TechniquePage.tsx | 282 ++++++++++++++++++++++----- 3 files changed, 379 insertions(+), 48 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 28925c8..2b64b2b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2261,3 +2261,132 @@ body { background: var(--color-bg-input); color: var(--color-text-muted); } + +/* ── Version Switcher ───────────────────────────────────────────────────── */ + +.technique-header__actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.version-switcher { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.version-switcher__label { + color: var(--color-text-muted); + font-size: 0.8rem; +} + +.version-switcher__select { + background: var(--color-bg-input); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.3rem 0.5rem; + font-size: 0.8rem; + cursor: pointer; +} + +.version-switcher__select:focus { + outline: none; + border-color: var(--color-accent); +} + +.version-switcher__loading { + color: var(--color-text-muted); + font-size: 0.75rem; + font-style: italic; +} + +.technique-banner--version { + background: var(--color-accent-subtle); + border: 1px solid var(--color-accent); + border-radius: 8px; + padding: 0.6rem 1rem; + color: var(--color-accent); + font-size: 0.85rem; + display: flex; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +/* ── Version Metadata ───────────────────────────────────────────────────── */ + +.version-metadata { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; +} + +.version-metadata__title { + color: var(--color-text-secondary); + font-size: 0.8rem; + font-weight: 600; + margin: 0 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.version-metadata__grid { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.version-metadata__item { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.version-metadata__item--wide { + flex-basis: 100%; +} + +.version-metadata__key { + color: var(--color-text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.version-metadata__value { + color: var(--color-text-primary); + font-size: 0.85rem; +} + +.version-metadata__hashes { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-top: 0.15rem; +} + +.version-metadata__hash { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.version-metadata__hash-file { + color: var(--color-text-secondary); +} + +.version-metadata__hash-value { + font-family: "SF Mono", "Fira Code", monospace; + color: var(--color-text-muted); + font-size: 0.75rem; + background: var(--color-bg-input); + padding: 0.1rem 0.35rem; + border-radius: 3px; +} diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index d7acce0..7683819 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -82,6 +82,13 @@ export interface TechniquePageVersionListResponse { total: number; } +export interface TechniquePageVersionDetail { + version_number: number; + content_snapshot: Record; + pipeline_metadata: Record | null; + created_at: string; +} + export interface TechniqueListItem { id: string; title: string; @@ -238,6 +245,15 @@ export async function fetchTechniqueVersions( ); } +export async function fetchTechniqueVersion( + slug: string, + versionNumber: number, +): Promise { + return request( + `${BASE}/techniques/${slug}/versions/${versionNumber}`, + ); +} + // ── Topics ─────────────────────────────────────────────────────────────────── export async function fetchTopics(): Promise { diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx index 9b8f80b..43bfe45 100644 --- a/frontend/src/pages/TechniquePage.tsx +++ b/frontend/src/pages/TechniquePage.tsx @@ -1,22 +1,20 @@ /** - * Technique page detail view. + * Technique page detail view with version switching. * - * Fetches a single technique by slug. Renders: - * - Header with title, category badge, tags, creator link, source quality - * - Amber banner for unstructured (livestream-sourced) content - * - Study guide prose from body_sections JSONB - * - Key moments index - * - Signal chains (if present) - * - Plugins referenced (if present) - * - Related techniques (if present) - * - Loading and 404 states + * Fetches a single technique by slug. When historical versions exist, + * shows a version switcher that lets admins view previous snapshots + * with pipeline metadata (prompt hashes, model config). */ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { fetchTechnique, + fetchTechniqueVersions, + fetchTechniqueVersion, type TechniquePageDetail as TechniqueDetail, + type TechniquePageVersionSummary, + type TechniquePageVersionDetail, } from "../api/public-client"; import ReportIssueModal from "../components/ReportIssueModal"; @@ -26,6 +24,45 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, "0")}`; } +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** Extract the displayable fields from a version snapshot, matching TechniqueDetail shape. */ +function snapshotToOverlay(snapshot: Record) { + return { + title: typeof snapshot.title === "string" ? snapshot.title : undefined, + summary: typeof snapshot.summary === "string" ? snapshot.summary : undefined, + topic_category: + typeof snapshot.topic_category === "string" + ? snapshot.topic_category + : undefined, + topic_tags: Array.isArray(snapshot.topic_tags) + ? (snapshot.topic_tags as string[]) + : undefined, + body_sections: + typeof snapshot.body_sections === "object" && snapshot.body_sections !== null + ? (snapshot.body_sections as Record) + : undefined, + signal_chains: Array.isArray(snapshot.signal_chains) + ? (snapshot.signal_chains as unknown[]) + : undefined, + plugins: Array.isArray(snapshot.plugins) + ? (snapshot.plugins as string[]) + : undefined, + source_quality: + typeof snapshot.source_quality === "string" + ? snapshot.source_quality + : undefined, + }; +} + export default function TechniquePage() { const { slug } = useParams<{ slug: string }>(); const [technique, setTechnique] = useState(null); @@ -34,6 +71,14 @@ export default function TechniquePage() { const [error, setError] = useState(null); const [showReport, setShowReport] = useState(false); + // Version switching + const [versions, setVersions] = useState([]); + const [selectedVersion, setSelectedVersion] = useState("current"); + const [versionDetail, setVersionDetail] = + useState(null); + const [versionLoading, setVersionLoading] = useState(false); + + // Load technique + version list useEffect(() => { if (!slug) return; @@ -41,17 +86,28 @@ export default function TechniquePage() { setLoading(true); setNotFound(false); setError(null); + setSelectedVersion("current"); + setVersionDetail(null); + setVersions([]); void (async () => { try { const data = await fetchTechnique(slug); - if (!cancelled) setTechnique(data); + if (!cancelled) { + setTechnique(data); + // Load versions if any exist + if (data.version_count > 0) { + try { + const vRes = await fetchTechniqueVersions(slug); + if (!cancelled) setVersions(vRes.items); + } catch { + // Non-critical — version list fails silently + } + } + } } catch (err) { if (!cancelled) { - if ( - err instanceof Error && - err.message.includes("404") - ) { + if (err instanceof Error && err.message.includes("404")) { setNotFound(true); } else { setError( @@ -69,6 +125,35 @@ export default function TechniquePage() { }; }, [slug]); + // Load version detail when selection changes + useEffect(() => { + if (!slug || selectedVersion === "current") { + setVersionDetail(null); + return; + } + + let cancelled = false; + setVersionLoading(true); + + void (async () => { + try { + const detail = await fetchTechniqueVersion( + slug, + Number(selectedVersion), + ); + if (!cancelled) setVersionDetail(detail); + } catch { + if (!cancelled) setVersionDetail(null); + } finally { + if (!cancelled) setVersionLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [slug, selectedVersion]); + if (loading) { return
Loading technique…
; } @@ -77,7 +162,7 @@ export default function TechniquePage() { return (

Technique Not Found

-

The technique "{slug}" doesn't exist.

+

The technique “{slug}” doesn’t exist.

Back to Home @@ -93,6 +178,21 @@ export default function TechniquePage() { ); } + // Overlay snapshot fields when viewing a historical version + const isHistorical = selectedVersion !== "current" && versionDetail != null; + const overlay = isHistorical + ? snapshotToOverlay(versionDetail.content_snapshot) + : null; + + const displayTitle = overlay?.title ?? technique.title; + const displaySummary = overlay?.summary ?? technique.summary; + const displayCategory = overlay?.topic_category ?? technique.topic_category; + const displayTags = overlay?.topic_tags ?? technique.topic_tags; + const displaySections = overlay?.body_sections ?? technique.body_sections; + const displayChains = overlay?.signal_chains ?? technique.signal_chains; + const displayPlugins = overlay?.plugins ?? technique.plugins; + const displayQuality = overlay?.source_quality ?? technique.source_quality; + return (
{/* Back link */} @@ -100,8 +200,23 @@ export default function TechniquePage() { ← Back + {/* Historical version banner */} + {isHistorical && ( +
+ 📋 Viewing version {versionDetail.version_number} from{" "} + {formatDate(versionDetail.created_at)} + +
+ )} + {/* Unstructured content warning */} - {technique.source_quality === "unstructured" && ( + {displayQuality === "unstructured" && (
⚠ This technique was sourced from a livestream and may have less structured content. @@ -110,14 +225,12 @@ export default function TechniquePage() { {/* Header */}
-

{technique.title}

+

{displayTitle}

- - {technique.topic_category} - - {technique.topic_tags && technique.topic_tags.length > 0 && ( + {displayCategory} + {displayTags && displayTags.length > 0 && ( - {technique.topic_tags.map((tag) => ( + {displayTags.map((tag) => ( {tag} @@ -132,14 +245,15 @@ export default function TechniquePage() { by {technique.creator_info.name} )} - {technique.source_quality && ( + {displayQuality && ( - {technique.source_quality} + {displayQuality} )}
+ {/* Meta stats line */}
{(() => { @@ -166,15 +280,87 @@ export default function TechniquePage() { return parts.join(" · "); })()}
- {/* Report issue button */} - + + {/* Version switcher + report button row */} +
+ {versions.length > 0 && ( +
+ + + {versionLoading && ( + Loading… + )} +
+ )} + +
+ {/* Pipeline metadata for historical versions */} + {isHistorical && versionDetail.pipeline_metadata && ( +
+

Pipeline metadata (v{versionDetail.version_number})

+
+ {"model" in versionDetail.pipeline_metadata && ( +
+ Model + + {String(versionDetail.pipeline_metadata.model)} + +
+ )} + {"captured_at" in versionDetail.pipeline_metadata && ( +
+ Captured + + {formatDate(String(versionDetail.pipeline_metadata.captured_at))} + +
+ )} + {typeof versionDetail.pipeline_metadata.prompt_hashes === "object" && + versionDetail.pipeline_metadata.prompt_hashes !== null && ( +
+ Prompt hashes +
+ {Object.entries( + versionDetail.pipeline_metadata.prompt_hashes as Record< + string, + string + >, + ).map(([file, hash]) => ( +
+ + {file.replace(/^.*\//, "")} + + + {hash.slice(0, 12)}… + +
+ ))} +
+
+ )} +
+
+ )} + {/* Report modal */} {showReport && ( -

{technique.summary}

+

{displaySummary}

)} {/* Study guide prose — body_sections */} - {technique.body_sections && - Object.keys(technique.body_sections).length > 0 && ( + {displaySections && + Object.keys(displaySections).length > 0 && (
- {Object.entries(technique.body_sections).map( - ([sectionTitle, content]) => ( + {Object.entries(displaySections).map( + ([sectionTitle, content]: [string, unknown]) => (

{sectionTitle}

{typeof content === "string" ? ( -

{content}

+

{content as string}

) : typeof content === "object" && content !== null ? (
                       {JSON.stringify(content, null, 2)}
                     
) : ( -

{String(content)}

+

{String(content as string)}

)}
), @@ -215,7 +401,7 @@ export default function TechniquePage() {
)} - {/* Key moments */} + {/* Key moments (always from live data — not versioned) */} {technique.key_moments.length > 0 && (

Key Moments

@@ -244,11 +430,11 @@ export default function TechniquePage() { )} {/* Signal chains */} - {technique.signal_chains && - technique.signal_chains.length > 0 && ( + {displayChains && + displayChains.length > 0 && (

Signal Chains

- {technique.signal_chains.map((chain, i) => { + {displayChains.map((chain, i) => { const chainObj = chain as Record; const chainName = typeof chainObj["name"] === "string" @@ -283,11 +469,11 @@ export default function TechniquePage() { )} {/* Plugins */} - {technique.plugins && technique.plugins.length > 0 && ( + {displayPlugins && displayPlugins.length > 0 && (

Plugins Referenced

- {technique.plugins.map((plugin) => ( + {displayPlugins.map((plugin) => ( {plugin} @@ -296,7 +482,7 @@ export default function TechniquePage() {
)} - {/* Related techniques */} + {/* Related techniques (always from live data) */} {technique.related_links.length > 0 && (

Related Techniques