docs(#32): inline-collapse session roots + transcript metadata header
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>
This commit is contained in:
@@ -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 `<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)
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
<>
|
||||
<TranscriptMetaHeader
|
||||
nnnn={nnnn}
|
||||
filename={filename}
|
||||
title={title}
|
||||
tldr={tldr}
|
||||
/>
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user