adb5d25715
Roadmap item #32 (session/transcript page polish) plus the /docs surfaces' share of #31, for rfc-app v0.21.0. - DocsSessionIndex: the session root (/docs/sessions/:nnnn) no longer renders a dead-end "N transcript(s) — select from the nav" placeholder. It now renders a transcript INLINE: the lone transcript for single-transcript sessions, or the `.0` driver transcript (falling back to first-by-sort) for multi-transcript sessions, with the remaining siblings listed/linked above the body. URL stays stable to the session number — inline render, no 301. - DocsSessionTranscript: adds a compact metadata header above the body (title; started/ended parsed from the filename's ISO segments, human-readable; derived duration; optional TL;DR from the manifest's `tldr` string field, graceful-degrade when absent; external "View source on git.wiggleverse.org" link). The parse/header helpers are exported so the inline-collapse view reuses identical rendering. - Docs.css (new): token-based styling for the new metadata-header + sibling-list elements only; existing docs classes stay owned by App.css to avoid racing the #31 sweep. - DocsUserGuide: loading/error states brought onto the shared .docs-empty/.docs-error convention with a retry button. No backend change: /api/docs/sessions/manifest already passes the full sessions.json entry through, so a `tldr` field on an entry reaches the frontend with no docs_sessions.py change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
267 lines
9.0 KiB
React
267 lines
9.0 KiB
React
// 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 `<start>--<end>` 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 (
|
|
<header className="docs-transcript-meta">
|
|
<h2 className="docs-transcript-meta-title">{heading}</h2>
|
|
{(started || ended || duration) && (
|
|
<dl className="docs-transcript-meta-grid">
|
|
{started && (
|
|
<div className="docs-transcript-meta-row">
|
|
<dt>Started</dt>
|
|
<dd>{started}</dd>
|
|
</div>
|
|
)}
|
|
{ended && (
|
|
<div className="docs-transcript-meta-row">
|
|
<dt>Ended</dt>
|
|
<dd>{ended}</dd>
|
|
</div>
|
|
)}
|
|
{duration && (
|
|
<div className="docs-transcript-meta-row">
|
|
<dt>Duration</dt>
|
|
<dd>{duration}</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
)}
|
|
{tldr && <p className="docs-transcript-meta-tldr">{tldr}</p>}
|
|
<a
|
|
className="docs-source-link docs-transcript-meta-source"
|
|
href={gitSourceUrl(nnnn, filename)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label={`View transcript ${ordinal} source on git.wiggleverse.org`}
|
|
data-amp-track-name="Docs Transcript Source Link"
|
|
data-amp-track-session={nnnn}
|
|
data-amp-track-filename={filename}
|
|
>
|
|
View source on git.wiggleverse.org ↗
|
|
</a>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<article className="docs-article">
|
|
<div className="docs-breadcrumbs">
|
|
<Link
|
|
to={`/docs/sessions/${nnnn}`}
|
|
aria-label={`Back to session ${nnnn} index`}
|
|
data-amp-track-name="Docs Transcript Back To Index"
|
|
>
|
|
← Session {nnnn}
|
|
</Link>
|
|
</div>
|
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
|
{status === 'notfound' && (
|
|
<div className="docs-empty">
|
|
<p>This transcript isn't published yet.</p>
|
|
<p>
|
|
<Link
|
|
to={`/docs/sessions/${nnnn}`}
|
|
aria-label={`Back to session ${nnnn}`}
|
|
data-amp-track-name="Docs Transcript Back To Session"
|
|
>
|
|
← Back to session {nnnn}
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
)}
|
|
{status === 'error' && (
|
|
<div className="docs-error" role="alert">
|
|
<p>Couldn't reach the session-history repo.</p>
|
|
<button
|
|
type="button"
|
|
onClick={retry}
|
|
aria-label="Retry"
|
|
data-amp-track-name="Docs Transcript Retry"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
)}
|
|
{status === 'ok' && (
|
|
<>
|
|
<TranscriptMetaHeader
|
|
nnnn={nnnn}
|
|
filename={filename}
|
|
title={title}
|
|
tldr={tldr}
|
|
/>
|
|
<div className="philosophy-body">
|
|
<MarkdownPreview content={body} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|