feat: Updated streamChat() API, ChatWidget, and ChatPage to thread conv…

- "frontend/src/api/chat.ts"
- "frontend/src/components/ChatWidget.tsx"
- "frontend/src/pages/ChatPage.tsx"
- "frontend/src/pages/ChatPage.module.css"

GSD-Task: S04/T02
This commit is contained in:
jlightner 2026-04-04 07:53:50 +00:00
parent d13d6c3aa1
commit 34acf468c6
7 changed files with 430 additions and 128 deletions

View file

@ -8,7 +8,7 @@
- Estimate: 1.5h - Estimate: 1.5h
- Files: backend/routers/chat.py, backend/chat_service.py, backend/tests/test_chat.py - Files: backend/routers/chat.py, backend/chat_service.py, backend/tests/test_chat.py
- Verify: cd backend && python -m pytest tests/test_chat.py -v 2>&1 | tail -30 - Verify: cd backend && python -m pytest tests/test_chat.py -v 2>&1 | tail -30
- [ ] **T02: Thread conversation_id through frontend chat API and both chat UIs** — Update the frontend to support multi-turn conversations. (1) `streamChat()` in `api/chat.ts` gains a `conversationId` param sent in the POST body and parsed from the `done` event. (2) ChatWidget already has a local messages array — add `conversationId` state (generated on first send via `crypto.randomUUID()`), thread it through `streamChat()` calls, update it from `done` event response, reset on panel close. (3) ChatPage converts from single-response to multi-message UI: replace single `responseText`/`sources` state with a `messages[]` array (same shape as ChatWidget), add `conversationId` state, render message history with existing `parseChatCitations()`, add a 'New conversation' button. Reuse ChatWidget's message rendering pattern. - [x] **T02: Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering** — Update the frontend to support multi-turn conversations. (1) `streamChat()` in `api/chat.ts` gains a `conversationId` param sent in the POST body and parsed from the `done` event. (2) ChatWidget already has a local messages array — add `conversationId` state (generated on first send via `crypto.randomUUID()`), thread it through `streamChat()` calls, update it from `done` event response, reset on panel close. (3) ChatPage converts from single-response to multi-message UI: replace single `responseText`/`sources` state with a `messages[]` array (same shape as ChatWidget), add `conversationId` state, render message history with existing `parseChatCitations()`, add a 'New conversation' button. Reuse ChatWidget's message rendering pattern.
- Estimate: 1.5h - Estimate: 1.5h
- Files: frontend/src/api/chat.ts, frontend/src/components/ChatWidget.tsx, frontend/src/pages/ChatPage.tsx, frontend/src/pages/ChatPage.module.css - Files: frontend/src/api/chat.ts, frontend/src/components/ChatWidget.tsx, frontend/src/pages/ChatPage.tsx, frontend/src/pages/ChatPage.module.css
- Verify: cd frontend && npm run build 2>&1 | tail -20 - Verify: cd frontend && npm run build 2>&1 | tail -20

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M022/S04/T01",
"timestamp": 1775289031003,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,81 @@
---
id: T02
parent: S04
milestone: M022
provides: []
requires: []
affects: []
key_files: ["frontend/src/api/chat.ts", "frontend/src/components/ChatWidget.tsx", "frontend/src/pages/ChatPage.tsx", "frontend/src/pages/ChatPage.module.css"]
key_decisions: ["ChatWidget resets conversation state on close rather than preserving across open/close cycles", "ChatDoneMeta exported type for typed done event parsing across consumers"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "cd frontend && npm run build — clean build with 0 errors, all 4 modified files compile and bundle correctly."
completed_at: 2026-04-04T07:53:47.090Z
blocker_discovered: false
---
# T02: Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering
> Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering
## What Happened
---
id: T02
parent: S04
milestone: M022
key_files:
- frontend/src/api/chat.ts
- frontend/src/components/ChatWidget.tsx
- frontend/src/pages/ChatPage.tsx
- frontend/src/pages/ChatPage.module.css
key_decisions:
- ChatWidget resets conversation state on close rather than preserving across open/close cycles
- ChatDoneMeta exported type for typed done event parsing across consumers
duration: ""
verification_result: passed
completed_at: 2026-04-04T07:53:47.090Z
blocker_discovered: false
---
# T02: Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering
**Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering**
## What Happened
Three changes across four files: (1) api/chat.ts gained conversationId param, ChatDoneMeta type, and conversation_id in POST body. (2) ChatWidget.tsx added conversationId state generated on first send via crypto.randomUUID(), threaded through streamChat(), updated from done event, reset on close via new handleClose callback. (3) ChatPage.tsx converted from single-response state to multi-message messages[] array with conversationId threading, new-conversation button, per-message citation rendering, typing indicator, and auto-scroll. (4) ChatPage.module.css replaced with conversation bubble layout, headerRow, sticky input, responsive styles.
## Verification
cd frontend && npm run build — clean build with 0 errors, all 4 modified files compile and bundle correctly.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 2150ms |
## Deviations
ChatWidget close now resets messages array in addition to conversationId for a clean slate on reopen. Plan didn't specify preservation behavior; chose reset for simplicity.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/api/chat.ts`
- `frontend/src/components/ChatWidget.tsx`
- `frontend/src/pages/ChatPage.tsx`
- `frontend/src/pages/ChatPage.module.css`
## Deviations
ChatWidget close now resets messages array in addition to conversationId for a clean slate on reopen. Plan didn't specify preservation behavior; chose reset for simplicity.
## Known Issues
None.

View file

@ -21,10 +21,15 @@ export interface ChatSource {
section_heading: string; section_heading: string;
} }
export interface ChatDoneMeta {
cascade_tier: string;
conversation_id: string;
}
export interface ChatCallbacks { export interface ChatCallbacks {
onSources: (sources: ChatSource[]) => void; onSources: (sources: ChatSource[]) => void;
onToken: (text: string) => void; onToken: (text: string) => void;
onDone: (meta: { cascade_tier: string }) => void; onDone: (meta: ChatDoneMeta) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@ -36,6 +41,7 @@ export function streamChat(
query: string, query: string,
callbacks: ChatCallbacks, callbacks: ChatCallbacks,
creator?: string, creator?: string,
conversationId?: string,
): AbortController { ): AbortController {
const controller = new AbortController(); const controller = new AbortController();
@ -57,7 +63,11 @@ export function streamChat(
fetch(`${BASE}/chat`, { fetch(`${BASE}/chat`, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ query, creator: creator ?? null }), body: JSON.stringify({
query,
creator: creator ?? null,
conversation_id: conversationId ?? null,
}),
signal: controller.signal, signal: controller.signal,
}) })
.then(async (res) => { .then(async (res) => {
@ -116,7 +126,7 @@ export function streamChat(
callbacks.onToken(JSON.parse(rawData) as string); callbacks.onToken(JSON.parse(rawData) as string);
break; break;
case "done": case "done":
callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string }); callbacks.onDone(JSON.parse(rawData) as ChatDoneMeta);
break; break;
case "error": { case "error": {
const err = JSON.parse(rawData) as { message?: string }; const err = JSON.parse(rawData) as { message?: string };

View file

@ -120,6 +120,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@ -151,15 +152,24 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
}; };
}, []); }, []);
const handleClose = useCallback(() => {
abortRef.current?.abort();
setOpen(false);
setMessages([]);
setInput("");
setStreaming(false);
setConversationId(undefined);
}, []);
// Escape key closes panel // Escape key closes panel
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false); if (e.key === "Escape") handleClose();
}; };
document.addEventListener("keydown", handleKey); document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [open]); }, [open, handleClose]);
const sendMessage = useCallback( const sendMessage = useCallback(
(text: string) => { (text: string) => {
@ -168,6 +178,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
abortRef.current?.abort(); abortRef.current?.abort();
// Generate conversation_id on first message
const cid = conversationId ?? crypto.randomUUID();
if (!conversationId) setConversationId(cid);
const userMsg: Message = { role: "user", text: q, sources: [], done: true }; const userMsg: Message = { role: "user", text: q, sources: [], done: true };
const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false }; const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false };
const newMessages = [...messages, userMsg, assistantMsg]; const newMessages = [...messages, userMsg, assistantMsg];
@ -192,7 +206,8 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
), ),
); );
}, },
onDone: () => { onDone: (meta) => {
setConversationId(meta.conversation_id);
setMessages((prev) => setMessages((prev) =>
prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)), prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)),
); );
@ -208,9 +223,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
}, },
}, },
creatorName, creatorName,
cid,
); );
}, },
[messages, streaming, creatorName], [messages, streaming, creatorName, conversationId],
); );
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@ -249,7 +265,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
</div> </div>
<button <button
className={styles.closeBtn} className={styles.closeBtn}
onClick={() => setOpen(false)} onClick={handleClose}
aria-label="Close chat" aria-label="Close chat"
> >

