-
- {activeConversation ? activeConversation.title : 'OpenJarvis Chat'}
-
-
- {serverInfo && (
- (null);
+ const abortRef = useRef(null);
+ const timerRef = useRef | null>(null);
- const fullMessage = attachment ? attachment + '\n' + typed : typed;
+ const activeId = useAppStore((s) => s.activeId);
+ const selectedModel = useAppStore((s) => s.selectedModel);
+ const streamState = useAppStore((s) => s.streamState);
+ const messages = useAppStore((s) => s.messages);
+ const createConversation = useAppStore((s) => s.createConversation);
+ const addMessage = useAppStore((s) => s.addMessage);
+ const updateLastAssistant = useAppStore((s) => s.updateLastAssistant);
+ const setStreamState = useAppStore((s) => s.setStreamState);
+ const resetStream = useAppStore((s) => s.resetStream);
- const handleSend = useCallback(() => {
- if (!fullMessage.trim() || isStreaming) return;
- onSend(fullMessage);
- setAttachment('');
- setTyped('');
- if (textareaRef.current) {
- textareaRef.current.style.height = 'auto';
+ // Auto-resize textarea
+ useEffect(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.style.height = 'auto';
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px';
+ }, [input]);
+
+ const stopStreaming = useCallback(() => {
+ abortRef.current?.abort();
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
}
- }, [fullMessage, isStreaming, onSend]);
+ resetStream();
+ }, [resetStream]);
- const handleKeyDown = (e: React.KeyboardEvent) => {
+ const sendMessage = useCallback(async () => {
+ const content = input.trim();
+ if (!content || streamState.isStreaming) return;
+
+ setInput('');
+
+ let convId = activeId;
+ if (!convId) {
+ convId = createConversation(selectedModel);
+ }
+
+ const userMsg: ChatMessage = {
+ id: generateId(),
+ role: 'user',
+ content,
+ timestamp: Date.now(),
+ };
+ addMessage(convId, userMsg);
+
+ // Build API messages before adding assistant placeholder
+ const currentMessages = useAppStore.getState().messages;
+ const apiMessages = currentMessages.map((m) => ({
+ role: m.role,
+ content: m.content,
+ }));
+
+ const assistantMsg: ChatMessage = {
+ id: generateId(),
+ role: 'assistant',
+ content: '',
+ timestamp: Date.now(),
+ };
+ addMessage(convId, assistantMsg);
+
+ // Start streaming
+ const startTime = Date.now();
+ const timer = setInterval(() => {
+ setStreamState({ elapsedMs: Date.now() - startTime });
+ }, 100);
+ timerRef.current = timer;
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ let accumulatedContent = '';
+ let usage: TokenUsage | undefined;
+ const toolCalls: ToolCallInfo[] = [];
+
+ setStreamState({
+ isStreaming: true,
+ phase: 'Sending...',
+ elapsedMs: 0,
+ activeToolCalls: [],
+ content: '',
+ });
+
+ try {
+ for await (const sseEvent of streamChat(
+ { model: selectedModel, messages: apiMessages, stream: true },
+ controller.signal,
+ )) {
+ const eventName = sseEvent.event;
+
+ if (eventName === 'agent_turn_start') {
+ setStreamState({ phase: 'Agent thinking...' });
+ } else if (eventName === 'inference_start') {
+ setStreamState({ phase: 'Generating...' });
+ } else if (eventName === 'tool_call_start') {
+ try {
+ const data = JSON.parse(sseEvent.data);
+ const tc: ToolCallInfo = {
+ id: generateId(),
+ tool: data.tool,
+ arguments: data.arguments || '',
+ status: 'running',
+ };
+ toolCalls.push(tc);
+ setStreamState({
+ phase: `Running ${data.tool}...`,
+ activeToolCalls: [...toolCalls],
+ });
+ updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
+ } catch {}
+ } else if (eventName === 'tool_call_end') {
+ try {
+ const data = JSON.parse(sseEvent.data);
+ const tc = toolCalls.find(
+ (t) => t.tool === data.tool && t.status === 'running',
+ );
+ if (tc) {
+ tc.status = data.success ? 'success' : 'error';
+ tc.latency = data.latency;
+ tc.result = data.result;
+ }
+ setStreamState({
+ phase: 'Generating...',
+ activeToolCalls: [...toolCalls],
+ });
+ updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
+ } catch {}
+ } else {
+ try {
+ const data = JSON.parse(sseEvent.data);
+ const delta = data.choices?.[0]?.delta;
+ if (data.usage) usage = data.usage;
+ if (delta?.content) {
+ accumulatedContent += delta.content;
+ setStreamState({ content: accumulatedContent });
+ updateLastAssistant(
+ convId,
+ accumulatedContent,
+ toolCalls.length > 0 ? [...toolCalls] : undefined,
+ );
+ }
+ if (data.choices?.[0]?.finish_reason === 'stop') break;
+ } catch {}
+ }
+ }
+ } catch (err: any) {
+ if (err.name !== 'AbortError') {
+ accumulatedContent =
+ accumulatedContent || 'Error: Failed to get response.';
+ }
+ } finally {
+ if (!accumulatedContent) {
+ accumulatedContent = 'No response was generated. Please try again.';
+ }
+ updateLastAssistant(
+ convId,
+ accumulatedContent,
+ toolCalls.length > 0 ? toolCalls : undefined,
+ usage,
+ );
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ resetStream();
+ abortRef.current = null;
+ }
+ }, [
+ input,
+ activeId,
+ selectedModel,
+ streamState.isStreaming,
+ createConversation,
+ addMessage,
+ updateLastAssistant,
+ setStreamState,
+ resetStream,
+ ]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
- handleSend();
+ sendMessage();
}
};
- const handleInput = (e: React.ChangeEvent) => {
- const newValue = e.target.value;
- setTyped(newValue);
- // Auto-resize textarea
- const ta = e.target;
- ta.style.height = 'auto';
- ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
- };
-
- const handlePaste = useCallback(
- (e: React.ClipboardEvent) => {
- const pasted = e.clipboardData.getData('text');
- if (shouldCollapse(pasted)) {
- e.preventDefault();
- // Store long paste as an attachment pill
- setAttachment((prev) => (prev ? prev + '\n' + pasted : pasted));
- }
- // Short pastes go directly into the textarea as normal
- },
- [],
- );
-
- const handleClearAttachment = useCallback(() => {
- setAttachment('');
- textareaRef.current?.focus();
- }, []);
-
- const handleExpandAttachment = useCallback(() => {
- // Move attachment content back into the textarea
- setTyped((prev) => (attachment + (prev ? '\n' + prev : '')));
- setAttachment('');
- // Let React render, then resize
- setTimeout(() => {
- if (textareaRef.current) {
- textareaRef.current.style.height = 'auto';
- textareaRef.current.style.height =
- Math.min(textareaRef.current.scrollHeight, 200) + 'px';
- }
- }, 0);
- }, [attachment]);
-
- // Focus textarea after attachment changes
- useEffect(() => {
- textareaRef.current?.focus();
- }, [attachment]);
-
return (
-
-
- {serverInfo.model || 'unknown'}
-
- {serverInfo.agent && (
-
- {serverInfo.agent}
-
- )}
+
- {streamState.isStreaming && (
-
- )}
-
-
+
+
);
}
diff --git a/frontend/src/components/Chat/CopyButton.tsx b/frontend/src/components/Chat/CopyButton.tsx
deleted file mode 100644
index e4062f62..00000000
--- a/frontend/src/components/Chat/CopyButton.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useState, useCallback } from 'react';
-
-interface CopyButtonProps {
- text: string;
-}
-
-export function CopyButton({ text }: CopyButtonProps) {
- const [copied, setCopied] = useState(false);
-
- const handleCopy = useCallback(async (e: React.MouseEvent) => {
- e.stopPropagation();
- try {
- await navigator.clipboard.writeText(text);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- // Fallback for non-secure contexts
- const ta = document.createElement('textarea');
- ta.value = text;
- document.body.appendChild(ta);
- ta.select();
- document.execCommand('copy');
- document.body.removeChild(ta);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- }, [text]);
-
- return (
-
- );
-}
diff --git a/frontend/src/components/Chat/InputArea.tsx b/frontend/src/components/Chat/InputArea.tsx
index 812e3d08..ba9b48c7 100644
--- a/frontend/src/components/Chat/InputArea.tsx
+++ b/frontend/src/components/Chat/InputArea.tsx
@@ -1,159 +1,252 @@
import { useState, useRef, useCallback, useEffect } from 'react';
+import { Send, Square, Paperclip } from 'lucide-react';
+import { useAppStore, generateId } from '../../lib/store';
+import { streamChat } from '../../lib/sse';
+import type { ChatMessage, ToolCallInfo, TokenUsage } from '../../types';
-const COLLAPSE_CHAR_THRESHOLD = 500;
-const COLLAPSE_LINE_THRESHOLD = 6;
-
-function shouldCollapse(text: string): boolean {
- return (
- text.length > COLLAPSE_CHAR_THRESHOLD ||
- text.split('\n').length > COLLAPSE_LINE_THRESHOLD
- );
-}
-
-function formatSize(text: string): string {
- const chars = text.length;
- const lines = text.split('\n').length;
- if (chars >= 1000) {
- return `${(chars / 1000).toFixed(1)}k chars, ${lines} line${lines !== 1 ? 's' : ''}`;
- }
- return `${chars} chars, ${lines} line${lines !== 1 ? 's' : ''}`;
-}
-
-interface InputAreaProps {
- onSend: (content: string) => void;
- onStop: () => void;
- isStreaming: boolean;
-}
-
-export function InputArea({ onSend, onStop, isStreaming }: InputAreaProps) {
- // Pasted/long content stored separately as an "attachment"
- const [attachment, setAttachment] = useState('');
- // Text typed in the visible textarea
- const [typed, setTyped] = useState('');
+export function InputArea() {
+ const [input, setInput] = useState('');
const textareaRef = useRef
+
-
+ {isEmpty ? (
+
+ ) : (
+
+
+
+
- )}
- {totalTokens > 0 && (
-
- {totalPrompt.toLocaleString()} in / {totalCompletion.toLocaleString()} out
-
- )}
- + {getGreeting()} +
++ Ask anything. Your AI runs locally — private, fast, and always available. +
+
+ {messages.map((msg) => (
+
+ ))}
+ {streamState.isStreaming && streamState.content === '' && (
+
+ )}
+
+
+ )}
+
- {attachment && (
-
-
- )}
-
-
- Pasted text
- {formatSize(attachment)}
-
-
-
-
+
+
+
);
}
diff --git a/frontend/src/components/Chat/MessageBubble.tsx b/frontend/src/components/Chat/MessageBubble.tsx
index b551e7aa..cbdf2d84 100644
--- a/frontend/src/components/Chat/MessageBubble.tsx
+++ b/frontend/src/components/Chat/MessageBubble.tsx
@@ -1,41 +1,137 @@
+import { useState } from 'react';
+import ReactMarkdown from 'react-markdown';
+import rehypeHighlight from 'rehype-highlight';
+import remarkGfm from 'remark-gfm';
+import { Copy, Check } from 'lucide-react';
+import { ToolCallCard } from './ToolCallCard';
import type { ChatMessage } from '../../types';
-import { ToolCallIndicator } from './ToolCallIndicator';
-import { CopyButton } from './CopyButton';
-interface MessageBubbleProps {
+interface Props {
message: ChatMessage;
}
-function formatTime(timestamp: number): string {
- return new Date(timestamp).toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit',
- });
-}
+function CodeBlock({ className, children, ...props }: any) {
+ const [copied, setCopied] = useState(false);
+ const match = /language-(\w+)/.exec(className || '');
+ const lang = match ? match[1] : '';
+ const code = String(children).replace(/\n$/, '');
-export function MessageBubble({ message }: MessageBubbleProps) {
- const usage = message.usage;
+ const handleCopy = () => {
+ navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ if (!className) {
+ return (
+
+
+ Enter to send ·{' '}
+ Shift+Enter for new line
+
+
+ {children}
+
+ );
+ }
return (
-
+
+ {copied ? : }
+
+ );
+}
+
+export function MessageBubble({ message }: Props) {
+ const isUser = message.role === 'user';
+
+ if (isUser) {
+ return (
+
+
+ );
+}
+
+function CopyMessageButton({ content }: { content: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(content);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ {lang || 'code'}
+ (e.currentTarget.style.color = 'var(--color-text-secondary)')}
+ onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
+ >
+ {copied ? : }
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
+ {children}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {message.content}
+
+
+ {/* Tool calls */}
{message.toolCalls && message.toolCalls.length > 0 && (
-
+
{message.toolCalls.map((tc) => (
-
+
))}
)}
-
- {message.content || (message.role === 'assistant' ? '\u200B' : '')}
- {message.role === 'assistant' && message.content && (
-
- )}
-
-
- {formatTime(message.timestamp)}
- {usage && (
-
- {usage.prompt_tokens} in / {usage.completion_tokens} out
+
+ {/* Assistant message */}
+ {message.content && (
+ (null);
- const [autoScroll, setAutoScroll] = useState(true);
- const greeting = useMemo(() => getGreeting(), []);
-
- // Auto-scroll to bottom on new messages
- useEffect(() => {
- if (autoScroll && listRef.current) {
- listRef.current.scrollTop = listRef.current.scrollHeight;
- }
- }, [messages, autoScroll, isStreaming]);
-
- const handleScroll = () => {
- if (!listRef.current) return;
- const { scrollTop, scrollHeight, clientHeight } = listRef.current;
- const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
- setAutoScroll(isAtBottom);
- };
-
- if (messages.length === 0) {
- return (
- (null);
+
+ const models = useAppStore((s) => s.models);
+ const selectedModel = useAppStore((s) => s.selectedModel);
+ const setSelectedModel = useAppStore((s) => s.setSelectedModel);
+ const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
+
+ const filtered = query
+ ? models.filter((m) =>
+ m.id.toLowerCase().includes(query.toLowerCase()),
+ )
+ : models;
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ useEffect(() => {
+ setSelectedIdx(0);
+ }, [query]);
+
+ const handleSelect = (modelId: string) => {
+ setSelectedModel(modelId);
+ setCommandPaletteOpen(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setCommandPaletteOpen(false);
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIdx((i) => Math.max(i - 1, 0));
+ } else if (e.key === 'Enter' && filtered.length > 0) {
+ e.preventDefault();
+ handleSelect(filtered[selectedIdx].id);
+ }
+ };
+
+ return (
+ (null);
+ const [telemetry, setTelemetry] = useState(null);
+ const [chartData, setChartData] = useState([]);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ try {
+ const base = import.meta.env.VITE_API_URL || '';
+ const [energyRes, telRes] = await Promise.allSettled([
+ fetch(`${base}/v1/telemetry/energy`).then((r) => r.ok ? r.json() : null),
+ fetch(`${base}/v1/telemetry/stats`).then((r) => r.ok ? r.json() : null),
+ ]);
+
+ if (energyRes.status === 'fulfilled' && energyRes.value) {
+ const data = energyRes.value as EnergyData;
+ setEnergy(data);
+ if (data.samples) {
+ setChartData(
+ data.samples.map((s) => ({
+ time: new Date(s.timestamp).toLocaleTimeString(),
+ power: Math.round(s.power_w * 10) / 10,
+ })),
+ );
+ }
+ setError(null);
+ }
+ if (telRes.status === 'fulfilled' && telRes.value) {
+ setTelemetry(telRes.value as TelemetryStats);
+ }
+ } catch {
+ setError('Cannot connect to server');
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchData();
+ const interval = setInterval(fetchData, 5000);
+ return () => clearInterval(interval);
+ }, [fetchData]);
+
+ const thermalStatus = (energy?.avg_power_w ?? 0) < 50
+ ? { label: 'Cool', color: 'var(--color-success)' }
+ : (energy?.avg_power_w ?? 0) < 150
+ ? { label: 'Warm', color: 'var(--color-warning)' }
+ : { label: 'Hot', color: 'var(--color-error)' };
+
+ if (error || !energy) {
+ return (
+ = {
+ route: 'var(--color-accent)',
+ retrieve: 'var(--color-success)',
+ generate: 'var(--color-warning)',
+ tool_call: '#a855f7',
+ respond: '#ec4899',
+};
+
+function StepBadge({ type }: { type: string }) {
+ const color = STEP_COLORS[type] || 'var(--color-text-tertiary)';
+ return (
+
+
+ {type}
+
+ );
+}
+
+function TraceCard({ trace, isActive, onClick }: { trace: TraceSummary; isActive: boolean; onClick: () => void }) {
+ const totalMs = trace.steps.reduce((sum, s) => sum + s.duration_ms, 0);
+
+ return (
+ { if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)'; }}
+ onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
+ >
+
+ );
+}
+
+function StepDetail({ step, index }: { step: TraceStep; index: number }) {
+ const [expanded, setExpanded] = useState(false);
+ const dataEntries = Object.entries(step.data).filter(([_, v]) => v != null);
+
+ return (
+ ([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [error, setError] = useState(null);
+
+ const fetchTraces = useCallback(async () => {
+ try {
+ const base = import.meta.env.VITE_API_URL || '';
+ const res = await fetch(`${base}/v1/traces?limit=50`);
+ if (!res.ok) throw new Error();
+ const data = await res.json();
+ setTraces(data.traces || []);
+ setError(null);
+ } catch {
+ setError('Cannot load traces');
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchTraces();
+ }, [fetchTraces]);
+
+ const selected = traces.find((t) => t.id === selectedId);
+
+ if (error) {
+ return (
+ {
}
componentDidCatch(error: Error, info: ErrorInfo) {
- console.error('ErrorBoundary caught:', error, info.componentStack);
+ console.error('ErrorBoundary caught:', error, info);
}
render() {
if (this.state.hasError) {
return (
-
+
+ {message.content}
+
+
+ )}
+
+ {/* Footer: usage + copy */}
+
+
+ {message.usage && (
+
+ {message.usage.total_tokens} tokens
)}
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx
deleted file mode 100644
index a6d07f16..00000000
--- a/frontend/src/components/Chat/MessageList.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useEffect, useRef, useState, useMemo } from 'react';
-import type { ChatMessage } from '../../types';
-import { MessageBubble } from './MessageBubble';
-
-function getGreeting(): string {
- const hour = new Date().getHours();
- if (hour >= 5 && hour < 12) return 'Good Morning! What shall we build today?';
- if (hour >= 12 && hour < 17) return 'Good Afternoon! Ready to create something?';
- if (hour >= 17 && hour < 21) return 'Good Evening! Let\'s get things done.';
- return 'Late Night Session \u2014 Let\'s make it count.';
-}
-
-interface MessageListProps {
- messages: ChatMessage[];
- isStreaming: boolean;
-}
-
-export function MessageList({ messages, isStreaming }: MessageListProps) {
- const listRef = useRef
-
- );
- }
-
- return (
-
-
- {greeting}
-
-
- Type a message below to get started.
-
-
-
- {messages.map((msg) => (
-
- ))}
-
- );
-}
diff --git a/frontend/src/components/Chat/StreamingDots.tsx b/frontend/src/components/Chat/StreamingDots.tsx
new file mode 100644
index 00000000..810a3da3
--- /dev/null
+++ b/frontend/src/components/Chat/StreamingDots.tsx
@@ -0,0 +1,29 @@
+interface Props {
+ phase: string;
+}
+
+export function StreamingDots({ phase }: Props) {
+ return (
+
+
+ );
+}
diff --git a/frontend/src/components/Chat/StreamingIndicator.tsx b/frontend/src/components/Chat/StreamingIndicator.tsx
deleted file mode 100644
index 218d1cfb..00000000
--- a/frontend/src/components/Chat/StreamingIndicator.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { ToolCallInfo } from '../../types';
-import { ToolCallIndicator } from './ToolCallIndicator';
-
-interface StreamingIndicatorProps {
- phase: string;
- elapsedMs: number;
- toolCalls: ToolCallInfo[];
-}
-
-function formatElapsed(ms: number): string {
- const seconds = Math.floor(ms / 1000);
- const tenths = Math.floor((ms % 1000) / 100);
- return `${seconds}.${tenths}s`;
-}
-
-export function StreamingIndicator({
- phase,
- elapsedMs,
- toolCalls,
-}: StreamingIndicatorProps) {
- return (
-
+
+
+
+
+ {phase && (
+
+ {phase}
+
+ )}
+
- {toolCalls.length > 0 && (
-
- );
-}
diff --git a/frontend/src/components/Chat/ToolCallCard.tsx b/frontend/src/components/Chat/ToolCallCard.tsx
new file mode 100644
index 00000000..ba4f5668
--- /dev/null
+++ b/frontend/src/components/Chat/ToolCallCard.tsx
@@ -0,0 +1,86 @@
+import { useState } from 'react';
+import { ChevronDown, ChevronRight, Wrench, Loader2, CheckCircle2, XCircle } from 'lucide-react';
+import type { ToolCallInfo } from '../../types';
+
+interface Props {
+ toolCall: ToolCallInfo;
+}
+
+const statusConfig = {
+ running: { icon: Loader2, label: 'Running', color: 'var(--color-accent)' },
+ success: { icon: CheckCircle2, label: 'Done', color: 'var(--color-success)' },
+ error: { icon: XCircle, label: 'Failed', color: 'var(--color-error)' },
+};
+
+export function ToolCallCard({ toolCall }: Props) {
+ const [expanded, setExpanded] = useState(false);
+ const config = statusConfig[toolCall.status];
+ const StatusIcon = config.icon;
+
+ return (
+
- {toolCalls.map((tc) => (
-
- ))}
-
- )}
-
-
-
-
-
- {phase}
- {formatElapsed(elapsedMs)}
-
+ setExpanded(!expanded)}
+ className="flex items-center gap-2 w-full px-3 py-2 transition-colors cursor-pointer"
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+ {toolCall.tool}
+
+
+
+ {toolCall.latency != null && (
+
+ {toolCall.latency < 1000
+ ? `${Math.round(toolCall.latency)}ms`
+ : `${(toolCall.latency / 1000).toFixed(1)}s`}
+
+ )}
+
+ {expanded && (
+
+ );
+}
diff --git a/frontend/src/components/Chat/ToolCallIndicator.tsx b/frontend/src/components/Chat/ToolCallIndicator.tsx
deleted file mode 100644
index 2583e20b..00000000
--- a/frontend/src/components/Chat/ToolCallIndicator.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { ToolCallInfo } from '../../types';
-
-interface ToolCallIndicatorProps {
- toolCall: ToolCallInfo;
-}
-
-export function ToolCallIndicator({ toolCall }: ToolCallIndicatorProps) {
- return (
-
+ {toolCall.arguments && (
+
+ )}
+
+
+ )}
+ {toolCall.result && (
+
+ Arguments
+
+
+ {toolCall.arguments}
+
+
+
+ )}
+
+ Result
+
+
+ {toolCall.result}
+
+
-
- {toolCall.tool}
- {toolCall.latency !== undefined && (
- {toolCall.latency.toFixed(0)}ms
- )}
-
- );
-}
diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx
new file mode 100644
index 00000000..f58d170a
--- /dev/null
+++ b/frontend/src/components/CommandPalette.tsx
@@ -0,0 +1,150 @@
+import { useState, useRef, useEffect } from 'react';
+import { Search, Cpu, X } from 'lucide-react';
+import { useAppStore } from '../lib/store';
+
+export function CommandPalette() {
+ const [query, setQuery] = useState('');
+ const [selectedIdx, setSelectedIdx] = useState(0);
+ const inputRef = useRef setCommandPaletteOpen(false)}
+ >
+ {/* Backdrop */}
+
+
+ {/* Palette */}
+
+ );
+}
diff --git a/frontend/src/components/Dashboard/CostComparison.tsx b/frontend/src/components/Dashboard/CostComparison.tsx
new file mode 100644
index 00000000..8563008a
--- /dev/null
+++ b/frontend/src/components/Dashboard/CostComparison.tsx
@@ -0,0 +1,116 @@
+import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
+import { useAppStore } from '../../lib/store';
+
+const CLOUD_PRICING = [
+ { name: 'GPT-4o', input: 2.50, output: 10.00 },
+ { name: 'Claude 3.5', input: 3.00, output: 15.00 },
+ { name: 'GPT-4o-mini', input: 0.15, output: 0.60 },
+];
+
+export function CostComparison() {
+ const savings = useAppStore((s) => s.savings);
+
+ if (!savings || savings.total_tokens === 0) {
+ return (
+ e.stopPropagation()}
+ >
+ {/* Search input */}
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Search models..."
+ className="flex-1 bg-transparent outline-none text-sm"
+ style={{ color: 'var(--color-text)' }}
+ />
+ setCommandPaletteOpen(false)}
+ className="p-1 rounded cursor-pointer"
+ style={{ color: 'var(--color-text-tertiary)' }}
+ >
+
+
+
+
+ {/* Results */}
+
+ {filtered.length === 0 ? (
+ handleSelect(model.id)}
+ className="flex items-center gap-3 w-full px-4 py-2.5 text-left transition-colors cursor-pointer"
+ style={{
+ background: isSelected ? 'var(--color-bg-secondary)' : 'transparent',
+ }}
+ onMouseEnter={() => setSelectedIdx(idx)}
+ >
+
+
+ );
+ })
+ )}
+
+
+ {/* Footer */}
+
+ {models.length === 0 ? 'No models available — is the server running?' : 'No matching models'}
+
+ ) : (
+ filtered.map((model, idx) => {
+ const isActive = model.id === selectedModel;
+ const isSelected = idx === selectedIdx;
+ return (
+
+
+ {isActive && (
+
+ Active
+
+ )}
+
+ {model.id}
+
+
+ ↑↓ Navigate
+ Enter Select
+ Esc Close
+
+
+
+
+
+ );
+ }
+
+ const promptK = savings.total_prompt_tokens / 1000;
+ const completionK = savings.total_completion_tokens / 1000;
+
+ return (
+
+
+ Cost Comparison
+
+
+ Start chatting to see local vs. cloud cost savings.
+
+
+
+
+
+ {/* Local stats */}
+
+ );
+}
diff --git a/frontend/src/components/Dashboard/EnergyDashboard.tsx b/frontend/src/components/Dashboard/EnergyDashboard.tsx
new file mode 100644
index 00000000..dd996934
--- /dev/null
+++ b/frontend/src/components/Dashboard/EnergyDashboard.tsx
@@ -0,0 +1,201 @@
+import { useState, useEffect, useCallback } from 'react';
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from 'recharts';
+import { Zap, Activity, Thermometer, Hash } from 'lucide-react';
+
+interface EnergySample {
+ timestamp: string;
+ power_w: number;
+ energy_j: number;
+}
+
+interface EnergyData {
+ total_energy_j?: number;
+ energy_per_token_j?: number;
+ avg_power_w?: number;
+ samples?: EnergySample[];
+}
+
+interface TelemetryStats {
+ total_requests?: number;
+ total_tokens?: number;
+}
+
+interface ChartPoint {
+ time: string;
+ power: number;
+}
+
+function StatCard({
+ icon: Icon,
+ label,
+ value,
+ unit,
+}: {
+ icon: typeof Zap;
+ label: string;
+ value: string;
+ unit?: string;
+}) {
+ return (
+
+
+ Cost Comparison
+
+
+ {/* Local stats */}
+
+
+
+
+ {/* Cloud comparisons */}
+
+
+
+ Local (your hardware)
+
+
+ {savings.total_calls} requests · {savings.total_tokens.toLocaleString()} tokens
+
+
+
+
+ ${savings.local_cost.toFixed(4)}
+
+
+ electricity only
+
+
+ {CLOUD_PRICING.map((provider) => {
+ const cost = (promptK * provider.input / 1000) + (completionK * provider.output / 1000);
+ const saved = cost - savings.local_cost;
+ return (
+
+
+ {/* Savings from API if available */}
+ {savings.per_provider.length > 0 && (
+
+
+
+ );
+ })}
+
+
+
+ {provider.name}
+
+
+
+
+ ${cost.toFixed(4)}
+
+ {saved > 0 && (
+
+
+ ${saved.toFixed(4)} saved
+
+ )}
+
+
+ )}
+
+ Server-reported savings
+
+ {savings.per_provider.map((p) => (
+
+ {p.label}
+ ${p.total_cost.toFixed(4)}
+
+ ))}
+
+
+ );
+}
+
+export function EnergyDashboard() {
+ const [energy, setEnergy] = useState
+
+
+ {label}
+
+
+
+ {value}
+ {unit && (
+
+ {unit}
+
+ )}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Energy Monitoring
+
+
+ {error || 'Waiting for energy data from the server...'}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Dashboard/TraceDebugger.tsx b/frontend/src/components/Dashboard/TraceDebugger.tsx
new file mode 100644
index 00000000..c51b7deb
--- /dev/null
+++ b/frontend/src/components/Dashboard/TraceDebugger.tsx
@@ -0,0 +1,210 @@
+import { useState, useEffect, useCallback } from 'react';
+import { GitBranch, Clock, ChevronRight, ChevronDown } from 'lucide-react';
+
+interface TraceStepData {
+ model?: string;
+ tokens?: number;
+ tool?: string;
+ input?: string;
+ output?: string;
+ [key: string]: unknown;
+}
+
+interface TraceStep {
+ step_type: string;
+ duration_ms: number;
+ data: TraceStepData;
+}
+
+interface TraceSummary {
+ id: string;
+ query: string;
+ steps: TraceStep[];
+ created_at: string;
+}
+
+const STEP_COLORS: Record
+
+ Energy Monitoring
+
+
+
+
+
+
+
+
+
+ {/* Thermal indicator */}
+
+
+ Thermal: {thermalStatus.label}
+ {telemetry?.total_tokens ?? 0} tokens processed
+
+
+ {/* Chart */}
+ {chartData.length > 1 && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {trace.query || 'Untitled query'}
+
+
+ {trace.steps.length} steps
+ ·
+ {totalMs.toFixed(0)}ms
+ ·
+ {new Date(trace.created_at).toLocaleTimeString()}
+
+
+ setExpanded(!expanded)}
+ className="flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors cursor-pointer"
+ style={{ background: 'var(--color-bg-secondary)' }}
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
+ >
+ {expanded ? : }
+
+ {index + 1}
+
+
+
+
+
+ {step.duration_ms.toFixed(0)}ms
+
+
+ {expanded && dataEntries.length > 0 && (
+
+ );
+}
+
+export function TraceDebugger() {
+ const [traces, setTraces] = useState
+ {dataEntries.map(([key, value]) => (
+
+ )}
+
+
+ {key}
+
+
+ {typeof value === 'object' ? JSON.stringify(value) : String(value)}
+
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Trace Debugger
+
+
+ {error}
+
+
+
+
+
+ {traces.length === 0 ? (
+
+ );
+}
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
index c370c090..58fb56b1 100644
--- a/frontend/src/components/ErrorBoundary.tsx
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -1,5 +1,6 @@
import { Component } from 'react';
-import type { ErrorInfo, ReactNode } from 'react';
+import type { ReactNode, ErrorInfo } from 'react';
+import { AlertTriangle, RotateCcw } from 'lucide-react';
interface Props {
children: ReactNode;
@@ -21,66 +22,39 @@ export class ErrorBoundary extends Component
+
+ Trace Debugger
+
+
+ {traces.length === 0 ? (
+
+ No traces yet. Start making queries to see them here.
+
+ ) : (
+
+ {/* Trace list */}
+
+ )}
+
+ {traces.map((trace) => (
+ setSelectedId(trace.id)}
+ />
+ ))}
+
+
+ {/* Trace detail */}
+
+ {selected ? (
+
+
+
+ ))}
+
+ ) : (
+
+ {selected.query}
+
+ {selected.steps.map((step, i) => (
+
+ Select a trace to view details
+
+ )}
+
-
- = {
- container: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- height: '100vh',
- backgroundColor: '#1a1a1e',
- color: '#e2e8f0',
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
- },
- card: {
- textAlign: 'center' as const,
- padding: 32,
- maxWidth: 420,
- },
- heading: {
- fontSize: 20,
- fontWeight: 600,
- marginBottom: 12,
- },
- message: {
- fontSize: 14,
- color: '#94a3b8',
- marginBottom: 24,
- lineHeight: 1.5,
- },
- button: {
- padding: '10px 24px',
- borderRadius: 8,
- border: 'none',
- backgroundColor: '#2563eb',
- color: 'white',
- fontSize: 14,
- fontWeight: 500,
- cursor: 'pointer',
- },
-};
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
new file mode 100644
index 00000000..ee1c7aef
--- /dev/null
+++ b/frontend/src/components/Layout.tsx
@@ -0,0 +1,23 @@
+import { Outlet } from 'react-router';
+import { Sidebar } from './Sidebar/Sidebar';
+import { useAppStore } from '../lib/store';
+
+export function Layout() {
+ const sidebarOpen = useAppStore((s) => s.sidebarOpen);
+
+ return (
+
Something went wrong
-+
+
);
}
+
return this.props.children;
}
}
-
-const styles: Record
+ this.setState({ hasError: false, error: null })}
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer"
+ style={{ background: 'var(--color-accent)', color: 'white' }}
>
+
Try again
+
+
+ + Something went wrong +
+{this.state.error?.message || 'An unexpected error occurred.'}
+
+ {/* Overlay for mobile when sidebar is open */}
+ {sidebarOpen && (
+
useAppStore.getState().setSidebarOpen(false)}
+ />
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Sidebar/ConversationList.tsx b/frontend/src/components/Sidebar/ConversationList.tsx
index a6f1791c..0541c5c3 100644
--- a/frontend/src/components/Sidebar/ConversationList.tsx
+++ b/frontend/src/components/Sidebar/ConversationList.tsx
@@ -1,44 +1,98 @@
-import type { Conversation } from '../../types';
+import { Trash2 } from 'lucide-react';
+import { useNavigate } from 'react-router';
+import { useAppStore } from '../../lib/store';
-interface ConversationListProps {
- conversations: Conversation[];
- activeId: string | null;
- onSelect: (id: string) => void;
- onDelete: (id: string) => void;
+interface Props {
+ searchQuery: string;
}
-export function ConversationList({
- conversations,
- activeId,
- onSelect,
- onDelete,
-}: ConversationListProps) {
+function formatRelativeTime(timestamp: number): string {
+ const diff = Date.now() - timestamp;
+ const minutes = Math.floor(diff / 60000);
+ if (minutes < 1) return 'Just now';
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d ago`;
+ return new Date(timestamp).toLocaleDateString();
+}
+
+export function ConversationList({ searchQuery }: Props) {
+ const navigate = useNavigate();
+ const conversations = useAppStore((s) => s.conversations);
+ const activeId = useAppStore((s) => s.activeId);
+ const selectConversation = useAppStore((s) => s.selectConversation);
+ const deleteConversation = useAppStore((s) => s.deleteConversation);
+
+ const filtered = searchQuery
+ ? conversations.filter((c) =>
+ c.title.toLowerCase().includes(searchQuery.toLowerCase()),
+ )
+ : conversations;
+
+ if (filtered.length === 0) {
+ return (
+
+ {searchQuery ? 'No matching chats' : 'No conversations yet'}
+
+ );
+ }
+
return (
-
- {conversations.length === 0 && (
- (INITIAL_STREAM_STATE);
- const [messages, setMessages] = useState(() => {
- if (!conversationId) return [];
- const conv = storage.getConversation(conversationId);
- return conv?.messages || [];
- });
-
- const abortRef = useRef(null);
- const timerRef = useRef | null>(null);
- const startTimeRef = useRef(0);
-
- // Reload messages when conversation changes
- const reloadMessages = useCallback(() => {
- if (!conversationId) {
- setMessages([]);
- return;
- }
- const conv = storage.getConversation(conversationId);
- setMessages(conv?.messages || []);
- }, [conversationId]);
-
- const stopStreaming = useCallback(() => {
- abortRef.current?.abort();
- if (timerRef.current) {
- clearInterval(timerRef.current);
- timerRef.current = null;
- }
- setStreamState(INITIAL_STREAM_STATE);
- }, []);
-
- const sendMessage = useCallback(
- async (content: string) => {
- if (!conversationId || !content.trim()) return;
-
- // Add user message
- const userMsg: ChatMessage = {
- id: storage.generateMessageId(),
- role: 'user',
- content: content.trim(),
- timestamp: Date.now(),
- };
- storage.addMessage(conversationId, userMsg);
-
- // Build API messages BEFORE adding the assistant placeholder,
- // so the placeholder's empty content isn't sent to the backend.
- const conv = storage.getConversation(conversationId);
- const apiMessages = (conv?.messages || []).map((m) => ({
- role: m.role,
- content: m.content,
- }));
-
- // Add placeholder assistant message (after building apiMessages)
- const assistantMsg: ChatMessage = {
- id: storage.generateMessageId(),
- role: 'assistant',
- content: '',
- timestamp: Date.now(),
- };
- storage.addMessage(conversationId, assistantMsg);
-
- // Update local state
- setMessages((prev) => [...prev, userMsg, assistantMsg]);
-
- // Start timer
- startTimeRef.current = Date.now();
- const timer = setInterval(() => {
- setStreamState((s) => ({
- ...s,
- elapsedMs: Date.now() - startTimeRef.current,
- }));
- }, 100);
- timerRef.current = timer;
-
- const controller = new AbortController();
- abortRef.current = controller;
-
- let accumulatedContent = '';
- let usage: TokenUsage | undefined;
- const toolCalls: ToolCallInfo[] = [];
-
- setStreamState({
- isStreaming: true,
- phase: 'Sending request...',
- elapsedMs: 0,
- activeToolCalls: [],
- content: '',
- });
-
- try {
- for await (const sseEvent of streamChat(
- { model, messages: apiMessages, stream: true },
- controller.signal,
- )) {
- const eventName = sseEvent.event;
-
- if (eventName === 'agent_turn_start') {
- setStreamState((s) => ({ ...s, phase: 'Agent thinking...' }));
- } else if (eventName === 'inference_start') {
- setStreamState((s) => ({ ...s, phase: 'Generating response...' }));
- } else if (eventName === 'inference_end') {
- // Just update phase
- } else if (eventName === 'tool_call_start') {
- try {
- const data = JSON.parse(sseEvent.data);
- const tc: ToolCallInfo = {
- id: storage.generateMessageId(),
- tool: data.tool,
- arguments: data.arguments || '',
- status: 'running',
- };
- toolCalls.push(tc);
- setStreamState((s) => ({
- ...s,
- phase: `Running ${data.tool}...`,
- activeToolCalls: [...toolCalls],
- }));
- // Update message with live tool call progress
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- if (last && last.role === 'assistant') {
- updated[updated.length - 1] = {
- ...last,
- toolCalls: [...toolCalls],
- };
- }
- return updated;
- });
- } catch {}
- } else if (eventName === 'tool_call_end') {
- try {
- const data = JSON.parse(sseEvent.data);
- const tc = toolCalls.find((t) => t.tool === data.tool && t.status === 'running');
- if (tc) {
- tc.status = data.success ? 'success' : 'error';
- tc.latency = data.latency;
- tc.result = data.result;
- }
- setStreamState((s) => ({
- ...s,
- phase: 'Generating response...',
- activeToolCalls: [...toolCalls],
- }));
- // Update message with completed tool call
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- if (last && last.role === 'assistant') {
- updated[updated.length - 1] = {
- ...last,
- toolCalls: [...toolCalls],
- };
- }
- return updated;
- });
- } catch {}
- } else {
- // Content chunk (no event name or event: content)
- try {
- const data = JSON.parse(sseEvent.data);
- const delta = data.choices?.[0]?.delta;
- if (data.usage) {
- usage = data.usage;
- }
- if (delta?.content) {
- accumulatedContent += delta.content;
- setStreamState((s) => ({
- ...s,
- content: accumulatedContent,
- }));
- // Update messages in state
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- if (last && last.role === 'assistant') {
- updated[updated.length - 1] = {
- ...last,
- content: accumulatedContent,
- toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined,
- };
- }
- return updated;
- });
- }
- if (data.choices?.[0]?.finish_reason === 'stop') break;
- } catch {}
- }
- }
- } catch (err: any) {
- if (err.name !== 'AbortError') {
- accumulatedContent = accumulatedContent || 'Error: Failed to get response.';
- }
- } finally {
- // Show a message if streaming completed with no content
- if (!accumulatedContent) {
- accumulatedContent = 'No response was generated. Please try again.';
- }
- // Save final state
- storage.updateLastAssistantMessage(
- conversationId,
- accumulatedContent,
- toolCalls.length > 0 ? toolCalls : undefined,
- usage,
- );
- // Update local messages with usage
- if (usage) {
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- if (last && last.role === 'assistant') {
- updated[updated.length - 1] = { ...last, usage };
- }
- return updated;
- });
- }
- if (timerRef.current) {
- clearInterval(timerRef.current);
- timerRef.current = null;
- }
- setStreamState(INITIAL_STREAM_STATE);
- abortRef.current = null;
- }
- },
- [conversationId, model],
- );
-
- return {
- messages,
- streamState,
- sendMessage,
- stopStreaming,
- reloadMessages,
- };
-}
diff --git a/frontend/src/hooks/useConversations.ts b/frontend/src/hooks/useConversations.ts
deleted file mode 100644
index 17ffa04b..00000000
--- a/frontend/src/hooks/useConversations.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useState, useCallback } from 'react';
-import type { Conversation } from '../types';
-import * as storage from '../storage/conversations';
-
-export function useConversations() {
- const [conversations, setConversations] = useState(
- storage.getConversations,
- );
- const [activeId, setActiveIdState] = useState(
- storage.getActiveId,
- );
-
- const reload = useCallback(() => {
- setConversations(storage.getConversations());
- setActiveIdState(storage.getActiveId());
- }, []);
-
- const createConversation = useCallback((model: string) => {
- const conv = storage.createConversation(model);
- setConversations(storage.getConversations());
- setActiveIdState(conv.id);
- return conv;
- }, []);
-
- const selectConversation = useCallback((id: string) => {
- storage.setActiveId(id);
- setActiveIdState(id);
- }, []);
-
- const removeConversation = useCallback((id: string) => {
- storage.deleteConversation(id);
- setConversations(storage.getConversations());
- setActiveIdState(storage.getActiveId());
- }, []);
-
- const activeConversation = activeId
- ? storage.getConversation(activeId)
- : null;
-
- return {
- conversations,
- activeId,
- activeConversation,
- createConversation,
- selectConversation,
- removeConversation,
- reload,
- };
-}
diff --git a/frontend/src/hooks/useModels.ts b/frontend/src/hooks/useModels.ts
deleted file mode 100644
index c39f6c2d..00000000
--- a/frontend/src/hooks/useModels.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { ModelInfo } from '../types';
-import { fetchModels } from '../api/client';
-
-export function useModels() {
- const [models, setModels] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- fetchModels()
- .then(setModels)
- .catch(() => setModels([]))
- .finally(() => setLoading(false));
- }, []);
-
- return { models, loading };
-}
diff --git a/frontend/src/hooks/useSavings.ts b/frontend/src/hooks/useSavings.ts
deleted file mode 100644
index 4d57c36f..00000000
--- a/frontend/src/hooks/useSavings.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useState, useEffect, useCallback } from 'react';
-import type { SavingsData } from '../types';
-import { fetchSavings } from '../api/client';
-
-export function useSavings() {
- const [savings, setSavings] = useState(null);
-
- const refresh = useCallback(() => {
- fetchSavings()
- .then(setSavings)
- .catch(() => {});
- }, []);
-
- useEffect(() => {
- refresh();
- const interval = setInterval(refresh, 30000);
- return () => clearInterval(interval);
- }, [refresh]);
-
- return { savings, refresh };
-}
diff --git a/frontend/src/hooks/useServerInfo.ts b/frontend/src/hooks/useServerInfo.ts
deleted file mode 100644
index 9dbe2392..00000000
--- a/frontend/src/hooks/useServerInfo.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { ServerInfo } from '../types';
-import { fetchServerInfo } from '../api/client';
-
-export function useServerInfo() {
- const [info, setInfo] = useState(null);
-
- useEffect(() => {
- fetchServerInfo()
- .then(setInfo)
- .catch(() => {});
- }, []);
-
- return info;
-}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 00000000..972eb393
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,327 @@
+@import "tailwindcss";
+
+/*
+ * OpenJarvis design tokens.
+ * Zinc/slate neutrals, blue accent. Light default, dark via class or system pref.
+ */
+
+@layer base {
+ :root {
+ --color-bg: #ffffff;
+ --color-bg-secondary: #f4f4f5;
+ --color-bg-tertiary: #e4e4e7;
+ --color-surface: #ffffff;
+ --color-sidebar: #fafafa;
+ --color-border: #e4e4e7;
+ --color-border-subtle: #f4f4f5;
+
+ --color-text: #09090b;
+ --color-text-secondary: #71717a;
+ --color-text-tertiary: #a1a1aa;
+ --color-text-inverse: #fafafa;
+
+ --color-accent: #2563eb;
+ --color-accent-hover: #1d4ed8;
+ --color-accent-subtle: #eff6ff;
+
+ --color-success: #16a34a;
+ --color-warning: #ca8a04;
+ --color-error: #dc2626;
+
+ --color-code-bg: #f4f4f5;
+ --color-input-bg: #ffffff;
+ --color-input-border: #d4d4d8;
+
+ --color-user-bubble: #2563eb;
+ --color-user-bubble-text: #ffffff;
+
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-full: 9999px;
+
+ --sidebar-width: 260px;
+ --chat-max-width: 720px;
+
+ color-scheme: light;
+ }
+
+ .dark {
+ --color-bg: #09090b;
+ --color-bg-secondary: #18181b;
+ --color-bg-tertiary: #27272a;
+ --color-surface: #18181b;
+ --color-sidebar: #0f0f12;
+ --color-border: #27272a;
+ --color-border-subtle: #1e1e22;
+
+ --color-text: #fafafa;
+ --color-text-secondary: #a1a1aa;
+ --color-text-tertiary: #71717a;
+ --color-text-inverse: #09090b;
+
+ --color-accent: #3b82f6;
+ --color-accent-hover: #60a5fa;
+ --color-accent-subtle: #172554;
+
+ --color-success: #22c55e;
+ --color-warning: #eab308;
+ --color-error: #ef4444;
+
+ --color-code-bg: #1e1e22;
+ --color-input-bg: #18181b;
+ --color-input-border: #3f3f46;
+
+ --color-user-bubble: #3b82f6;
+ --color-user-bubble-text: #ffffff;
+
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
+
+ color-scheme: dark;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root:not(.light) {
+ --color-bg: #09090b;
+ --color-bg-secondary: #18181b;
+ --color-bg-tertiary: #27272a;
+ --color-surface: #18181b;
+ --color-sidebar: #0f0f12;
+ --color-border: #27272a;
+ --color-border-subtle: #1e1e22;
+
+ --color-text: #fafafa;
+ --color-text-secondary: #a1a1aa;
+ --color-text-tertiary: #71717a;
+ --color-text-inverse: #09090b;
+
+ --color-accent: #3b82f6;
+ --color-accent-hover: #60a5fa;
+ --color-accent-subtle: #172554;
+
+ --color-success: #22c55e;
+ --color-warning: #eab308;
+ --color-error: #ef4444;
+
+ --color-code-bg: #1e1e22;
+ --color-input-bg: #18181b;
+ --color-input-border: #3f3f46;
+
+ --color-user-bubble: #3b82f6;
+ --color-user-bubble-text: #ffffff;
+
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
+
+ color-scheme: dark;
+ }
+ }
+
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ html, body, #root {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ }
+
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ ::selection {
+ background-color: var(--color-accent);
+ color: white;
+ }
+
+ ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 3px;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-tertiary);
+ }
+}
+
+/* Syntax highlighting theme (highlight.js) */
+@layer components {
+ .hljs {
+ background: var(--color-code-bg) !important;
+ color: var(--color-text) !important;
+ border-radius: var(--radius-md);
+ padding: 1rem !important;
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ overflow-x: auto;
+ }
+
+ .hljs-keyword,
+ .hljs-selector-tag,
+ .hljs-literal,
+ .hljs-section,
+ .hljs-link { color: #7c3aed; }
+ .dark .hljs-keyword,
+ .dark .hljs-selector-tag,
+ .dark .hljs-literal,
+ .dark .hljs-section,
+ .dark .hljs-link { color: #a78bfa; }
+
+ .hljs-string,
+ .hljs-title,
+ .hljs-name,
+ .hljs-type,
+ .hljs-attribute,
+ .hljs-symbol,
+ .hljs-bullet,
+ .hljs-addition,
+ .hljs-variable,
+ .hljs-template-tag,
+ .hljs-template-variable { color: #059669; }
+ .dark .hljs-string,
+ .dark .hljs-title,
+ .dark .hljs-name,
+ .dark .hljs-type,
+ .dark .hljs-attribute,
+ .dark .hljs-symbol,
+ .dark .hljs-bullet,
+ .dark .hljs-addition,
+ .dark .hljs-variable,
+ .dark .hljs-template-tag,
+ .dark .hljs-template-variable { color: #34d399; }
+
+ .hljs-comment,
+ .hljs-quote,
+ .hljs-deletion,
+ .hljs-meta { color: #a1a1aa; }
+
+ .hljs-number,
+ .hljs-regexp,
+ .hljs-built_in,
+ .hljs-params { color: #d97706; }
+ .dark .hljs-number,
+ .dark .hljs-regexp,
+ .dark .hljs-built_in,
+ .dark .hljs-params { color: #fbbf24; }
+
+ .hljs-function { color: #2563eb; }
+ .dark .hljs-function { color: #60a5fa; }
+
+ /* Markdown prose styling */
+ .prose {
+ line-height: 1.65;
+ font-size: 0.9375rem;
+ }
+
+ .prose p {
+ margin-bottom: 0.75em;
+ }
+
+ .prose p:last-child {
+ margin-bottom: 0;
+ }
+
+ .prose code:not(pre code) {
+ background: var(--color-code-bg);
+ padding: 0.15em 0.35em;
+ border-radius: var(--radius-sm);
+ font-size: 0.85em;
+ font-family: "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
+ }
+
+ .prose pre {
+ margin: 0.75em 0;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ position: relative;
+ }
+
+ .prose pre code {
+ font-family: "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
+ }
+
+ .prose ul, .prose ol {
+ padding-left: 1.5em;
+ margin-bottom: 0.75em;
+ }
+
+ .prose li {
+ margin-bottom: 0.25em;
+ }
+
+ .prose blockquote {
+ border-left: 3px solid var(--color-accent);
+ padding-left: 1em;
+ margin: 0.75em 0;
+ color: var(--color-text-secondary);
+ }
+
+ .prose h1, .prose h2, .prose h3, .prose h4 {
+ font-weight: 600;
+ margin-top: 1em;
+ margin-bottom: 0.5em;
+ }
+
+ .prose h1 { font-size: 1.375em; }
+ .prose h2 { font-size: 1.2em; }
+ .prose h3 { font-size: 1.075em; }
+
+ .prose table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.75em 0;
+ font-size: 0.875em;
+ }
+
+ .prose th, .prose td {
+ border: 1px solid var(--color-border);
+ padding: 0.5em 0.75em;
+ text-align: left;
+ }
+
+ .prose th {
+ background: var(--color-bg-secondary);
+ font-weight: 600;
+ }
+
+ .prose a {
+ color: var(--color-accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+
+ .prose a:hover {
+ color: var(--color-accent-hover);
+ }
+
+ .prose hr {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: 1em 0;
+ }
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 00000000..56a638b8
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,98 @@
+import type { ModelInfo, SavingsData, ServerInfo } from '../types';
+
+declare global {
+ interface Window {
+ __TAURI_INTERNALS__?: unknown;
+ }
+}
+
+const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
+
+const getBase = () => import.meta.env.VITE_API_URL || '';
+
+async function tauriInvoke(command: string, args: Record = {}): Promise {
+ const { invoke } = await import('@tauri-apps/api/core');
+ const apiUrl = getBase() || 'http://localhost:8000';
+ return invoke(command, { apiUrl, ...args });
+}
+
+export async function fetchModels(): Promise {
+ if (isTauri()) {
+ try {
+ const result = await tauriInvoke<{ data?: ModelInfo[] }>('fetch_models');
+ return result?.data || [];
+ } catch {
+ // Fall through to fetch
+ }
+ }
+ const res = await fetch(`${getBase()}/v1/models`);
+ if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
+ const data = await res.json();
+ return data.data || [];
+}
+
+export async function fetchSavings(): Promise {
+ const res = await fetch(`${getBase()}/v1/savings`);
+ if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`);
+ return res.json();
+}
+
+export async function fetchServerInfo(): Promise {
+ const res = await fetch(`${getBase()}/v1/info`);
+ if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`);
+ return res.json();
+}
+
+export async function checkHealth(): Promise {
+ if (isTauri()) {
+ try {
+ const apiUrl = getBase() || 'http://localhost:8000';
+ await tauriInvoke('check_health', { apiUrl });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ try {
+ const res = await fetch(`${getBase()}/health`);
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+export async function fetchEnergy(): Promise {
+ if (isTauri()) {
+ try {
+ const apiUrl = getBase() || 'http://localhost:8000';
+ return await tauriInvoke('fetch_energy', { apiUrl });
+ } catch {}
+ }
+ const res = await fetch(`${getBase()}/v1/telemetry/energy`);
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
+ return res.json();
+}
+
+export async function fetchTelemetry(): Promise {
+ if (isTauri()) {
+ try {
+ const apiUrl = getBase() || 'http://localhost:8000';
+ return await tauriInvoke('fetch_telemetry', { apiUrl });
+ } catch {}
+ }
+ const res = await fetch(`${getBase()}/v1/telemetry/stats`);
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
+ return res.json();
+}
+
+export async function fetchTraces(limit: number = 50): Promise {
+ if (isTauri()) {
+ try {
+ const apiUrl = getBase() || 'http://localhost:8000';
+ return await tauriInvoke('fetch_traces', { apiUrl, limit });
+ } catch {}
+ }
+ const res = await fetch(`${getBase()}/v1/traces?limit=${limit}`);
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
+ return res.json();
+}
diff --git a/frontend/src/api/sse.ts b/frontend/src/lib/sse.ts
similarity index 89%
rename from frontend/src/api/sse.ts
rename to frontend/src/lib/sse.ts
index 871f91b8..ff778a1a 100644
--- a/frontend/src/api/sse.ts
+++ b/frontend/src/lib/sse.ts
@@ -10,7 +10,8 @@ export async function* streamChat(
request: ChatRequest,
signal?: AbortSignal,
): AsyncGenerator {
- const response = await fetch('/v1/chat/completions', {
+ const base = import.meta.env.VITE_API_URL || '';
+ const response = await fetch(`${base}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
@@ -41,9 +42,7 @@ export async function* streamChat(
currentEvent = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
const data = line.slice(6);
- if (data === '[DONE]') {
- return;
- }
+ if (data === '[DONE]') return;
yield { event: currentEvent, data };
currentEvent = undefined;
} else if (line.trim() === '') {
diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts
new file mode 100644
index 00000000..8fefe5db
--- /dev/null
+++ b/frontend/src/lib/store.ts
@@ -0,0 +1,319 @@
+import { create } from 'zustand';
+import type {
+ Conversation,
+ ChatMessage,
+ ModelInfo,
+ SavingsData,
+ ServerInfo,
+ StreamState,
+ ToolCallInfo,
+ TokenUsage,
+} from '../types';
+
+// ── localStorage persistence ──────────────────────────────────────────
+
+const CONVERSATIONS_KEY = 'openjarvis-conversations';
+const SETTINGS_KEY = 'openjarvis-settings';
+
+interface ConversationStore {
+ version: 1;
+ conversations: Record;
+ activeId: string | null;
+}
+
+function generateId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
+}
+
+function loadConversations(): ConversationStore {
+ try {
+ const raw = localStorage.getItem(CONVERSATIONS_KEY);
+ if (!raw) return { version: 1, conversations: {}, activeId: null };
+ const parsed = JSON.parse(raw);
+ if (parsed.version === 1) return parsed;
+ return { version: 1, conversations: {}, activeId: null };
+ } catch {
+ return { version: 1, conversations: {}, activeId: null };
+ }
+}
+
+function saveConversations(store: ConversationStore): void {
+ localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(store));
+}
+
+export type ThemeMode = 'light' | 'dark' | 'system';
+
+interface Settings {
+ theme: ThemeMode;
+ apiUrl: string;
+ fontSize: 'small' | 'default' | 'large';
+ defaultModel: string;
+ defaultAgent: string;
+ temperature: number;
+ maxTokens: number;
+}
+
+function loadSettings(): Settings {
+ const defaults: Settings = {
+ theme: 'system',
+ apiUrl: '',
+ fontSize: 'default',
+ defaultModel: '',
+ defaultAgent: '',
+ temperature: 0.7,
+ maxTokens: 4096,
+ };
+ try {
+ const raw = localStorage.getItem(SETTINGS_KEY);
+ if (!raw) return defaults;
+ return { ...defaults, ...JSON.parse(raw) };
+ } catch {
+ return defaults;
+ }
+}
+
+function saveSettings(settings: Settings): void {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
+}
+
+// ── Store ─────────────────────────────────────────────────────────────
+
+const INITIAL_STREAM: StreamState = {
+ isStreaming: false,
+ phase: '',
+ elapsedMs: 0,
+ activeToolCalls: [],
+ content: '',
+};
+
+interface AppState {
+ // Conversations
+ conversations: Conversation[];
+ activeId: string | null;
+ messages: ChatMessage[];
+ streamState: StreamState;
+
+ // Models & server
+ models: ModelInfo[];
+ modelsLoading: boolean;
+ selectedModel: string;
+ serverInfo: ServerInfo | null;
+ savings: SavingsData | null;
+
+ // Settings
+ settings: Settings;
+
+ // Command palette
+ commandPaletteOpen: boolean;
+
+ // Sidebar
+ sidebarOpen: boolean;
+
+ // Actions: conversations
+ loadConversations: () => void;
+ createConversation: (model?: string) => string;
+ selectConversation: (id: string) => void;
+ deleteConversation: (id: string) => void;
+ loadMessages: (conversationId: string | null) => void;
+ addMessage: (conversationId: string, message: ChatMessage) => void;
+ updateLastAssistant: (
+ conversationId: string,
+ content: string,
+ toolCalls?: ToolCallInfo[],
+ usage?: TokenUsage,
+ ) => void;
+ setStreamState: (state: Partial) => void;
+ resetStream: () => void;
+
+ // Actions: models & server
+ setModels: (models: ModelInfo[]) => void;
+ setModelsLoading: (loading: boolean) => void;
+ setSelectedModel: (model: string) => void;
+ setServerInfo: (info: ServerInfo | null) => void;
+ setSavings: (data: SavingsData | null) => void;
+
+ // Actions: settings
+ updateSettings: (partial: Partial) => void;
+
+ // Actions: UI
+ setCommandPaletteOpen: (open: boolean) => void;
+ toggleSidebar: () => void;
+ setSidebarOpen: (open: boolean) => void;
+}
+
+export const useAppStore = create((set, get) => {
+ const initial = loadConversations();
+ const convList = Object.values(initial.conversations).sort(
+ (a, b) => b.updatedAt - a.updatedAt,
+ );
+
+ return {
+ conversations: convList,
+ activeId: initial.activeId,
+ messages:
+ initial.activeId && initial.conversations[initial.activeId]
+ ? initial.conversations[initial.activeId].messages
+ : [],
+ streamState: INITIAL_STREAM,
+
+ models: [],
+ modelsLoading: true,
+ selectedModel: '',
+ serverInfo: null,
+ savings: null,
+
+ settings: loadSettings(),
+
+ commandPaletteOpen: false,
+ sidebarOpen: true,
+
+ // ── Conversations ───────────────────────────────────────────────
+
+ loadConversations: () => {
+ const store = loadConversations();
+ set({
+ conversations: Object.values(store.conversations).sort(
+ (a, b) => b.updatedAt - a.updatedAt,
+ ),
+ activeId: store.activeId,
+ });
+ },
+
+ createConversation: (model?: string) => {
+ const store = loadConversations();
+ const conv: Conversation = {
+ id: generateId(),
+ title: 'New chat',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ model: model || get().selectedModel || 'default',
+ messages: [],
+ };
+ store.conversations[conv.id] = conv;
+ store.activeId = conv.id;
+ saveConversations(store);
+ set({
+ conversations: Object.values(store.conversations).sort(
+ (a, b) => b.updatedAt - a.updatedAt,
+ ),
+ activeId: conv.id,
+ messages: [],
+ });
+ return conv.id;
+ },
+
+ selectConversation: (id: string) => {
+ const store = loadConversations();
+ store.activeId = id;
+ saveConversations(store);
+ const conv = store.conversations[id];
+ set({
+ activeId: id,
+ messages: conv ? conv.messages : [],
+ });
+ },
+
+ deleteConversation: (id: string) => {
+ const store = loadConversations();
+ delete store.conversations[id];
+ if (store.activeId === id) {
+ const remaining = Object.keys(store.conversations);
+ store.activeId = remaining.length > 0 ? remaining[0] : null;
+ }
+ saveConversations(store);
+ const convList = Object.values(store.conversations).sort(
+ (a, b) => b.updatedAt - a.updatedAt,
+ );
+ const activeConv = store.activeId
+ ? store.conversations[store.activeId]
+ : null;
+ set({
+ conversations: convList,
+ activeId: store.activeId,
+ messages: activeConv ? activeConv.messages : [],
+ });
+ },
+
+ loadMessages: (conversationId: string | null) => {
+ if (!conversationId) {
+ set({ messages: [] });
+ return;
+ }
+ const store = loadConversations();
+ const conv = store.conversations[conversationId];
+ set({ messages: conv ? conv.messages : [] });
+ },
+
+ addMessage: (conversationId: string, message: ChatMessage) => {
+ const store = loadConversations();
+ const conv = store.conversations[conversationId];
+ if (!conv) return;
+ conv.messages.push(message);
+ conv.updatedAt = Date.now();
+ if (message.role === 'user' && conv.title === 'New chat') {
+ conv.title =
+ message.content.slice(0, 50) +
+ (message.content.length > 50 ? '...' : '');
+ }
+ saveConversations(store);
+ set({
+ messages: [...conv.messages],
+ conversations: Object.values(store.conversations).sort(
+ (a, b) => b.updatedAt - a.updatedAt,
+ ),
+ });
+ },
+
+ updateLastAssistant: (
+ conversationId: string,
+ content: string,
+ toolCalls?: ToolCallInfo[],
+ usage?: TokenUsage,
+ ) => {
+ const store = loadConversations();
+ const conv = store.conversations[conversationId];
+ if (!conv) return;
+ const lastMsg = conv.messages[conv.messages.length - 1];
+ if (lastMsg && lastMsg.role === 'assistant') {
+ lastMsg.content = content;
+ if (toolCalls) lastMsg.toolCalls = toolCalls;
+ if (usage) lastMsg.usage = usage;
+ conv.updatedAt = Date.now();
+ saveConversations(store);
+ set({ messages: [...conv.messages] });
+ }
+ },
+
+ setStreamState: (partial: Partial) => {
+ set((s) => ({ streamState: { ...s.streamState, ...partial } }));
+ },
+
+ resetStream: () => {
+ set({ streamState: INITIAL_STREAM });
+ },
+
+ // ── Models & server ────────────────────────────────────────────
+
+ setModels: (models: ModelInfo[]) => set({ models }),
+ setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
+ setSelectedModel: (model: string) => set({ selectedModel: model }),
+ setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
+ setSavings: (data: SavingsData | null) => set({ savings: data }),
+
+ // ── Settings ───────────────────────────────────────────────────
+
+ updateSettings: (partial: Partial) => {
+ const updated = { ...get().settings, ...partial };
+ saveSettings(updated);
+ set({ settings: updated });
+ },
+
+ // ── UI ──────────────────────────────────────────────────────────
+
+ setCommandPaletteOpen: (open: boolean) => set({ commandPaletteOpen: open }),
+ toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
+ setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
+ };
+});
+
+export { generateId };
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 0bfeeefc..2126e656 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,17 +1,33 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router';
import { ErrorBoundary } from './components/ErrorBoundary';
import App from './App';
-import './styles/variables.css';
-import './styles/base.css';
-import './styles/sidebar.css';
-import './styles/chat.css';
-import './styles/input.css';
+import './index.css';
+
+function applyTheme() {
+ try {
+ const raw = localStorage.getItem('openjarvis-settings');
+ const settings = raw ? JSON.parse(raw) : {};
+ const theme = settings.theme || 'system';
+ if (theme === 'dark') {
+ document.documentElement.classList.add('dark');
+ document.documentElement.classList.remove('light');
+ } else if (theme === 'light') {
+ document.documentElement.classList.add('light');
+ document.documentElement.classList.remove('dark');
+ }
+ } catch { /* use system default */ }
+}
+
+applyTheme();
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
);
diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx
new file mode 100644
index 00000000..43ff34e0
--- /dev/null
+++ b/frontend/src/pages/ChatPage.tsx
@@ -0,0 +1,5 @@
+import { ChatArea } from '../components/Chat/ChatArea';
+
+export function ChatPage() {
+ return ;
+}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 00000000..2c7bbe3a
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -0,0 +1,26 @@
+import { BarChart3 } from 'lucide-react';
+import { EnergyDashboard } from '../components/Dashboard/EnergyDashboard';
+import { CostComparison } from '../components/Dashboard/CostComparison';
+import { TraceDebugger } from '../components/Dashboard/TraceDebugger';
+
+export function DashboardPage() {
+ return (
+ (null);
+ const [saved, setSaved] = useState(false);
+
+ useEffect(() => {
+ checkHealth().then(setHealthy);
+ }, []);
+
+ const showSaved = () => {
+ setSaved(true);
+ setTimeout(() => setSaved(false), 1500);
+ };
+
+ const handleExport = () => {
+ const data = localStorage.getItem('openjarvis-conversations') || '{}';
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `openjarvis-export-${new Date().toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleImport = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.onchange = (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ try {
+ const data = JSON.parse(ev.target?.result as string);
+ if (data.version === 1) {
+ localStorage.setItem('openjarvis-conversations', JSON.stringify(data));
+ useAppStore.getState().loadConversations();
+ showSaved();
+ }
+ } catch {}
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+ };
+
+ const handleClear = () => {
+ if (confirm('Delete all conversations? This cannot be undone.')) {
+ localStorage.removeItem('openjarvis-conversations');
+ useAppStore.getState().loadConversations();
+ }
+ };
+
+ return (
+
- No conversations yet
-
- )}
- {conversations.map((conv) => (
- onSelect(conv.id)}
- >
- {conv.title}
- {
- e.stopPropagation();
- onDelete(conv.id);
+
);
}
diff --git a/frontend/src/components/Sidebar/ModelSelector.tsx b/frontend/src/components/Sidebar/ModelSelector.tsx
deleted file mode 100644
index 9df74d23..00000000
--- a/frontend/src/components/Sidebar/ModelSelector.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { ModelInfo } from '../../types';
-
-interface ModelSelectorProps {
- models: ModelInfo[];
- selected: string;
- onSelect: (model: string) => void;
-}
-
-export function ModelSelector({ models, selected, onSelect }: ModelSelectorProps) {
- return (
-
+ {filtered.map((conv) => {
+ const isActive = conv.id === activeId;
+ return (
+ {
+ selectConversation(conv.id);
+ navigate('/');
+ }}
+ className="flex-1 text-left px-3 py-2 min-w-0 cursor-pointer"
+ >
+
+ {
+ e.stopPropagation();
+ deleteConversation(conv.id);
+ }}
+ className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
+ style={{ color: 'var(--color-text-tertiary)' }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
+ onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
+ title="Delete conversation"
+ >
+
+
+
+ );
+ })}
{
+ if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)';
+ }}
+ onMouseLeave={(e) => {
+ if (!isActive) e.currentTarget.style.background = 'transparent';
}}
- aria-label="Delete conversation"
>
- ×
-
-
- ))}
+
+ {conv.title}
+
+
+ {formatRelativeTime(conv.updatedAt)}
+
+
-
-
-
- );
-}
diff --git a/frontend/src/components/Sidebar/SavingsPanel.tsx b/frontend/src/components/Sidebar/SavingsPanel.tsx
deleted file mode 100644
index b8b25cf2..00000000
--- a/frontend/src/components/Sidebar/SavingsPanel.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import type { SavingsData } from '../../types';
-
-interface SavingsPanelProps {
- savings: SavingsData | null;
- localModel?: string;
-}
-
-function formatDollars(n: number): string {
- if (n >= 1) return '$' + n.toFixed(2);
- if (n > 0) return '$' + n.toFixed(4);
- return '$0.00';
-}
-
-function formatJoules(joules: number): string {
- if (joules >= 1e6) return (joules / 1e6).toFixed(1) + ' MJ';
- if (joules >= 1000) return (joules / 1000).toFixed(1) + ' kJ';
- if (joules >= 1) return joules.toFixed(0) + ' J';
- return '0 J';
-}
-
-function formatFlops(n: number): string {
- if (n >= 1e15) return (n / 1e15).toFixed(1) + ' PF';
- if (n >= 1e12) return (n / 1e12).toFixed(1) + ' TF';
- if (n >= 1e9) return (n / 1e9).toFixed(1) + ' GF';
- if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MF';
- return n.toFixed(0) + ' F';
-}
-
-export function SavingsPanel({ savings, localModel }: SavingsPanelProps) {
- if (!savings) return null;
-
- // Use max savings across providers as the headline
- const maxSavings = savings.per_provider.reduce(
- (max, p) => (p.total_cost > max ? p.total_cost : max),
- 0,
- );
-
- // Average energy/flops across providers
- const avgJoules = savings.per_provider.reduce(
- (sum, p) => sum + p.energy_joules,
- 0,
- ) / (savings.per_provider.length || 1);
-
- const avgFlops = savings.per_provider.reduce(
- (sum, p) => sum + p.flops,
- 0,
- ) / (savings.per_provider.length || 1);
-
- // Cloud model labels for comparison
- const cloudModels = savings.per_provider.map((p) => p.label);
-
- return (
-
-
- );
-}
diff --git a/frontend/src/components/Sidebar/Sidebar.tsx b/frontend/src/components/Sidebar/Sidebar.tsx
index 84db0373..15ca632d 100644
--- a/frontend/src/components/Sidebar/Sidebar.tsx
+++ b/frontend/src/components/Sidebar/Sidebar.tsx
@@ -1,55 +1,164 @@
-import type { Conversation, ModelInfo, SavingsData } from '../../types';
+import { useState } from 'react';
+import { useNavigate, useLocation } from 'react-router';
+import {
+ MessageSquare,
+ Plus,
+ BarChart3,
+ Settings,
+ Search,
+ PanelLeftClose,
+ PanelLeft,
+ Cpu,
+} from 'lucide-react';
import { ConversationList } from './ConversationList';
-import { ModelSelector } from './ModelSelector';
-import { SavingsPanel } from './SavingsPanel';
+import { useAppStore } from '../../lib/store';
-interface SidebarProps {
- isOpen: boolean;
- conversations: Conversation[];
- activeId: string | null;
- models: ModelInfo[];
- selectedModel: string;
- savings: SavingsData | null;
- localModel?: string;
- onSelectModel: (model: string) => void;
- onNewChat: () => void;
- onSelectConversation: (id: string) => void;
- onDeleteConversation: (id: string) => void;
-}
+export function Sidebar() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const sidebarOpen = useAppStore((s) => s.sidebarOpen);
+ const toggleSidebar = useAppStore((s) => s.toggleSidebar);
+ const createConversation = useAppStore((s) => s.createConversation);
+ const selectedModel = useAppStore((s) => s.selectedModel);
+ const serverInfo = useAppStore((s) => s.serverInfo);
+ const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
+
+ const handleNewChat = () => {
+ createConversation(selectedModel);
+ navigate('/');
+ };
+
+ const navItems = [
+ { path: '/', icon: MessageSquare, label: 'Chat' },
+ { path: '/dashboard', icon: BarChart3, label: 'Dashboard' },
+ { path: '/settings', icon: Settings, label: 'Settings' },
+ ];
-export function Sidebar({
- isOpen,
- conversations,
- activeId,
- models,
- selectedModel,
- savings,
- localModel,
- onSelectModel,
- onNewChat,
- onSelectConversation,
- onDeleteConversation,
-}: SidebarProps) {
return (
-
+ )}
+
+
+ >
);
}
diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts
deleted file mode 100644
index 2a969afb..00000000
--- a/frontend/src/hooks/useChat.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import { useState, useRef, useCallback } from 'react';
-import type { ChatMessage, StreamState, ToolCallInfo, TokenUsage } from '../types';
-import { streamChat } from '../api/sse';
-import * as storage from '../storage/conversations';
-
-const INITIAL_STREAM_STATE: StreamState = {
- isStreaming: false,
- phase: '',
- elapsedMs: 0,
- activeToolCalls: [],
- content: '',
-};
-
-export function useChat(conversationId: string | null, model: string) {
- const [streamState, setStreamState] = useStateSavings vs Cloud
-
-
-
- LOCAL
- {localModel || 'local model'}
-
-
- CLOUD
- {cloudModels.join(', ')}
-
-
-
-
-
- Requests
- {savings.total_calls.toLocaleString()}
-
-
- $ Saved
- {formatDollars(maxSavings)}
-
-
- Energy
- {formatJoules(avgJoules)}
-
-
- FLOPs
- {formatFlops(avgFlops)}
-
+
+ );
+}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
new file mode 100644
index 00000000..82155399
--- /dev/null
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -0,0 +1,298 @@
+import { useState, useEffect } from 'react';
+import {
+ Settings,
+ Palette,
+ Globe,
+ Cpu,
+ Database,
+ Info,
+ Check,
+ Sun,
+ Moon,
+ Monitor,
+ Download,
+ Upload,
+ Trash2,
+} from 'lucide-react';
+import { useAppStore, type ThemeMode } from '../lib/store';
+import { checkHealth } from '../lib/api';
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ + Dashboard +
+
+
+
+
+
+
+
+ );
+}
+
+function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
+ return (
+ + {title} +
+ {children} +
+
+ );
+}
+
+const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
+ { value: 'light', label: 'Light', icon: Sun },
+ { value: 'dark', label: 'Dark', icon: Moon },
+ { value: 'system', label: 'System', icon: Monitor },
+];
+
+export function SettingsPage() {
+ const settings = useAppStore((s) => s.settings);
+ const updateSettings = useAppStore((s) => s.updateSettings);
+ const conversations = useAppStore((s) => s.conversations);
+ const serverInfo = useAppStore((s) => s.serverInfo);
+ const [healthy, setHealthy] = useState
+
+ {label}
+ {description && (
+ {description}
+ )}
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/storage/conversations.ts b/frontend/src/storage/conversations.ts
deleted file mode 100644
index 50dd4445..00000000
--- a/frontend/src/storage/conversations.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import type { Conversation, ConversationStore, ChatMessage } from '../types';
-
-const STORAGE_KEY = 'openjarvis-conversations';
-
-function generateId(): string {
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
-}
-
-function loadStore(): ConversationStore {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return { version: 1, conversations: {}, activeId: null };
- const parsed = JSON.parse(raw);
- if (parsed.version === 1) return parsed;
- return { version: 1, conversations: {}, activeId: null };
- } catch {
- return { version: 1, conversations: {}, activeId: null };
- }
-}
-
-function saveStore(store: ConversationStore): void {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
-}
-
-export function getConversations(): Conversation[] {
- const store = loadStore();
- return Object.values(store.conversations).sort(
- (a, b) => b.updatedAt - a.updatedAt,
- );
-}
-
-export function getActiveId(): string | null {
- return loadStore().activeId;
-}
-
-export function setActiveId(id: string | null): void {
- const store = loadStore();
- store.activeId = id;
- saveStore(store);
-}
-
-export function getConversation(id: string): Conversation | null {
- const store = loadStore();
- return store.conversations[id] || null;
-}
-
-export function createConversation(model: string): Conversation {
- const store = loadStore();
- const conv: Conversation = {
- id: generateId(),
- title: 'New chat',
- createdAt: Date.now(),
- updatedAt: Date.now(),
- model,
- messages: [],
- };
- store.conversations[conv.id] = conv;
- store.activeId = conv.id;
- saveStore(store);
- return conv;
-}
-
-export function addMessage(
- conversationId: string,
- message: ChatMessage,
-): void {
- const store = loadStore();
- const conv = store.conversations[conversationId];
- if (!conv) return;
- conv.messages.push(message);
- conv.updatedAt = Date.now();
- // Update title from first user message
- if (message.role === 'user' && conv.title === 'New chat') {
- conv.title = message.content.slice(0, 50) + (message.content.length > 50 ? '...' : '');
- }
- saveStore(store);
-}
-
-export function updateLastAssistantMessage(
- conversationId: string,
- content: string,
- toolCalls?: ChatMessage['toolCalls'],
- usage?: ChatMessage['usage'],
-): void {
- const store = loadStore();
- const conv = store.conversations[conversationId];
- if (!conv) return;
- const lastMsg = conv.messages[conv.messages.length - 1];
- if (lastMsg && lastMsg.role === 'assistant') {
- lastMsg.content = content;
- if (toolCalls) lastMsg.toolCalls = toolCalls;
- if (usage) lastMsg.usage = usage;
- conv.updatedAt = Date.now();
- saveStore(store);
- }
-}
-
-export function deleteConversation(id: string): void {
- const store = loadStore();
- delete store.conversations[id];
- if (store.activeId === id) {
- const remaining = Object.keys(store.conversations);
- store.activeId = remaining.length > 0 ? remaining[0] : null;
- }
- saveStore(store);
-}
-
-export function generateMessageId(): string {
- return generateId();
-}
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
deleted file mode 100644
index d13f70fd..00000000
--- a/frontend/src/styles/base.css
+++ /dev/null
@@ -1,64 +0,0 @@
-/* Reset & layout */
-*, *::before, *::after {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
- background: var(--color-bg);
- color: var(--color-text);
- height: 100vh;
- overflow: hidden;
-}
-
-#root {
- height: 100vh;
-}
-
-.app {
- display: flex;
- height: 100vh;
- position: relative;
-}
-
-.sidebar-toggle {
- display: none;
- position: fixed;
- top: 12px;
- left: 12px;
- z-index: 1000;
- background: var(--color-primary);
- color: var(--ctp-crust);
- border: none;
- border-radius: 8px;
- width: 36px;
- height: 36px;
- font-size: 18px;
- cursor: pointer;
- align-items: center;
- justify-content: center;
-}
-
-@media (max-width: 768px) {
- .sidebar-toggle {
- display: flex;
- }
-
- .sidebar {
- position: fixed;
- left: 0;
- top: 0;
- z-index: 999;
- transform: translateX(-100%);
- }
-
- .sidebar.open {
- transform: translateX(0);
- }
-
- .message-bubble {
- max-width: 90%;
- }
-}
diff --git a/frontend/src/styles/chat.css b/frontend/src/styles/chat.css
deleted file mode 100644
index 23353b43..00000000
--- a/frontend/src/styles/chat.css
+++ /dev/null
@@ -1,291 +0,0 @@
-/* Chat Area */
-.chat-area {
- flex: 1;
- display: flex;
- flex-direction: column;
- height: 100vh;
- min-width: 0;
-}
-
-.chat-header {
- padding: 12px 24px;
- border-bottom: 1px solid var(--color-border);
- background: var(--color-bg);
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- flex-wrap: wrap;
-}
-
-.chat-header-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.chat-header-meta {
- display: flex;
- align-items: center;
- gap: 10px;
- flex-shrink: 0;
-}
-
-.chat-header-info {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.header-badge {
- display: inline-block;
- padding: 3px 8px;
- border-radius: 4px;
- font-size: 11px;
- font-weight: 600;
- font-family: monospace;
-}
-
-.model-badge {
- background: rgba(137, 180, 250, 0.12);
- color: var(--ctp-blue);
- border: 1px solid rgba(137, 180, 250, 0.25);
-}
-
-.agent-badge {
- background: rgba(166, 227, 161, 0.12);
- color: var(--ctp-green);
- border: 1px solid rgba(166, 227, 161, 0.25);
-}
-
-.chat-header-tokens {
- font-size: 11px;
- color: var(--color-text-secondary);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
-}
-
-/* Message List */
-.message-list {
- flex: 1;
- overflow-y: auto;
- padding: 24px;
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.message-list-empty {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--color-text-secondary);
- font-size: 15px;
-}
-
-/* Message Bubble */
-.message-bubble {
- max-width: 75%;
- position: relative;
-}
-
-.message-bubble.user {
- align-self: flex-end;
-}
-
-.message-bubble.assistant {
- align-self: flex-start;
-}
-
-.message-content {
- padding: 12px 16px;
- border-radius: 16px;
- font-size: 14px;
- line-height: 1.6;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.message-bubble.user .message-content {
- background: var(--color-user-bubble);
- color: var(--ctp-crust);
- border-bottom-right-radius: 4px;
-}
-
-.message-bubble.assistant .message-content {
- background: var(--color-assistant-bubble);
- color: var(--color-text);
- border: 1px solid var(--ctp-surface1);
- border-bottom-left-radius: 4px;
-}
-
-.message-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 4px;
- padding: 0 4px;
-}
-
-.message-time {
- font-size: 11px;
- color: var(--color-text-secondary);
-}
-
-.message-tokens {
- font-size: 10px;
- color: var(--color-text-secondary);
- font-variant-numeric: tabular-nums;
- opacity: 0.7;
-}
-
-.message-bubble.user .message-meta {
- justify-content: flex-end;
-}
-
-/* Copy Button */
-.copy-btn {
- position: absolute;
- top: 8px;
- right: 8px;
- opacity: 0;
- background: rgba(255, 255, 255, 0.06);
- border: none;
- border-radius: 6px;
- padding: 4px 8px;
- cursor: pointer;
- font-size: 12px;
- color: var(--color-text-secondary);
- transition: opacity 0.15s;
-}
-
-.message-bubble:hover .copy-btn {
- opacity: 1;
-}
-
-.copy-btn:hover {
- background: rgba(255, 255, 255, 0.1);
-}
-
-.message-bubble.user .copy-btn {
- color: rgba(0, 0, 0, 0.5);
- background: rgba(0, 0, 0, 0.1);
-}
-
-.message-bubble.user .copy-btn:hover {
- background: rgba(0, 0, 0, 0.2);
-}
-
-/* Tool Call Indicator */
-.tool-calls {
- margin-top: 8px;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.tool-call {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: var(--color-tool-bg);
- border: 1px solid var(--color-tool-border);
- border-radius: 8px;
- font-size: 13px;
-}
-
-.tool-call .tool-status {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.tool-call .tool-status.running {
- background: var(--color-tool-running);
- animation: pulse 1.5s infinite;
-}
-
-.tool-call .tool-status.success {
- background: var(--color-tool-success);
-}
-
-.tool-call .tool-status.error {
- background: var(--color-tool-error);
-}
-
-.tool-call .tool-name {
- font-weight: 600;
- color: var(--color-text);
-}
-
-.tool-call .tool-latency {
- margin-left: auto;
- font-size: 11px;
- color: var(--color-text-secondary);
-}
-
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
-}
-
-/* Streaming Indicator */
-.streaming-indicator {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 12px 16px;
- margin: 0 24px;
-}
-
-.streaming-tool-calls {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-.streaming-progress-row {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.streaming-bar-container {
- flex: 1;
- height: 4px;
- background: var(--color-border);
- border-radius: 2px;
- overflow: hidden;
-}
-
-.streaming-bar {
- height: 100%;
- background: var(--color-primary);
- border-radius: 2px;
- animation: stream-progress 2s ease-in-out infinite;
-}
-
-@keyframes stream-progress {
- 0% { width: 0%; margin-left: 0%; }
- 50% { width: 40%; margin-left: 30%; }
- 100% { width: 0%; margin-left: 100%; }
-}
-
-.streaming-phase {
- font-size: 12px;
- color: var(--color-text-secondary);
- white-space: nowrap;
-}
-
-.streaming-elapsed {
- font-size: 12px;
- color: var(--color-text-secondary);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
-}
diff --git a/frontend/src/styles/input.css b/frontend/src/styles/input.css
deleted file mode 100644
index 4398ef52..00000000
--- a/frontend/src/styles/input.css
+++ /dev/null
@@ -1,131 +0,0 @@
-/* Input Area */
-.input-area {
- padding: 16px 24px 24px;
- border-top: 1px solid var(--color-border);
- background: var(--color-bg);
-}
-
-.input-attachment-row {
- margin-bottom: 8px;
-}
-
-.input-container {
- display: flex;
- gap: 8px;
- align-items: flex-end;
-}
-
-.input-container textarea {
- flex: 1;
- resize: none;
- border: 1px solid var(--color-border);
- border-radius: 12px;
- padding: 12px 16px;
- font-size: 14px;
- font-family: inherit;
- line-height: 1.5;
- min-height: 72px;
- max-height: 200px;
- outline: none;
- background: var(--ctp-surface0);
- color: var(--color-text);
- transition: border-color 0.15s;
-}
-
-.input-container textarea:focus {
- border-color: var(--color-primary);
-}
-
-.input-container textarea::placeholder {
- color: var(--ctp-overlay0);
-}
-
-.send-btn, .stop-btn {
- padding: 10px 20px;
- border: none;
- border-radius: 12px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- flex-shrink: 0;
- transition: background 0.15s;
-}
-
-.send-btn {
- background: var(--color-primary);
- color: var(--ctp-crust);
-}
-
-.send-btn:hover:not(:disabled) {
- background: var(--color-primary-dark);
-}
-
-.send-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.stop-btn {
- background: var(--color-tool-error);
- color: var(--ctp-crust);
-}
-
-.stop-btn:hover {
- background: var(--ctp-maroon);
-}
-
-/* Pasted text pill */
-.pasted-pill {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- background: var(--ctp-surface0);
- border: 1px solid var(--color-border);
- border-radius: 10px;
- padding: 10px 14px;
- color: var(--color-text-secondary);
- font-size: 13px;
- max-width: 100%;
-}
-
-.pasted-pill svg {
- flex-shrink: 0;
- opacity: 0.6;
-}
-
-.pasted-pill-text {
- font-weight: 500;
- color: var(--color-text);
-}
-
-.pasted-pill-size {
- color: var(--color-text-secondary);
- font-size: 12px;
-}
-
-.pasted-pill-action {
- background: none;
- border: 1px solid var(--color-border);
- border-radius: 6px;
- padding: 2px 8px;
- font-size: 12px;
- color: var(--color-text-secondary);
- cursor: pointer;
- transition: background 0.15s, color 0.15s;
-}
-
-.pasted-pill-action:hover {
- background: var(--ctp-surface1);
- color: var(--color-text);
-}
-
-.pasted-pill-remove {
- font-size: 16px;
- line-height: 1;
- padding: 2px 6px;
-}
-
-.pasted-pill-remove:hover {
- color: var(--color-tool-error);
- border-color: var(--color-tool-error);
-}
diff --git a/frontend/src/styles/sidebar.css b/frontend/src/styles/sidebar.css
deleted file mode 100644
index 1043494c..00000000
--- a/frontend/src/styles/sidebar.css
+++ /dev/null
@@ -1,217 +0,0 @@
-/* Sidebar */
-.sidebar {
- width: var(--sidebar-width);
- background: var(--color-bg-sidebar);
- border-right: 1px solid var(--color-border);
- display: flex;
- flex-direction: column;
- height: 100vh;
- flex-shrink: 0;
- transition: transform 0.2s ease;
-}
-
-.sidebar-header {
- padding: 16px;
- border-bottom: 1px solid var(--color-border);
-}
-
-.sidebar-header h1 {
- font-size: 18px;
- font-weight: 700;
- color: var(--color-primary);
- margin-bottom: 12px;
-}
-
-.new-chat-btn {
- width: 100%;
- padding: 10px 16px;
- background: var(--color-primary);
- color: var(--ctp-crust);
- border: none;
- border-radius: 8px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.new-chat-btn:hover {
- background: var(--color-primary-dark);
-}
-
-/* Model Selector */
-.model-selector {
- padding: 12px 16px;
- border-bottom: 1px solid var(--color-border);
-}
-
-.model-selector label {
- display: block;
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--color-text-secondary);
- margin-bottom: 6px;
-}
-
-.model-selector select {
- width: 100%;
- padding: 8px 10px;
- border: 1px solid var(--color-border);
- border-radius: 6px;
- background: var(--ctp-surface0);
- font-size: 13px;
- color: var(--color-text);
- cursor: pointer;
-}
-
-/* Conversation List */
-.conversation-list {
- flex: 1;
- overflow-y: auto;
- padding: 8px;
-}
-
-.conversation-item {
- display: flex;
- align-items: center;
- padding: 10px 12px;
- border-radius: 8px;
- cursor: pointer;
- font-size: 13px;
- color: var(--color-text);
- transition: background 0.15s;
- margin-bottom: 2px;
-}
-
-.conversation-item:hover {
- background: var(--ctp-surface0);
-}
-
-.conversation-item.active {
- background: var(--color-primary);
- color: var(--ctp-crust);
-}
-
-.conversation-item .conv-title {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.conversation-item .conv-delete {
- opacity: 0;
- border: none;
- background: none;
- color: inherit;
- cursor: pointer;
- padding: 2px 6px;
- border-radius: 4px;
- font-size: 16px;
- line-height: 1;
-}
-
-.conversation-item:hover .conv-delete {
- opacity: 0.6;
-}
-
-.conversation-item .conv-delete:hover {
- opacity: 1;
- background: rgba(255, 255, 255, 0.08);
-}
-
-.conversation-item.active .conv-delete:hover {
- background: rgba(0, 0, 0, 0.2);
-}
-
-/* Savings Panel */
-.savings-panel {
- padding: 16px;
- border-top: 1px solid var(--color-primary);
- background: var(--ctp-crust);
-}
-
-.savings-panel h3 {
- font-size: 13px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--color-primary);
- margin-bottom: 8px;
-}
-
-.savings-models {
- display: flex;
- flex-direction: column;
- gap: 6px;
- margin-bottom: 12px;
-}
-
-.savings-model-card {
- background: var(--ctp-surface0);
- border: 1px solid var(--color-border);
- border-radius: 8px;
- padding: 8px 10px;
- border-left: 3px solid var(--color-border);
-}
-
-.savings-model-card.local {
- border-left-color: var(--ctp-green);
-}
-
-.savings-model-card.cloud {
- border-left-color: var(--ctp-yellow);
-}
-
-.savings-model-card-label {
- display: block;
- font-size: 9px;
- font-weight: 700;
- letter-spacing: 0.08em;
- color: var(--color-text-secondary);
- margin-bottom: 2px;
-}
-
-.savings-model-card-name {
- display: block;
- font-family: monospace;
- font-size: 11px;
- color: var(--color-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.savings-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
-}
-
-.savings-item {
- background: var(--ctp-surface0);
- border-radius: 8px;
- padding: 10px;
- border: 1px solid var(--color-border);
-}
-
-.savings-item .savings-label {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--color-text-secondary);
- margin-bottom: 4px;
-}
-
-.savings-item .savings-value {
- font-size: 18px;
- font-weight: 800;
- color: var(--color-savings);
- font-variant-numeric: tabular-nums;
-}
-
-.savings-item.calls .savings-value {
- color: var(--color-primary);
-}
diff --git a/frontend/src/styles/variables.css b/frontend/src/styles/variables.css
deleted file mode 100644
index d7f80218..00000000
--- a/frontend/src/styles/variables.css
+++ /dev/null
@@ -1,50 +0,0 @@
-/* Catppuccin Mocha theme tokens */
-:root {
- /* Base palette */
- --ctp-rosewater: #f5e0dc;
- --ctp-flamingo: #f2cdcd;
- --ctp-pink: #f5c2e7;
- --ctp-mauve: #cba6f7;
- --ctp-red: #f38ba8;
- --ctp-maroon: #eba0ac;
- --ctp-peach: #fab387;
- --ctp-yellow: #f9e2af;
- --ctp-green: #a6e3a1;
- --ctp-teal: #94e2d5;
- --ctp-sky: #89dceb;
- --ctp-sapphire: #74c7ec;
- --ctp-blue: #89b4fa;
- --ctp-lavender: #b4befe;
- --ctp-text: #cdd6f4;
- --ctp-subtext1: #bac2de;
- --ctp-subtext0: #a6adc8;
- --ctp-overlay2: #9399b2;
- --ctp-overlay1: #7f849c;
- --ctp-overlay0: #6c7086;
- --ctp-surface2: #585b70;
- --ctp-surface1: #45475a;
- --ctp-surface0: #313244;
- --ctp-base: #1e1e2e;
- --ctp-mantle: #181825;
- --ctp-crust: #11111b;
-
- /* Semantic tokens */
- --color-primary: var(--ctp-blue);
- --color-primary-light: var(--ctp-sapphire);
- --color-primary-dark: var(--ctp-lavender);
- --color-bg: var(--ctp-base);
- --color-bg-sidebar: var(--ctp-mantle);
- --color-bg-surface: var(--ctp-surface0);
- --color-text: var(--ctp-text);
- --color-text-secondary: var(--ctp-subtext0);
- --color-border: var(--ctp-surface0);
- --color-user-bubble: var(--ctp-blue);
- --color-assistant-bubble: var(--ctp-surface0);
- --color-tool-bg: rgba(137, 180, 250, 0.08);
- --color-tool-border: var(--ctp-surface1);
- --color-tool-success: var(--ctp-green);
- --color-tool-running: var(--ctp-yellow);
- --color-tool-error: var(--ctp-red);
- --color-savings: var(--ctp-green);
- --sidebar-width: 280px;
-}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index c71ac7ac..4dcec29b 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,18 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
+ tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'OpenJarvis',
short_name: 'Jarvis',
description: 'On-device AI assistant',
- theme_color: '#1a1a1e',
- background_color: '#1a1a1e',
+ theme_color: '#09090b',
+ background_color: '#09090b',
display: 'standalone',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
@@ -22,12 +24,24 @@ export default defineConfig({
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
navigateFallbackDenylist: [/^\/v1\//, /^\/health/, /^\/dashboard/],
+ mode: 'development',
},
}),
],
build: {
outDir: '../src/openjarvis/server/static',
emptyOutDir: true,
+ minify: 'esbuild',
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ react: ['react', 'react-dom'],
+ markdown: ['react-markdown', 'rehype-highlight', 'remark-gfm'],
+ charts: ['recharts'],
+ router: ['react-router'],
+ },
+ },
+ },
},
server: {
port: 5173,
+
+
+
+ Saved
+
+ )}
+
+
+ + Settings +
+ {saved && ( + +
+ {/* Appearance */}
+
+
+
+
+
+
+
+
+ {/* Connection */}
+
+
+
+
+ { updateSettings({ apiUrl: e.target.value }); showSaved(); }}
+ placeholder="http://localhost:8000"
+ className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
+ style={{
+ background: 'var(--color-bg-secondary)',
+ color: 'var(--color-text)',
+ border: '1px solid var(--color-border)',
+ }}
+ />
+
+
+
+ {/* Model defaults */}
+
+
+ { updateSettings({ temperature: parseFloat(e.target.value) }); showSaved(); }}
+ className="w-32 cursor-pointer accent-[var(--color-accent)]"
+ />
+
+
+ { updateSettings({ maxTokens: parseInt(e.target.value) }); showSaved(); }}
+ className="w-32 cursor-pointer accent-[var(--color-accent)]"
+ />
+
+
+
+ {/* Data */}
+
+
+
+
+ (e.currentTarget.style.background = 'rgba(220,38,38,0.1)')}
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
+ >
+ Clear
+
+
+
+
+ {/* About */}
+
+
+
+
+ {themeOptions.map((opt) => {
+ const isActive = settings.theme === opt.value;
+ return (
+ { updateSettings({ theme: opt.value }); showSaved(); }}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer"
+ style={{
+ background: isActive ? 'var(--color-surface)' : 'transparent',
+ color: isActive ? 'var(--color-text)' : 'var(--color-text-tertiary)',
+ boxShadow: isActive ? 'var(--shadow-sm)' : 'none',
+ }}
+ >
+
+ {opt.label}
+
+ );
+ })}
+
+
+
+
+ {healthy === true ? 'Connected' : healthy === false ? 'Disconnected' : 'Checking...'}
+
+
+
+ (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
+ >
+ Export
+
+ (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
+ >
+ Import
+
+
+
+
+ + OpenJarvis — Programming abstractions for on-device AI. +
++ Part of Intelligence Per Watt, a research initiative at Stanford SAIL. +
+ +