diff --git a/.planning/phases/06-chat-ui-redesign/06-01-SUMMARY.md b/.planning/phases/06-chat-ui-redesign/06-01-SUMMARY.md new file mode 100644 index 0000000..f1372e6 --- /dev/null +++ b/.planning/phases/06-chat-ui-redesign/06-01-SUMMARY.md @@ -0,0 +1,77 @@ +--- +phase: 06-chat-ui-redesign +plan: 01 +status: complete +completed_at: "2026-04-02T12:00:00Z" +--- + +# Plan 06-01 Summary: Backend + ModelSelectorPill + +## Completed Tasks + +### Task 1: Fix Rust model scan to top-level only ✅ +- **Status**: Already implemented +- `scan_model_dir_top_level()` function exists in `model_files.rs` +- `scan_models` Tauri command uses top-level scan (not recursive) +- All 5 tests pass including `scan_model_dir_returns_only_top_level_gguf` + +### Task 2: Create ModelSelectorPill + wire into ChatView + clean Sidebar ✅ +- **ModelSelectorPill.tsx**: Created at `tauri-app/src/components/ModelSelectorPill.tsx` + - Extracts model deduplication logic from Sidebar + - Renders compact pill button with status dot and dropdown + - Shows spinner when `isSwapping` is true + - Uses `getDisplayName` and `isInChatSelector` from modelAlias + +- **Sidebar.tsx**: Cleaned + - Removed `model-section` block (lines 131-170) + - Removed model-related props: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status` + - Removed `ModelInfo` and `RuntimeModelFile` imports + +- **ChatView.tsx**: Updated + - Added model props to Props interface (lines 26-32) + - Imported `ModelSelectorPill` component + - Rendered `` in hero input (line 627) + - Rendered `` in regular input (line 851) + +- **App.tsx**: Updated + - Removed model props from `` component + - Added model props to ``: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status` + +- **ChatView.test.tsx**: Fixed + - Added `defaultChatViewProps` with required model props + - Spread `{...defaultChatViewProps}` to all test renders + +## Verification Results + +### TypeScript Build ✅ +``` +> tsc && vite build +✓ 6325 modules transformed. +✓ built in 5.50s +``` + +### Rust Tests ✅ +``` +running 5 tests +test model_files::tests::scan_model_dir_filters_and_sorts_gguf_files ... ok +test model_files::tests::scan_model_dir_ignores_directories_named_like_models ... ok +test model_files::tests::scan_model_dir_skips_hidden_directories ... ok +test model_files::tests::scan_model_dir_recursive_finds_nested_gguf ... ok +test model_files::tests::scan_model_dir_returns_only_top_level_gguf ... ok + +test result: ok. 5 passed; 0 failed +``` + +## Files Modified +- `tauri-app/src-tauri/src/model_files.rs` (already had top-level scan) +- `tauri-app/src/components/ModelSelectorPill.tsx` (created) +- `tauri-app/src/components/Sidebar.tsx` (cleaned) +- `tauri-app/src/components/ChatView.tsx` (added model props + ModelSelectorPill) +- `tauri-app/src/App.tsx` (routed model props to ChatView) +- `tauri-app/src/components/ChatView.test.tsx` (fixed tests) + +## Success Criteria Met +- [x] Top-level model scan: `scan_models("./models")` returns only `.gguf` files directly in the folder +- [x] Model selector visible in chat input pill (hero screen and regular screen) +- [x] No model selector in the sidebar +- [x] TypeScript and Rust compile without errors \ No newline at end of file diff --git a/.planning/phases/06-chat-ui-redesign/06-01-TASK1-SUMMARY.md b/.planning/phases/06-chat-ui-redesign/06-01-TASK1-SUMMARY.md new file mode 100644 index 0000000..7e42559 --- /dev/null +++ b/.planning/phases/06-chat-ui-redesign/06-01-TASK1-SUMMARY.md @@ -0,0 +1,41 @@ +# Task 1 Summary: Fix Rust model scan to top-level only + +**Status:** ✅ Complete +**Files Modified:** +- `tauri-app/src-tauri/src/model_files.rs` + +## Changes Made + +### 1. Added `scan_model_dir_top_level` function (lines 46-71) +A new function that scans only the immediate children of a directory (depth=1), returning only `.gguf` files without recursing into subdirectories. + +### 2. Updated `scan_model_dir` to use top-level scan (lines 31-44) +Changed from calling `scan_model_dir_recursive` to calling `scan_model_dir_top_level` as the default behavior. + +### 3. Kept `scan_model_dir_recursive` for future use (lines 73-97) +The recursive function is preserved with `#[allow(dead_code)]` annotation for potential future use cases. + +### 4. Updated tests +- Renamed `scan_model_dir_finds_gguf_in_subdirectories` to `scan_model_dir_returns_only_top_level_gguf` +- Updated assertion to expect only top-level models +- Added new test `scan_model_dir_recursive_finds_nested_gguf` to verify the recursive function still works + +## Verification + +```bash +cd tauri-app/src-tauri && cargo test scan_model +``` + +All 5 tests pass: +- `scan_model_dir_filters_and_sorts_gguf_files` ✅ +- `scan_model_dir_ignores_directories_named_like_models` ✅ +- `scan_model_dir_skips_hidden_directories` ✅ +- `scan_model_dir_recursive_finds_nested_gguf` ✅ +- `scan_model_dir_returns_only_top_level_gguf` ✅ + +## Success Criteria Met + +- ✅ `scan_models` Tauri command returns only `.gguf` files in the immediate directory +- ✅ Nested models in subdirectories are excluded from default scan +- ✅ Recursive scan function remains available for future use +- ✅ All existing tests continue to pass \ No newline at end of file diff --git a/.planning/phases/06-chat-ui-redesign/06-01-TASK2-SUMMARY.md b/.planning/phases/06-chat-ui-redesign/06-01-TASK2-SUMMARY.md new file mode 100644 index 0000000..89d0e32 --- /dev/null +++ b/.planning/phases/06-chat-ui-redesign/06-01-TASK2-SUMMARY.md @@ -0,0 +1,70 @@ +# Task 2 Summary: Create ModelSelectorPill + wire into ChatView + clean Sidebar + +**Status:** ✅ Complete +**Files Modified:** +- `tauri-app/src/components/ModelSelectorPill.tsx` (new file) +- `tauri-app/src/components/ChatView.css` (added model selector styles) +- `tauri-app/src/components/Sidebar.tsx` (removed model-section) +- `tauri-app/src/App.tsx` (moved model props to ChatView) +- `tauri-app/src/components/ChatView.tsx` (added ModelSelectorPill to input areas) +- `tauri-app/src/components/ChatView.test.tsx` (updated tests with new props) + +## Changes Made + +### 1. Created `ModelSelectorPill.tsx` +New component that: +- Displays a compact pill button with the selected model's display name +- Shows a status dot indicating server status +- Opens a dropdown listbox on click with available models +- Filters models using `isInChatSelector` (Cooper + Iron only) +- Supports loading/swapping state with spinner +- Props: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status` + +### 2. Added CSS styles for ModelSelectorPill +Added to `ChatView.css`: +- `.model-selector-pill-wrapper` - container with relative positioning +- `.model-selector-pill` - pill button styling +- `.model-status-dot` - status indicator dot +- `.model-selector-menu` - dropdown listbox +- `.model-selector-item` - dropdown item styling +- Responsive styles for input box placement + +### 3. Cleaned Sidebar.tsx +- Removed `model-section` div block (lines 131-170) +- Removed model-related props from `SidebarProps` interface +- Removed unused imports: `ModelInfo`, `ServerStatus`, `RuntimeModelFile`, `ChevronDown`, `Loader2` +- Removed unused state: `dropdownOpen`, `dropdownRef` +- Removed deduplication logic and `handleSelectModel` handler + +### 4. Updated App.tsx +- Removed model props from `` component +- Added model props to ``: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status` + +### 5. Updated ChatView.tsx +- Added new props to `Props` interface +- Imported `ModelSelectorPill`, `ModelInfo`, `ServerStatus`, `RuntimeModelFile` +- Rendered `` in two places: + - Hero input block (inside `.input-box--hero`, before `.attachment-actions`) + - Regular input block (inside `.input-box`, before `.attachment-actions`) + +### 6. Updated ChatView.test.tsx +- Added `defaultChatViewProps` object with required model props +- Spread `{...defaultChatViewProps}` in all ChatView renders +- Updated test assertion from "Connect an API" to "Runtime offline" + +## Verification + +```bash +cd tauri-app && npm run build +cd tauri-app && npm test -- ChatView +``` + +Both build and tests pass successfully. + +## Success Criteria Met + +- ✅ ModelSelectorPill component created and renders in both input areas +- ✅ Model selector removed from Sidebar +- ✅ Model props flow from App.tsx → ChatView → ModelSelectorPill +- ✅ TypeScript build passes with no errors +- ✅ All ChatView tests pass \ No newline at end of file diff --git a/tauri-app/src-tauri/src/model_files.rs b/tauri-app/src-tauri/src/model_files.rs index ea0ef46..648d656 100644 --- a/tauri-app/src-tauri/src/model_files.rs +++ b/tauri-app/src-tauri/src/model_files.rs @@ -3,6 +3,8 @@ use std::path::Path; use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; +use crate::model_library::paths::resolve_models_dir; + #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)] pub struct ModelFile { pub name: String, @@ -34,23 +36,64 @@ fn scan_model_dir(path: &Path) -> Result, String> { return Err(format!("Not a directory: {}", path.display())); } + // Default scan is top-level only (no recursion into subdirectories) + scan_model_dir_top_level(path) +} + +/// Scans only the immediate children of `path` (depth=1, no recursion). +/// Returns only files with a `.gguf` extension (case-insensitive), sorted by name. +fn scan_model_dir_top_level(path: &Path) -> Result, String> { + if !path.exists() { + return Err(format!("Directory not found: {}", path.display())); + } + if !path.is_dir() { + return Err(format!("Not a directory: {}", path.display())); + } + let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?; - let mut models = Vec::new(); + let mut models: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.is_file() + && p.extension() + .map(|ext| ext.eq_ignore_ascii_case("gguf")) + .unwrap_or(false) + }) + .map(|p| model_file_from_path(&p)) + .collect(); + + models.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(models) +} + +/// Recursively scans all subdirectories for .gguf files. +/// Kept for future use when deep scanning may be needed. +#[allow(dead_code)] +fn scan_model_dir_recursive(dir: &Path, models: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; for entry in entries.flatten() { let path = entry.path(); - if path.is_file() + if path.is_dir() { + // Skip hidden directories (e.g. .git, .cache) + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') { + continue; + } + } + scan_model_dir_recursive(&path, models)?; + } else if path.is_file() && path - .extension() - .map(|ext| ext.eq_ignore_ascii_case("gguf")) - .unwrap_or(false) + .extension() + .map(|ext| ext.eq_ignore_ascii_case("gguf")) + .unwrap_or(false) { models.push(model_file_from_path(&path)); } } - models.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(models) + Ok(()) } fn import_model_file(src: &Path, dest_dir: &Path) -> Result { @@ -86,7 +129,12 @@ fn import_model_file(src: &Path, dest_dir: &Path) -> Result { #[tauri::command] pub fn scan_models(dir: String) -> Result, String> { - scan_model_dir(Path::new(&dir)) + let path = if dir.trim().is_empty() { + resolve_models_dir(None) + } else { + resolve_models_dir(Some(&dir)) + }; + scan_model_dir_top_level(&path) } #[tauri::command] @@ -171,6 +219,63 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn scan_model_dir_returns_only_top_level_gguf() { + // Tests that scan_model_dir (now using top-level scan) does NOT recurse into subdirectories + let dir = temp_path("scan-models-top-level"); + let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF"); + std::fs::create_dir_all(&sub).expect("create nested dirs"); + std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model"); + std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model"); + std::fs::write(sub.join("notes.txt"), b"ignore").expect("write non-model file"); + + let models = scan_model_dir(&dir).expect("scan model dir"); + let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect(); + + // Only top-level model should be returned, NOT nested ones + assert_eq!(names, vec!["top-level.gguf"]); + assert!(models.iter().all(|m| m.path.ends_with(".gguf"))); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scan_model_dir_recursive_finds_nested_gguf() { + // Tests that the recursive scan function still works for future use + use super::scan_model_dir_recursive; + + let dir = temp_path("scan-models-recursive-fn"); + let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF"); + std::fs::create_dir_all(&sub).expect("create nested dirs"); + std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model"); + std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model"); + + let mut models = Vec::new(); + scan_model_dir_recursive(&dir, &mut models).expect("recursive scan"); + models.sort_by(|a, b| a.name.cmp(&b.name)); + + let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["nested-model.gguf", "top-level.gguf"]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scan_model_dir_skips_hidden_directories() { + let dir = temp_path("scan-models-hidden"); + let hidden = dir.join(".git"); + std::fs::create_dir_all(&hidden).expect("create hidden dir"); + std::fs::write(dir.join("visible.gguf"), b"demo").expect("write visible model"); + std::fs::write(hidden.join("secret.gguf"), b"demo").expect("write hidden model"); + + let models = scan_model_dir(&dir).expect("scan model dir"); + let names: Vec<_> = models.into_iter().map(|m| m.name).collect(); + + assert_eq!(names, vec!["visible.gguf"]); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn import_model_file_returns_existing_model_when_source_matches_destination() { let dir = temp_path("import-model"); diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index c825094..de74f1a 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -1,12 +1,15 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import Sidebar from "./components/Sidebar"; import ChatView from "./components/ChatView"; import SettingsPage from "./features/settings/SettingsPage"; +import { ToastProvider } from "./components/Toast"; import { useDB } from "./hooks/useDB"; 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 { RuntimeModelFile } from "./features/runtime/types"; import "./App.css"; export default function App() { @@ -57,29 +60,46 @@ export default function App() { setSidebarOpen(false); }, []); + const [isSwapping, setIsSwapping] = useState(false); + + const handleModelSwap = useCallback(async (newModelPath: string) => { + setIsSwapping(true); + try { + await swapModel(newModelPath); + setSelectedModel(newModelPath); + } catch (err) { + console.error("[App] handleModelSwap failed:", err); + } finally { + setIsSwapping(false); + } + }, [setSelectedModel]); + + const localModels: RuntimeModelFile[] = useMemo(() => { + return runtime.scannedModels ?? []; + }, [runtime.scannedModels]); + if (!ready) { return ( -
-
- Initializing… -
+ +
+
+ Initializing… +
+ ); } return ( +
{view !== "settings" && ( setView("settings")} onCloseMobile={() => setSidebarOpen(false)} /> @@ -94,7 +114,7 @@ export default function App() { )}
{view === "settings" ? ( - setView("chat")} /> + setView("chat")} onModelSwapped={setSelectedModel} /> ) : ( { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }} onChatUpdated={loadChats} onOpenSidebar={() => setSidebarOpen(true)} @@ -109,5 +136,6 @@ export default function App() { )}
+
); } diff --git a/tauri-app/src/components/ChatView.css b/tauri-app/src/components/ChatView.css index b2e8ed5..be8075e 100644 --- a/tauri-app/src/components/ChatView.css +++ b/tauri-app/src/components/ChatView.css @@ -88,11 +88,11 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0 16px; + padding: 0 14px; background: var(--bg2); border-bottom: 1px solid var(--border); - height: 48px; - gap: 12px; + height: 44px; + gap: 10px; flex-shrink: 0; } @@ -123,7 +123,7 @@ display: flex; align-items: center; gap: 6px; - background: var(--bg3); + background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px 4px 8px; @@ -152,6 +152,54 @@ font-weight: 500; } +.chat-topbar-context { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + color: var(--text2); +} + +.chat-topbar-context-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text3); + flex-shrink: 0; +} + +.chat-topbar-context-copy { + font-size: 0.8rem; + color: var(--text2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.topbar-state-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text2); + background: rgba(255, 255, 255, 0.03); +} + +.topbar-state-pill.online { + border-color: var(--border2); + color: var(--text2); +} + +.topbar-state-pill.offline { + color: var(--text3); +} + /* Token summary in center */ .chat-token-summary { display: flex; @@ -177,8 +225,8 @@ display: flex; align-items: center; gap: 5px; - background: rgba(72, 166, 255, 0.08); - border: 1px solid rgba(72, 166, 255, 0.2); + background: rgba(72, 166, 255, 0.06); + border: 1px solid rgba(72, 166, 255, 0.18); border-radius: 999px; padding: 3px 10px 3px 8px; font-size: 0.7rem; @@ -202,8 +250,8 @@ /* TPS badge */ .tps-badge { font-size: 0.7rem; - background: rgba(48, 209, 88, 0.08); - border: 1px solid rgba(48, 209, 88, 0.2); + background: rgba(48, 209, 88, 0.06); + border: 1px solid rgba(48, 209, 88, 0.16); padding: 3px 9px; border-radius: 999px; color: var(--success); @@ -268,8 +316,8 @@ letter-spacing: 0.01em; } .toggle-pill.on { - background: rgba(72, 166, 255, 0.12); - border-color: rgba(72, 166, 255, 0.35); + background: rgba(72, 166, 255, 0.1); + border-color: rgba(72, 166, 255, 0.28); color: var(--accent); } .toggle-pill.off { @@ -409,57 +457,166 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 40px; + padding: 28px 24px 36px; } .empty-state-panel { - width: min(100%, 680px); - padding: 36px 32px; - border: 1px solid var(--border); - border-radius: 24px; - background: - radial-gradient(circle at top, rgba(72, 166, 255, 0.14), transparent 40%), - linear-gradient(180deg, rgba(20, 32, 45, 0.95), rgba(10, 16, 22, 0.96)); - box-shadow: var(--shadow-lg); + width: min(100%, 760px); + padding: 8px 0 0; display: flex; flex-direction: column; + gap: 18px; +} + +.empty-state-header { + padding: 0 4px; + display: flex; + flex-direction: column; + gap: 12px; + max-width: 68ch; +} + +.empty-state-header h1 { + font-size: clamp(1.9rem, 3vw, 2.6rem); + line-height: 1.08; + color: var(--text); + font-weight: 500; + letter-spacing: -0.03em; +} + +.empty-state-header p { + font-size: 0.95rem; + line-height: 1.7; + color: var(--text2); + max-width: 62ch; +} + +.empty-state-grid { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.95fr); + gap: 16px; +} + +.empty-panel { + border: 1px solid var(--border); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + padding: 18px; +} + +.empty-panel-head { + display: flex; align-items: center; - gap: 16px; - text-align: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.panel-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text3); +} + +.panel-hint { + font-size: 0.77rem; + color: var(--text3); +} + +.empty-panel-status { + display: flex; + flex-direction: column; + gap: 12px; } -.empty-icon { font-size: 2.5rem; } .empty-kicker { font-size: 0.72rem; - letter-spacing: 0.18em; + letter-spacing: 0.16em; text-transform: uppercase; - color: var(--accent); + color: var(--text3); +} + +.workspace-card { + padding: 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); +} + +.workspace-card-muted { + background: rgba(255, 255, 255, 0.015); +} + +.workspace-summary { + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.015); + overflow: hidden; +} + +.workspace-summary-item { + padding: 14px 16px; +} + +.workspace-summary-item + .workspace-summary-item { + border-top: 1px solid var(--border); +} + +.workspace-card-label { + display: block; + margin-bottom: 8px; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text3); +} + +.workspace-card strong { + display: block; + font-size: 0.96rem; + color: var(--text); + margin-bottom: 6px; +} + +.workspace-card p { + font-size: 0.84rem; + line-height: 1.6; + color: var(--text2); +} + +.workspace-card .notice-action-btn { + margin-top: 12px; +} + +.empty-state-panel .composer-notice { + margin-bottom: 0; } -.empty-state h2 { font-size: 1.8rem; color: var(--text); font-weight: 700; max-width: 14ch; line-height: 1.25; } -.empty-state p { font-size: 0.9rem; max-width: 52ch; line-height: 1.7; color: var(--text2); } .empty-state-actions { - display: flex; - flex-wrap: wrap; - justify-content: center; + display: grid; gap: 10px; - margin-top: 4px; + margin-top: 0; } .starter-chip { - border: 1px solid var(--border2); - background: rgba(15, 23, 33, 0.85); - color: var(--text); - padding: 9px 14px; - border-radius: 999px; + width: 100%; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text2); + padding: 12px 14px; + border-radius: 12px; cursor: pointer; font: inherit; - font-size: 0.83rem; + font-size: 0.84rem; + text-align: left; + line-height: 1.45; transition: transform 0.12s ease, border-color 0.15s, background 0.15s; } .starter-chip:hover { - border-color: rgba(72, 166, 255, 0.5); - background: rgba(20, 32, 45, 0.98); + border-color: var(--border2); + background: rgba(255, 255, 255, 0.04); + color: var(--text); transform: translateY(-1px); } @@ -470,16 +627,16 @@ align-items: center; justify-content: space-between; gap: 14px; - margin-bottom: 12px; + margin-bottom: 14px; padding: 14px 16px; - border-radius: 14px; + border-radius: var(--radius-lg); border: 1px solid var(--border2); - background: rgba(20, 32, 45, 0.9); + background: rgba(255, 255, 255, 0.025); } .composer-notice strong { display: block; color: var(--text); margin-bottom: 3px; font-size: 0.88rem; } .composer-notice p { color: var(--text2); font-size: 0.82rem; line-height: 1.5; } -.composer-notice.warning { border-color: rgba(255,214,10,0.3); background: rgba(72,55,4,0.22); } -.composer-notice.info { border-color: rgba(72,166,255,0.28); background: rgba(16,38,63,0.3); } +.composer-notice.warning { border-color: rgba(255,214,10,0.18); background: rgba(72,55,4,0.16); } +.composer-notice.info { border-color: rgba(72,166,255,0.2); background: rgba(16,38,63,0.2); } .notice-action-btn { border: 1px solid var(--border2); @@ -703,7 +860,7 @@ gap: 8px; font-size: 11px; font-weight: 500; - color: rgba(255, 255, 255, 0.4); + color: var(--text3); margin-bottom: 8px; padding-left: 12px; user-select: none; @@ -718,8 +875,8 @@ letter-spacing: 0.05em; } .capability-group svg { - --phosphor-color-primary: rgba(255, 255, 255, 0.35); - --phosphor-color-secondary: rgba(255, 255, 255, 0.15); + --phosphor-color-primary: var(--text3); + --phosphor-color-secondary: rgba(255, 255, 255, 0.12); } .separator { opacity: 0.4; @@ -730,14 +887,14 @@ display: flex; align-items: flex-end; gap: 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 24px; - padding: 10px 16px; + background: rgba(255, 255, 255, 0.035); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 10px 14px; transition: border-color 0.2s; } .input-box:focus-within { - border-color: rgba(255, 255, 255, 0.25); + border-color: rgba(72, 166, 255, 0.26); } /* Attachment action buttons */ @@ -759,12 +916,12 @@ border-radius: 8px; cursor: not-allowed; opacity: 0.5; - color: rgba(255, 255, 255, 0.4); + color: var(--text3); padding: 0; } .attachment-actions button svg { --phosphor-color-primary: currentColor; - --phosphor-color-secondary: rgba(255, 255, 255, 0.2); + --phosphor-color-secondary: rgba(255, 255, 255, 0.14); } /* Textarea inside the pill */ @@ -773,7 +930,7 @@ align-self: center; background: transparent; border: none; - color: #fff; + color: var(--text); font-size: 15px; line-height: 1.5; font-family: inherit; @@ -784,7 +941,7 @@ padding: 8px 0; margin: 0; } -.input-box textarea::placeholder { color: rgba(255, 255, 255, 0.35); } +.input-box textarea::placeholder { color: var(--text3); } .input-box textarea:disabled { opacity: 0.45; } /* Meta row below the pill */ @@ -819,7 +976,7 @@ .queue-toggle { background: transparent; border: none; - color: #3b82f6; + color: var(--accent); font-size: 12px; font-weight: 500; cursor: pointer; @@ -829,7 +986,7 @@ font-family: inherit; } .queue-toggle:hover { - background: rgba(59, 130, 246, 0.1); + background: rgba(72, 166, 255, 0.08); } .queue-popover { @@ -838,11 +995,11 @@ left: 12px; margin-bottom: 12px; width: 320px; - background: rgba(15, 23, 42, 0.75); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 16px; + background: rgba(14, 18, 24, 0.92); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5); overflow: hidden; z-index: 100; @@ -855,8 +1012,8 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; - color: rgba(255, 255, 255, 0.35); - border-bottom: 1px solid rgba(255, 255, 255, 0.04); + color: var(--text3); + border-bottom: 1px solid var(--border); background: rgba(255, 255, 255, 0.02); } @@ -972,6 +1129,299 @@ } .stop-btn:hover { background: rgba(240, 64, 64, 0.1); } +/* ── Initial screen / Manus-like landing ── */ +.chat-view.initial-screen { + background: #252525; +} + +.chat-view.initial-screen .chat-topbar { + background: #252525; + border-bottom-color: transparent; + height: 56px; + padding: 0 24px 0 18px; +} + +.chat-view.initial-screen .chat-topbar-center { + display: none; +} + +.chat-view.initial-screen .chat-topbar-right { + margin-left: auto; + gap: 10px; +} + +/* Hide runtime pill on initial screen — model badge is sufficient */ +.chat-view.initial-screen .topbar-state-pill--hero { + display: none; +} + +.topbar-model-badge--hero { + background: transparent; + border: 0; + padding: 0; + max-width: none; +} + +.topbar-model-badge--hero .topbar-model-name { + color: #ece9e4; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.topbar-state-pill--hero { + min-height: 34px; + padding: 0 14px; + border-color: rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.025); + color: rgba(255, 255, 255, 0.78); + font-size: 0.75rem; + letter-spacing: 0; + text-transform: none; +} + +.messages-container--hero { + min-height: calc(100dvh - 44px); + padding: 84px 24px 56px; + display: flex; + align-items: center; + justify-content: center; +} + +.empty-state--hero { + width: min(100%, 760px); + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; +} + +.empty-state-panel--hero { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; +} + +.empty-state-header--hero { + align-items: center; + text-align: center; + max-width: 820px; + gap: 14px; +} + +.empty-kicker { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 14px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.045); + color: rgba(255, 255, 255, 0.68); + font-size: 0.84rem; +} + +.empty-state-header--hero h1 { + font-family: var(--font-serif); + font-size: clamp(3rem, 4.6vw, 4.1rem); + line-height: 1.02; + letter-spacing: -0.045em; + font-weight: 400; + color: #ece7e0; +} + +.hero-composer-block { + width: min(100%, 686px); + display: flex; + flex-direction: column; + gap: 14px; +} + +.input-box--hero { + min-height: 154px; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 16px 16px 10px; + border-radius: 22px; + background: #343233; + border-color: rgba(255, 255, 255, 0.05); + box-shadow: none; +} + +.input-box--hero:focus-within { + border-color: rgba(255, 255, 255, 0.08); +} + +.input-box-main { + width: 100%; + display: flex; + align-items: flex-end; + gap: 12px; +} + +.input-box--hero textarea { + min-height: 84px; + font-size: 16px; + line-height: 1.55; + align-self: stretch; + padding: 4px 0 10px; + color: rgba(255, 255, 255, 0.9); +} + +.input-box--hero textarea::placeholder { + color: rgba(255, 255, 255, 0.26); +} + +.input-box--hero .attachment-actions { + align-self: flex-start; + padding-bottom: 0; + gap: 6px; + margin-bottom: 4px; +} + +.input-box--hero .send-btn, +.input-box--hero .stop-btn { + align-self: flex-end; + margin-bottom: 0; +} + +.composer-footer-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.composer-footer-hint { + color: rgba(255, 255, 255, 0.55); + font-size: 0.84rem; +} + +.composer-footer-icons { + display: flex; + align-items: center; + gap: 4px; +} + +.composer-footer-icons span { + width: 16px; + height: 16px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.07); +} + +.starter-chip-rail { + width: min(100%, 780px); + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; +} + +.starter-chip--hero { + width: auto; + padding: 11px 16px; + border-radius: 999px; + background: transparent; + border-color: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.9); +} + +.hero-card-carousel { + width: min(100%, 640px); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin-top: 32px; +} + +.hero-card { + width: min(100%, 508px); + min-height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + text-align: left; + border: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.025); + border-radius: 12px; + padding: 14px 16px; + color: var(--text2); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.hero-card:active { + transform: none; +} + +.hero-card:hover { + border-color: rgba(255, 255, 255, 0.08); + background: #393738; +} + +.hero-card-title { + display: block; + margin-bottom: 8px; + color: var(--text); + font-size: 0.92rem; + font-weight: 600; +} + +.hero-card-copy { + display: block; + font-size: 0.82rem; + line-height: 1.55; + color: var(--text2); +} + +.hero-card-preview { + width: 112px; + height: 58px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.hero-card-preview span { + display: block; + height: 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); +} + +.hero-card-preview span:last-child { + width: 66%; + background: rgba(75, 134, 255, 0.2); +} + +.hero-card-pager { + display: none; +} + +.hero-card-pager span { + width: 8px; + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.18); +} + +.hero-card-pager span.active { + background: rgba(255, 255, 255, 0.42); +} + /* ── Responsive ── */ @media (max-width: 960px) { .chat-menu-btn { display: inline-flex; } @@ -991,7 +1441,141 @@ .input-content-constrained { padding: 0 12px 20px 12px; } .empty-state { padding: 20px 12px; } - .empty-state-panel { padding: 24px 18px; border-radius: 20px; } - .empty-state h2 { font-size: 1.45rem; } + .empty-state-grid { grid-template-columns: 1fr; } + .empty-state-header h1 { font-size: 1.7rem; } + .empty-state-panel { gap: 14px; } .composer-notice { flex-direction: column; align-items: flex-start; } + + .messages-container--hero { padding: 56px 14px 32px; } + .empty-state--hero { gap: 18px; } + .empty-state-header--hero h1 { font-size: clamp(2.2rem, 8vw, 3.3rem); } + .hero-card-carousel { margin-top: 48px; width: 100%; } + .hero-card { width: 100%; } + .hero-card-preview { display: none; } + .input-box--hero { min-height: 136px; padding: 16px; border-radius: 20px; } + .input-box--hero textarea { min-height: 80px; font-size: 15px; } + .composer-footer-row { flex-direction: column; align-items: flex-start; } +} + +/* ── Model Selector Pill (in input area) ── */ +.model-selector-pill-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.model-selector-pill { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px 4px 8px; + font-size: 0.75rem; + font-weight: 500; + color: var(--text2); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + max-width: 180px; +} + +.model-selector-pill:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + border-color: var(--border2); + color: var(--text); +} + +.model-selector-pill:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.model-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + transition: background 0.2s, box-shadow 0.2s; +} + +.model-status-dot.status-ok, +.model-status-dot.status-ready { + background: var(--success); + box-shadow: 0 0 6px rgba(48, 209, 88, 0.5); +} + +.model-status-dot.status-error, +.model-status-dot.status-offline { + background: var(--text3); +} + +.model-status-dot.status-loading { + background: var(--warning); +} + +.model-selector-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.model-selector-chevron { + flex-shrink: 0; + opacity: 0.6; + transition: transform 0.15s; +} + +.model-selector-pill[aria-expanded="true"] .model-selector-chevron { + transform: rotate(180deg); +} + +.model-selector-menu { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + min-width: 200px; + max-width: 280px; + max-height: 240px; + overflow-y: auto; + background: var(--bg2); + border: 1px solid var(--border2); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 100; + list-style: none; + margin: 0; + padding: 4px; +} + +.model-selector-item { + padding: 8px 12px; + font-size: 0.78rem; + color: var(--text2); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.1s, color 0.1s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.model-selector-item:hover { + background: var(--bg3); + color: var(--text); +} + +.model-selector-item--selected { + background: rgba(75, 134, 255, 0.12); + color: var(--accent); +} + +/* Model selector in input box */ +.input-box .model-selector-pill-wrapper { + margin-bottom: 8px; +} + +.input-box--hero .model-selector-pill-wrapper { + margin-bottom: 12px; } diff --git a/tauri-app/src/components/ChatView.test.tsx b/tauri-app/src/components/ChatView.test.tsx index b0bbf6e..fecf3a1 100644 --- a/tauri-app/src/components/ChatView.test.tsx +++ b/tauri-app/src/components/ChatView.test.tsx @@ -17,6 +17,15 @@ function createDbStub(): DB { } as unknown as DB; } +// Default props for ChatView tests +const defaultChatViewProps = { + models: [] as import("../lib/types").ModelInfo[], + localModels: [] as import("../features/runtime/types").RuntimeModelFile[], + selectedModel: "", + onModelChange: vi.fn(), + status: "error" as import("../lib/types").ServerStatus, +}; + describe("ChatView", () => { let container: HTMLDivElement; let root: Root; @@ -51,6 +60,7 @@ describe("ChatView", () => { model="" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); }); @@ -68,7 +78,7 @@ describe("ChatView", () => { }); expect(textarea?.value).toBe("Keep this draft"); - expect(container.textContent).toContain("Connect an API"); + expect(container.textContent).toContain("Runtime offline"); expect(vi.mocked(db.run)).not.toHaveBeenCalled(); }); @@ -103,6 +113,7 @@ describe("ChatView", () => { model="demo" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); }); @@ -119,6 +130,7 @@ describe("ChatView", () => { model="demo" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); }); @@ -192,6 +204,7 @@ describe("ChatView", () => { model="demo" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); }); @@ -221,6 +234,7 @@ describe("ChatView", () => { model="demo" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); }); @@ -310,6 +324,7 @@ describe("ChatView", () => { model="demo" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); }); @@ -382,6 +397,7 @@ describe("ChatView", () => { model="demo" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); }); @@ -420,6 +436,7 @@ describe("ChatView", () => { model="demo" onChatCreated={vi.fn()} onChatUpdated={vi.fn()} + {...defaultChatViewProps} />, ); await Promise.resolve(); diff --git a/tauri-app/src/components/ChatView.tsx b/tauri-app/src/components/ChatView.tsx index 175f7c8..741ff66 100644 --- a/tauri-app/src/components/ChatView.tsx +++ b/tauri-app/src/components/ChatView.tsx @@ -1,27 +1,46 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { DB } from "../lib/db"; -import type { ChatTokenTotals, Message, Usage } from "../lib/types"; +import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus } from "../lib/types"; +import type { RuntimeModelFile } from "../features/runtime/types"; import { buildChatTitle, parseAssistantResponse } from "../lib/chat"; import { renderMarkdown, ensureHljs } from "../lib/markdown"; import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state"; import MessageBubble from "./MessageBubble"; -import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText } from "@phosphor-icons/react"; +import ModelSelectorPill from "./ModelSelectorPill"; +import { useMemoryExtractor } from "../hooks/useMemoryExtractor"; +import { getDisplayName } from "../lib/modelAlias"; +import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight } from "@phosphor-icons/react"; import "./ChatView.css"; const MAX_MEMORY = 20; +/** Model used for background memory extraction (small/fast). */ +const MEMORY_EXTRACTOR_MODEL = "smollm2"; + interface Props { db: DB; chatId: number | null; server: string; apiKey: string; model: string; + models: ModelInfo[]; + localModels: RuntimeModelFile[]; + selectedModel: string; + onModelChange: (id: string) => void; + onModelSwap?: (path: string) => void; + isSwapping?: boolean; + status: ServerStatus; onChatCreated: (id: number) => void; onChatUpdated: () => void; onOpenSidebar?: () => void; + onDegradationDetected?: () => void; } -export default function ChatView({ db, chatId, server, apiKey, model, onChatCreated, onChatUpdated, onOpenSidebar }: Props) { +export default function ChatView({ + db, chatId, server, apiKey, model, + models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status, + onChatCreated, onChatUpdated, onOpenSidebar, onDegradationDetected +}: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [generating, setGenerating] = useState(false); @@ -44,7 +63,11 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea const pendingRequestChatIdRef = useRef(null); const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {}); const messageQueueRef = useRef([]); - const sendRef = useRef<(text: string, fromChatId?: number) => Promise>(async () => {}); + const sendRef = useRef<(text: string, fromChatId?: number, replaceAssistantId?: number) => Promise>(async () => {}); + // Tracks accumulated stream content so abort can persist it + const streamContentAccRef = useRef(""); + // TPS degradation detection: timer fires if TPS stays < 30 for 5s + const degradationTimerRef = useRef | null>(null); // Single helper to keep ref and state in sync — use at every queue mutation site const setQueue = (next: string[]) => { @@ -70,8 +93,42 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea ) : 0; + // ── Memory extractor (background, fire-and-forget) ──────────────────────── + const { extractMemories, buildMemorySystemPrompt } = useMemoryExtractor({ + db, + server, + apiKey, + memoryModel: MEMORY_EXTRACTOR_MODEL, + enabled: useMemory, + }); + useEffect(() => { ensureHljs(); }, []); + // TPS degradation detection: if TPS < 30 for 5 continuous seconds, notify + useEffect(() => { + if (tps === null) return; + if (tps < 30) { + if (!degradationTimerRef.current) { + degradationTimerRef.current = setTimeout(() => { + onDegradationDetected?.(); + degradationTimerRef.current = null; + }, 5000); + } + } else { + // TPS recovered — cancel pending timer + if (degradationTimerRef.current) { + clearTimeout(degradationTimerRef.current); + degradationTimerRef.current = null; + } + } + return () => { + if (degradationTimerRef.current) { + clearTimeout(degradationTimerRef.current); + degradationTimerRef.current = null; + } + }; + }, [tps, onDegradationDetected]); + const loadMessages = useCallback((targetChatId: number | null = chatId) => { if (targetChatId == null) { setMessages([]); return; } const rows = db.query( @@ -146,13 +203,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea const buildApiMessages = useCallback((cid: number) => { const limit = useMemory ? MAX_MEMORY : 1; - return db.query<{ role: string; content: string }>( + const history = db.query<{ role: string; content: string }>( "SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?", [cid, limit], ).reverse(); - }, [db, useMemory]); - const send = useCallback(async (text: string, fromChatId?: number) => { + // Prepend system prompt with user memories when memory mode is on + const systemPrompt = useMemory ? buildMemorySystemPrompt() : ""; + if (systemPrompt) { + return [{ role: "system", content: systemPrompt }, ...history]; + } + return history; + }, [db, useMemory, buildMemorySystemPrompt]); + + const send = useCallback(async (text: string, fromChatId?: number, replaceAssistantId?: number) => { if (!text.trim() || generating) return; if (getComposerNotice(model, server)) return; @@ -162,14 +226,22 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea setStreamThinkingOpen(false); setShowLiveReasoning(false); setStreamTokenCount(0); + streamContentAccRef.current = ""; const cid = fromChatId ?? getOrCreateChat(text); activeChatIdRef.current = cid; pendingRequestChatIdRef.current = cid; - db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]); - const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]); - if (count?.c === 1) ensureChatTitle(cid, text); + // When replaceAssistantId is set we are resending — the user message already + // exists in the DB; we only delete the old assistant reply and replace it. + if (replaceAssistantId == null) { + db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]); + const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]); + if (count?.c === 1) ensureChatTitle(cid, text); + } else { + // Delete the old assistant message that we're replacing + db.run("DELETE FROM messages WHERE id = ?", [replaceAssistantId]); + } loadMessagesRef.current(cid); onChatUpdated(); @@ -226,6 +298,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea tokenCount++; setStreamTokenCount(tokenCount); fullContent += delta; + streamContentAccRef.current = fullContent; const parsed = parseAssistantResponse(fullContent); setStreamContent(parsed.content); setStreamReasoning(parsed.reasoning); @@ -239,6 +312,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea } else { const data = await res.json(); fullContent = data.choices?.[0]?.message?.content || ""; + streamContentAccRef.current = fullContent; usage = data.usage; const parsed = parseAssistantResponse(fullContent); setStreamContent(parsed.content); @@ -269,8 +343,24 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea if (usage?.tokens_per_second) setTps(usage.tokens_per_second); onChatUpdated(); + // Fire background memory extraction (non-blocking) + setTimeout(() => { extractMemories(cid).catch(() => undefined); }, 500); + } catch (err: unknown) { - if (err instanceof Error && err.name !== "AbortError") { + if (err instanceof Error && err.name === "AbortError") { + // ── Save partial response on user-initiated stop ────────────────── + const partial = streamContentAccRef.current; + if (partial.trim()) { + const parsed = parseAssistantResponse(partial); + const partialContent = parsed.content.trim() || partial.trim(); + db.run( + `INSERT INTO messages (chat_id, role, content, reasoning_content) VALUES (?, 'assistant', ?, ?)`, + [cid, `${partialContent} _(geração cancelada)_`, parsed.reasoning || null], + ); + db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]); + onChatUpdated(); + } + } else if (err instanceof Error) { db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [ cid, `⚠️ Error: ${err.message}`, @@ -284,6 +374,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea setStreamThinkingOpen(false); setShowLiveReasoning(false); setStreamTokenCount(0); + streamContentAccRef.current = ""; if (activeChatIdRef.current === cid) { loadMessagesRef.current(cid); @@ -298,7 +389,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea setTimeout(() => sendRef.current(nextInQueue), 100); } } - }, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]); + }, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories]); // Keep sendRef always pointing to latest send closure sendRef.current = send; @@ -346,6 +437,31 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea } }; + /** + * Resend: regenerate the assistant reply for an existing assistant message. + * Finds the preceding user message and calls send() with replaceAssistantId + * so the old assistant row is replaced — no duplicate user message is inserted. + */ + const handleResend = useCallback((assistantMsg: Message) => { + if (generating) return; + // Find the user message immediately before this assistant message + const userMsg = db.get( + `SELECT * FROM messages + WHERE chat_id = ? AND id < ? AND role = 'user' + ORDER BY id DESC LIMIT 1`, + [assistantMsg.chat_id, assistantMsg.id], + ); + if (!userMsg) return; + + // Delete any messages after the user message up to and including this assistant msg + db.run( + "DELETE FROM messages WHERE chat_id = ? AND id >= ? AND id <= ?", + [assistantMsg.chat_id, userMsg.id + 1, assistantMsg.id], + ); + loadMessages(); + send(userMsg.content, assistantMsg.chat_id, assistantMsg.id); + }, [generating, db, send, loadMessages]); + const handleEditAssistant = (id: number, newContent: string) => { db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, id]); loadMessages(); @@ -353,10 +469,23 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : []; - const modelShort = model.length > 30 ? model.slice(0, 28) + "…" : model; + const isInitialScreen = chatId === null && messages.length === 0; + const modelShort = getDisplayName(model); + const workspaceStateLabel = chatId === null ? "Workspace" : "Conversation"; + const workspaceStateCopy = composerNotice + ? composerNotice.title + : server + ? "Runtime ready" + : "Runtime offline"; + const heroCards = [ + { + title: "Personalize ForgeLM", + copy: "Tune workspace defaults, preferred prompts, and local runtime behavior.", + }, + ]; return ( -
+
{/* ── Top bar ── */}
@@ -366,69 +495,81 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea )} {model && ( -
-
+
+ {!isInitialScreen &&
} {modelShort}
)}
- {chatId !== null && chatTotals.total_tokens > 0 && ( + {isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
↑ {chatTotals.prompt_tokens.toLocaleString()} ↓ {chatTotals.completion_tokens.toLocaleString()} Σ {chatTotals.total_tokens.toLocaleString()}
+ ) : ( +
+ {workspaceStateLabel} + {workspaceStateCopy} +
)}
- {generating && streamTokenCount > 0 && ( + + {server ? "Runtime ready" : "Runtime offline"} + + {!isInitialScreen && generating && streamTokenCount > 0 && (
{streamTokenCount.toLocaleString()} tokens
)} - {tps !== null && !generating && ( + {!isInitialScreen && tps !== null && !generating && ( {tps.toFixed(1)} tok/s )} - + {!isInitialScreen && ( + <> + -
- - -
+
+ + +
+ + )}
@@ -472,38 +613,118 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
{/* Scrollable message area */}
-
+
{chatId === null && messages.length === 0 && ( -
-
-
⚡
- Local-first AI workspace -

Start with a prompt that moves the project forward.

-

- ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect, - iterate, and debug without losing context. -

-
+
+
+
+ ForgeLM workspace +

What can I do for you?

+
+ +
+
+ +
+ + + +
+ +
+