Skip to content

ai-partner: streaming chat UI with citation pills #1455

@CraigBuckmaster

Description

@CraigBuckmaster

Parent epic: #1446 (Amicus — AI Study Partner v1)
Phase: 2 · Size: L · Depends on: #1454 (shell), #1457 (persistence), #1450 (proxy)

The conversation view. Streaming AI responses with inline citation pills, message bubbles, follow-up suggestions, and input handling. The heart of the Amicus tab.


Files to create

  • app/src/components/amicus/MessageList.tsx — inverted FlatList of messages
  • app/src/components/amicus/UserMessageBubble.tsx — user message row
  • app/src/components/amicus/AssistantMessageBubble.tsx — streaming-capable AI message row
  • app/src/components/amicus/CitationPill.tsx — inline pill for [chunk_id] references
  • app/src/components/amicus/FollowUpChips.tsx — 3 tappable follow-up suggestions under last AI message
  • app/src/components/amicus/InputBar.tsx — sticky bottom input with send button
  • app/src/components/amicus/StreamingDot.tsx — visual indicator during streaming
  • app/src/services/amicus/chat.ts — orchestrator that composes retrieval (ai-partner: client-side retrieval with sqlite-vec #1451) + streaming fetch to proxy (ai-partner: Cloudflare Worker AI proxy stand-up #1450)
  • app/src/hooks/useAmicusThread.ts — hook managing thread state, streaming, optimistic user messages
  • app/src/components/amicus/__tests__/*.test.tsx — component tests

Files to modify


The streaming flow (chat.ts orchestrator)

export interface StreamChatParams {
  threadId: string;
  userQuery: string;
  currentChapterRef?: { book_id: string; chapter_num: number } | null;
  onDelta: (token: string) => void;       // partial assistant response
  onCitation: (pillData: CitationPillData) => void;   // structured citation events
  onGapSignal: (gap: GapSignal) => void;   // parsed from structured envelope
  onComplete: (finalMessage: AssistantMessage) => void;
  onError: (err: AmicusError) => void;
  signal: AbortSignal;
}

export async function streamChat(params: StreamChatParams): Promise<void>;

Sequence per user message:

  1. Optimistic user message insert (via ai-partner: conversation persistence (user.db) #1457 mutation)
  2. Generate compressed profile (from ai-partner: compressed profile generator #1452)
  3. Retrieve chunks (from ai-partner: client-side retrieval with sqlite-vec #1451)
  4. Open SSE connection to /ai/chat proxy (from ai-partner: Cloudflare Worker AI proxy stand-up #1450) with:
    { query, retrieved_chunks, profile_summary, current_chapter_ref,
      model_tier: "sonnet", conversation_history }
    
  5. Parse stream:
    • Prose tokens → onDelta
    • Inline [chunk_id] markers → onCitation with resolved { chunk_id, source_type, display_label }
    • JSON envelope at end → onGapSignal
  6. On [DONE]: assemble final message, persist via ai-partner: conversation persistence (user.db) #1457, call onComplete

Network errors, aborts, and rate-limit 429s surface as typed AmicusError via onError.

Assistant message rendering

The assistant bubble interleaves prose and citation pills. Input from the stream:

Reformed scholars like Calvin read Romans 9 as describing God's sovereign freedom [CITE:section_panel:romans-9-s1-calvin]. Jewish scholars like Sarna emphasize the covenantal dimension [CITE:section_panel:exodus-33-s2-sarna].

Parser produces alternating text + pill nodes. Pills are inline React components (not markdown) — tappable, visually distinct (gold-bordered pill, small scholar avatar if available).

CitationPill

interface CitationPillProps {
  chunkId: string;
  sourceType: string;        // "section_panel" | "chapter_panel" | etc.
  displayLabel: string;      // e.g. "Calvin · Romans 9:15"
  scholarId?: string;        // drives avatar
  onTap: () => void;         // wired to navigation in #1456
}

Display: compact gold pill (base.gold + '20' bg, base.gold border, text base.gold, 11px). Scholar avatar circle if scholarId present. Tap handled in #1456.

Streaming visual

  • While streaming, last assistant bubble shows a subtle pulsing StreamingDot after the last token
  • Auto-scroll to bottom while user has not manually scrolled up (standard "sticky bottom" chat pattern)
  • If user scrolls up, show a "New message ↓" pill at the bottom to jump back

Follow-up chips

After onComplete, render 3 follow-up chips under the last assistant message. Generation: the assistant's structured envelope (separate from gap_signal) includes follow_ups: string[] — the server-side system prompt asks for them. If the envelope is missing/malformed, render no chips.

Tap → send chip text as next user message (same flow as typing it).

Input bar

  • Multi-line text input with max 4 visible lines, scrolls internally after that
  • Send button (gold circle with arrow icon) — enabled when text length > 0 and not currently streaming
  • During streaming: send button replaced with "Stop" (square) — tapping aborts the stream via AbortController
  • Safe-area aware; keyboard avoidance via KeyboardAvoidingView
  • Character counter only if > 500 chars (subtle warning if approaching 2000-char limit)

Hook: useAmicusThread(threadId)

Returns:

{
  messages: AmicusMessage[];
  isStreaming: boolean;
  error: AmicusError | null;
  sendMessage: (text: string) => Promise<void>;
  abortStream: () => void;
  refresh: () => Promise<void>;
}

Loads message history from #1457 on mount, subscribes to real-time streaming updates, persists on complete.

Error states

Error UI
OFFLINE Banner: "Amicus needs an internet connection" + retry button
RATE_LIMIT Banner: "You've reached your monthly limit. Upgrade or wait until [date]." + upgrade CTA
EMBED_FAILED Banner: "Couldn't prepare your question. Try again?" + retry
PROXY_5XX Banner: "Amicus is temporarily unavailable. Try again." + retry
ABORTED (silent — user-initiated)

Errors render as dismissible banners above the input bar, not as message bubbles.


Conventions to follow

  • Component layout: functional components with hooks, useTheme() for colors
  • Streaming parser: small pure function, unit-testable
  • Never block on network: all async ops cancellable via AbortController
  • Logger, not console.log
  • Strict TS, no any
  • FlatList inverted for chat; keyExtractor={m => m.message_id}

Performance targets

  • First token visible within 1.5s of send (p95)
  • No dropped frames during stream (measure with React DevTools Profiler)
  • MessageList handles 500-message threads without lag

Acceptance criteria

  • User can type and send a message; optimistic bubble appears immediately
  • Assistant response streams token-by-token into the bubble
  • Citation pills render inline in assistant prose; visually distinct
  • Tap on citation pill triggers onTap callback (navigation wired in ai-partner: citation → source navigation #1456)
  • Follow-up chips appear under completed AI message; tapping a chip sends it
  • Stop button aborts an in-flight stream cleanly; partial response preserved
  • Auto-scroll keeps latest message visible while user is at bottom
  • "New message ↓" pill appears if user scrolled up during stream
  • All error states render correct banners
  • 429 rate limit surfaces upgrade CTA
  • Streaming parser correctly extracts prose / citations / envelope from mock stream in unit tests
  • First token < 1.5s p95 on staging proxy
  • Component tests pass for MessageList, CitationPill, InputBar
  • No any types; lint clean

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions