feat(06-03): add AdvisorPage, /advisor route, and TopBar link

- AdvisorPage: two-panel layout (sidebar + chat), streaming SSE tokens
- Sidebar: conversation list with refetchInterval 5s, New Chat button
- Chat: optimistic user messages, streaming cursor, model dropdown
- router.tsx: lazy AdvisorPage import + advisorRoute at /advisor
- TopBar.tsx: MessageSquare icon + Advisor link between Test and Scan
- AppShell.tsx: noPadding prop for full-height layouts
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:39:56 +00:00
parent 811223ddf7
commit bcc360892c
4 changed files with 315 additions and 4 deletions

View file

@ -1,11 +1,17 @@
import * as React from 'react'
import { TopBar } from './TopBar'
export function AppShell({ children }: { children: React.ReactNode }) {
interface AppShellProps {
children: React.ReactNode
/** When true, removes default padding/max-width from the main content area */
noPadding?: boolean
}
export function AppShell({ children, noPadding = false }: AppShellProps) {
return (
<div className="min-h-screen bg-canvas flex flex-col">
<TopBar />
<main className="flex-1 px-4 py-6 max-w-7xl mx-auto w-full">
<main className={noPadding ? 'flex-1 flex flex-col overflow-hidden' : 'flex-1 px-4 py-6 max-w-7xl mx-auto w-full'}>
{children}
</main>
</div>

View file

@ -1,4 +1,4 @@
import { Plus, QrCode, Cable } from 'lucide-react'
import { Plus, QrCode, Cable, MessageSquare } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
@ -9,6 +9,12 @@ export function TopBar() {
HWLab
</Link>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/advisor">
<MessageSquare className="w-4 h-4 mr-1.5" />
<span className="hidden sm:inline">Advisor</span>
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to="/test">
<Cable className="w-4 h-4 mr-1.5" />

View file

@ -0,0 +1,288 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Send, MessageSquare, Plus } from 'lucide-react'
import { AppShell } from '@/components/layout/AppShell'
import { Button } from '@/components/ui/button'
import {
streamChat,
fetchConversations,
fetchConversation,
type ChatMessage,
type ConversationSummary,
} from '@/api/advisor'
const AVAILABLE_MODELS = [
'anthropic/claude-opus-4',
'anthropic/claude-sonnet-4-5',
'anthropic/claude-3-5-haiku',
'openai/gpt-4o',
]
const DEFAULT_MODEL = 'anthropic/claude-opus-4'
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
}
export function AdvisorPage() {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [streamingContent, setStreamingContent] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [currentConversationId, setCurrentConversationId] = useState<string | undefined>()
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
const [input, setInput] = useState('')
const [selectedSidebarId, setSelectedSidebarId] = useState<string | undefined>()
// Use a ref to capture latest streamingContent inside async callback
const streamingContentRef = useRef('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const conversationsQuery = useQuery({
queryKey: ['advisor-conversations'],
queryFn: fetchConversations,
refetchInterval: 5000,
})
const conversationQuery = useQuery({
queryKey: ['advisor-conversation', selectedSidebarId],
queryFn: () => fetchConversation(selectedSidebarId!),
enabled: !!selectedSidebarId,
})
// Load messages when a sidebar conversation is selected
useEffect(() => {
if (conversationQuery.data && selectedSidebarId) {
setMessages(conversationQuery.data.messages)
setCurrentConversationId(selectedSidebarId)
setStreamingContent('')
}
}, [conversationQuery.data, selectedSidebarId])
// Scroll to bottom when messages or streaming content changes
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streamingContent])
function handleNewChat() {
setMessages([])
setCurrentConversationId(undefined)
setStreamingContent('')
streamingContentRef.current = ''
setInput('')
setSelectedSidebarId(undefined)
textareaRef.current?.focus()
}
function handleSelectConversation(conv: ConversationSummary) {
if (isStreaming) return
setSelectedSidebarId(conv.id)
}
async function handleSend() {
const trimmed = input.trim()
if (!trimmed || isStreaming) return
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMessage])
setInput('')
setIsStreaming(true)
setStreamingContent('')
streamingContentRef.current = ''
await streamChat(
{
conversationId: currentConversationId,
message: trimmed,
model: selectedModel,
},
(token, convId) => {
streamingContentRef.current += token
setStreamingContent(streamingContentRef.current)
setCurrentConversationId(convId)
},
() => {
// onDone — finalize assistant message
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: streamingContentRef.current,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, assistantMessage])
setStreamingContent('')
streamingContentRef.current = ''
setIsStreaming(false)
// Refresh sidebar conversation list
conversationsQuery.refetch().catch(() => {})
},
(err) => {
toast.error(err)
setIsStreaming(false)
setStreamingContent('')
streamingContentRef.current = ''
},
)
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const allMessages = messages
return (
<AppShell noPadding>
<div className="flex h-[calc(100vh-57px)]">
{/* Left sidebar */}
<aside className="hidden lg:flex flex-col w-[280px] flex-shrink-0 border-r border-charcoal/60 bg-[#0a0a0a] overflow-hidden">
<div className="p-4 border-b border-charcoal/60">
<h2 className="text-volt font-bold text-lg mb-3">Lab Advisor</h2>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleNewChat}
>
<Plus className="w-4 h-4 mr-1.5" />
New Chat
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{conversationsQuery.isLoading && (
<p className="text-[#666] text-xs text-center py-4">Loading...</p>
)}
{conversationsQuery.data?.length === 0 && (
<p className="text-[#666] text-xs text-center py-4 px-3">
No conversations yet
</p>
)}
{conversationsQuery.data?.map((conv) => (
<button
key={conv.id}
onClick={() => handleSelectConversation(conv)}
className={`w-full text-left px-4 py-3 border-b border-charcoal/30 hover:bg-[#111] transition-colors ${
selectedSidebarId === conv.id ? 'bg-[#1a1a1a]' : ''
}`}
>
<div className="flex items-center gap-2 mb-1">
<MessageSquare className="w-3 h-3 text-volt flex-shrink-0" />
<span className="text-[#e0e0e0] text-xs font-medium truncate">
{conv.model.split('/').pop()}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[#666] text-xs">
{conv.message_count} msg{conv.message_count !== 1 ? 's' : ''}
</span>
<span className="text-[#555] text-xs">
{formatRelativeTime(conv.started_at)}
</span>
</div>
</button>
))}
</div>
</aside>
{/* Main chat panel */}
<main className="flex flex-col flex-1 min-w-0">
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{allMessages.length === 0 && !streamingContent && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<MessageSquare className="w-12 h-12 text-[#333]" />
<p className="text-[#666] text-sm">Ask anything about your homelab</p>
</div>
)}
{allMessages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`rounded-lg p-3 max-w-[80%] text-sm whitespace-pre-wrap break-words ${
msg.role === 'user'
? 'bg-[#1a1a1a] text-white'
: 'bg-[#111] text-[#e0e0e0]'
}`}
>
{msg.content}
</div>
</div>
))}
{/* Streaming assistant message */}
{streamingContent && (
<div className="flex justify-start">
<div className="rounded-lg p-3 max-w-[80%] text-sm whitespace-pre-wrap break-words bg-[#111] text-[#e0e0e0]">
{streamingContent}
<span className="inline-block w-0.5 h-4 bg-volt ml-0.5 animate-pulse align-text-bottom" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input row */}
<div className="border-t border-charcoal/60 p-4 flex gap-2 items-end">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={isStreaming}
className="bg-[#111] border border-charcoal/60 text-[#e0e0e0] text-xs rounded-md px-2 py-2 focus:outline-none focus:border-volt/50 disabled:opacity-50 shrink-0"
>
{AVAILABLE_MODELS.map((m) => (
<option key={m} value={m}>
{m.split('/').pop()}
</option>
))}
</select>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={2}
disabled={isStreaming}
placeholder="Ask about your homelab..."
className="flex-1 bg-[#111] border border-charcoal/60 text-white text-sm rounded-md px-3 py-2 resize-none focus:outline-none focus:border-volt/50 placeholder-[#555] disabled:opacity-50"
/>
<Button
variant="forest"
size="sm"
onClick={handleSend}
disabled={isStreaming || !input.trim()}
className="shrink-0 self-end"
>
<Send className="w-4 h-4" />
</Button>
</div>
</main>
</div>
</AppShell>
)
}

View file

@ -7,6 +7,7 @@ const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then((m) => (
const IntakePage = lazy(() => import('./pages/IntakePage').then((m) => ({ default: m.IntakePage })))
const ScanPage = lazy(() => import('./pages/ScanPage').then((m) => ({ default: m.ScanPage })))
const CableTestPage = lazy(() => import('./pages/CableTestPage').then((m) => ({ default: m.CableTestPage })))
const AdvisorPage = lazy(() => import('./pages/AdvisorPage').then((m) => ({ default: m.AdvisorPage })))
const Spinner = () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
@ -74,7 +75,17 @@ const cableTestRoute = createRoute({
),
})
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute, cableTestRoute])
const advisorRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/advisor',
component: () => (
<Suspense fallback={<Spinner />}>
<AdvisorPage />
</Suspense>
),
})
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute, cableTestRoute, advisorRoute])
export const router = createRouter({
routeTree,