feat: Version switcher on technique pages — view historical snapshots with pipeline metadata
- 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
This commit is contained in:
parent
324e933670
commit
b3204bece9
3 changed files with 379 additions and 48 deletions
|
|
@ -2261,3 +2261,132 @@ body {
|
||||||
background: var(--color-bg-input);
|
background: var(--color-bg-input);
|
||||||
color: var(--color-text-muted);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,13 @@ export interface TechniquePageVersionListResponse {
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TechniquePageVersionDetail {
|
||||||
|
version_number: number;
|
||||||
|
content_snapshot: Record<string, unknown>;
|
||||||
|
pipeline_metadata: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TechniqueListItem {
|
export interface TechniqueListItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -238,6 +245,15 @@ export async function fetchTechniqueVersions(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTechniqueVersion(
|
||||||
|
slug: string,
|
||||||
|
versionNumber: number,
|
||||||
|
): Promise<TechniquePageVersionDetail> {
|
||||||
|
return request<TechniquePageVersionDetail>(
|
||||||
|
`${BASE}/techniques/${slug}/versions/${versionNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Topics ───────────────────────────────────────────────────────────────────
|
// ── Topics ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function fetchTopics(): Promise<TopicCategory[]> {
|
export async function fetchTopics(): Promise<TopicCategory[]> {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* Technique page detail view.
|
* Technique page detail view with version switching.
|
||||||
*
|
*
|
||||||
* Fetches a single technique by slug. Renders:
|
* Fetches a single technique by slug. When historical versions exist,
|
||||||
* - Header with title, category badge, tags, creator link, source quality
|
* shows a version switcher that lets admins view previous snapshots
|
||||||
* - Amber banner for unstructured (livestream-sourced) content
|
* with pipeline metadata (prompt hashes, model config).
|
||||||
* - 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
fetchTechnique,
|
fetchTechnique,
|
||||||
|
fetchTechniqueVersions,
|
||||||
|
fetchTechniqueVersion,
|
||||||
type TechniquePageDetail as TechniqueDetail,
|
type TechniquePageDetail as TechniqueDetail,
|
||||||
|
type TechniquePageVersionSummary,
|
||||||
|
type TechniquePageVersionDetail,
|
||||||
} from "../api/public-client";
|
} from "../api/public-client";
|
||||||
import ReportIssueModal from "../components/ReportIssueModal";
|
import ReportIssueModal from "../components/ReportIssueModal";
|
||||||
|
|
||||||
|
|
@ -26,6 +24,45 @@ function formatTime(seconds: number): string {
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>)
|
||||||
|
: 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() {
|
export default function TechniquePage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const [technique, setTechnique] = useState<TechniqueDetail | null>(null);
|
const [technique, setTechnique] = useState<TechniqueDetail | null>(null);
|
||||||
|
|
@ -34,6 +71,14 @@ export default function TechniquePage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showReport, setShowReport] = useState(false);
|
const [showReport, setShowReport] = useState(false);
|
||||||
|
|
||||||
|
// Version switching
|
||||||
|
const [versions, setVersions] = useState<TechniquePageVersionSummary[]>([]);
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState<string>("current");
|
||||||
|
const [versionDetail, setVersionDetail] =
|
||||||
|
useState<TechniquePageVersionDetail | null>(null);
|
||||||
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Load technique + version list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
|
||||||
|
|
@ -41,17 +86,28 @@ export default function TechniquePage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setNotFound(false);
|
setNotFound(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSelectedVersion("current");
|
||||||
|
setVersionDetail(null);
|
||||||
|
setVersions([]);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchTechnique(slug);
|
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) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
if (
|
if (err instanceof Error && err.message.includes("404")) {
|
||||||
err instanceof Error &&
|
|
||||||
err.message.includes("404")
|
|
||||||
) {
|
|
||||||
setNotFound(true);
|
setNotFound(true);
|
||||||
} else {
|
} else {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -69,6 +125,35 @@ export default function TechniquePage() {
|
||||||
};
|
};
|
||||||
}, [slug]);
|
}, [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) {
|
if (loading) {
|
||||||
return <div className="loading">Loading technique…</div>;
|
return <div className="loading">Loading technique…</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +162,7 @@ export default function TechniquePage() {
|
||||||
return (
|
return (
|
||||||
<div className="technique-404">
|
<div className="technique-404">
|
||||||
<h2>Technique Not Found</h2>
|
<h2>Technique Not Found</h2>
|
||||||
<p>The technique "{slug}" doesn't exist.</p>
|
<p>The technique “{slug}” doesn’t exist.</p>
|
||||||
<Link to="/" className="btn">
|
<Link to="/" className="btn">
|
||||||
Back to Home
|
Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<article className="technique-page">
|
<article className="technique-page">
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
|
|
@ -100,8 +200,23 @@ export default function TechniquePage() {
|
||||||
← Back
|
← Back
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Historical version banner */}
|
||||||
|
{isHistorical && (
|
||||||
|
<div className="technique-banner technique-banner--version">
|
||||||
|
📋 Viewing version {versionDetail.version_number} from{" "}
|
||||||
|
{formatDate(versionDetail.created_at)}
|
||||||
|
<button
|
||||||
|
className="btn btn--small btn--primary"
|
||||||
|
style={{ marginLeft: "1rem" }}
|
||||||
|
onClick={() => setSelectedVersion("current")}
|
||||||
|
>
|
||||||
|
Back to current
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Unstructured content warning */}
|
{/* Unstructured content warning */}
|
||||||
{technique.source_quality === "unstructured" && (
|
{displayQuality === "unstructured" && (
|
||||||
<div className="technique-banner technique-banner--amber">
|
<div className="technique-banner technique-banner--amber">
|
||||||
⚠ This technique was sourced from a livestream and may have less
|
⚠ This technique was sourced from a livestream and may have less
|
||||||
structured content.
|
structured content.
|
||||||
|
|
@ -110,14 +225,12 @@ export default function TechniquePage() {
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="technique-header">
|
<header className="technique-header">
|
||||||
<h1 className="technique-header__title">{technique.title}</h1>
|
<h1 className="technique-header__title">{displayTitle}</h1>
|
||||||
<div className="technique-header__meta">
|
<div className="technique-header__meta">
|
||||||
<span className="badge badge--category">
|
<span className="badge badge--category">{displayCategory}</span>
|
||||||
{technique.topic_category}
|
{displayTags && displayTags.length > 0 && (
|
||||||
</span>
|
|
||||||
{technique.topic_tags && technique.topic_tags.length > 0 && (
|
|
||||||
<span className="technique-header__tags">
|
<span className="technique-header__tags">
|
||||||
{technique.topic_tags.map((tag) => (
|
{displayTags.map((tag) => (
|
||||||
<span key={tag} className="pill">
|
<span key={tag} className="pill">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -132,14 +245,15 @@ export default function TechniquePage() {
|
||||||
by {technique.creator_info.name}
|
by {technique.creator_info.name}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{technique.source_quality && (
|
{displayQuality && (
|
||||||
<span
|
<span
|
||||||
className={`badge badge--quality badge--quality-${technique.source_quality}`}
|
className={`badge badge--quality badge--quality-${displayQuality}`}
|
||||||
>
|
>
|
||||||
{technique.source_quality}
|
{displayQuality}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta stats line */}
|
{/* Meta stats line */}
|
||||||
<div className="technique-header__stats">
|
<div className="technique-header__stats">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|
@ -166,15 +280,87 @@ export default function TechniquePage() {
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{/* Report issue button */}
|
|
||||||
|
{/* Version switcher + report button row */}
|
||||||
|
<div className="technique-header__actions">
|
||||||
|
{versions.length > 0 && (
|
||||||
|
<div className="version-switcher">
|
||||||
|
<label className="version-switcher__label">Version:</label>
|
||||||
|
<select
|
||||||
|
className="version-switcher__select"
|
||||||
|
value={selectedVersion}
|
||||||
|
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||||
|
disabled={versionLoading}
|
||||||
|
>
|
||||||
|
<option value="current">Current (live)</option>
|
||||||
|
{versions.map((v) => (
|
||||||
|
<option key={v.version_number} value={String(v.version_number)}>
|
||||||
|
v{v.version_number} — {formatDate(v.created_at)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{versionLoading && (
|
||||||
|
<span className="version-switcher__loading">Loading…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn--secondary btn--small report-issue-btn"
|
className="btn btn--secondary btn--small report-issue-btn"
|
||||||
onClick={() => setShowReport(true)}
|
onClick={() => setShowReport(true)}
|
||||||
>
|
>
|
||||||
⚑ Report issue
|
⚑ Report issue
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Pipeline metadata for historical versions */}
|
||||||
|
{isHistorical && versionDetail.pipeline_metadata && (
|
||||||
|
<div className="version-metadata">
|
||||||
|
<h4 className="version-metadata__title">Pipeline metadata (v{versionDetail.version_number})</h4>
|
||||||
|
<div className="version-metadata__grid">
|
||||||
|
{"model" in versionDetail.pipeline_metadata && (
|
||||||
|
<div className="version-metadata__item">
|
||||||
|
<span className="version-metadata__key">Model</span>
|
||||||
|
<span className="version-metadata__value">
|
||||||
|
{String(versionDetail.pipeline_metadata.model)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{"captured_at" in versionDetail.pipeline_metadata && (
|
||||||
|
<div className="version-metadata__item">
|
||||||
|
<span className="version-metadata__key">Captured</span>
|
||||||
|
<span className="version-metadata__value">
|
||||||
|
{formatDate(String(versionDetail.pipeline_metadata.captured_at))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{typeof versionDetail.pipeline_metadata.prompt_hashes === "object" &&
|
||||||
|
versionDetail.pipeline_metadata.prompt_hashes !== null && (
|
||||||
|
<div className="version-metadata__item version-metadata__item--wide">
|
||||||
|
<span className="version-metadata__key">Prompt hashes</span>
|
||||||
|
<div className="version-metadata__hashes">
|
||||||
|
{Object.entries(
|
||||||
|
versionDetail.pipeline_metadata.prompt_hashes as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>,
|
||||||
|
).map(([file, hash]) => (
|
||||||
|
<div key={file} className="version-metadata__hash">
|
||||||
|
<span className="version-metadata__hash-file">
|
||||||
|
{file.replace(/^.*\//, "")}
|
||||||
|
</span>
|
||||||
|
<code className="version-metadata__hash-value">
|
||||||
|
{hash.slice(0, 12)}…
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Report modal */}
|
{/* Report modal */}
|
||||||
{showReport && (
|
{showReport && (
|
||||||
<ReportIssueModal
|
<ReportIssueModal
|
||||||
|
|
@ -186,28 +372,28 @@ export default function TechniquePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
{technique.summary && (
|
{displaySummary && (
|
||||||
<section className="technique-summary">
|
<section className="technique-summary">
|
||||||
<p>{technique.summary}</p>
|
<p>{displaySummary}</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Study guide prose — body_sections */}
|
{/* Study guide prose — body_sections */}
|
||||||
{technique.body_sections &&
|
{displaySections &&
|
||||||
Object.keys(technique.body_sections).length > 0 && (
|
Object.keys(displaySections).length > 0 && (
|
||||||
<section className="technique-prose">
|
<section className="technique-prose">
|
||||||
{Object.entries(technique.body_sections).map(
|
{Object.entries(displaySections).map(
|
||||||
([sectionTitle, content]) => (
|
([sectionTitle, content]: [string, unknown]) => (
|
||||||
<div key={sectionTitle} className="technique-prose__section">
|
<div key={sectionTitle} className="technique-prose__section">
|
||||||
<h2>{sectionTitle}</h2>
|
<h2>{sectionTitle}</h2>
|
||||||
{typeof content === "string" ? (
|
{typeof content === "string" ? (
|
||||||
<p>{content}</p>
|
<p>{content as string}</p>
|
||||||
) : typeof content === "object" && content !== null ? (
|
) : typeof content === "object" && content !== null ? (
|
||||||
<pre className="technique-prose__json">
|
<pre className="technique-prose__json">
|
||||||
{JSON.stringify(content, null, 2)}
|
{JSON.stringify(content, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<p>{String(content)}</p>
|
<p>{String(content as string)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|
@ -215,7 +401,7 @@ export default function TechniquePage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Key moments */}
|
{/* Key moments (always from live data — not versioned) */}
|
||||||
{technique.key_moments.length > 0 && (
|
{technique.key_moments.length > 0 && (
|
||||||
<section className="technique-moments">
|
<section className="technique-moments">
|
||||||
<h2>Key Moments</h2>
|
<h2>Key Moments</h2>
|
||||||
|
|
@ -244,11 +430,11 @@ export default function TechniquePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Signal chains */}
|
{/* Signal chains */}
|
||||||
{technique.signal_chains &&
|
{displayChains &&
|
||||||
technique.signal_chains.length > 0 && (
|
displayChains.length > 0 && (
|
||||||
<section className="technique-chains">
|
<section className="technique-chains">
|
||||||
<h2>Signal Chains</h2>
|
<h2>Signal Chains</h2>
|
||||||
{technique.signal_chains.map((chain, i) => {
|
{displayChains.map((chain, i) => {
|
||||||
const chainObj = chain as Record<string, unknown>;
|
const chainObj = chain as Record<string, unknown>;
|
||||||
const chainName =
|
const chainName =
|
||||||
typeof chainObj["name"] === "string"
|
typeof chainObj["name"] === "string"
|
||||||
|
|
@ -283,11 +469,11 @@ export default function TechniquePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Plugins */}
|
{/* Plugins */}
|
||||||
{technique.plugins && technique.plugins.length > 0 && (
|
{displayPlugins && displayPlugins.length > 0 && (
|
||||||
<section className="technique-plugins">
|
<section className="technique-plugins">
|
||||||
<h2>Plugins Referenced</h2>
|
<h2>Plugins Referenced</h2>
|
||||||
<div className="pill-list">
|
<div className="pill-list">
|
||||||
{technique.plugins.map((plugin) => (
|
{displayPlugins.map((plugin) => (
|
||||||
<span key={plugin} className="pill pill--plugin">
|
<span key={plugin} className="pill pill--plugin">
|
||||||
{plugin}
|
{plugin}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -296,7 +482,7 @@ export default function TechniquePage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Related techniques */}
|
{/* Related techniques (always from live data) */}
|
||||||
{technique.related_links.length > 0 && (
|
{technique.related_links.length > 0 && (
|
||||||
<section className="technique-related">
|
<section className="technique-related">
|
||||||
<h2>Related Techniques</h2>
|
<h2>Related Techniques</h2>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue