// DocsSessionTranscript.jsx — v0.21.0 (was v0.19.0 / roadmap item #30). // // Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the // transcript body via the backend mediator and renders it through the // shared MarkdownPreview. // // v0.21.0 / roadmap item #32: // - A compact metadata header now sits above the rendered body // (title, started/ended, duration, optional TL;DR, and a // "View source on git.wiggleverse.org" external link). The parse // + render helpers (`parseTranscriptMeta`, `TranscriptMetaHeader`, // `gitSourceUrl`) are exported here so the session-root inline- // collapse view (DocsSessionIndex.jsx) reuses the exact same // rendering for the transcript(s) it inlines. // // Empty-state contract: // 404 → "This transcript isn't published yet" with a link back to // the parent session index // 502 → "Couldn't reach the session-history repo" + retry button import { useEffect, useState, useCallback } from 'react' import { Link, useParams } from 'react-router-dom' import MarkdownPreview from './MarkdownPreview.jsx' import { getSessionTranscript, getSessionsManifest } from '../api.js' import { EVENTS, track } from '../lib/analytics' import './Docs.css' // The canonical published-repo source URL for a transcript file, per // SESSION-PROTOCOL.md §1's folder layout (one folder per session). export function gitSourceUrl(nnnn, filename) { return ( 'https://git.wiggleverse.org/wiggleverse/ohm-session-history/src/branch/main/' + `${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}` ) } // Extract the `.N` ordinal from a transcript filename: // "SESSION-0014.1-TRANSCRIPT-...md" → "0014.1" // "SESSION-0013.1.1-TRANSCRIPT-...md" → "0013.1.1" (nested subagent) export function transcriptOrdinal(filename) { const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename || '') return m ? m[1] : filename || '' } // Parse the `--` ISO segment out of a transcript filename. // Per the protocol the segment is `YYYY-MM-DDTHH-MM--YYYY-MM-DDTHH-MM` // (colons replaced by dashes for filesystem portability, minute // precision, PST implied). Legacy renamed-letter transcripts omit the // segment entirely; in that case every derived field comes back null // and the header degrades gracefully. // // Returns { ordinal, start: Date|null, end: Date|null, durationMs: number|null }. export function parseTranscriptMeta(filename) { const ordinal = transcriptOrdinal(filename) const m = /-TRANSCRIPT-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})--(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})\.md$/.exec( filename || '' ) if (!m) { return { ordinal, start: null, end: null, durationMs: null } } const [, sDate, sH, sM, eDate, eH, eM] = m // Parse as local wall-clock time. The filename carries no timezone // (PST is implied per the protocol); we render the wall-clock value // verbatim rather than shifting it, so we build a local Date and read // it back with the same calendar fields. Duration is a difference of // two local Dates, so the implied-timezone ambiguity cancels out. const start = new Date(`${sDate}T${sH}:${sM}:00`) const end = new Date(`${eDate}T${eH}:${eM}:00`) const startOk = !Number.isNaN(start.getTime()) const endOk = !Number.isNaN(end.getTime()) const durationMs = startOk && endOk && end.getTime() >= start.getTime() ? end.getTime() - start.getTime() : null return { ordinal, start: startOk ? start : null, end: endOk ? end : null, durationMs, } } function fmtDateTime(d) { if (!d) return null // e.g. "May 28, 2026, 11:11 AM" — human-readable, wall-clock. try { return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }) } catch { return d.toISOString() } } function fmtDuration(ms) { if (ms == null || ms <= 0) return null const totalMin = Math.round(ms / 60000) const h = Math.floor(totalMin / 60) const m = totalMin % 60 if (h > 0 && m > 0) return `${h}h ${m}m` if (h > 0) return `${h}h` return `${m}m` } // The compact metadata block rendered above every transcript body. // Shared between the standalone transcript route and the session-root // inline-collapse view. `tldr` is optional — absent ⇒ rendered nothing // (graceful degrade, per the manifest schema where `tldr` may be unset). export function TranscriptMetaHeader({ nnnn, filename, title, tldr }) { const { ordinal, start, end, durationMs } = parseTranscriptMeta(filename) const started = fmtDateTime(start) const ended = fmtDateTime(end) const duration = fmtDuration(durationMs) const heading = title ? `${ordinal} — ${title}` : `Session ${ordinal}` return (

{heading}

{(started || ended || duration) && (
{started && (
Started
{started}
)} {ended && (
Ended
{ended}
)} {duration && (
Duration
{duration}
)}
)} {tldr &&

{tldr}

} View source on git.wiggleverse.org ↗
) } export default function DocsSessionTranscript() { const { nnnn, filename } = useParams() const [body, setBody] = useState('') const [title, setTitle] = useState('') const [tldr, setTldr] = useState('') const [status, setStatus] = useState('loading') // loading | ok | notfound | error const [reloadTick, setReloadTick] = useState(0) useEffect(() => { track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` }) }, [nnnn, filename]) // Title + optional tl;dr from the manifest — decorative metadata that // feeds the header. Failure leaves the header showing the bare NNNN // and no TL;DR; the body fetch below is the load-bearing one. useEffect(() => { let active = true getSessionsManifest() .then(payload => { if (!active) return const entry = (payload && payload[nnnn]) || {} setTitle(entry.title || '') setTldr(typeof entry.tldr === 'string' ? entry.tldr : '') }) .catch(() => {}) return () => { active = false } }, [nnnn]) useEffect(() => { let active = true setStatus('loading') getSessionTranscript(nnnn, filename) .then(text => { if (!active) return setBody(text || '') setStatus('ok') }) .catch(e => { if (!active) return if (e.status === 404) { setStatus('notfound') } else { setStatus('error') } }) return () => { active = false } }, [nnnn, filename, reloadTick]) const retry = useCallback(() => setReloadTick(t => t + 1), []) return (
← Session {nnnn}
{status === 'loading' &&

Loading…

} {status === 'notfound' && (

This transcript isn't published yet.

← Back to session {nnnn}

)} {status === 'error' && (

Couldn't reach the session-history repo.

)} {status === 'ok' && ( <>
)}
) }