diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 22500a5..3285efd 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import Sidebar from "./components/Sidebar"; import ChatView from "./components/ChatView"; +import AppHeader from "./components/AppHeader"; import SettingsPage from "./features/settings/SettingsPage"; import { ToastProvider } from "./components/Toast"; import { useDB } from "./hooks/useDB"; @@ -8,19 +9,60 @@ import { useModels } from "./hooks/useModels"; import { useRuntimeManager } from "./features/runtime/useRuntimeManager"; import { useRuntimeReadyModelRefresh } from "./features/runtime/useRuntimeReadyModelRefresh"; import { swapModel } from "./features/runtime/api"; -import type { Chat } from "./lib/types"; +import type { Chat, ChatTokenTotals } from "./lib/types"; import type { RuntimeModelFile } from "./features/runtime/types"; import "./App.css"; +const SIDEBAR_STORAGE_KEY = "forgelm.sidebar.open"; + +function readStoredSidebarOpen(): boolean { + if (typeof window === "undefined") return true; + + const getItem = window.localStorage?.getItem; + if (typeof getItem !== "function") return true; + + try { + const saved = getItem.call(window.localStorage, SIDEBAR_STORAGE_KEY); + return saved === null ? true : saved === "true"; + } catch { + return true; + } +} + +function persistSidebarOpen(value: boolean): void { + if (typeof window === "undefined") return; + + const setItem = window.localStorage?.setItem; + if (typeof setItem !== "function") return; + + try { + setItem.call(window.localStorage, SIDEBAR_STORAGE_KEY, String(value)); + } catch { + // Ignore storage failures and keep the in-memory UI state functional. + } +} + export default function App() { const { db, ready } = useDB(); const [currentChatId, setCurrentChatId] = useState(null); const [chats, setChats] = useState([]); - const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(readStoredSidebarOpen); const [view, setView] = useState<"chat" | "settings">("chat"); + // Header live-state (lifted from ChatView via callbacks) + const [generating, setGenerating] = useState(false); + const [streamTokenCount, setStreamTokenCount] = useState(0); + const [tps, setTps] = useState(null); + const [chatTokenTotals, setChatTokenTotals] = useState({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); + const runtime = useRuntimeManager(); - const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels( + const { + models, + selectedModel, + setSelectedModel, + status, + refresh: refreshModels, + } = useModels( runtime.connection.serverUrl, runtime.connection.apiKey, Boolean(runtime.connection.serverUrl), @@ -36,12 +78,15 @@ export default function App() { useEffect(() => { loadChats(); }, [loadChats]); + useEffect(() => { + persistSidebarOpen(sidebarOpen); + }, [sidebarOpen]); + const newChat = useCallback(() => { if (!db) return; const id = db.insert("INSERT INTO chats (title) VALUES ('New Chat')"); if (id > 0) { setCurrentChatId(id); - setSidebarOpen(false); loadChats(); } }, [db, loadChats]); @@ -51,13 +96,11 @@ export default function App() { if (!window.confirm("Delete this conversation and all of its messages?")) return; db.run("DELETE FROM chats WHERE id = ?", [id]); if (currentChatId === id) setCurrentChatId(null); - setSidebarOpen(false); loadChats(); }, [db, currentChatId, loadChats]); const handleSelectChat = useCallback((id: number) => { setCurrentChatId(id); - setSidebarOpen(false); }, []); const [isSwapping, setIsSwapping] = useState(false); @@ -77,9 +120,13 @@ export default function App() { } }, [setSelectedModel]); - const localModels: RuntimeModelFile[] = useMemo(() => { - return runtime.scannedModels ?? []; - }, [runtime.scannedModels]); + const localModels: RuntimeModelFile[] = useMemo(() => runtime.scannedModels ?? [], [runtime.scannedModels]); + + // Current chat title for breadcrumb + const currentChatTitle = useMemo( + () => chats.find((c) => c.id === currentChatId)?.title ?? undefined, + [chats, currentChatId], + ); if (!ready) { return ( @@ -92,56 +139,92 @@ export default function App() { ); } + const serverConnected = Boolean(runtime.connection.serverUrl); + return ( -
- {view !== "settings" && ( - + + {/* ── Global header — always visible (settings mode shows back button) ── */} + setSidebarOpen((v) => !v)} onOpenSettings={() => setView("settings")} - onCloseMobile={() => setSidebarOpen(false)} + onBackToChat={view === "settings" ? () => setView("chat") : undefined} + models={models} + localModels={localModels} + selectedModel={selectedModel} + onModelChange={setSelectedModel} + onModelSwap={handleModelSwap} + isSwapping={isSwapping} + status={status} + runtimeStatus={runtime.snapshot.status} + serverConnected={serverConnected} + generating={generating} + streamTokenCount={streamTokenCount} + tps={tps} + chatTitle={view === "settings" ? "Settings" : currentChatTitle} + chatTokenTotals={chatTokenTotals} /> - )} - {view !== "settings" && sidebarOpen && ( -
-
); } diff --git a/tauri-app/src/components/ChatView.tsx b/tauri-app/src/components/ChatView.tsx index 55a3050..9cd20e2 100644 --- a/tauri-app/src/components/ChatView.tsx +++ b/tauri-app/src/components/ChatView.tsx @@ -60,6 +60,7 @@ interface Props { status: ServerStatus; runtimeMode?: "local" | "remote"; runtimeStatus?: RuntimeStatus; + runtimeLastError?: string; showDebugTools?: boolean; onChatCreated: (id: number) => void; onChatUpdated: () => void; @@ -74,16 +75,15 @@ interface Props { /** Lift TPS to parent for header display */ onTpsChange?: (v: number | null) => void; onChatTokenTotalsChange?: (totals: ChatTokenTotals) => void; -} + } -export default function ChatView({ - db, chatId, server, apiKey, model, + export default function ChatView({ + db, chatId, server, apiKey, model, models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status, - runtimeMode = "remote", runtimeStatus = "ready", showDebugTools = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV), + runtimeMode = "remote", runtimeStatus = "ready", runtimeLastError, showDebugTools = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV), onChatCreated, onChatUpdated, onNewChat, onOpenSidebar, onOpenSettings, onDegradationDetected, onGeneratingChange, onStreamTokenCountChange, onTpsChange, onChatTokenTotalsChange, -}: Props) { - const [messages, setMessages] = useState([]); + }: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [generating, setGenerating] = useState(false); const [streamContent, setStreamContent] = useState(""); @@ -727,6 +727,7 @@ export default function ChatView({ latestInteraction, runtimeMode, runtimeStatus, + runtimeLastError, }); await navigator.clipboard.writeText(transcript); setDebugCopied(true); diff --git a/tauri-app/src/lib/chatDebug.ts b/tauri-app/src/lib/chatDebug.ts index 96452ef..e316381 100644 --- a/tauri-app/src/lib/chatDebug.ts +++ b/tauri-app/src/lib/chatDebug.ts @@ -14,6 +14,7 @@ interface FormatChatDebugTranscriptOptions { latestInteraction?: InteractionLog | null; runtimeMode?: "local" | "remote"; runtimeStatus?: string; + runtimeLastError?: string; } function formatTimestamp(timestamp: number | null | undefined): string { @@ -113,6 +114,7 @@ export function formatChatDebugTranscript({ latestInteraction, runtimeMode, runtimeStatus, + runtimeLastError, }: FormatChatDebugTranscriptOptions): string { const lines = [ "# Chat Debug Export", @@ -127,9 +129,14 @@ export function formatChatDebugTranscript({ `Agentic mode: ${agenticMode ? "on" : "off"}`, `Runtime mode: ${runtimeMode ?? "unknown"}`, `Runtime status: ${runtimeStatus ?? "unknown"}`, - "", ]; + if (runtimeLastError) { + lines.push(`Runtime error: ${runtimeLastError}`); + } + + lines.push(""); + if (savedMemories.length > 0) { lines.push("## Saved Memories Considered"); for (const memory of savedMemories) {