Frontend¶
The Ekklesia frontend is a single-page React application (web/) built with Vite and Tailwind CSS. It implements an Agentic Workspace layout: a fixed pipeline sidebar on the left shows live stage progress, while a tabbed canvas on the right presents each agent's output and the final editable sermon.
Component tree¶
graph TD
APP[App.tsx]
PC[PanelContext.Provider]
TF[TopicForm]
ST[StageTimeline]
SC[StageCard x5]
SW[SermonWorkspace]
TABS[Tab bar]
CANVAS[SermonCanvas]
BR[BriefRenderer]
RV[ResearchView]
EV[ExegesisView]
STRV[StructureView]
CV[CritiqueView]
SP[StrongsPopover]
PP[PassagePanelDrawer]
SRC[SourcePanel]
APP --> PC
PC --> ST
ST --> SC
PC --> SW
SW --> TABS
SW --> CANVAS
SW --> RV
SW --> EV
SW --> STRV
SW --> CV
CANVAS --> BR
RV --> PP
RV --> SRC
EV --> SP
EV --> PP
STRV --> SP
STRV --> PP
PC --> PP
Layout — two-pane workspace¶
File: web/src/App.tsx
The root layout is a full-viewport flex row (h-screen w-full overflow-hidden):
| Pane | Width | Contents |
|---|---|---|
| Left sidebar | 350px fixed | TopicForm, StageTimeline, error state |
| Right workspace | flex-1 | SermonWorkspace (tab bar + content area) |
PassagePanelDrawer is rendered at the root, outside both panes, so the slide-in drawer layers over the full viewport.
State machine — useSermonRun¶
File: web/src/state/useSermonRun.ts
The pipeline run lifecycle is managed by a useReducer-based hook. State transitions are strict and predictable — no imperative mutation.
stateDiagram-v2
[*] --> idle
idle --> streaming : start(topic)
streaming --> streaming : event(started|completed)
streaming --> completed : event(brief.completed)
streaming --> failed : event(*.failed)
completed --> streaming : start(topic)
failed --> streaming : start(topic)
RunState shape¶
interface RunState {
status: 'idle' | 'streaming' | 'completed' | 'failed';
topic: string | null;
startedAt: number | null;
stages: Record<PipelineStage, StageSlot>; // planner..critique
brief: SermonBrief | null;
error: { stage: StageName; detail: StageError } | null;
}
interface StageSlot {
status: 'pending' | 'running' | 'done' | 'failed';
elapsedMs: number | null;
payload: unknown | null;
sources: RetrievalSource[] | null; // populated from StageEvent.sources
}
Actions¶
| Action | Trigger | Effect |
|---|---|---|
start |
User submits topic | Aborts any in-flight stream; resets all stage slots to pending; opens new SSE connection |
event |
SSE StageEvent arrives |
Updates the matching stage slot; transitions global status on brief.completed or any failed |
reset |
Manual reset | Returns to INITIAL_STATE |
Concurrent starts are safe — an AbortController cancels the previous stream before the new one opens.
Canvas state — useSermonCanvas¶
File: web/src/state/useSermonCanvas.ts
A second useReducer-based hook manages the editable sermon canvas independently of the pipeline run state. It is seeded when brief.completed fires (via useEffect in App.tsx) and tracks edits, mode, and revision lifecycle.
interface CanvasState {
markdown: string; // current (possibly edited) content
originalMarkdown: string; // last committed version — used by Reset
isDirty: boolean;
mode: 'preview' | 'edit';
revisionStatus: 'idle' | 'loading' | 'done' | 'error';
revisionError: string | null;
}
Canvas actions¶
| Action | Effect |
|---|---|
seed(markdown) |
Called on brief.completed; sets both markdown and originalMarkdown; resets dirty flag |
edit(markdown) |
User keystroke; sets isDirty = true when content differs from originalMarkdown |
reset() |
Restores originalMarkdown; clears dirty flag |
toggleMode() |
Swaps preview ↔ edit |
startRevision |
Sets revisionStatus = 'loading' |
completeRevision(markdown) |
Replaces content; resets originalMarkdown to the new text; clears dirty |
failRevision(error) |
Sets revisionStatus = 'error' with message |
SSE client — streamSermon¶
File: web/src/api/sermonStream.ts
Uses fetch + ReadableStream rather than the native EventSource API because EventSource does not support POST requests.
export async function* streamSermon(
topic: string,
opts?: { signal?: AbortSignal; baseUrl?: string },
): AsyncGenerator<StageEvent, void, void>
- Reads the response body chunk by chunk, decoding
\r\n→\n - Splits on
\n\n(SSE event boundaries) - Ignores lines starting with
:(heartbeat pings fromsse-starlette) - Yields parsed
StageEventobjects; silently skips malformed JSON
The baseUrl defaults to import.meta.env.VITE_API_URL ?? ''. An empty base URL produces relative paths (/sermons), which are proxied by Vite in development and by nginx in production.
SermonWorkspace¶
File: web/src/components/SermonWorkspace.tsx
The right pane host. Renders a tab bar followed by a scrollable content area. Tabs unlock as each agent completes — locked tabs are non-clickable and greyed out.
| Tab | Unlocks when | Content component |
|---|---|---|
| Sermon Brief | Always (skeleton while streaming) | SermonCanvas |
| Research | research.completed |
ResearchView |
| Exegesis | exegesis.completed |
ExegesisView |
| Outline | structure.completed |
StructureView |
| Critique | critique.completed |
CritiqueView |
Auto-switch behaviour: When a stage fires .completed, the workspace automatically switches to that stage's tab — giving the user immediate visibility into each result as it arrives. Auto-switch is suppressed once the user manually clicks any tab. It resets when a new run starts.
StageTimeline¶
File: web/src/components/StageTimeline.tsx
Renders a vertical list of five StageCard components in the left sidebar. The timeline is a progress tracker only — expanded stage output (Research, Exegesis, Outline, Critique) is shown exclusively in the workspace tabs. Only the Planner card expands inline, since the plan doesn't have a dedicated workspace tab.
StageCard (StageCard.tsx) displays the stage name, a status icon, elapsed time, and — while running — animated thinking-log strings that cycle every 3 seconds:
const STAGE_LOGS = {
planner: ['Identifying passage context…', 'Setting audience profile…', ...],
research: ['Querying pgvector…', 'Fetching cross-references…', ...],
exegesis: ['Parsing literary context…', 'Running word studies…', ...],
structure: ['Drafting sermon outline…', 'Assigning passage refs…', ...],
critique: ['Reviewing sermon draft…', 'Fact-checking claims…', ...],
};
The blinking cursor (▌) animates via Tailwind's animate-pulse. The stage counter ticks at 100ms intervals and resets on each status change.
Stage views¶
Each stage has a dedicated view component that renders the stage-specific payload. These are rendered inside SermonWorkspace tabs:
| Component | File | Renders |
|---|---|---|
PlanView |
stages/PlanView.tsx |
Passage reference, audience badge, occasion badge, main question, research/exegesis/structure goal lists |
ResearchView |
stages/ResearchView.tsx |
Passage summary, key themes, commentary highlights, cross-reference links, open questions; SourcePanel drawer at bottom showing retrieved sources |
ExegesisView |
stages/ExegesisView.tsx |
Main idea, literary context, theological moves, word study cards with StrongsPopover, application principles |
StructureView |
stages/StructureView.tsx |
Outline sections with headings; each point shows scripture ref chips (PassagePanel) and Strong's number chips (StrongsPopover) drawn from the agent's citation fields |
CritiqueView |
stages/CritiqueView.tsx |
Strengths list, concerns, fact-checks with verdict badges, revision suggestions |
Source panel — SourcePanel¶
File: web/src/components/SourcePanel.tsx
A collapsible drawer rendered at the bottom of the ResearchView. It displays the raw retrieval results (RetrievalSource[]) that fed the research stage.
Each source shows:
- A colour-coded type badge (Bible / Commentary / Cultural)
- The reference string (e.g. "Matthew Henry on John 3:16")
- A relevance score (RRF-fused, displayed to 3 decimal places)
- Expandable text excerpt (first 600 characters)
Sources are populated from StageEvent.sources, which carries RetrievalResult objects with stable id fields. This is the designed attachment point for inline per-claim citations in a future iteration.
Cross-reference link extraction — ResearchView¶
Cross-references arrive from the API as strings like "John 7:47: Jewish leaders questioned...". Only the leading reference ("John 7:47") should be sent to the passage lookup API. ResearchView uses a regex to extract it:
const LEAD_REF_REGEX = /^((?:1|2|3)\s*)?([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+\d+:\d+(?:-\d+)?/;
function extractLeadRef(xref: string): string {
const m = LEAD_REF_REGEX.exec(xref);
return m ? m[0].trim() : xref;
}
SermonCanvas¶
File: web/src/components/SermonCanvas.tsx
The editable sermon pane, rendered in the Sermon Brief workspace tab. Combines a toolbar with a content area that switches between edit (textarea) and preview (BriefRenderer) modes.
┌──────────────────────────────────────────────────┐
│ [Edit] [Preview] [Reset] [Apply Critique]│ ← toolbar
├──────────────────────────────────────────────────┤
│ <textarea> (edit mode) │
│ or <BriefRenderer markdown={...} /> (preview) │
└──────────────────────────────────────────────────┘
| Button | Available when | Action |
|---|---|---|
| Edit / Preview | Brief seeded | Toggle canvas.mode |
| Reset | canvas.isDirty |
Restore originalMarkdown |
| Apply Critique | Critique completed, not loading | Call POST /sermons/revise with current markdown + recommended_revisions |
The "Apply Critique" button shows a spinner while the revision is in flight. On success, the canvas content and originalMarkdown are both updated to the revised text (the revision becomes the new baseline).
Actionable critique — revision flow¶
sequenceDiagram
participant U as User
participant SC as SermonCanvas
participant APP as App.tsx
participant API as POST /sermons/revise
U->>SC: click "Apply Critique"
SC->>APP: onApplyCritique()
APP->>API: {current_markdown, recommended_revisions}
API-->>APP: {revised_markdown, changes_summary}
APP->>SC: completeRevision(revised_markdown)
SC-->>U: canvas updates with revised sermon
The recommended_revisions list comes from the Critique agent's CritiqueReport.recommended_revisions. The revision agent receives the full current markdown + the revision list and returns an improved version with a short changes_summary.
BriefRenderer¶
File: web/src/components/BriefRenderer.tsx
Renders a sermon markdown string as rich HTML. Accepts markdown: string directly (the SermonBrief wrapper is unwrapped in App.tsx before being passed to useSermonCanvas, which then passes the string to SermonCanvas → BriefRenderer).
Strong's numbers in the markdown are detected and wrapped in StrongsPopover. Biblical references are wrapped in PassagePanel links.
A BriefSkeleton (BriefSkeleton.tsx) is shown in the Sermon Brief tab while state.status === 'streaming' and state.brief is still null.
Citation system¶
StrongsPopover¶
File: web/src/components/StrongsPopover.tsx
Wraps any inline text node. On mouseenter it fetches /lookup/strongs/{strongsNumber} and displays the lexicon entry in a floating popover. Results are cached in a ref — subsequent hovers are instant and produce no network requests.
sequenceDiagram
participant U as User (hover)
participant C as StrongsPopover
participant A as API
U->>C: mouseenter
alt cached
C-->>U: show cached data
else not cached
C->>A: GET /lookup/strongs/G4102
A-->>C: LexiconEntry
C-->>U: show popover
end
U->>C: mouseleave (100ms delay)
C-->>U: close popover
PassagePanel¶
File: web/src/components/PassagePanel.tsx
A slide-in drawer that fetches and displays a full Bible passage. It is controlled by PanelContext — any component anywhere in the tree can call openPanel(reference) to trigger it.
sequenceDiagram
participant U as User (click)
participant RV as ResearchView
participant CTX as PanelContext
participant PP as PassagePanelDrawer
participant A as API
U->>RV: click "John 7:47"
RV->>CTX: openPanel("John 7:47")
CTX->>PP: activePanel = "John 7:47"
PP->>A: GET /passage/John 7:47
A-->>PP: {passage, verses}
PP-->>U: slide-in drawer
PanelContext (state/PanelContext.tsx) provides { activePanel, openPanel, closePanel }. The single PassagePanelDrawer instance lives at the root of App.tsx so it renders outside any scrolling container.
HTTP client¶
File: web/src/api/client.ts
Three fetch-based functions:
| Function | Endpoint | Use |
|---|---|---|
lookupStrongs(strongsNumber) |
GET /lookup/strongs/{id} |
StrongsPopover |
lookupPassage(reference) |
GET /passage/{ref} |
PassagePanelDrawer |
reviseSermon(currentMarkdown, recommendedRevisions) |
POST /sermons/revise |
SermonCanvas Apply Critique button |
Development proxy¶
File: web/vite.config.ts
proxy: {
'/sermons': { target: 'http://localhost:8000', changeOrigin: true },
'/lookup': { target: 'http://localhost:8000', changeOrigin: true },
'/passage': { target: 'http://localhost:8000', changeOrigin: true },
'/health': { target: 'http://localhost:8000', changeOrigin: true },
}
All API paths are proxied to the local FastAPI server. The /sermons prefix covers both POST /sermons (stream) and POST /sermons/revise (revision). In production, nginx handles the same proxying — relative URLs work without any code change.
Environment variables¶
| Variable | Default | Notes |
|---|---|---|
VITE_API_URL |
"" (empty) |
Override to target a remote API; empty = relative URLs |
API_HOST |
api:8000 |
nginx runtime env var — Docker internal hostname of API service |