docs: add desktop UX improvements spec and implementation plan

This commit is contained in:
Jon Saad-Falcon
2026-03-14 12:37:19 -07:00
parent e09d70dbc7
commit 46fcd5bab4
2 changed files with 1085 additions and 0 deletions
@@ -0,0 +1,930 @@
# Desktop UX Improvements Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Improve the OpenJarvis desktop app and web browser UX with 7 changes: new app logo, cleaner system panel with device temps, theme toggle, better streaming labels, model switching feedback, and a logs page.
**Architecture:** All changes are frontend-first (React/TypeScript/Zustand). One backend change extends the existing `/v1/telemetry/energy` endpoint to include temperature fields already collected by `EnergySample`. No new backend endpoints needed — temperatures are already in the data pipeline, just not exposed via the API.
**Tech Stack:** React 19, TypeScript, Zustand, Tailwind CSS, Lucide icons, FastAPI (backend), Python telemetry modules, sips (macOS image conversion)
**Spec:** `docs/superpowers/specs/2026-03-14-desktop-ux-improvements-design.md`
---
## Chunk 0: App Logo
### Task 0: Replace app icons with new arc reactor logo
**Source image:** `/Users/jonsaadfalcon/Documents/OpenJarvis_Logos/OpenJarvis_DesktopAppLogo_DarkBackground_ZoomedIn.png`
**Files:**
- Replace: `desktop/src-tauri/icons/32x32.png`
- Replace: `desktop/src-tauri/icons/128x128.png`
- Replace: `desktop/src-tauri/icons/128x128@2x.png`
- Replace: `desktop/src-tauri/icons/256x256.png`
- Replace: `desktop/src-tauri/icons/icon.png`
- Replace: `desktop/src-tauri/icons/icon.icns`
- Replace: `desktop/src-tauri/icons/icon.ico`
- Replace: `frontend/public/favicon.ico`
- Replace: `frontend/public/apple-touch-icon.png`
- Replace: `frontend/public/pwa-192x192.png`
- Replace: `frontend/public/pwa-512x512.png`
- [ ] **Step 1: Generate all PNG sizes from source image**
```bash
SRC="/Users/jonsaadfalcon/Documents/OpenJarvis_Logos/OpenJarvis_DesktopAppLogo_DarkBackground_ZoomedIn.png"
ICONS="desktop/src-tauri/icons"
PUBLIC="frontend/public"
# Desktop icons
sips -z 32 32 "$SRC" --out "$ICONS/32x32.png"
sips -z 128 128 "$SRC" --out "$ICONS/128x128.png"
sips -z 256 256 "$SRC" --out "$ICONS/128x128@2x.png"
sips -z 256 256 "$SRC" --out "$ICONS/256x256.png"
sips -z 1024 1024 "$SRC" --out "$ICONS/icon.png"
# Frontend icons
sips -z 180 180 "$SRC" --out "$PUBLIC/apple-touch-icon.png"
sips -z 192 192 "$SRC" --out "$PUBLIC/pwa-192x192.png"
sips -z 512 512 "$SRC" --out "$PUBLIC/pwa-512x512.png"
```
- [ ] **Step 2: Generate .icns for macOS**
```bash
mkdir -p /tmp/openjarvis-icon.iconset
sips -z 16 16 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_16x16.png
sips -z 32 32 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_16x16@2x.png
sips -z 32 32 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_32x32.png
sips -z 64 64 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_32x32@2x.png
sips -z 128 128 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_128x128.png
sips -z 256 256 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_128x128@2x.png
sips -z 256 256 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_256x256.png
sips -z 512 512 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_256x256@2x.png
sips -z 512 512 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_512x512.png
sips -z 1024 1024 "$SRC" --out /tmp/openjarvis-icon.iconset/icon_512x512@2x.png
iconutil -c icns /tmp/openjarvis-icon.iconset -o "$ICONS/icon.icns"
rm -rf /tmp/openjarvis-icon.iconset
```
- [ ] **Step 3: Generate .ico for Windows**
```bash
# Use sips to create the sizes, then ImageMagick convert (or pip install Pillow)
python3 -c "
from PIL import Image
img = Image.open('$ICONS/icon.png')
img.save('$ICONS/icon.ico', sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)])
"
```
If Pillow is not available, install with `pip3 install Pillow` first.
- [ ] **Step 4: Generate favicon.ico for web**
```bash
python3 -c "
from PIL import Image
img = Image.open('$PUBLIC/pwa-512x512.png')
img.save('$PUBLIC/favicon.ico', sizes=[(16,16),(32,32),(48,48)])
"
```
- [ ] **Step 5: Verify icons exist and are correct sizes**
```bash
file desktop/src-tauri/icons/*.png desktop/src-tauri/icons/icon.icns desktop/src-tauri/icons/icon.ico
file frontend/public/favicon.ico frontend/public/apple-touch-icon.png frontend/public/pwa-*.png
```
- [ ] **Step 6: Commit**
```bash
git add desktop/src-tauri/icons/ frontend/public/favicon.ico frontend/public/apple-touch-icon.png frontend/public/pwa-192x192.png frontend/public/pwa-512x512.png
git commit -m "feat: replace app icons with arc reactor logo"
```
---
## Chunk 1: Foundation — Types, Store, and Backend
### Task 1: Add LogEntry type and extend store
**Files:**
- Modify: `frontend/src/types/index.ts`
- Modify: `frontend/src/lib/store.ts`
- [ ] **Step 1: Add LogEntry interface to types**
In `frontend/src/types/index.ts`, add after the `ServerInfo` interface (line 120):
```typescript
// --- Log Types ---
export interface LogEntry {
timestamp: number;
level: 'info' | 'warn' | 'error';
category: 'server' | 'model' | 'chat' | 'tool';
message: string;
}
```
- [ ] **Step 2: Add log and model-loading state to store**
In `frontend/src/lib/store.ts`, add to the `AppState` interface (after line 183, the `setSelectedAgentId` line):
```typescript
// Logs
logEntries: LogEntry[];
addLogEntry: (entry: LogEntry) => void;
clearLogs: () => void;
// Model loading
modelLoading: boolean;
setModelLoading: (loading: boolean) => void;
```
Add the import at the top of `store.ts`:
```typescript
import type { LogEntry } from '../types';
```
(Add `LogEntry` to the existing import from `'../types'`.)
Add the implementations in the store body (after the agent events section, ~line 388):
```typescript
// ── Logs ────────────────────────────────────────────────────────
logEntries: [],
addLogEntry: (entry) => set((s) => ({
logEntries: [...s.logEntries.slice(-499), entry],
})),
clearLogs: () => set({ logEntries: [] }),
// ── Model loading ───────────────────────────────────────────────
modelLoading: false,
setModelLoading: (loading) => set({ modelLoading: loading }),
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add frontend/src/types/index.ts frontend/src/lib/store.ts
git commit -m "feat: add LogEntry type, log store, and modelLoading state"
```
---
### Task 2: Expose temperature in /v1/telemetry/energy endpoint
**Files:**
- Modify: `src/openjarvis/server/api_routes.py:240-271`
The `EnergySample` dataclass already has `mean_temperature_c` and `peak_temperature_c` fields (populated by NVIDIA monitors, zero for others). We just need to include them in the API response.
- [ ] **Step 1: Add temp fields to the energy endpoint response**
In `src/openjarvis/server/api_routes.py`, in the `telemetry_energy` function, update the return dict (lines 259-267) to:
```python
return {
"total_energy_j": total_energy,
"energy_per_token_j": (
total_energy / total_tokens if total_tokens > 0 else 0
),
"avg_power_w": (
total_energy / total_latency if total_latency > 0 else 0
),
"cpu_temp_c": None, # populated per-platform below
"gpu_temp_c": None,
}
```
Note: The current aggregator `summary()` doesn't directly expose per-sample temperature. For v1, return `None` (the frontend handles graceful degradation). A follow-up task can wire the live energy monitor's last sample temperature through.
Also update the empty fallback response (line 250) to include the new fields:
```python
return {"total_energy_j": 0, "energy_per_token_j": 0, "avg_power_w": 0, "cpu_temp_c": None, "gpu_temp_c": None}
```
- [ ] **Step 2: Run lint and tests**
Run: `uv run ruff check src/openjarvis/server/api_routes.py`
Run: `uv run pytest tests/server/ -v --tb=short`
Expected: all pass
- [ ] **Step 3: Commit**
```bash
git add src/openjarvis/server/api_routes.py
git commit -m "feat: include cpu/gpu temp fields in telemetry energy endpoint"
```
---
### Task 3: Add preloadModel to api.ts
**Files:**
- Modify: `frontend/src/lib/api.ts`
- [ ] **Step 1: Add preloadModel function**
Add after the `deleteModel` function in `api.ts`:
```typescript
export async function preloadModel(modelName: string): Promise<void> {
// Trigger Ollama to load the model into memory (empty prompt, no generation).
// In Tauri, use the Rust backend; in browser, call Ollama directly via the server.
const ollamaUrl = 'http://127.0.0.1:11434';
try {
const res = await fetch(`${ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: modelName, prompt: '', keep_alive: '5m' }),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`Preload failed: ${res.status}`);
} catch (e: any) {
if (e.name === 'TimeoutError') throw new Error('Model load timed out (120s)');
throw e;
}
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add frontend/src/lib/api.ts
git commit -m "feat: add preloadModel API function for model warm-up"
```
---
## Chunk 2: UI Changes — SystemPanel, Sidebar, Streaming Labels
### Task 4: Clean up SystemPanel (remove redundancy, rename ENERGY to DEVICE)
**Files:**
- Modify: `frontend/src/components/Chat/SystemPanel.tsx`
- [ ] **Step 1: Update fetch to use getBase()**
Replace the `fetchData` callback (lines 42-58) to use `getBase()` from api.ts:
```typescript
import { getBase } from '../../lib/api';
```
Then change line 44 from:
```typescript
const base = import.meta.env.VITE_API_URL || '';
```
to:
```typescript
const base = getBase();
```
- [ ] **Step 2: Add temp fields to EnergyData interface**
Update the `EnergyData` interface (lines 17-21):
```typescript
interface EnergyData {
total_energy_j?: number;
energy_per_token_j?: number;
avg_power_w?: number;
cpu_temp_c?: number | null;
gpu_temp_c?: number | null;
}
```
- [ ] **Step 3: Remove thermalStatus logic and "Cool" label**
Delete lines 71-76 (the `thermalStatus` const).
Replace the Energy section (lines 121-155) with:
```tsx
{/* Device */}
<section>
<h4 className="text-[11px] font-medium uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Device
</h4>
<div className="grid grid-cols-2 gap-2">
{energy?.cpu_temp_c != null && (
<MiniStat icon={Thermometer} label="CPU Temp" value={String(Math.round(energy.cpu_temp_c))} unit="°C" />
)}
{energy?.gpu_temp_c != null && (
<MiniStat icon={Thermometer} label="GPU Temp" value={String(Math.round(energy.gpu_temp_c))} unit="°C" />
)}
<MiniStat
icon={Zap}
label="Power"
value={(energy?.avg_power_w ?? 0).toFixed(1)}
unit="W"
/>
<MiniStat
icon={Activity}
label="Energy"
value={((energy?.total_energy_j ?? 0) / 1000).toFixed(1)}
unit="kJ"
/>
</div>
</section>
```
- [ ] **Step 4: Remove Server-reported section**
Delete lines 220-233 (the "Server-reported savings" block).
- [ ] **Step 5: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 6: Commit**
```bash
git add frontend/src/components/Chat/SystemPanel.tsx
git commit -m "feat: replace Energy section with Device (temps, power, energy), remove server-reported"
```
---
### Task 5: Add theme toggle to sidebar header
**Files:**
- Modify: `frontend/src/components/Sidebar/Sidebar.tsx`
- [ ] **Step 1: Add imports and store access**
Add `Sun`, `Moon`, `Monitor` to the lucide-react import (line 3):
```typescript
import {
MessageSquare,
Plus,
BarChart3,
Settings,
Search,
PanelLeftClose,
PanelLeft,
Cpu,
Rocket,
Bot,
Sun,
Moon,
Monitor,
} from 'lucide-react';
```
Inside the `Sidebar` component, add after `setCommandPaletteOpen` (line 28):
```typescript
const settings = useAppStore((s) => s.settings);
const updateSettings = useAppStore((s) => s.updateSettings);
const ThemeIcon = settings.theme === 'light' ? Sun : settings.theme === 'dark' ? Moon : Monitor;
const nextTheme = settings.theme === 'light' ? 'dark' : settings.theme === 'dark' ? 'system' : 'light';
```
- [ ] **Step 2: Add the icon button in the header**
In the header `div` (line 68), insert the theme toggle button between the collapse button and the new-chat button. Replace the header div:
```tsx
<div className="flex items-center justify-between px-3 pt-3 pb-2">
<button
onClick={toggleSidebar}
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<PanelLeftClose size={18} />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => updateSettings({ theme: nextTheme })}
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
title={`Theme: ${settings.theme} (click for ${nextTheme})`}
>
<ThemeIcon size={16} />
</button>
<button
onClick={handleNewChat}
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
title="New chat"
>
<Plus size={18} />
</button>
</div>
</div>
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/Sidebar/Sidebar.tsx
git commit -m "feat: add theme toggle icon in sidebar header"
```
---
### Task 6: Update streaming status labels
**Files:**
- Modify: `frontend/src/components/Chat/InputArea.tsx`
- [ ] **Step 1: Change "Sending..." to "Connecting..."**
Find the `setStreamState` call at ~line 132:
```typescript
setStreamState({
isStreaming: true,
phase: 'Sending...',
```
Change to:
```typescript
setStreamState({
isStreaming: true,
phase: 'Connecting...',
```
- [ ] **Step 2: Change "Running {tool}..." to "Calling {tool}..."**
Find the string `Running ${data.tool}...` (in the `tool_call_start` handler):
```typescript
phase: `Running ${data.tool}...`,
```
Change to:
```typescript
phase: `Calling ${data.tool}...`,
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/Chat/InputArea.tsx
git commit -m "fix: update streaming labels to Connecting/Calling"
```
---
## Chunk 3: Model Loading Indicator
### Task 7: Wire model preloading through CommandPalette and show loading in sidebar
**Files:**
- Modify: `frontend/src/components/CommandPalette.tsx`
- Modify: `frontend/src/components/Sidebar/Sidebar.tsx`
- Modify: `frontend/src/components/Chat/InputArea.tsx`
- [ ] **Step 1: Trigger preload on model selection in CommandPalette**
In `CommandPalette.tsx`, add imports:
```typescript
import { pullModel, deleteModel, fetchModels, preloadModel } from '../lib/api';
```
(`preloadModel` added to the existing import.)
Update `handleSelect` (line 68-71) to:
```typescript
const handleSelect = async (modelId: string) => {
const previousModel = selectedModel;
setSelectedModel(modelId);
setCommandPaletteOpen(false);
// Preload the model if switching to a different one
if (modelId !== previousModel) {
const { setModelLoading, addLogEntry } = useAppStore.getState();
setModelLoading(true);
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
try {
await preloadModel(modelId);
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `${modelId} loaded` });
} catch (e: any) {
addLogEntry({ timestamp: Date.now(), level: 'error', category: 'model', message: `Failed to load ${modelId}: ${e.message}` });
} finally {
setModelLoading(false);
}
}
};
```
- [ ] **Step 2: Show loading state in sidebar model badge**
In `Sidebar.tsx`, add to store access:
```typescript
const modelLoading = useAppStore((s) => s.modelLoading);
```
Add `Loader2` to the lucide-react imports.
Update the model badge inner content (lines 102-111 inside the button). Replace everything between the opening `>` of the button and the closing `</button>`:
```tsx
{modelLoading ? (
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--color-accent)' }} />
) : (
<Cpu size={14} />
)}
<div className="flex-1 min-w-0">
<span className="truncate block text-left" style={{ color: 'var(--color-text)' }}>
{selectedModel || serverInfo?.model || 'Select model'}
</span>
{modelLoading && (
<span className="text-[10px] block" style={{ color: 'var(--color-accent)' }}>
Loading model...
</span>
)}
</div>
{!modelLoading && (
<kbd
className="text-[10px] px-1.5 py-0.5 rounded font-mono"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
>
K
</kbd>
)}
```
- [ ] **Step 3: Disable input during model loading**
In `InputArea.tsx`, add:
```typescript
const modelLoading = useAppStore((s) => s.modelLoading);
```
Find the textarea's `disabled={streamState.isStreaming}` and change to:
```typescript
disabled={streamState.isStreaming || modelLoading}
```
Find the send button's `disabled={!input.trim()}` and change to:
```typescript
disabled={!input.trim() || modelLoading}
```
- [ ] **Step 4: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/CommandPalette.tsx frontend/src/components/Sidebar/Sidebar.tsx frontend/src/components/Chat/InputArea.tsx
git commit -m "feat: model switching shows loading spinner, disables input until ready"
```
---
## Chunk 4: Logs Page and Log Emission
### Task 8: Create LogsPage component
**Files:**
- Create: `frontend/src/pages/LogsPage.tsx`
- [ ] **Step 1: Create the LogsPage**
```typescript
import { useRef, useEffect } from 'react';
import { ScrollText, Copy, Trash2 } from 'lucide-react';
import { useAppStore } from '../lib/store';
const LEVEL_COLORS: Record<string, string> = {
info: 'var(--color-text)',
warn: 'var(--color-warning)',
error: 'var(--color-error)',
};
function formatTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function LogsPage() {
const logEntries = useAppStore((s) => s.logEntries);
const clearLogs = useAppStore((s) => s.clearLogs);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logEntries.length]);
const handleCopy = async () => {
const text = logEntries
.map((e) => `${formatTime(e.timestamp)} [${e.level}] [${e.category}] ${e.message}`)
.join('\n');
await navigator.clipboard.writeText(text);
};
return (
<div className="flex-1 flex flex-col overflow-hidden p-6">
<div className="max-w-4xl mx-auto w-full flex flex-col flex-1 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 mb-4 shrink-0">
<ScrollText size={24} style={{ color: 'var(--color-accent)' }} />
<h1 className="text-xl font-semibold flex-1" style={{ color: 'var(--color-text)' }}>
Logs
</h1>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{logEntries.length} entries
</span>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)' }}
>
<Copy size={12} /> Copy All
</button>
<button
onClick={clearLogs}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)' }}
>
<Trash2 size={12} /> Clear
</button>
</div>
{/* Log entries */}
<div
className="flex-1 overflow-y-auto rounded-xl p-4 font-mono text-xs leading-relaxed"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
{logEntries.length === 0 ? (
<div className="text-center py-12" style={{ color: 'var(--color-text-tertiary)' }}>
No log entries yet. Logs appear as you chat, switch models, and interact with the app.
</div>
) : (
logEntries.map((entry, i) => (
<div key={i} className="py-0.5">
<span style={{ color: 'var(--color-text-tertiary)' }}>{formatTime(entry.timestamp)}</span>
{' '}
<span style={{ color: LEVEL_COLORS[entry.level] || 'var(--color-text)' }}>
[{entry.category}]
</span>
{' '}
<span style={{ color: LEVEL_COLORS[entry.level] || 'var(--color-text)' }}>
{entry.message}
</span>
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
</div>
);
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add frontend/src/pages/LogsPage.tsx
git commit -m "feat: create LogsPage component"
```
---
### Task 9: Add Logs route and nav item
**Files:**
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/components/Sidebar/Sidebar.tsx`
- [ ] **Step 1: Add route in App.tsx**
Add import at top of `App.tsx`:
```typescript
import { LogsPage } from './pages/LogsPage';
```
Add route inside the `<Route element={<Layout />}>` block, after the agents route:
```tsx
<Route path="logs" element={<LogsPage />} />
```
- [ ] **Step 2: Add Logs nav item in Sidebar.tsx**
Add `ScrollText` to the lucide-react import.
In the `navItems` array (lines 35-41), add between Agents and Settings:
```typescript
{ path: '/logs', icon: ScrollText, label: 'Logs' },
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add frontend/src/App.tsx frontend/src/components/Sidebar/Sidebar.tsx
git commit -m "feat: add Logs route and nav item"
```
---
### Task 10: Emit log entries from chat and model operations
**Files:**
- Modify: `frontend/src/components/Chat/InputArea.tsx`
- Modify: `frontend/src/components/CommandPalette.tsx`
- [ ] **Step 1: Add log emission to InputArea streaming**
In `InputArea.tsx`, add at top of the `sendMessage` callback, after the `setStreamState` call (~line 122):
```typescript
useAppStore.getState().addLogEntry({
timestamp: Date.now(),
level: 'info',
category: 'chat',
message: `Request: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}" → ${selectedModel}`,
});
```
In the `inference_start` event handler (~line 149), add:
```typescript
setStreamState({ phase: 'Generating...' });
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'chat',
message: `Generating with ${selectedModel}...`,
});
```
In the `tool_call_start` handler (~line 152), add after the existing `setStreamState`:
```typescript
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'tool',
message: `Calling ${data.tool}(${data.arguments || ''})`,
});
```
In the error catch (~line 191), add:
```typescript
if (err.name !== 'AbortError') {
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'error', category: 'chat',
message: `Stream error: ${err?.message || String(err)}`,
});
}
```
In the finally block, after `resetStream()`, add:
```typescript
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'chat',
message: `Response: ${accumulatedContent.length} chars`,
});
```
- [ ] **Step 2: Add log emission to CommandPalette model operations**
In `CommandPalette.tsx`, the model switch logging was already added in Task 7. Add logging for pull and delete operations.
In `handlePull` (~line 80), add after `setPullSuccess`:
```typescript
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'model',
message: `Downloaded ${modelId}`,
});
```
In `handlePull` catch block:
```typescript
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'error', category: 'model',
message: `Download failed for ${modelId}: ${e.message}`,
});
```
In `handleDelete`, after the `deleteModel` call:
```typescript
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'model',
message: `Deleted ${modelId}`,
});
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Run full lint**
Run: `uv run ruff check src/ tests/`
Expected: all pass
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/Chat/InputArea.tsx frontend/src/components/CommandPalette.tsx
git commit -m "feat: emit log entries from chat streaming and model operations"
```
---
## Chunk 5: Final Verification
### Task 11: Build and test everything
- [ ] **Step 1: Run TypeScript check**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors
- [ ] **Step 2: Run frontend build**
Run: `cd frontend && npm run build`
Expected: successful build
- [ ] **Step 3: Run Python lint**
Run: `uv run ruff check src/ tests/`
Expected: all pass
- [ ] **Step 4: Run Python tests**
Run: `uv run pytest tests/server/ -v --tb=short`
Expected: all 100+ tests pass
- [ ] **Step 5: Run Rust cargo check (for desktop)**
Run: `cd desktop/src-tauri && cargo check`
Expected: no errors (our changes are frontend + Python only, but verify no breakage)
- [ ] **Step 6: Final commit with all files verified**
If any files weren't committed in previous tasks:
```bash
git status
# add any remaining files
git commit -m "chore: final verification — all checks pass"
```
@@ -0,0 +1,155 @@
# Desktop App UX Improvements — Design Spec
**Date:** 2026-03-14
**Scope:** Frontend (React) + minor backend (telemetry)
**Affects:** SystemPanel, Sidebar, InputArea, StreamingDots, new LogsPage, store, api, types
---
## 1. System Panel — Remove Redundancy, Add Device Temps
**Problem:** The right-column system panel has a redundant "Server-reported" section that duplicates Cost Comparison data. The "Cool" thermal label is confusing without context.
**Change:**
- Remove the "Server-reported" / per-provider savings section entirely from SystemPanel.
- Rename the "ENERGY" section to "DEVICE".
- Replace the "Cool/Warm/Hot" label with actual CPU and GPU temperatures (e.g. "CPU: 62°C", "GPU: 71°C").
- Keep Power (W) and Energy (kJ) in the same section.
- Display temp rows only when values are non-null (graceful degradation).
**Data flow — per platform:**
- **NVIDIA GPUs:** Already collected via pynvml in `NvidiaEnergyMonitor` (`mean_temperature_c`, `peak_temperature_c` on `EnergySample`). Just needs to be exposed through the API endpoint.
- **macOS (Apple Silicon):** Read CPU die temperature via IOKit SMC keys through the Rust extension (PyO3 bindings already exist in `rust/crates/openjarvis-python/`). Does not require root. Return `null` if unavailable.
- **Linux (no GPU):** Read from `/sys/class/thermal/thermal_zone*/temp`. Return `null` if path doesn't exist.
- **Windows:** Return `null` for v1 (future: WMI queries).
- Backend: extend the `/v1/telemetry/energy` endpoint in `api_routes.py` to include `cpu_temp_c` and `gpu_temp_c` fields from the energy sample.
- Frontend: `SystemPanel.tsx` already polls every 3s. Refactor to use `getBase()` from `api.ts` (fixing existing inconsistency) and add a `fetchDeviceInfo()` function in `api.ts`.
**Files:**
- `frontend/src/components/Chat/SystemPanel.tsx` — remove server-reported section, rename ENERGY to DEVICE, add temp rows, remove Cool/Warm/Hot logic, use `getBase()`
- `frontend/src/lib/api.ts` — add `fetchDeviceInfo()` function
- `src/openjarvis/telemetry/` — add CPU/GPU temp collection for macOS/Linux
- `src/openjarvis/server/api_routes.py` — expose temp data in `/v1/telemetry/energy` response
---
## 2. Theme Toggle — Sidebar Header
**Problem:** Theme switching is buried in Settings > Appearance. Needs one-click access.
**Change:**
- Add a sun/moon/monitor icon button in the sidebar header, between the collapse button and the new-chat button.
- Three-state cycle matching the Settings page: light → dark → system → light.
- Uses the existing `updateSettings({ theme })` store action.
- Icons: `Sun` when current theme is light (click → dark), `Moon` when dark (click → system), `Monitor` when system (click → light).
**Files:**
- `frontend/src/components/Sidebar/Sidebar.tsx` — add icon button to header
---
## 3. Streaming Status Labels
**Problem:** Status text shows "Sending..." at start of generation. Tool call status says "Running {tool}..." which is vague.
**Change:**
- Initial phase: `"Sending..."``"Connecting..."` (accurately describes the period between HTTP request and first SSE event — the model hasn't started generating yet).
- Tool call start: `"Running ${data.tool}..."``"Calling ${data.tool}..."`
- All other phases unchanged (`"Agent thinking..."`, `"Generating..."` on `inference_start` are already correct).
**Files:**
- `frontend/src/components/Chat/InputArea.tsx` — update two string literals
---
## 4. Model Switching — Loading Indicator
**Problem:** When switching models via Cmd+K, there's no visual feedback. The model badge updates instantly but Ollama needs time to load the new model into memory. Users may send messages before the model is ready.
**Change:**
- Add `modelLoading` boolean to the Zustand store.
- When `setSelectedModel` is called with a different model:
1. Set `modelLoading = true`
2. Update the model badge to show a spinner icon + "Loading model..." subtitle text
3. Trigger Ollama to preload the model via `POST /api/generate` with `{"model": "...", "prompt": "", "keep_alive": "5m"}` (empty prompt loads the model without generating output). Use the same dual-path pattern as other API calls: Tauri invoke with HTTP fallback.
4. On success, set `modelLoading = false`
5. On failure after 120-second timeout, set `modelLoading = false` and show error in logs
- While `modelLoading` is true, disable the chat input (prevent sending before model is ready).
- Note: this preload mechanism is Ollama-specific. For other backends (vLLM, SGLang), skip the preload step (models are always loaded).
**Files:**
- `frontend/src/lib/store.ts` — add `modelLoading` state + `setModelLoading` action
- `frontend/src/lib/api.ts` — add `preloadModel()` function (Tauri invoke + HTTP fallback)
- `frontend/src/components/Sidebar/Sidebar.tsx` — show spinner + subtitle in model badge when loading
- `frontend/src/components/Chat/InputArea.tsx` — disable input when `modelLoading` is true
- `frontend/src/components/CommandPalette.tsx` — trigger model preloading on selection
---
## 5. Logs Page
**Problem:** Error logging is opaque. When generation fails, users see "Error: Failed to get response" with no way to diagnose what happened or share logs.
**Change:**
- Add a new `LogsPage` component at route `/logs`.
- Add "Logs" nav item to sidebar bottom nav (between Agents and Settings), with `ScrollText` icon.
- Log entries are collected in a Zustand array (`logEntries`) during the session.
- Sources of log entries:
- SSE events during streaming (inference_start, tool_call_start, tool_call_end, errors)
- Model switches and preload results
- Server health check results
- Fetch errors (chat, models, savings)
- Model pull/delete operations
- Each entry: `{ timestamp: number, level: 'info' | 'warn' | 'error', category: string, message: string }`
- Add `LogEntry` interface to `frontend/src/types/index.ts`.
- Categories: `server`, `model`, `chat`, `tool`
- Color coding by **level only**: info = default text color, warn = yellow, error = red. Category shown as a bracketed prefix (e.g. `[model]`, `[chat]`).
- Actions: "Copy All" (copies log text to clipboard), "Clear" (resets array)
- Max 500 entries (FIFO via `.slice(-499)` before appending, matching the existing `addAgentEvent` pattern). Session-scoped (cleared on page reload).
**Files:**
- `frontend/src/pages/LogsPage.tsx` — new page component
- `frontend/src/types/index.ts` — add `LogEntry` interface
- `frontend/src/lib/store.ts` — add `logEntries` array, `addLogEntry` action, `clearLogs` action
- `frontend/src/components/Sidebar/Sidebar.tsx` — add Logs nav item
- `frontend/src/App.tsx` — add route for `/logs`
- `frontend/src/components/Chat/InputArea.tsx` — emit log entries for chat events
- `frontend/src/components/CommandPalette.tsx` — emit log entries for model operations
---
## Non-Goals
- No log persistence across sessions (localStorage is too small for meaningful logs)
- No log filtering/search (keep it simple for v1)
- No backend log forwarding (frontend-only collection from existing event streams)
- No changes to the backend streaming protocol (already emits the events we need)
- No Ollama-level model load progress bar (Ollama doesn't expose loading progress via API)
- No Windows temperature support in v1
---
## Testing
- Existing `tests/server/test_model_management.py` covers pull/delete/streaming endpoints (no changes needed)
- Frontend: TypeScript compilation (`tsc --noEmit`) validates all type changes
- Manual testing: verify all 6 changes in desktop app and web browser
- Add a pytest test for the extended `/v1/telemetry/energy` response (temp fields present, nullable)
---
## Summary of Changes by File
| File | Changes |
|------|---------|
| `SystemPanel.tsx` | Remove server-reported, rename ENERGY→DEVICE, add temps, remove Cool, use `getBase()` |
| `Sidebar.tsx` | Add theme toggle icon, add Logs nav item, model loading spinner |
| `InputArea.tsx` | Update phase labels ("Connecting...", "Calling ..."), disable during model loading |
| `CommandPalette.tsx` | Trigger model preloading, emit log entries |
| `store.ts` | Add `modelLoading`, `setModelLoading`, `logEntries`, `addLogEntry`, `clearLogs` |
| `types/index.ts` | Add `LogEntry` interface |
| `App.tsx` | Add `/logs` route, import LogsPage |
| `LogsPage.tsx` | New file — timestamped log viewer with Copy All / Clear |
| `api.ts` | Add `fetchDeviceInfo()`, `preloadModel()` functions |
| `api_routes.py` | Extend `/v1/telemetry/energy` to include `cpu_temp_c`, `gpu_temp_c` |
| `telemetry/` | Add CPU/GPU temp collection (macOS IOKit, Linux sysfs, NVIDIA already done) |