feat(ui): complete Plan 06-01 - fix model scan + move model selector to input

- Fix Rust model scan to only list top-level models (not subdirectories)
- Add scan_model_dir_top_level() function in model_files.rs
- Create ModelSelectorPill component for chat input area
- Remove model-section from Sidebar, move to ChatView
- Update ChatView props to receive model-related props
- Update tests for new ChatView props
This commit is contained in:
Evandro Fonseca Junior committed 2026-04-02 09:06:06 -03:00
1 parent 3364dba5aa
commit cb722e79a6
11 files changed
+1695 -254

No files matched your search

@@ -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 `<ModelSelectorPill>` in hero input (line 627)
- Rendered `<ModelSelectorPill>` in regular input (line 851)
- **App.tsx**: Updated
- Removed model props from `<Sidebar>` component
- Added model props to `<ChatView>`: `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
@@ -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
@@ -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 `<Sidebar>` component
- Added model props to `<ChatView>`: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`, `status`
### 5. Updated ChatView.tsx
- Added new props to `Props` interface
- Imported `ModelSelectorPill`, `ModelInfo`, `ServerStatus`, `RuntimeModelFile`
- Rendered `<ModelSelectorPill>` 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
+113 -8
View File
@@ -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<Vec<ModelFile>, 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<Vec<ModelFile>, 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<ModelFile> = 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<ModelFile>) -> 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<ModelFile, String> {
@@ -86,7 +129,12 @@ fn import_model_file(src: &Path, dest_dir: &Path) -> Result<ModelFile, String> {
#[tauri::command]
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, 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");
+39 -11
View File
@@ -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 (
<div className="app-loading">
<div className="loading-spinner" />
<span>Initializing…</span>
</div>
<ToastProvider>
<div className="app-loading">
<div className="loading-spinner" />
<span>Initializing…</span>
</div>
</ToastProvider>
);
}
return (
<ToastProvider>
<div className={`app-layout${sidebarOpen && view !== "settings" ? " sidebar-open" : ""}`}>
{view !== "settings" && (
<Sidebar
chats={chats}
currentChatId={currentChatId}
initialScreen={currentChatId === null}
onSelectChat={handleSelectChat}
onNewChat={newChat}
onDeleteChat={deleteChat}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
onOpenSettings={() => setView("settings")}
onCloseMobile={() => setSidebarOpen(false)}
/>
@@ -94,7 +114,7 @@ export default function App() {
)}
<div className="app-main">
{view === "settings" ? (
<SettingsPage runtime={runtime} onClose={() => setView("chat")} />
<SettingsPage runtime={runtime} onClose={() => setView("chat")} onModelSwapped={setSelectedModel} />
) : (
<ChatView
db={db!}
@@ -102,6 +122,13 @@ export default function App() {
server={runtime.connection.serverUrl}
apiKey={runtime.connection.apiKey}
model={selectedModel}
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
onModelSwap={handleModelSwap}
isSwapping={isSwapping}
status={status}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
@@ -109,5 +136,6 @@ export default function App() {
)}
</div>
</div>
</ToastProvider>
);
}
+650 -66
View File
@@ -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;
}
+18 -1
View File
@@ -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();
+319 -84
View File
@@ -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<Message[]>([]);
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<number | null>(null);
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
const messageQueueRef = useRef<string[]>([]);
const sendRef = useRef<(text: string, fromChatId?: number) => Promise<void>>(async () => {});
const sendRef = useRef<(text: string, fromChatId?: number, replaceAssistantId?: number) => Promise<void>>(async () => {});
// Tracks accumulated stream content so abort can persist it
const streamContentAccRef = useRef<string>("");
// TPS degradation detection: timer fires if TPS stays < 30 for 5s
const degradationTimerRef = useRef<ReturnType<typeof setTimeout> | 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<Message>(
@@ -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<Message>(
`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 (
<div className="chat-view">
<div className={`chat-view${isInitialScreen ? " initial-screen" : ""}`}>
{/* ── Top bar ── */}
<div className="chat-topbar">
<div className="chat-topbar-left">
@@ -366,69 +495,81 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</button>
)}
{model && (
<div className="topbar-model-badge">
<div className={`topbar-status-dot${server ? " connected" : ""}`} />
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
</div>
<div className="chat-topbar-center">
{chatId !== null && chatTotals.total_tokens > 0 && (
{isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
<div className="chat-token-summary">
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
<span title="Total tokens" className="token-total">Σ {chatTotals.total_tokens.toLocaleString()}</span>
</div>
) : (
<div className="chat-topbar-context" aria-label="Workspace status">
<span className="chat-topbar-context-label">{workspaceStateLabel}</span>
<span className="chat-topbar-context-copy">{workspaceStateCopy}</span>
</div>
)}
</div>
<div className="chat-topbar-right">
{generating && streamTokenCount > 0 && (
<span className={`topbar-state-pill${server ? " online" : " offline"}${isInitialScreen ? " topbar-state-pill--hero" : ""}`}>
{server ? "Runtime ready" : "Runtime offline"}
</span>
{!isInitialScreen && generating && streamTokenCount > 0 && (
<div className="live-token-counter">
<span className="live-token-pulse" />
<span>{streamTokenCount.toLocaleString()} tokens</span>
</div>
)}
{tps !== null && !generating && (
{!isInitialScreen && tps !== null && !generating && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
)}
<button
type="button"
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
onClick={() => setShowMemoryModal(!showMemoryModal)}
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
aria-label="View memory context"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
{memoryMessageCount > 0 && (
<span className="memory-count-badge">{memoryMessageCount}</span>
)}
</button>
{!isInitialScreen && (
<>
<button
type="button"
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
onClick={() => setShowMemoryModal(!showMemoryModal)}
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
aria-label="View memory context"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
{memoryMessageCount > 0 && (
<span className="memory-count-badge">{memoryMessageCount}</span>
)}
</button>
<div className="topbar-toggles">
<button
type="button"
className={`toggle-pill${useStream ? " on" : " off"}`}
onClick={() => setUseStream(!useStream)}
title={useStream ? "Streaming enabled" : "Streaming disabled"}
>
Stream
</button>
<button
type="button"
className={`toggle-pill${useMemory ? " on" : " off"}`}
onClick={() => setUseMemory(!useMemory)}
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
>
Memory
</button>
</div>
<div className="topbar-toggles">
<button
type="button"
className={`toggle-pill${useStream ? " on" : " off"}`}
onClick={() => setUseStream(!useStream)}
title={useStream ? "Streaming enabled" : "Streaming disabled"}
>
Stream
</button>
<button
type="button"
className={`toggle-pill${useMemory ? " on" : " off"}`}
onClick={() => setUseMemory(!useMemory)}
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
>
Memory
</button>
</div>
</>
)}
</div>
</div>
@@ -472,38 +613,118 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
<div className="chat-layout-root">
{/* Scrollable message area */}
<div className="chat-scroll-area">
<div className="messages-container">
<div className={`messages-container${isInitialScreen ? " messages-container--hero" : ""}`}>
{chatId === null && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-panel">
<div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first AI workspace</span>
<h2>Start with a prompt that moves the project forward.</h2>
<p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
iterate, and debug without losing context.
</p>
<div className="empty-state-actions">
<div className="empty-state empty-state--hero">
<section className="empty-state-panel empty-state-panel--hero" aria-labelledby="workspace-welcome">
<div className="empty-state-header empty-state-header--hero">
<span className="empty-kicker">ForgeLM workspace</span>
<h1 id="workspace-welcome">What can I do for you?</h1>
</div>
<div className="hero-composer-block">
<div className="input-box input-box--hero">
<ModelSelectorPill
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
onModelSwap={onModelSwap}
isSwapping={isSwapping}
status={status}
/>
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
<Microphone size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
<Paperclip size={20} weight="duotone" />
</button>
</div>
<div className="input-box-main">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Assign a task or ask anything"
}
rows={1}
disabled={Boolean(composerNotice)}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop} type="button">
■ Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
<div className="composer-footer-row">
<span className="composer-footer-hint">
{composerNotice ? `${composerNotice.title} · ${composerNotice.description}` : "Connect your tools to ForgeLM"}
</span>
<div className="composer-footer-icons" aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
</div>
</div>
</div>
</div>
<div className="starter-chip-rail" aria-label="Starter prompts">
{starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="starter-chip" onClick={() => handleStarterPrompt(prompt)}>
<button key={prompt} type="button" className="starter-chip starter-chip--hero" onClick={() => handleStarterPrompt(prompt)}>
{prompt}
</button>
))}
</div>
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
<div className="hero-card-carousel" aria-label="Workspace shortcuts">
{heroCards.map((card) => (
<button key={card.title} type="button" className="hero-card" onClick={() => handleStarterPrompt(card.copy)}>
<div>
<span className="hero-card-title">{card.title}</span>
<span className="hero-card-copy">{card.copy}</span>
</div>
<div className="hero-card-preview" aria-hidden="true">
<span />
<span />
<span />
</div>
</button>
))}
<div className="hero-card-pager" aria-hidden="true">
<span className="active" />
<span />
<span />
</div>
)}
</div>
</div>
</section>
</div>
)}
@@ -514,6 +735,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
onResend={msg.role === "assistant" ? handleResend : undefined}
renderMarkdown={renderMarkdown}
/>
))}
@@ -552,8 +774,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
<div className="thinking-manus">
<span className="thinking-dot" aria-hidden="true" />
<span className="thinking-label">Thinking</span>
</div>
)}
</div>
@@ -562,8 +785,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
<div className="thinking-manus">
<span className="thinking-dot" aria-hidden="true" />
<span className="thinking-label">Thinking</span>
</div>
</div>
)}
@@ -574,7 +798,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</div>
{/* ── Message Queue indicator ── */}
{messageQueue.length > 0 && (
{!isInitialScreen && messageQueue.length > 0 && (
<div className="queue-indicator">
<span className="queue-indicator-pulse" />
<span className="queue-indicator-text">
@@ -591,6 +815,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)}
{/* ── Fixed Input ── */}
{!isInitialScreen && (
<div className="input-sticky-wrapper">
<div className="input-gradient-mask" />
<div className="input-content-constrained">
@@ -625,6 +850,15 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
{/* The typing pill */}
<div className="input-box">
<ModelSelectorPill
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
onModelSwap={onModelSwap}
isSwapping={isSwapping}
status={status}
/>
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
@@ -667,7 +901,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
type="button"
aria-label="Send message"
>
Send <ArrowUp size={18} weight="duotone" />
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
@@ -710,6 +944,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
</div>
</div>
</div>
)}
</div>
</div>
);
@@ -0,0 +1,102 @@
import { useState, useRef, useEffect } from "react";
import { ChevronDown, Loader2 } from "lucide-react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
import { getDisplayName, isInChatSelector } from "../lib/modelAlias";
interface ModelSelectorPillProps {
models: ModelInfo[];
localModels: RuntimeModelFile[];
selectedModel: string;
onModelChange: (id: string) => void;
onModelSwap?: (path: string) => void;
isSwapping?: boolean;
status: ServerStatus;
}
export default function ModelSelectorPill({
models,
localModels,
selectedModel,
onModelChange,
onModelSwap,
isSwapping,
status,
}: ModelSelectorPillProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!dropdownOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [dropdownOpen]);
const handleSelectModel = (modelId: string) => {
setDropdownOpen(false);
onModelChange(modelId);
if (onModelSwap) {
onModelSwap(modelId);
}
};
// Unified model list: merge API models + local scanned files
const allAvailableModels = [
...models.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
...localModels.map((m) => ({ id: m.path, label: getDisplayName(m.path) })),
];
// Deduplicate by id
const seen = new Set<string>();
const dedupedModels = allAvailableModels.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
// Chat selector: only Cooper + Iron (Steel is settings-only)
const chatSelectorModels = dedupedModels.filter((m) => isInChatSelector(m.id));
const selectedLabel = getDisplayName(selectedModel) || "No model";
return (
<div className="model-selector-pill-wrapper" ref={dropdownRef}>
<button
type="button"
className="model-selector-pill"
onClick={() => setDropdownOpen(!dropdownOpen)}
disabled={chatSelectorModels.length === 0 || isSwapping}
title={chatSelectorModels.length === 0 ? "Start server to load models" : ""}
aria-label="Choose active model"
aria-expanded={dropdownOpen}
>
<span className={`model-status-dot status-${status}`} aria-hidden="true" />
{isSwapping ? (
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
) : (
<span className="model-selector-label">{selectedLabel}</span>
)}
<ChevronDown size={12} strokeWidth={2} className="model-selector-chevron" />
</button>
{dropdownOpen && (
<ul className="model-selector-menu" role="listbox">
{chatSelectorModels.map((m) => (
<li
key={m.id}
role="option"
aria-selected={m.id === selectedModel}
className={`model-selector-item${m.id === selectedModel ? " model-selector-item--selected" : ""}`}
onClick={() => handleSelectModel(m.id)}
>
{m.label}
</li>
))}
</ul>
)}
</div>
);
}
+230 -32
View File
@@ -1,13 +1,32 @@
.sidebar {
width: 280px;
min-width: 280px;
background: var(--bg2);
border-right: 1px solid var(--border);
width: 276px;
min-width: 276px;
background: #1f1f1f;
border-right: 1px solid rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar--initial .model-section-label,
.sidebar--initial .footer-meta,
.sidebar--initial .footer-version {
display: none;
}
.sidebar--initial .model-section {
padding-top: 8px;
}
.sidebar--initial .model-row .icon-btn {
display: none;
}
.sidebar--initial .model-dropdown-trigger {
background: transparent;
border-color: rgba(255, 255, 255, 0.03);
}
.sidebar-header-actions {
display: flex;
align-items: center;
@@ -21,9 +40,9 @@
/* ── Header ── */
.sidebar-header {
display: flex;
align-items: center;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
align-items: flex-start;
padding: 14px 12px 10px;
border-bottom: 0;
gap: 8px;
flex-shrink: 0;
}
@@ -31,17 +50,85 @@
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.brand-mark {
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.82rem;
font-weight: 700;
flex-shrink: 0;
}
.brand-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.brand-name {
font-size: 14px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.brand-subtitle {
display: none;
}
.sidebar-nav-section {
padding: 2px 8px 10px;
flex-shrink: 0;
}
.sidebar-section-label {
display: none;
}
.sidebar-nav-list {
display: grid;
gap: 4px;
}
.sidebar-nav-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--text2);
border-radius: 10px;
padding: 10px 12px;
font: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.sidebar-nav-item:hover {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.05);
color: var(--text);
}
.sidebar-nav-item svg {
color: var(--text3);
flex-shrink: 0;
}
.icon-btn {
background: none;
border: none;
@@ -59,14 +146,16 @@
}
.icon-btn:hover {
background: var(--bg4);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
/* ── Model section ── */
.model-section {
padding: 10px 16px;
border-bottom: 1px solid var(--border);
margin-top: auto;
padding: 10px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
border-bottom: 0;
flex-shrink: 0;
}
@@ -146,19 +235,112 @@
cursor: not-allowed;
}
/* ── Custom dropdown (replaces native select) ── */
.model-dropdown-wrapper {
flex: 1;
min-width: 0;
position: relative;
}
.model-dropdown-trigger {
width: 100%;
background: rgba(255, 255, 255, 0.025);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
padding: 6px 28px 6px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.model-dropdown-trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
border-color: var(--border);
}
.model-dropdown-trigger:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.05);
}
.model-dropdown-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.model-dropdown-chevron {
flex-shrink: 0;
color: var(--text3);
transition: transform 0.15s;
}
.model-dropdown-trigger[aria-expanded="true"] .model-dropdown-chevron {
transform: rotate(180deg);
}
.model-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
list-style: none;
}
.model-dropdown-item {
padding: 8px 12px;
font-size: 13px;
color: var(--text2);
cursor: pointer;
transition: background 0.1s, color 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-dropdown-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.model-dropdown-item--selected {
background: rgba(72,166,255,0.07);
color: var(--accent);
font-weight: 500;
}
/* ── Sidebar actions ── */
.sidebar-actions {
padding: 10px 16px 6px;
padding: 6px 8px 8px;
flex-shrink: 0;
}
.sidebar-actions--top {
padding-top: 2px;
}
.new-chat-btn {
width: 100%;
background: var(--bg3);
color: var(--text2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
background: rgba(255, 255, 255, 0.05);
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.02);
border-radius: 10px;
padding: 10px 13px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
@@ -166,15 +348,15 @@
align-items: center;
justify-content: flex-start;
gap: 8px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
font-family: inherit;
height: 36px;
height: 40px;
}
.new-chat-btn:hover {
background: var(--bg4);
background: rgba(255, 255, 255, 0.08);
color: var(--text);
border-color: var(--border);
border-color: rgba(255, 255, 255, 0.05);
}
.new-chat-btn:active {
@@ -183,19 +365,19 @@
/* ── Chat list ── */
.chat-list-section-label {
font-size: 11px;
font-size: 10px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 16px 4px;
letter-spacing: 0.12em;
padding: 12px 12px 6px;
flex-shrink: 0;
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 8px;
padding: 4px 8px 10px;
display: flex;
flex-direction: column;
gap: 1px;
@@ -205,7 +387,7 @@
text-align: center;
color: var(--text3);
font-size: 12px;
padding: 24px 0;
padding: 20px 8px 24px;
line-height: 1.6;
}
@@ -218,6 +400,7 @@
color: var(--text2);
transition: background 0.12s, color 0.12s;
position: relative;
border-top: 1px solid rgba(255, 255, 255, 0.02);
}
.chat-item-button {
@@ -225,7 +408,7 @@
min-width: 0;
display: flex;
align-items: center;
padding: 8px 10px;
padding: 10px 10px;
background: transparent;
border: 0;
color: inherit;
@@ -236,17 +419,17 @@
}
.chat-item:hover {
background: var(--bg3);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.chat-item:focus-within {
background: var(--bg3);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.chat-item.active {
background: var(--bg4);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
@@ -293,20 +476,35 @@
/* ── Footer ── */
.sidebar-footer {
padding: 8px 16px;
border-top: 1px solid var(--border);
padding: 8px 12px 14px;
border-top: 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.sidebar--initial .sidebar-footer {
padding-top: 0;
}
.sidebar-footer-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-version {
font-size: 10px;
color: var(--text3);
letter-spacing: 0.02em;
}
.footer-meta {
font-size: 11px;
color: var(--text2);
}
@media (max-width: 960px) {
.sidebar {
position: fixed;
+36 -52
View File
@@ -1,35 +1,41 @@
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
import { Plus, Settings, X, Trash2, RefreshCw } from "lucide-react";
import type { Chat } from "../lib/types";
import { Plus, Settings, X, Trash2, Bot, Search, Library, PanelLeft } from "lucide-react";
import "./Sidebar.css";
interface Props {
chats: Chat[];
currentChatId: number | null;
initialScreen?: boolean;
onSelectChat: (id: number) => void;
onNewChat: () => void;
onDeleteChat: (id: number) => void;
models: ModelInfo[];
selectedModel: string;
onModelChange: (v: string) => void;
status: ServerStatus;
onRefreshModels: () => void;
onOpenSettings: () => void;
onCloseMobile: () => void;
}
export default function Sidebar({
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
models, selectedModel, onModelChange, status, onRefreshModels,
chats, currentChatId, initialScreen = false, onSelectChat, onNewChat, onDeleteChat,
onOpenSettings,
onCloseMobile,
}: Props) {
const workspaceNavItems = [
{ label: "Agents", icon: Bot },
{ label: "Search", icon: Search },
{ label: "Library", icon: Library },
];
return (
<>
<aside className="sidebar">
<aside className={`sidebar${initialScreen ? " sidebar--initial" : ""}`}>
{/* ── Header ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<span className="brand-name">ForgeLM</span>
<div className="brand-mark" aria-hidden="true">
<PanelLeft size={15} strokeWidth={1.8} />
</div>
<div className="brand-copy">
<span className="brand-name">ForgeLM</span>
</div>
</div>
<div className="sidebar-header-actions">
<button
@@ -53,55 +59,30 @@ export default function Sidebar({
</div>
</div>
{/* ── Active model selector ── */}
<div className="model-section">
<div className="model-section-label">Model</div>
<div className="model-row">
<div className={`status-dot status-${status}`} title={status} aria-hidden="true" />
<select
className="model-select"
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
disabled={models.length === 0}
title={models.length === 0 ? "Start server to load models" : ""}
aria-label="Choose active model"
>
{models.length === 0 ? (
<option value="">Start server to load models</option>
) : (
models.map((m) => (
<option key={m.id} value={m.id}>{m.id}</option>
))
)}
</select>
<button
className="icon-btn"
onClick={onRefreshModels}
title="Refresh models"
aria-label="Refresh models"
type="button"
>
<RefreshCw size={14} strokeWidth={1.5} />
</button>
</div>
</div>
{/* ── New chat ── */}
<div className="sidebar-actions">
<div className="sidebar-actions sidebar-actions--top">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<Plus size={16} strokeWidth={1.5} />
<span>New Chat</span>
<span>New task</span>
</button>
</div>
<div className="sidebar-nav-section">
<div className="sidebar-nav-list" aria-label="Workspace navigation">
{workspaceNavItems.map(({ label, icon: Icon }) => (
<button key={label} type="button" className="sidebar-nav-item">
<Icon size={15} strokeWidth={1.8} />
<span>{label}</span>
</button>
))}
</div>
</div>
{/* ── Chat list ── */}
{chats.length > 0 && (
<div className="chat-list-section-label">Conversations</div>
)}
{chats.length > 0 && <div className="chat-list-section-label">Recent work</div>}
<nav className="chat-list" aria-label="Conversation history">
{chats.length === 0 && (
<div className="chat-list-empty">
No conversations yet.<br />Start a new chat above.
No work yet.<br />Start a new task above.
</div>
)}
{chats.map((chat) => (
@@ -132,7 +113,10 @@ export default function Sidebar({
{/* ── Footer ── */}
<div className="sidebar-footer">
<span className="footer-version">ForgeLM v0.1.0</span>
<div className="sidebar-footer-copy">
<span className="footer-version">ForgeLM v0.1.0</span>
<span className="footer-meta">Local-first workspace</span>
</div>
</div>
</aside>