e0d9ed7c5a
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>
341 lines
12 KiB
React
341 lines
12 KiB
React
// 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 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).
|
|
//
|
|
// Amplitude analytics (per SPEC §21):
|
|
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
|
|
// 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, 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')
|
|
getSessionsManifest()
|
|
.then(payload => {
|
|
if (!active) return
|
|
setManifest(payload || {})
|
|
setManifestState('ok')
|
|
})
|
|
.catch(() => {
|
|
if (!active) return
|
|
setManifest({})
|
|
setManifestState('error')
|
|
})
|
|
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(() => {
|
|
setDrawerOpen(false)
|
|
}, [location.pathname])
|
|
|
|
const retryManifest = useCallback(() => {
|
|
setReloadTick(t => t + 1)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="docs-layout">
|
|
<header className="docs-header">
|
|
<button
|
|
className="docs-back"
|
|
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
|
aria-label="Back to previous page"
|
|
data-amp-track-name="Docs Back"
|
|
>
|
|
← Back
|
|
</button>
|
|
<button
|
|
className="docs-drawer-toggle"
|
|
onClick={() => setDrawerOpen(o => !o)}
|
|
aria-label="Toggle docs navigation"
|
|
aria-expanded={drawerOpen}
|
|
data-amp-track-name="Docs Drawer Toggle"
|
|
>
|
|
<span aria-hidden>☰</span>
|
|
</button>
|
|
<span className="docs-title">Docs</span>
|
|
{!authenticated && (
|
|
<Link
|
|
className="docs-signin"
|
|
to="/"
|
|
aria-label="Home"
|
|
data-amp-track-name="Docs Home"
|
|
>
|
|
Home
|
|
</Link>
|
|
)}
|
|
</header>
|
|
<div className={'docs-body' + (drawerOpen ? ' docs-body--drawer-open' : '')}>
|
|
<aside className="docs-nav" aria-label="Docs navigation">
|
|
<DocsNav
|
|
manifest={manifest}
|
|
manifestState={manifestState}
|
|
sessionFiles={sessionFiles}
|
|
specs={specs}
|
|
specsState={specsState}
|
|
onRetry={retryManifest}
|
|
currentPath={location.pathname}
|
|
/>
|
|
</aside>
|
|
<main className="docs-content">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
{drawerOpen && (
|
|
<button
|
|
className="docs-drawer-scrim"
|
|
aria-label="Close drawer"
|
|
onClick={() => setDrawerOpen(false)}
|
|
data-amp-track-name="Docs Drawer Close"
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DocsNav({
|
|
manifest,
|
|
manifestState,
|
|
sessionFiles,
|
|
specs,
|
|
specsState,
|
|
onRetry,
|
|
currentPath,
|
|
}) {
|
|
const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
|
|
const isExactly = (path) => currentPath === path
|
|
|
|
const sessionKeys = Object.keys(manifest || {}).sort()
|
|
|
|
return (
|
|
<nav className="docs-nav-inner">
|
|
<div className="docs-nav-section">
|
|
<div className="docs-nav-section-label">Docs</div>
|
|
<ul className="docs-nav-list">
|
|
<li>
|
|
<Link
|
|
to="/docs/user-guide"
|
|
className={isActive('/docs/user-guide') ? 'active' : ''}
|
|
aria-label="User Guide"
|
|
data-amp-track-name="Docs Nav User Guide"
|
|
>
|
|
User Guide
|
|
</Link>
|
|
</li>
|
|
</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={isExactly('/docs/sessions/about') ? 'active' : ''}
|
|
aria-label="About sessions"
|
|
data-amp-track-name="Docs Nav Sessions About"
|
|
>
|
|
About
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
|
|
{manifestState === 'loading' && (
|
|
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
|
<li><span className="skeleton-row" /></li>
|
|
<li><span className="skeleton-row" /></li>
|
|
<li><span className="skeleton-row" /></li>
|
|
</ul>
|
|
)}
|
|
|
|
{manifestState === 'error' && (
|
|
<div className="docs-nav-error" role="alert">
|
|
<span>Couldn't load session list.</span>
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
aria-label="Retry session list"
|
|
data-amp-track-name="Docs Nav Sessions Retry"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
|
<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={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>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|