diff --git a/frontend/src/components/Docs.css b/frontend/src/components/Docs.css
new file mode 100644
index 0000000..d5ceb00
--- /dev/null
+++ b/frontend/src/components/Docs.css
@@ -0,0 +1,128 @@
+/* Docs.css — docs-surface polish scoped to v0.21.0 / roadmap item #32.
+ *
+ * This sheet owns ONLY the classes introduced by item #32 (the
+ * transcript metadata header and the session-root sibling list). The
+ * pre-existing docs classes (.docs-article, .docs-empty, .docs-error,
+ * .docs-source-link, .philosophy-body, .muted) live in App.css and are
+ * deliberately NOT touched here — redefining them would race the #31
+ * App.css token sweep for the same selectors. Every value below reads
+ * a token from tokens.css so the new surfaces sit on the same
+ * spacing/type/color scale as the rest of the docs chrome.
+ *
+ * Imported from DocsSessionTranscript.jsx + DocsSessionIndex.jsx (the
+ * two components that render these elements). CSS custom properties are
+ * not import-order-sensitive at use time, so the import site doesn't
+ * matter for correctness.
+ */
+
+/* ── Transcript metadata header ─────────────────────────────────────
+ * A compact card above the rendered transcript body: title, the
+ * started/ended/duration grid, an optional TL;DR, and the external
+ * "view source" link. */
+.docs-transcript-meta {
+ margin: 0 0 var(--space-9);
+ padding: var(--space-7);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-sunken);
+}
+
+.docs-transcript-meta-title {
+ margin: 0 0 var(--space-5);
+ font-size: var(--text-lg);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-tight);
+ color: var(--color-text-strong);
+ font-family: var(--font-mono);
+ word-break: break-word;
+}
+
+.docs-transcript-meta-grid {
+ margin: 0;
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: var(--space-2) var(--space-7);
+ align-items: baseline;
+}
+
+.docs-transcript-meta-row {
+ display: contents;
+}
+
+.docs-transcript-meta-grid dt {
+ margin: 0;
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--color-text-muted);
+}
+
+.docs-transcript-meta-grid dd {
+ margin: 0;
+ font-size: var(--text-base);
+ color: var(--color-text);
+}
+
+.docs-transcript-meta-tldr {
+ margin: var(--space-6) 0 0;
+ padding-top: var(--space-6);
+ border-top: 1px solid var(--color-border);
+ font-size: var(--text-base);
+ line-height: var(--leading-relaxed);
+ color: var(--color-text);
+}
+
+.docs-transcript-meta-source {
+ display: inline-block;
+ margin-top: var(--space-6);
+}
+
+/* ── Session-root sibling-transcript list ───────────────────────────
+ * Rendered above the inlined primary transcript when a session has
+ * more than one transcript (driver `.0` + subagents). The primary is
+ * marked "(shown below)"; the rest link to their standalone routes. */
+.docs-session-siblings {
+ margin: 0 0 var(--space-9);
+ padding: var(--space-6) var(--space-7);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-muted);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-3) var(--space-6);
+}
+
+.docs-session-siblings-label {
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--color-text-muted);
+}
+
+.docs-session-siblings-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-5);
+ font-family: var(--font-mono);
+ font-size: var(--text-base);
+}
+
+.docs-session-siblings-list a {
+ color: var(--color-link);
+ text-decoration: none;
+}
+
+.docs-session-siblings-list a:hover {
+ color: var(--color-accent-strong);
+ text-decoration: underline;
+}
+
+.docs-session-siblings-current {
+ color: var(--color-text-muted);
+}
diff --git a/frontend/src/components/DocsSessionIndex.jsx b/frontend/src/components/DocsSessionIndex.jsx
index 62df647..2224a0a 100644
--- a/frontend/src/components/DocsSessionIndex.jsx
+++ b/frontend/src/components/DocsSessionIndex.jsx
@@ -1,46 +1,86 @@
-// DocsSessionIndex.jsx — v0.20.0 (was v0.19.0 / roadmap item #30).
+// DocsSessionIndex.jsx — v0.21.0 (was v0.20.0 / roadmap item #30).
//
-// Per-session landing at `/docs/sessions/:nnnn`. v0.19.0 listed the
-// transcripts as body links; v0.20.0 drops the body list — navigation
-// is via the left flyout nav (which renders each session's transcripts
-// nested under the session row). The body now serves as a
-// session-overview card with the title, file count, and a hint to
-// pick a transcript from the nav.
+// Per-session landing at `/docs/sessions/:nnnn`. v0.20.0 rendered a
+// dead-end "N transcript(s) in this session. Select one from the
+// navigation." placeholder. v0.21.0 / roadmap item #32 collapses that:
+// the session root now renders a transcript INLINE so the URL is never
+// an empty stop.
//
-// The transcript count still comes from `/api/docs/sessions/:nnnn/index`
+// - Exactly one transcript → render it inline at the session root.
+// - Multiple transcripts → render the `.0` driver transcript
+// inline (fall back to the first file by
+// sort order if there's no `.0`), AND
+// list/link the remaining transcripts so
+// the siblings are one click away.
+//
+// The URL stays stable to the session number — this is an inline
+// render, not a 301/redirect. The per-transcript route
+// (`/docs/sessions/:nnnn/:filename`) still exists and is what the
+// sibling links and the left-nav transcript rows point at.
+//
+// The transcript count + filenames come from `/api/docs/sessions/:nnnn/index`
// so the empty-state ("no transcripts yet"), not-found, and error
-// paths remain meaningful — the page still does something useful when
-// the upstream is mid-publish or unreachable.
+// paths remain meaningful when the upstream is mid-publish or
+// unreachable. The metadata header + body rendering are imported from
+// DocsSessionTranscript.jsx so the inline view is byte-identical to the
+// standalone per-transcript view.
import { useEffect, useState, useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
-import { getSessionsManifest, getSessionIndex } from '../api.js'
+import MarkdownPreview from './MarkdownPreview.jsx'
+import {
+ getSessionsManifest,
+ getSessionIndex,
+ getSessionTranscript,
+} from '../api.js'
+import {
+ TranscriptMetaHeader,
+ transcriptOrdinal,
+} from './DocsSessionTranscript.jsx'
import { EVENTS, track } from '../lib/analytics'
+import './Docs.css'
+
+// Pick the transcript to render inline at the session root: prefer the
+// `.0` driver transcript; otherwise the first file by sort order. The
+// backend already returns the file list sorted, so `files[0]` is a
+// stable fallback.
+function pickPrimary(files) {
+ if (!files || files.length === 0) return null
+ const driver = files.find(f => /^SESSION-\d{4}\.0-TRANSCRIPT/.test(f))
+ return driver || files[0]
+}
export default function DocsSessionIndex() {
const { nnnn } = useParams()
const [title, setTitle] = useState('')
+ const [tldr, setTldr] = useState('')
const [files, setFiles] = useState([])
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
const [reloadTick, setReloadTick] = useState(0)
+ // The inline body for the primary transcript.
+ const [body, setBody] = useState('')
+ const [bodyStatus, setBodyStatus] = useState('idle') // idle | loading | ok | notfound | error
+
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
}, [nnnn])
- // Title from manifest — cheap, manifest is cached server-side.
+ // Title + optional TL;DR from the manifest — cheap, cached server-side.
useEffect(() => {
let active = true
getSessionsManifest()
.then(payload => {
if (!active) return
- const entry = payload && payload[nnnn]
- setTitle((entry && entry.title) || '')
+ const entry = (payload && payload[nnnn]) || {}
+ setTitle(entry.title || '')
+ // `tldr` is an optional manifest field (string). Absent ⇒ the
+ // header renders no TL;DR line (graceful degrade).
+ setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
})
.catch(() => {
- // Title is decorative; failure to load just leaves the header
- // showing the bare NNNN. The transcript list fetch below is
- // the load-bearing one.
+ // Title + TL;DR are decorative; the transcript list + body
+ // fetches below are the load-bearing ones.
})
return () => { active = false }
}, [nnnn])
@@ -66,14 +106,41 @@ export default function DocsSessionIndex() {
return () => { active = false }
}, [nnnn, reloadTick])
+ const primary = status === 'ok' ? pickPrimary(files) : null
+
+ // Fetch the primary transcript body once we know which file it is.
+ useEffect(() => {
+ if (!primary) {
+ setBody('')
+ setBodyStatus('idle')
+ return
+ }
+ let active = true
+ setBodyStatus('loading')
+ getSessionTranscript(nnnn, primary)
+ .then(text => {
+ if (!active) return
+ setBody(text || '')
+ setBodyStatus('ok')
+ })
+ .catch(e => {
+ if (!active) return
+ setBodyStatus(e.status === 404 ? 'notfound' : 'error')
+ })
+ return () => { active = false }
+ }, [nnnn, primary, reloadTick])
+
const retry = useCallback(() => setReloadTick(t => t + 1), [])
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
+ const siblings = primary ? files.filter(f => f !== primary) : []
return (
{header}
+
{status === 'loading' &&
Loading…
}
+
{status === 'notfound' && (
@@ -88,6 +155,7 @@ export default function DocsSessionIndex() {
)}
+
{status === 'error' && (
Couldn't reach the session-history repo.
@@ -101,20 +169,77 @@ export default function DocsSessionIndex() {
)}
+
{status === 'ok' && files.length === 0 && (
This session has no transcripts published.
)}
- {status === 'ok' && files.length > 0 && (
-
-
- {files.length === 1
- ? '1 transcript in this session.'
- : `${files.length} transcripts in this session.`}{' '}
- Select one from the navigation on the left.
-
+ >
+ )}
+ >
)}
)
diff --git a/frontend/src/components/DocsSessionTranscript.jsx b/frontend/src/components/DocsSessionTranscript.jsx
index 4f38d6b..2684fec 100644
--- a/frontend/src/components/DocsSessionTranscript.jsx
+++ b/frontend/src/components/DocsSessionTranscript.jsx
@@ -1,9 +1,18 @@
-// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
+// 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
@@ -12,12 +21,149 @@
import { useEffect, useState, useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
-import { getSessionTranscript } from '../api.js'
+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)
@@ -25,6 +171,22 @@ export default function DocsSessionTranscript() {
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')
@@ -87,9 +249,17 @@ export default function DocsSessionTranscript() {
)}
{status === 'ok' && (
-
-
-
+ <>
+
+
+
+
+ >
)}
)
diff --git a/frontend/src/components/DocsUserGuide.jsx b/frontend/src/components/DocsUserGuide.jsx
index d2b9588..3e62311 100644
--- a/frontend/src/components/DocsUserGuide.jsx
+++ b/frontend/src/components/DocsUserGuide.jsx
@@ -7,15 +7,21 @@
// `MarkdownPreview` (the same component the `/philosophy` route uses,
// so we don't introduce a second markdown library).
-import { useEffect, useState } from 'react'
+// v0.21.0 / roadmap item #31: the loading + error states are brought
+// onto the same `.docs-empty` / `.docs-error` convention every other
+// docs surface uses (was a bare `
`), with a retry
+// button so a transient `/api/docs` failure isn't a dead end.
+
+import { useEffect, useState, useCallback } from 'react'
+import { Link } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getDocs } from '../api.js'
import { EVENTS, track } from '../lib/analytics'
export default function DocsUserGuide() {
const [body, setBody] = useState('')
- const [error, setError] = useState(null)
- const [loading, setLoading] = useState(true)
+ const [status, setStatus] = useState('loading') // loading | ok | error
+ const [reloadTick, setReloadTick] = useState(0)
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
@@ -23,19 +29,49 @@ export default function DocsUserGuide() {
useEffect(() => {
let active = true
+ setStatus('loading')
getDocs()
- .then(r => { if (active) setBody(r.body || '') })
- .catch(e => { if (active) setError(e.message || String(e)) })
- .finally(() => { if (active) setLoading(false) })
+ .then(r => {
+ if (!active) return
+ setBody(r.body || '')
+ setStatus('ok')
+ })
+ .catch(() => {
+ if (!active) return
+ setStatus('error')
+ })
return () => { active = false }
- }, [])
+ }, [reloadTick])
+
+ const retry = useCallback(() => setReloadTick(t => t + 1), [])
return (