diff --git a/.gsd/milestones/M022/slices/S04/S04-PLAN.md b/.gsd/milestones/M022/slices/S04/S04-PLAN.md index 93f2348..4c82877 100644 --- a/.gsd/milestones/M022/slices/S04/S04-PLAN.md +++ b/.gsd/milestones/M022/slices/S04/S04-PLAN.md @@ -8,7 +8,7 @@ - Estimate: 1.5h - 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 -- [ ] **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 - 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 diff --git a/.gsd/milestones/M022/slices/S04/tasks/T01-VERIFY.json b/.gsd/milestones/M022/slices/S04/tasks/T01-VERIFY.json new file mode 100644 index 0000000..0537f6e --- /dev/null +++ b/.gsd/milestones/M022/slices/S04/tasks/T01-VERIFY.json @@ -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" + } + ] +} diff --git a/.gsd/milestones/M022/slices/S04/tasks/T02-SUMMARY.md b/.gsd/milestones/M022/slices/S04/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..6f89a7b --- /dev/null +++ b/.gsd/milestones/M022/slices/S04/tasks/T02-SUMMARY.md @@ -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. diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 82a1a24..cc75c36 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -21,10 +21,15 @@ export interface ChatSource { section_heading: string; } +export interface ChatDoneMeta { + cascade_tier: string; + conversation_id: string; +} + export interface ChatCallbacks { onSources: (sources: ChatSource[]) => void; onToken: (text: string) => void; - onDone: (meta: { cascade_tier: string }) => void; + onDone: (meta: ChatDoneMeta) => void; onError: (message: string) => void; } @@ -36,6 +41,7 @@ export function streamChat( query: string, callbacks: ChatCallbacks, creator?: string, + conversationId?: string, ): AbortController { const controller = new AbortController(); @@ -57,7 +63,11 @@ export function streamChat( fetch(`${BASE}/chat`, { method: "POST", headers, - body: JSON.stringify({ query, creator: creator ?? null }), + body: JSON.stringify({ + query, + creator: creator ?? null, + conversation_id: conversationId ?? null, + }), signal: controller.signal, }) .then(async (res) => { @@ -116,7 +126,7 @@ export function streamChat( callbacks.onToken(JSON.parse(rawData) as string); break; case "done": - callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string }); + callbacks.onDone(JSON.parse(rawData) as ChatDoneMeta); break; case "error": { const err = JSON.parse(rawData) as { message?: string }; diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx index 0a64071..1f45a1e 100644 --- a/frontend/src/components/ChatWidget.tsx +++ b/frontend/src/components/ChatWidget.tsx @@ -120,6 +120,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps) const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); + const [conversationId, setConversationId] = useState(undefined); const abortRef = useRef(null); const messagesEndRef = useRef(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 useEffect(() => { if (!open) return; const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); + if (e.key === "Escape") handleClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); - }, [open]); + }, [open, handleClose]); const sendMessage = useCallback( (text: string) => { @@ -168,6 +178,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps) 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]; @@ -192,7 +206,8 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps) ), ); }, - onDone: () => { + onDone: (meta) => { + setConversationId(meta.conversation_id); setMessages((prev) => prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)), ); @@ -208,9 +223,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps) }, }, creatorName, + cid, ); }, - [messages, streaming, creatorName], + [messages, streaming, creatorName, conversationId], ); const handleSubmit = (e: React.FormEvent) => { @@ -249,7 +265,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps) + )} + + + {!hasMessages && !streaming && ( +
+ 💬 +

Ask any question about music production techniques.

+

Responses include citations linking to source pages and videos.

+
+ )} + + {hasMessages && ( +
+ {messages.map((msg, i) => { + if (msg.role === "user") { + return ( +
+ {msg.text} +
+ ); + } + + // Assistant message + return ( +
+ {msg.error ? ( +
{msg.error}
+ ) : ( + <> +
+ {parseChatCitations(msg.text, msg.sources)} + {!msg.done && } +
+ {msg.done && msg.sources.length > 0 && ( +
+

Sources

+
    + {msg.sources.map((src) => { + const href = src.section_anchor + ? `/techniques/${src.slug}#${src.section_anchor}` + : `/techniques/${src.slug}`; + return ( +
  1. + + {src.number} + +
    + + {src.title} + {src.section_heading + ? ` — ${src.section_heading}` + : ""} + + {src.creator_name && ( + + {" "} + · {src.creator_name} + + )} +
    +
  2. + ); + })} +
+
+ )} + + )} +
+ ); + })} + + {/* 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 && ( +
+
+ Thinking… +
+ )} + +
+
+ )}
setQuery(e.target.value)} + value={input} + onChange={(e) => setInput(e.target.value)} placeholder="Ask about music production techniques…" disabled={streaming} autoFocus @@ -155,66 +322,11 @@ export default function ChatPage() {
- - {error &&
{error}
} - - {loading && ( -
-
- Searching for sources… -
- )} - - {hasResponse && ( -
-
- {parseChatCitations(responseText, sources)} - {streaming && } -
- - {sources.length > 0 && (done || !streaming) && ( -
-

Sources

-
    - {sources.map((src) => { - const href = src.section_anchor - ? `/techniques/${src.slug}#${src.section_anchor}` - : `/techniques/${src.slug}`; - return ( -
  1. - {src.number} -
    - - {src.title} - {src.section_heading ? ` — ${src.section_heading}` : ""} - - {src.creator_name && ( - - {" "}· {src.creator_name} - - )} -
    -
  2. - ); - })} -
-
- )} -
- )} - - {!hasResponse && !loading && !error && ( -
- 💬 -

Ask any question about music production techniques.

-

Responses include citations linking to source pages and videos.

-
- )}
); }