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. -

-
+ + {status === 'ok' && primary && ( + <> + {siblings.length > 0 && ( + + )} + + {bodyStatus === 'loading' &&

Loading transcript…

} + {bodyStatus === 'notfound' && ( +
+

This transcript isn't published yet.

+
+ )} + {bodyStatus === 'error' && ( +
+

Couldn't reach the session-history repo.

+ +
+ )} + {bodyStatus === 'ok' && ( + <> + +
+ +
+ + )} + )}
) 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 (

User guide

- {loading &&

Loading…

} - {error &&

Could not load the guide: {error}

} - {!loading && !error && ( + {status === 'loading' &&

Loading…

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

Couldn't load the user guide.

+ +

+ + About sessions + +

+
+ )} + {status === 'ok' && (