From 46fcd5bab45966a636d18fe295c239c8e4accb5b Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:28:20 -0700 Subject: [PATCH] docs: add desktop UX improvements spec and implementation plan --- .../2026-03-14-desktop-ux-improvements.md | 930 ++++++++++++++++++ ...26-03-14-desktop-ux-improvements-design.md | 155 +++ 2 files changed, 1085 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-14-desktop-ux-improvements.md create mode 100644 docs/superpowers/specs/2026-03-14-desktop-ux-improvements-design.md diff --git a/docs/superpowers/plans/2026-03-14-desktop-ux-improvements.md b/docs/superpowers/plans/2026-03-14-desktop-ux-improvements.md new file mode 100644 index 00000000..ac2b7c74 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-desktop-ux-improvements.md @@ -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 { + // 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 */} +
+

+ Device +

+
+ {energy?.cpu_temp_c != null && ( + + )} + {energy?.gpu_temp_c != null && ( + + )} + + +
+
+``` + +- [ ] **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 +
+ +
+ + +
+
+``` + +- [ ] **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 ``: + +```tsx + {modelLoading ? ( + + ) : ( + + )} +
+ + {selectedModel || serverInfo?.model || 'Select model'} + + {modelLoading && ( + + Loading model... + + )} +
+ {!modelLoading && ( + + ⌘K + + )} +``` + +- [ ] **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 = { + 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(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 ( +
+
+ {/* Header */} +
+ +

+ Logs +

+ + {logEntries.length} entries + + + +
+ + {/* Log entries */} +
+ {logEntries.length === 0 ? ( +
+ No log entries yet. Logs appear as you chat, switch models, and interact with the app. +
+ ) : ( + logEntries.map((entry, i) => ( +
+ {formatTime(entry.timestamp)} + {' '} + + [{entry.category}] + + {' '} + + {entry.message} + +
+ )) + )} +
+
+
+
+ ); +} +``` + +- [ ] **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 `}>` block, after the agents route: + +```tsx + } /> +``` + +- [ ] **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" +``` diff --git a/docs/superpowers/specs/2026-03-14-desktop-ux-improvements-design.md b/docs/superpowers/specs/2026-03-14-desktop-ux-improvements-design.md new file mode 100644 index 00000000..2fc62d62 --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-desktop-ux-improvements-design.md @@ -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) |