View file

@ -1,4 +1,4 @@
/* ChatPage — dark theme matching existing Chrysopedia styles */ /* ChatPage — dark theme, multi-message conversation layout */
.container { .container {
max-width: 800px; max-width: 800px;
@ -9,11 +9,39 @@
min-height: 60vh; min-height: 60vh;
} }
/* ── Header ───────────────────────────────────────────────── */
.headerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.title { .title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-primary);
margin: 0 0 1.5rem; margin: 0;
}
.newConversationBtn {
padding: 0.375rem 0.75rem;
background: transparent;
color: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.newConversationBtn:hover {
background: var(--color-accent);
color: #0f0f14;
} }
/* ── Input area ───────────────────────────────────────────── */ /* ── Input area ───────────────────────────────────────────── */
@ -21,7 +49,11 @@
.inputRow { .inputRow {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1.5rem; margin-top: auto;
padding-top: 1rem;
position: sticky;
bottom: 0;
background: var(--color-bg-page);
} }
.input { .input {
@ -68,17 +100,40 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Response area ────────────────────────────────────────── */ /* ── Messages area ────────────────────────────────────────── */
.responseArea { .messagesArea {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-bottom: 0.5rem;
}
.userMsg {
align-self: flex-end;
max-width: 80%;
background: var(--color-accent);
color: #0f0f14;
padding: 0.625rem 0.875rem;
border-radius: 12px 12px 2px 12px;
font-size: 0.9375rem;
line-height: 1.5;
word-break: break-word;
}
.assistantMsg {
align-self: flex-start;
max-width: 90%;
background: var(--color-bg-surface); background: var(--color-bg-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 12px; border-radius: 12px 12px 12px 2px;
padding: 1.5rem; padding: 1rem;
margin-bottom: 1.5rem;
min-height: 100px;
} }
/* ── Response text ────────────────────────────────────────── */
.responseText { .responseText {
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 0.9375rem; font-size: 0.9375rem;
@ -133,13 +188,14 @@
border-radius: 8px; border-radius: 8px;
padding: 0.75rem; padding: 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 1rem;
} }
/* ── Citations / Sources ──────────────────────────────────── */ /* ── Citations / Sources ──────────────────────────────────── */
.sourcesSection { .sourcesSection {
margin-top: 0.5rem; margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
} }
.sourcesTitle { .sourcesTitle {
@ -148,7 +204,7 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin: 0 0 0.75rem; margin: 0 0 0.5rem;
} }
.sourceList { .sourceList {
@ -157,27 +213,27 @@
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.375rem;
} }
.sourceItem { .sourceItem {
display: flex; display: flex;
gap: 0.625rem; gap: 0.625rem;
align-items: baseline; align-items: baseline;
font-size: 0.875rem; font-size: 0.8125rem;
} }
.sourceNumber { .sourceNumber {
flex-shrink: 0; flex-shrink: 0;
width: 1.5rem; width: 1.25rem;
height: 1.5rem; height: 1.25rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--color-accent); background: var(--color-accent);
color: #0f0f14; color: #0f0f14;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.6875rem;
font-weight: 700; font-weight: 700;
} }
@ -193,7 +249,7 @@
.sourceMeta { .sourceMeta {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.8125rem; font-size: 0.75rem;
} }
/* ── Citation superscript links in text ───────────────────── */ /* ── Citation superscript links in text ───────────────────── */
@ -222,6 +278,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1;
min-height: 200px; min-height: 200px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.9375rem; font-size: 0.9375rem;
@ -241,6 +298,12 @@
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
} }
.headerRow {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.inputRow { .inputRow {
flex-direction: column; flex-direction: column;
} }
@ -248,4 +311,8 @@
.submitBtn { .submitBtn {
width: 100%; width: 100%;
} }
.userMsg {
max-width: 90%;
}
} }

