Skip to content

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 previewedit
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 from sse-starlette)
  • Yields parsed StageEvent objects; 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-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 SermonCanvasBriefRenderer).

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