Release v0.20.0: /docs/specs surface + nested flyout nav + session body-list removal
Wave 9 follow-up to roadmap item #30 (Session 0017.0 shipped #30 as v0.19.0; v0.20.0 lands the operator-feedback follow-ups on top). 1. Specs on /docs/specs/<name> (backend docs_specs.py + frontend DocsSpec.jsx + DocsSpecsIndex.jsx). Configured via OHM_DOCS_SPECS; framework default carries OHM's two specs (rfc-app/SPEC.md + flotilla SPEC.md). Runtime fetch from gitea raw with 5-min TTL cache, mirroring docs_sessions.py. 2. Nested flyout nav hierarchy (DocsLayout.jsx). Sessions render as a tree with transcripts nested under each session row (labeled by .N ordinal). New Specs section between User Guide and Sessions. 3. /docs/sessions/<NNNN> body-list removed (DocsSessionIndex.jsx). Body becomes a session-overview card; navigation lives in the left nav. 19 new pytest cases for docs_specs (332 backend total green). Frontend build clean. Sync frontend/package-lock.json version drift (0.15.0 → 0.20.0) alongside the VERSION + package.json bump. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,64 @@
|
||||
// DocsLayout.jsx — v0.19.0 / roadmap item #30.
|
||||
// DocsLayout.jsx — v0.20.0 (was v0.19.0 / roadmap item #30).
|
||||
//
|
||||
// Left-side flyout nav + content area for the `/docs/*` route tree:
|
||||
//
|
||||
// /docs → redirect to /docs/user-guide
|
||||
// /docs/user-guide → DOCS.md (existing v0.14.0 content)
|
||||
// /docs/specs → client-side redirect to first configured spec
|
||||
// /docs/specs/:name → a single framework spec (v0.20.0)
|
||||
// /docs/sessions → redirect to /docs/sessions/about
|
||||
// /docs/sessions/about → README.md from the sessions repo
|
||||
// /docs/sessions/:nnnn → per-session index page
|
||||
// /docs/sessions/:nnnn → per-session overview (nav-only navigation)
|
||||
// /docs/sessions/:nnnn/:file → per-transcript view
|
||||
//
|
||||
// v0.20.0 changes (Session 0018.0):
|
||||
// - Adds a "Specs" section between User Guide and Sessions, driven
|
||||
// by `/api/docs/specs/manifest`.
|
||||
// - Sessions render a nested tree: each session row has the
|
||||
// session's transcripts nested under it as their own nav rows
|
||||
// (labeled by `.N` ordinal). The transcript list is fetched per
|
||||
// session via `/api/docs/sessions/:nnnn/index` (cached server-
|
||||
// side, so the manifest+index fan-out is cheap on subsequent
|
||||
// loads). Always-expanded; no collapse toggle (current scale is
|
||||
// under twenty sessions — well under the threshold where lazy
|
||||
// expansion would pay).
|
||||
//
|
||||
// The flyout is a persistent left sidebar on desktop and a slide-out
|
||||
// drawer on mobile (toggled by the icon button in the docs header).
|
||||
// The session list is driven by the `/api/docs/sessions/manifest`
|
||||
// fetch:
|
||||
// - loading → skeleton in the nav (three placeholder rows)
|
||||
// - manifest 502 → error banner in the nav with "Try again"
|
||||
// - empty manifest → only "About" under Sessions; no NNNN rows
|
||||
//
|
||||
// Amplitude analytics (per SPEC §21):
|
||||
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
|
||||
// the sub-route component owns the fire (it knows the section).
|
||||
// the sub-route component owns the fire.
|
||||
// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
|
||||
// so autocapture rows are readable rather than ":nth-child(7)".
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||
import { getSessionsManifest } from '../api.js'
|
||||
import { getSessionsManifest, getSessionIndex, getSpecsManifest } from '../api.js'
|
||||
|
||||
// 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)
|
||||
// Returns the bare filename as fallback if the expected shape isn't
|
||||
// matched (which shouldn't happen — the backend index endpoint
|
||||
// filters by the same regex).
|
||||
function transcriptOrdinal(filename) {
|
||||
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename)
|
||||
return m ? m[1] : filename
|
||||
}
|
||||
|
||||
export default function DocsLayout({ authenticated }) {
|
||||
const [manifest, setManifest] = useState(null)
|
||||
const [manifestState, setManifestState] = useState('loading') // loading | ok | error
|
||||
const [sessionFiles, setSessionFiles] = useState({}) // { nnnn: [filename, ...] }
|
||||
const [specs, setSpecs] = useState([])
|
||||
const [specsState, setSpecsState] = useState('loading') // loading | ok | error
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// Manifest fetch — drives the Sessions section.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setManifestState('loading')
|
||||
@@ -52,6 +76,45 @@ export default function DocsLayout({ authenticated }) {
|
||||
return () => { active = false }
|
||||
}, [reloadTick])
|
||||
|
||||
// Per-session transcript lists — fan out from the manifest. Always-
|
||||
// expanded means we pre-fetch every session's index alongside the
|
||||
// manifest, gated on the manifest having loaded successfully. The
|
||||
// backend's 5-minute content TTL makes the repeat cost negligible.
|
||||
useEffect(() => {
|
||||
if (manifestState !== 'ok' || !manifest) return
|
||||
let active = true
|
||||
const nnnnList = Object.keys(manifest).sort()
|
||||
Promise.all(
|
||||
nnnnList.map(nnnn =>
|
||||
getSessionIndex(nnnn)
|
||||
.then(payload => [nnnn, (payload && payload.files) || []])
|
||||
.catch(() => [nnnn, []])
|
||||
)
|
||||
).then(pairs => {
|
||||
if (!active) return
|
||||
setSessionFiles(Object.fromEntries(pairs))
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [manifest, manifestState])
|
||||
|
||||
// Specs fetch — drives the Specs section. Independent of sessions.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setSpecsState('loading')
|
||||
getSpecsManifest()
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
setSpecs((payload && payload.specs) || [])
|
||||
setSpecsState('ok')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setSpecs([])
|
||||
setSpecsState('error')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [reloadTick])
|
||||
|
||||
// Close the mobile drawer on every navigation so a click in the nav
|
||||
// doesn't strand the user on a drawer-open view.
|
||||
useEffect(() => {
|
||||
@@ -99,6 +162,9 @@ export default function DocsLayout({ authenticated }) {
|
||||
<DocsNav
|
||||
manifest={manifest}
|
||||
manifestState={manifestState}
|
||||
sessionFiles={sessionFiles}
|
||||
specs={specs}
|
||||
specsState={specsState}
|
||||
onRetry={retryManifest}
|
||||
currentPath={location.pathname}
|
||||
/>
|
||||
@@ -119,12 +185,18 @@ export default function DocsLayout({ authenticated }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
||||
function DocsNav({
|
||||
manifest,
|
||||
manifestState,
|
||||
sessionFiles,
|
||||
specs,
|
||||
specsState,
|
||||
onRetry,
|
||||
currentPath,
|
||||
}) {
|
||||
const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
|
||||
const isExactly = (path) => currentPath === path
|
||||
|
||||
// Sort session keys ascending (newest sessions render last). The
|
||||
// manifest's keys are zero-padded 4-digit strings so lexicographic
|
||||
// order is the same as numeric.
|
||||
const sessionKeys = Object.keys(manifest || {}).sort()
|
||||
|
||||
return (
|
||||
@@ -145,13 +217,48 @@ function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Specs</div>
|
||||
{specsState === 'loading' && (
|
||||
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
</ul>
|
||||
)}
|
||||
{specsState === 'error' && (
|
||||
<div className="docs-nav-error" role="alert">
|
||||
<span>Couldn't load specs.</span>
|
||||
</div>
|
||||
)}
|
||||
{specsState === 'ok' && specs.length > 0 && (
|
||||
<ul className="docs-nav-list">
|
||||
{specs.map(spec => {
|
||||
const to = `/docs/specs/${spec.name}`
|
||||
return (
|
||||
<li key={spec.name}>
|
||||
<Link
|
||||
to={to}
|
||||
className={isExactly(to) ? 'active' : ''}
|
||||
aria-label={`Spec: ${spec.title}`}
|
||||
data-amp-track-name="Docs Nav Spec"
|
||||
data-amp-track-spec={spec.name}
|
||||
>
|
||||
{spec.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Sessions</div>
|
||||
<ul className="docs-nav-list">
|
||||
<li>
|
||||
<Link
|
||||
to="/docs/sessions/about"
|
||||
className={currentPath === '/docs/sessions/about' ? 'active' : ''}
|
||||
className={isExactly('/docs/sessions/about') ? 'active' : ''}
|
||||
aria-label="About sessions"
|
||||
data-amp-track-name="Docs Nav Sessions About"
|
||||
>
|
||||
@@ -183,23 +290,45 @@ function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
||||
)}
|
||||
|
||||
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
||||
<ul className="docs-nav-list">
|
||||
<ul className="docs-nav-list docs-nav-list--tree">
|
||||
{sessionKeys.map(nnnn => {
|
||||
const entry = manifest[nnnn] || {}
|
||||
const title = entry.title || ''
|
||||
const label = title ? `${nnnn} — ${title}` : nnnn
|
||||
const to = `/docs/sessions/${nnnn}`
|
||||
const files = sessionFiles[nnnn] || []
|
||||
return (
|
||||
<li key={nnnn}>
|
||||
<Link
|
||||
to={to}
|
||||
className={isActive(to) ? 'active' : ''}
|
||||
className={isExactly(to) ? 'active' : ''}
|
||||
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
|
||||
data-amp-track-name="Docs Nav Session"
|
||||
data-amp-track-session={nnnn}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
{files.length > 0 && (
|
||||
<ul className="docs-nav-list docs-nav-list--children">
|
||||
{files.map(f => {
|
||||
const tTo = `/docs/sessions/${nnnn}/${f}`
|
||||
return (
|
||||
<li key={f}>
|
||||
<Link
|
||||
to={tTo}
|
||||
className={isExactly(tTo) ? 'active' : ''}
|
||||
aria-label={`Transcript ${transcriptOrdinal(f)}`}
|
||||
data-amp-track-name="Docs Nav Transcript"
|
||||
data-amp-track-session={nnnn}
|
||||
data-amp-track-filename={f}
|
||||
>
|
||||
{transcriptOrdinal(f)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user