View file

@ -1,9 +1,10 @@
/** /**
* ChatPage ask a question, receive a streamed encyclopedic response with citations. * ChatPage multi-turn chat with streamed encyclopedic responses and citations.
* *
* Uses SSE streaming via api/chat.ts to display tokens in real-time. * Uses SSE streaming via api/chat.ts to display tokens in real-time.
* Citation markers [N] in the streamed text become superscript links to * Citation markers [N] in the streamed text become superscript links to
* the source list rendered below the response. * the source list rendered below the response. Conversations are threaded
* via conversation_id backed by Redis history on the server.
*/ */
import React, { useState, useRef, useCallback, useEffect } from "react"; import React, { useState, useRef, useCallback, useEffect } from "react";
@ -15,9 +16,16 @@ import styles from "./ChatPage.module.css";
// Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx // Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
interface Message {
role: "user" | "assistant";
text: string;
sources: ChatSource[];
done: boolean;
error?: string;
}
/** /**
* Parse text containing [N] markers into React nodes with citation links. * Parse text containing [N] markers into React nodes with citation links.
* Links scroll to the source list item and reference ChatSource metadata.
*/ */
function parseChatCitations( function parseChatCitations(
text: string, text: string,
@ -80,16 +88,16 @@ function parseChatCitations(
export default function ChatPage() { export default function ChatPage() {
useDocumentTitle("Chat — Chrysopedia"); useDocumentTitle("Chat — Chrysopedia");
const [query, setQuery] = useState(""); const [messages, setMessages] = useState<Message[]>([]);
const [sources, setSources] = useState<ChatSource[]>([]); const [input, setInput] = useState("");
const [responseText, setResponseText] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [loading, setLoading] = useState(false); const [conversationId, setConversationId] = useState<string | undefined>(
const [error, setError] = useState<string | null>(null); undefined,
const [done, setDone] = useState(false); );
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
@ -98,104 +106,177 @@ export default function ChatPage() {
}; };
}, []); }, []);
// Scroll to bottom when messages update
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const sendMessage = useCallback(
(text: string) => {
const q = text.trim();
if (!q || streaming) return;
abortRef.current?.abort();
// Generate conversation_id on first message
const cid = conversationId ?? crypto.randomUUID();
if (!conversationId) setConversationId(cid);
const userMsg: Message = {
role: "user",
text: q,
sources: [],
done: true,
};
const assistantMsg: Message = {
role: "assistant",
text: "",
sources: [],
done: false,
};
const newMessages = [...messages, userMsg, assistantMsg];
setMessages(newMessages);
setInput("");
setStreaming(true);
const assistantIndex = newMessages.length - 1;
abortRef.current = streamChat(
q,
{
onSources: (sources) => {
setMessages((prev) =>
prev.map((m, i) =>
i === assistantIndex ? { ...m, sources } : m,
),
);
},
onToken: (token) => {
setMessages((prev) =>
prev.map((m, i) =>
i === assistantIndex ? { ...m, text: m.text + token } : m,
),
);
},
onDone: (meta) => {
setConversationId(meta.conversation_id);
setMessages((prev) =>
prev.map((m, i) =>
i === assistantIndex ? { ...m, done: true } : m,
),
);
setStreaming(false);
},
onError: (msg) => {
setMessages((prev) =>
prev.map((m, i) =>
i === assistantIndex ? { ...m, done: true, error: msg } : m,
),
);
setStreaming(false);
},
},
undefined, // no creator scope on the main chat page
cid,
);
},
[messages, streaming, conversationId],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const q = query.trim(); sendMessage(input);
if (!q || streaming) return;
// Reset state
abortRef.current?.abort();
setSources([]);
setResponseText("");
setError(null);
setDone(false);
setLoading(true);
setStreaming(true);
abortRef.current = streamChat(q, {
onSources: (s) => {
setSources(s);
setLoading(false);
}, },
onToken: (text) => { [input, sendMessage],
setResponseText((prev) => prev + text);
},
onDone: () => {
setStreaming(false);
setDone(true);
},
onError: (msg) => {
setError(msg);
setStreaming(false);
setLoading(false);
},
});
},
[query, streaming],
); );
const hasResponse = responseText.length > 0 || sources.length > 0; const handleNewConversation = useCallback(() => {
abortRef.current?.abort();
setMessages([]);
setInput("");
setStreaming(false);
setConversationId(undefined);
inputRef.current?.focus();
}, []);
const hasMessages = messages.length > 0;
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.headerRow}>
<h1 className={styles.title}>Ask Chrysopedia</h1> <h1 className={styles.title}>Ask Chrysopedia</h1>
{hasMessages && (
<form onSubmit={handleSubmit} className={styles.inputRow}>
<input
ref={inputRef}
type="text"
className={styles.input}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ask about music production techniques…"
disabled={streaming}
autoFocus
/>
<button <button
type="submit" className={styles.newConversationBtn}
className={styles.submitBtn} onClick={handleNewConversation}
disabled={!query.trim() || streaming} title="Start a new conversation"
> >
{streaming ? "Streaming…" : "Ask"} + New conversation
</button> </button>
</form> )}
</div>
{error && <div className={styles.error}>{error}</div>} {!hasMessages && !streaming && (
<div className={styles.placeholder}>
{loading && ( <span className={styles.placeholderIcon}>💬</span>
<div className={styles.loading}> <p>Ask any question about music production techniques.</p>
<div className={styles.spinner} /> <p>Responses include citations linking to source pages and videos.</p>
Searching for sources
</div> </div>
)} )}
{hasResponse && ( {hasMessages && (
<div className={styles.responseArea}> <div className={styles.messagesArea}>
<div className={styles.responseText}> {messages.map((msg, i) => {
{parseChatCitations(responseText, sources)} if (msg.role === "user") {
{streaming && <span className={styles.cursor} />} return (
<div key={i} className={styles.userMsg}>
{msg.text}
</div> </div>
);
}
{sources.length > 0 && (done || !streaming) && ( // Assistant message
return (
<div key={i} className={styles.assistantMsg}>
{msg.error ? (
<div className={styles.error}>{msg.error}</div>
) : (
<>
<div className={styles.responseText}>
{parseChatCitations(msg.text, msg.sources)}
{!msg.done && <span className={styles.cursor} />}
</div>
{msg.done && msg.sources.length > 0 && (
<div className={styles.sourcesSection}> <div className={styles.sourcesSection}>
<h2 className={styles.sourcesTitle}>Sources</h2> <h3 className={styles.sourcesTitle}>Sources</h3>
<ol className={styles.sourceList}> <ol className={styles.sourceList}>
{sources.map((src) => { {msg.sources.map((src) => {
const href = src.section_anchor const href = src.section_anchor
? `/techniques/${src.slug}#${src.section_anchor}` ? `/techniques/${src.slug}#${src.section_anchor}`
: `/techniques/${src.slug}`; : `/techniques/${src.slug}`;
return ( return (
<li key={src.number} className={styles.sourceItem}> <li
<span className={styles.sourceNumber}>{src.number}</span> key={src.number}
className={styles.sourceItem}
>
<span className={styles.sourceNumber}>
{src.number}
</span>
<div> <div>
<Link to={href} className={styles.sourceLink}> <Link
to={href}
className={styles.sourceLink}
>
{src.title} {src.title}
{src.section_heading ? `${src.section_heading}` : ""} {src.section_heading
? `${src.section_heading}`
: ""}
</Link> </Link>
{src.creator_name && ( {src.creator_name && (
<span className={styles.sourceMeta}> <span className={styles.sourceMeta}>
{" "}· {src.creator_name} {" "}
· {src.creator_name}
</span> </span>
)} )}
</div> </div>
@ -205,16 +286,47 @@ export default function ChatPage() {
</ol> </ol>
</div> </div>
)} )}
</div> </>
)}
{!hasResponse && !loading && !error && (
<div className={styles.placeholder}>
<span className={styles.placeholderIcon}>💬</span>
<p>Ask any question about music production techniques.</p>
<p>Responses include citations linking to source pages and videos.</p>
</div>
)} )}
</div> </div>
); );
})}
{/* Typing indicator — before first token arrives */}
{streaming &&
messages.length > 0 &&
messages[messages.length - 1]?.role === "assistant" &&
messages[messages.length - 1]?.text === "" &&
!messages[messages.length - 1]?.error && (
<div className={styles.loading}>
<div className={styles.spinner} />
Thinking
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
<form onSubmit={handleSubmit} className={styles.inputRow}>
<input
ref={inputRef}
type="text"
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about music production techniques…"
disabled={streaming}
autoFocus
/>
<button
type="submit"
className={styles.submitBtn}
disabled={!input.trim() || streaming}
>
{streaming ? "Streaming…" : "Send"}
</button>
</form>
</div>
);
} }