--- phase: 06-lab-advisor plan: "03" type: execute wave: 3 depends_on: [06-02] files_modified: - web/src/pages/AdvisorPage.tsx - web/src/api/advisor.ts - web/src/router.tsx - web/src/components/layout/TopBar.tsx autonomous: true requirements: [ADV-01, ADV-03, ADV-04] must_haves: truths: - "User can navigate to /advisor from the TopBar" - "Left sidebar lists all past conversations ordered newest-first" - "User can start a new conversation or select a prior one from the sidebar" - "User types a message, presses Send, and sees streaming tokens appear in real time" - "Conversation history (prior messages) is displayed above the input" - "Model dropdown shows current model; changing it takes effect on the next message" - "conversation_id is tracked across messages so all turns land in the same thread" artifacts: - path: "web/src/api/advisor.ts" provides: "Typed API wrappers: streamChat (SSE EventSource), fetchConversations, fetchConversation" exports: [streamChat, fetchConversations, fetchConversation, Conversation, ConversationSummary, ChatMessage] - path: "web/src/pages/AdvisorPage.tsx" provides: "AdvisorPage component: two-panel layout (sidebar + chat)" - path: "web/src/router.tsx" provides: "/advisor route added" - path: "web/src/components/layout/TopBar.tsx" provides: "Advisor nav link added to header" key_links: - from: "AdvisorPage.tsx — Send button" to: "POST /api/advisor/chat via EventSource/fetch SSE" via: "streamChat() in api/advisor.ts" pattern: "EventSource|fetch.*text/event-stream" - from: "AdvisorPage.tsx — sidebar" to: "GET /api/advisor/conversations" via: "useQuery + fetchConversations" pattern: "fetchConversations" - from: "AdvisorPage.tsx — conversation selection" to: "GET /api/advisor/conversations/:id" via: "useQuery + fetchConversation keyed by selected ID" pattern: "fetchConversation" --- Build the AdvisorPage React component at /advisor: two-panel chat UI with a conversation sidebar, streaming message display, and model selection dropdown. Purpose: ADV-01 (streaming chat UI), ADV-03 (history viewable in UI), ADV-04 (model dropdown, conversation list). Output: AdvisorPage.tsx, api/advisor.ts, router + TopBar updates. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/06-lab-advisor/06-02-SUMMARY.md # Design system: ClickHouse-inspired dark theme (see ~/.claude/DESIGN.md) # Canvas: #000000, accent/volt: #faff69, charcoal border: #222, muted text: #888 # Tailwind tokens already in project: bg-canvas, text-volt, border-charcoal # All existing pages use: AppShell wrapping, lucide-react icons, shadcn/ui Button/Input # Router: TanStack Router v1 — lazy import pattern from router.tsx (see below) # API pattern: useQuery from @tanstack/react-query for GET, fetch/EventSource for SSE # Backend endpoints (from Plan 02): # POST /api/advisor/chat # Body: { conversation_id?: string, message: string, model?: string } # Response: SSE stream, each event: data: {"conversation_id":"...","token":"..."}\n\n # Final event: data: [DONE]\n\n # GET /api/advisor/conversations # Response: [{id, started_at, model, message_count}] # GET /api/advisor/conversations/:id # Response: {id, started_at, model, messages: [{id, role, content, created_at}]} # Router lazy import pattern (match existing pages): # const AdvisorPage = lazy(() => import('./pages/AdvisorPage').then((m) => ({ default: m.AdvisorPage }))) # createRoute({ path: '/advisor', component: () => }> }) # TopBar pattern: Button variant="outline" size="sm" asChild wrapping Link to="/advisor" # Icon to use: MessageSquare from lucide-react # SSE streaming: use fetch() with ReadableStream (not EventSource — POST body needed) # Pattern: const res = await fetch('/api/advisor/chat', {method:'POST', body:JSON.stringify(...)}) # const reader = res.body!.getReader(); const decoder = new TextDecoder() # loop: read chunk, decode, split on \n\n, parse data: lines # Available OpenRouter models for dropdown (hardcoded list, no API call needed): # ["anthropic/claude-opus-4", "anthropic/claude-sonnet-4-5", "anthropic/claude-3-5-haiku", "openai/gpt-4o"] # Default: "anthropic/claude-opus-4" Task 1: API wrappers for advisor endpoints web/src/api/advisor.ts Create web/src/api/advisor.ts with typed wrappers. Types: ```typescript export interface ChatMessage { id: string role: 'user' | 'assistant' | 'system' content: string created_at: string } export interface ConversationSummary { id: string started_at: string model: string message_count: number } export interface Conversation { id: string started_at: string model: string messages: ChatMessage[] } ``` fetchConversations(): Promise — GET /api/advisor/conversations; returns [] on 404 fetchConversation(id: string): Promise — GET /api/advisor/conversations/${id} streamChat(params: { conversationId?: string; message: string; model: string }, onToken: (token: string, conversationId: string) => void, onDone: () => void, onError: (err: string) => void): Promise — POST /api/advisor/chat with Content-Type: application/json — Read response.body via getReader() + TextDecoder — Split chunks by '\n\n'; each 'data: ' prefixed line is an event — If data === '[DONE]' call onDone() and return — Else parse JSON { token, conversation_id }; call onToken(token, conversation_id) — On JSON parse error or fetch error call onError(message) cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | grep advisor web/src/api/advisor.ts compiles with no TypeScript errors; all three exported functions present Task 2: AdvisorPage component, route, and TopBar link web/src/pages/AdvisorPage.tsx, web/src/router.tsx, web/src/components/layout/TopBar.tsx Create web/src/pages/AdvisorPage.tsx. Layout: AppShell > two-panel flex (lg: sidebar 280px fixed + main flex-1; mobile: main only, sidebar hidden): - Left sidebar (bg-[#0a0a0a] border-r border-charcoal/60): - "Lab Advisor" heading (text-volt font-bold) - "New Chat" button (full width, variant outline) - Conversation list from useQuery(['conversations'], fetchConversations, { refetchInterval: 5000 }) Each item: truncated first message time + model name; selected item highlighted with bg-[#1a1a1a] Click: set selectedConversationId, load messages via useQuery(['conversation', id], ...) - Main chat panel (flex-col): - Messages area (flex-1 overflow-y-auto, scroll to bottom on new message via useEffect + ref): - Empty state: centered text "Ask anything about your homelab" - User messages: right-aligned, bg-[#1a1a1a] rounded-lg p-3 max-w-[80%] - Assistant messages: left-aligned, bg-[#111] rounded-lg p-3 max-w-[80%], text-[#e0e0e0] - Streaming assistant message shown inline as tokens arrive (append to streamingContent state) - Input row (border-t border-charcoal/60 p-4 flex gap-2): - Model select (shadcn/ui Select or native