diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index fa915bc..c28406f 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::process::{Child, Command}; use std::sync::Mutex; use tauri::{Manager, State}; @@ -114,6 +115,51 @@ fn server_status(state: State) -> String { } } +/// Scan a directory for .gguf model files. +/// Returns a list of { name, path } objects. +#[tauri::command] +fn scan_models(dir: String) -> Result, String> { + let path = Path::new(&dir); + if !path.exists() { + return Err(format!("Directory not found: {dir}")); + } + if !path.is_dir() { + return Err(format!("Not a directory: {dir}")); + } + + let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?; + let mut models = Vec::new(); + + for entry in entries.flatten() { + let p = entry.path(); + if p.is_file() { + if let Some(ext) = p.extension() { + if ext.eq_ignore_ascii_case("gguf") { + let name = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let full_path = p.to_string_lossy().to_string(); + models.push(ModelFile { + name, + path: full_path, + }); + } + } + } + } + + models.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(models) +} + +#[derive(serde::Serialize, Clone)] +pub struct ModelFile { + pub name: String, + pub path: String, +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -121,6 +167,7 @@ pub fn run() { .manage(ServerState(Mutex::new(None))) .invoke_handler(tauri::generate_handler![ query_gpu, + scan_models, start_server, stop_server, server_status diff --git a/tauri-app/src/components/ServerPanel.css b/tauri-app/src/components/ServerPanel.css index ab81219..5f51e70 100644 --- a/tauri-app/src/components/ServerPanel.css +++ b/tauri-app/src/components/ServerPanel.css @@ -1,30 +1,35 @@ .server-panel { + margin: 8px 10px; + border-radius: var(--radius); + background: var(--bg3); border: 1px solid var(--border); - border-radius: 8px; - margin: 8px 12px; overflow: hidden; - background: var(--surface); + transition: box-shadow 0.2s; } +.server-panel:has(.server-panel-body) { + box-shadow: 0 2px 12px rgba(0,0,0,0.3); +} + +/* ── Header ── */ .server-panel-header { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; + padding: 10px 12px; cursor: pointer; user-select: none; transition: background 0.15s; } .server-panel-header:hover { - background: var(--surface-hover, rgba(255,255,255,0.04)); + background: rgba(255,255,255,0.03); } .server-panel-title { display: flex; align-items: center; gap: 8px; - font-size: 13px; } .server-status-dot { @@ -32,146 +37,216 @@ height: 8px; border-radius: 50%; flex-shrink: 0; - transition: background 0.3s; + transition: background 0.3s, box-shadow 0.3s; } .server-panel-label { - color: var(--text-secondary, #9ca3af); - font-weight: 500; + font-size: 12px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.01em; } -.server-status-text { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; +.server-panel-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.server-status-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + transition: color 0.3s; } .server-panel-chevron { - font-size: 10px; - color: var(--text-secondary, #9ca3af); + font-size: 9px; + color: var(--text3); + transition: transform 0.2s; } +/* ── Body ── */ .server-panel-body { padding: 4px 12px 12px; display: flex; flex-direction: column; gap: 4px; border-top: 1px solid var(--border); + animation: sp-expand 0.15s ease; } -.sp-label { - font-size: 11px; - color: var(--text-secondary, #9ca3af); - margin-top: 6px; -} - -.sp-input { - background: var(--input-bg, #1a1a1e); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text, #e5e7eb); - font-size: 12px; - padding: 6px 8px; - outline: none; - width: 100%; - box-sizing: border-box; - transition: border-color 0.15s; -} - -.sp-input:focus { - border-color: var(--accent, #5b6af0); -} - -.sp-input:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.sp-input-num { - width: 80px; -} - -.sp-actions { - margin-top: 8px; -} - -.sp-btn { - width: 100%; - padding: 8px; - border: none; - border-radius: 6px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: opacity 0.15s, transform 0.1s; -} - -.sp-btn:active { - transform: scale(0.98); -} - -.sp-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.sp-btn-start { - background: var(--accent, #5b6af0); - color: #fff; -} - -.sp-btn-start:hover:not(:disabled) { - opacity: 0.88; -} - -.sp-btn-stop { - background: #ef4444; - color: #fff; -} - -.sp-btn-stop:hover:not(:disabled) { - opacity: 0.88; -} - -.sp-message { - font-size: 11px; - color: #22c55e; - margin-top: 6px; - word-break: break-all; - line-height: 1.4; -} - -.sp-message-error { - color: #ef4444; +@keyframes sp-expand { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } } +/* ── GPU badge ── */ .sp-gpu-badge { + display: flex; + align-items: center; + gap: 6px; font-size: 11px; - color: #22c55e; - background: rgba(34, 197, 94, 0.08); - border: 1px solid rgba(34, 197, 94, 0.2); - border-radius: 5px; - padding: 4px 8px; + font-weight: 500; + color: var(--success); + background: rgba(48, 209, 88, 0.08); + border: 1px solid rgba(48, 209, 88, 0.18); + border-radius: var(--radius-sm); + padding: 5px 8px; margin-top: 6px; } .sp-gpu-cpu { - color: #9ca3af; - background: rgba(156, 163, 175, 0.08); - border-color: rgba(156, 163, 175, 0.2); + color: var(--text2); + background: rgba(255,255,255,0.04); + border-color: var(--border); } .sp-gpu-loading { - color: #f59e0b; - background: rgba(245, 158, 11, 0.08); - border-color: rgba(245, 158, 11, 0.2); + color: var(--warning); + background: rgba(255, 214, 10, 0.07); + border-color: rgba(255, 214, 10, 0.2); +} + +.sp-gpu-icon { + font-size: 12px; + flex-shrink: 0; +} + +.sp-gpu-vram { + margin-left: auto; + font-size: 10px; + font-weight: 700; + color: var(--text2); + background: rgba(255,255,255,0.06); + padding: 1px 6px; + border-radius: 4px; +} + +/* ── Labels ── */ +.sp-label { + font-size: 10px; + font-weight: 700; + color: var(--text3); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 8px; } .sp-label-hint { - color: var(--accent, #5b6af0); - font-weight: 400; + color: var(--accent); + font-weight: 500; + text-transform: none; + letter-spacing: 0; } +/* ── Inputs ── */ +.sp-input { + background: var(--bg4); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 12px; + padding: 7px 9px; + outline: none; + width: 100%; + box-sizing: border-box; + transition: border-color 0.15s, background 0.15s; + font-family: inherit; +} + +.sp-input:focus { + border-color: var(--accent); + background: var(--bg5); +} + +.sp-input:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sp-input-num { + width: 72px; +} + +/* ── Directory row ── */ +.sp-dir-row { + display: flex; + gap: 6px; + align-items: center; +} + +.sp-dir-row .sp-input { + flex: 1; +} + +.sp-scan-btn { + background: var(--bg5); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text2); + font-size: 14px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s, color 0.15s, border-color 0.15s; + font-family: inherit; +} + +.sp-scan-btn:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.sp-scan-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Model select ── */ +.sp-model-select { + background: var(--bg4); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 12px; + padding: 7px 9px; + outline: none; + width: 100%; + box-sizing: border-box; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 9px center; + padding-right: 28px; +} + +.sp-model-select:focus { + border-color: var(--accent); +} + +.sp-model-select:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sp-scan-error { + font-size: 10px; + color: var(--danger); + margin-top: 2px; + line-height: 1.4; +} + +/* ── Layers row ── */ .sp-layers-row { display: flex; align-items: center; @@ -179,45 +254,50 @@ } .sp-btn-max { - background: var(--accent, #5b6af0); - color: #fff; - border: none; - border-radius: 5px; + background: rgba(91,106,240,0.15); + color: var(--accent); + border: 1px solid rgba(91,106,240,0.3); + border-radius: var(--radius-sm); padding: 5px 10px; font-size: 11px; - font-weight: 600; + font-weight: 700; cursor: pointer; - transition: opacity 0.15s; + transition: background 0.15s, border-color 0.15s; + font-family: inherit; + white-space: nowrap; } .sp-btn-max:hover:not(:disabled) { - opacity: 0.85; + background: rgba(91,106,240,0.25); + border-color: var(--accent); } .sp-btn-max:disabled { - opacity: 0.4; + opacity: 0.35; cursor: not-allowed; } .sp-layers-hint { font-size: 11px; - color: #9ca3af; + color: var(--text3); } +/* ── VRAM bar ── */ .sp-vram-bar { position: relative; - height: 18px; - background: rgba(255,255,255,0.06); - border-radius: 4px; + height: 20px; + background: var(--bg4); + border: 1px solid var(--border); + border-radius: var(--radius-sm); overflow: hidden; margin-top: 4px; } .sp-vram-fill { height: 100%; - background: linear-gradient(90deg, var(--accent, #5b6af0), #818cf8); - transition: width 0.3s ease; - border-radius: 4px; + background: linear-gradient(90deg, var(--accent), #818cf8); + transition: width 0.35s ease; + border-radius: var(--radius-sm); } .sp-vram-label { @@ -227,7 +307,84 @@ align-items: center; justify-content: center; font-size: 10px; - color: #e5e7eb; - font-weight: 600; - text-shadow: 0 1px 2px rgba(0,0,0,0.5); + font-weight: 700; + color: #fff; + text-shadow: 0 1px 3px rgba(0,0,0,0.7); + letter-spacing: 0.02em; +} + +/* ── Action buttons ── */ +.sp-actions { + margin-top: 10px; +} + +.sp-btn { + width: 100%; + padding: 9px 12px; + border: none; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + letter-spacing: 0.01em; + font-family: inherit; +} + +.sp-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.sp-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.sp-btn-icon { + font-size: 11px; +} + +.sp-btn-start { + background: linear-gradient(135deg, var(--accent), #7c3aed); + color: #fff; + box-shadow: 0 2px 12px rgba(91,106,240,0.3); +} + +.sp-btn-start:hover:not(:disabled) { + opacity: 0.9; + box-shadow: 0 4px 16px rgba(91,106,240,0.45); +} + +.sp-btn-stop { + background: rgba(240,64,64,0.15); + color: var(--danger); + border: 1px solid rgba(240,64,64,0.3); +} + +.sp-btn-stop:hover:not(:disabled) { + background: rgba(240,64,64,0.25); + border-color: var(--danger); +} + +/* ── Status message ── */ +.sp-message { + font-size: 11px; + color: var(--success); + margin-top: 6px; + word-break: break-all; + line-height: 1.5; + padding: 5px 8px; + background: rgba(48,209,88,0.07); + border-radius: var(--radius-sm); + border: 1px solid rgba(48,209,88,0.15); +} + +.sp-message-error { + color: var(--danger); + background: rgba(240,64,64,0.07); + border-color: rgba(240,64,64,0.2); } diff --git a/tauri-app/src/components/ServerPanel.tsx b/tauri-app/src/components/ServerPanel.tsx index a1b5a41..857c592 100644 --- a/tauri-app/src/components/ServerPanel.tsx +++ b/tauri-app/src/components/ServerPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess"; import "./ServerPanel.css"; @@ -16,6 +16,11 @@ interface GpuInfo { recommended_layers: number; } +interface ModelFile { + name: string; + path: string; +} + const STATUS_LABEL: Record = { stopped: "Stopped", starting: "Starting…", @@ -32,8 +37,15 @@ const STATUS_COLOR: Record = { error: "#ef4444", }; +// Default models directory relative to the repo root +const DEFAULT_MODELS_DIR = "models"; + export default function ServerPanel({ status, message, onStart, onStop }: Props) { - const [modelPath, setModelPath] = useState( + const [modelsDir, setModelsDir] = useState( + () => localStorage.getItem("forgelm_models_dir") || DEFAULT_MODELS_DIR + ); + const [scannedModels, setScannedModels] = useState([]); + const [selectedModel, setSelectedModel] = useState( () => localStorage.getItem("forgelm_model_path") || "" ); const [addr, setAddr] = useState( @@ -44,35 +56,62 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) ); const [gpuInfo, setGpuInfo] = useState(null); const [gpuError, setGpuError] = useState(""); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(true); + const [scanning, setScanning] = useState(false); + const [scanError, setScanError] = useState(""); - // Detect GPU on mount + const scanModels = useCallback(async (dir: string) => { + if (!dir.trim()) return; + setScanning(true); + setScanError(""); + try { + const models = await invoke("scan_models", { dir: dir.trim() }); + setScannedModels(models); + // Auto-select first model if none selected or current not in list + if (models.length > 0) { + const current = localStorage.getItem("forgelm_model_path") || ""; + const stillValid = models.some((m) => m.path === current); + if (!stillValid) { + setSelectedModel(models[0].path); + } + } + } catch (e) { + setScanError(String(e)); + setScannedModels([]); + } finally { + setScanning(false); + } + }, []); + + // Detect GPU and scan models on mount useEffect(() => { invoke("query_gpu") .then((info) => { setGpuInfo(info); - // Only auto-set if user hasn't saved a preference yet if (!localStorage.getItem("forgelm_gpu_layers")) { setGpuLayers(info.recommended_layers); } }) .catch((e) => { setGpuError(String(e)); - // Default to CPU-only if no GPU if (!localStorage.getItem("forgelm_gpu_layers")) { setGpuLayers(0); } }); - }, []); + + scanModels(modelsDir); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const isActive = status === "running" || status === "starting" || status === "stopping"; const maxLayers = gpuInfo ? gpuInfo.recommended_layers : 999; + const canStart = !!selectedModel.trim() && !isActive; const handleStart = () => { - localStorage.setItem("forgelm_model_path", modelPath); + localStorage.setItem("forgelm_model_path", selectedModel); localStorage.setItem("forgelm_server_addr", addr); localStorage.setItem("forgelm_gpu_layers", String(gpuLayers)); - onStart({ modelPath, addr, gpuLayers }); + localStorage.setItem("forgelm_models_dir", modelsDir); + onStart({ modelPath: selectedModel, addr, gpuLayers }); }; const handleLayersChange = (val: number) => { @@ -80,48 +119,108 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) setGpuLayers(clamped); }; + const handleScanDir = () => { + localStorage.setItem("forgelm_models_dir", modelsDir); + scanModels(modelsDir); + }; + + const vramPct = gpuInfo && gpuLayers > 0 + ? Math.min(100, (gpuLayers / gpuInfo.recommended_layers) * 100) + : 0; + return (
+ {/* Header */}
setExpanded(!expanded)}>
- + Local Server - +
+
+ {STATUS_LABEL[status]} + {expanded ? "▲" : "▼"}
- {expanded ? "▲" : "▼"}
{expanded && (
- {/* GPU info badge */} + {/* GPU badge */} {gpuInfo ? (
- 🎮 {gpuInfo.name} — {(gpuInfo.vram_mib / 1024).toFixed(1)} GB VRAM + ⚡ + {gpuInfo.name} + {(gpuInfo.vram_mib / 1024).toFixed(1)} GB
) : gpuError ? (
- 💻 CPU only — {gpuError.includes("not found") ? "No NVIDIA GPU" : gpuError} + 🖥 + CPU only
) : ( -
🔍 Detecting GPU…
+
+ ⟳ + Detecting GPU… +
)} - - setModelPath(e.target.value)} - placeholder="models/your-model.gguf" - disabled={isActive} - /> + {/* Models directory */} + +
+ setModelsDir(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleScanDir()} + placeholder="models/" + disabled={isActive} + /> + +
- + {/* Model selector */} + + {scannedModels.length > 0 ? ( + + ) : ( + setSelectedModel(e.target.value)} + placeholder="path/to/model.gguf" + disabled={isActive} + /> + )} + {scanError &&
{scanError}
} + + {/* Address */} + + {/* GPU Layers */}
@@ -157,31 +257,29 @@ export default function ServerPanel({ status, message, onStart, onStop }: Props) Max )} - {gpuLayers === 0 && ( - CPU only - )} + {gpuLayers === 0 && CPU only}
+ {/* VRAM bar */} {gpuInfo && gpuLayers > 0 && (
-
+
~{Math.round(gpuLayers * 80)} MB / {gpuInfo.vram_mib} MB VRAM
)} + {/* Action button */}
{status === "stopped" || status === "error" ? ( ) : ( )}
diff --git a/tauri-app/src/components/Sidebar.css b/tauri-app/src/components/Sidebar.css index 88f1bba..e95a33e 100644 --- a/tauri-app/src/components/Sidebar.css +++ b/tauri-app/src/components/Sidebar.css @@ -1,6 +1,6 @@ .sidebar { - width: 260px; - min-width: 260px; + width: 280px; + min-width: 280px; background: var(--bg2); border-right: 1px solid var(--border); display: flex; @@ -8,54 +8,93 @@ overflow: hidden; } -/* Header */ +/* ── Header ── */ .sidebar-header { display: flex; align-items: center; padding: 14px 14px 12px; border-bottom: 1px solid var(--border); gap: 8px; + flex-shrink: 0; } .sidebar-brand { display: flex; align-items: center; - gap: 8px; + gap: 9px; flex: 1; + min-width: 0; } -.brand-icon { font-size: 1.1rem; } -.brand-name { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; } +.brand-icon { + width: 28px; + height: 28px; + background: linear-gradient(135deg, var(--accent), #7c3aed); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(91,106,240,0.35); +} + +.brand-name { + font-size: 15px; + font-weight: 800; + letter-spacing: -0.02em; + background: linear-gradient(135deg, #e8e8ed, #9ca3af); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} .icon-btn { background: none; border: none; - color: var(--text2); + color: var(--text3); cursor: pointer; - font-size: 1rem; - padding: 4px 6px; + font-size: 15px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; border-radius: var(--radius-sm); transition: background 0.15s, color 0.15s; + flex-shrink: 0; line-height: 1; } -.icon-btn:hover { background: var(--bg4); color: var(--text); } -/* Settings */ +.icon-btn:hover { + background: var(--bg4); + color: var(--text); +} + +/* ── Settings panel ── */ .settings-panel { - padding: 12px 14px; + padding: 10px 12px 12px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; - gap: 6px; + gap: 5px; background: var(--bg3); + animation: slide-down 0.15s ease; + flex-shrink: 0; +} + +@keyframes slide-down { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } } .settings-label { - font-size: 0.7rem; - font-weight: 600; + font-size: 10px; + font-weight: 700; color: var(--text3); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; + margin-top: 4px; } .settings-input { @@ -63,24 +102,40 @@ border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); - font-size: 0.8rem; - padding: 6px 10px; + font-size: 12px; + padding: 7px 10px; outline: none; - transition: border-color 0.15s; + transition: border-color 0.15s, background 0.15s; font-family: inherit; + width: 100%; + box-sizing: border-box; } -.settings-input:focus { border-color: var(--accent); } -/* Model section */ +.settings-input:focus { + border-color: var(--accent); + background: var(--bg5); +} + +/* ── Model section ── */ .model-section { - padding: 10px 14px; + padding: 8px 12px; border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.model-section-label { + font-size: 10px; + font-weight: 700; + color: var(--text3); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; } .model-row { display: flex; align-items: center; - gap: 8px; + gap: 7px; } .status-dot { @@ -88,13 +143,27 @@ height: 7px; border-radius: 50%; flex-shrink: 0; - transition: background 0.3s; + transition: background 0.3s, box-shadow 0.3s; } -.status-dot.status-ok { background: var(--success); box-shadow: 0 0 6px var(--success); } -.status-dot.status-error { background: var(--danger); } -.status-dot.status-loading { background: var(--warning); animation: pulse 1s infinite; } -@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.status-dot.status-ok { + background: var(--success); + box-shadow: 0 0 6px rgba(48,209,88,0.5); +} + +.status-dot.status-error { + background: var(--danger); +} + +.status-dot.status-loading { + background: var(--warning); + animation: pulse 1.2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} .model-select { flex: 1; @@ -102,45 +171,73 @@ border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); - font-size: 0.78rem; - padding: 5px 8px; + font-size: 12px; + padding: 6px 28px 6px 9px; outline: none; cursor: pointer; min-width: 0; font-family: inherit; + transition: border-color 0.15s; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; } -.model-select:focus { border-color: var(--accent); } -/* Actions */ +.model-select:focus { + border-color: var(--accent); +} + +/* ── Sidebar actions ── */ .sidebar-actions { - padding: 10px 14px 6px; + padding: 10px 12px 6px; + flex-shrink: 0; } .new-chat-btn { width: 100%; - background: var(--accent); + background: linear-gradient(135deg, var(--accent), #7c3aed); color: #fff; border: none; border-radius: var(--radius-sm); - padding: 8px 12px; - font-size: 0.82rem; - font-weight: 600; + padding: 9px 12px; + font-size: 13px; + font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; - gap: 6px; - transition: background 0.15s, transform 0.1s; + gap: 7px; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; font-family: inherit; + letter-spacing: 0.01em; + box-shadow: 0 2px 10px rgba(91,106,240,0.25); +} + +.new-chat-btn:hover { + opacity: 0.9; + box-shadow: 0 4px 16px rgba(91,106,240,0.4); +} + +.new-chat-btn:active { + transform: scale(0.98); +} + +/* ── Chat list ── */ +.chat-list-section-label { + font-size: 10px; + font-weight: 700; + color: var(--text3); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 8px 12px 4px; + flex-shrink: 0; } -.new-chat-btn:hover { background: var(--accent2); } -.new-chat-btn:active { transform: scale(0.98); } -/* Chat list */ .chat-list { flex: 1; overflow-y: auto; - padding: 6px 8px; + padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 1px; @@ -149,8 +246,9 @@ .chat-list-empty { text-align: center; color: var(--text3); - font-size: 0.8rem; - padding: 20px 0; + font-size: 12px; + padding: 24px 0; + line-height: 1.6; } .chat-item { @@ -160,46 +258,81 @@ padding: 8px 10px; border-radius: var(--radius-sm); cursor: pointer; - font-size: 0.82rem; + font-size: 13px; color: var(--text2); - transition: background 0.1s, color 0.1s; + transition: background 0.12s, color 0.12s; position: relative; + border-left: 2px solid transparent; } -.chat-item:hover { background: var(--bg3); color: var(--text); } -.chat-item.active { background: var(--bg4); color: var(--text); } -.chat-item-icon { font-size: 0.85rem; flex-shrink: 0; opacity: 0.6; } +.chat-item:hover { + background: var(--bg3); + color: var(--text); +} + +.chat-item.active { + background: rgba(91,106,240,0.1); + color: var(--text); + border-left-color: var(--accent); +} + +.chat-item-icon { + font-size: 13px; + flex-shrink: 0; + opacity: 0.5; +} + +.chat-item.active .chat-item-icon { + opacity: 0.8; +} .chat-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 13px; } .chat-item-del { opacity: 0; background: none; border: none; - color: var(--danger); + color: var(--text3); cursor: pointer; - font-size: 0.75rem; - padding: 2px 5px; + font-size: 12px; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; border-radius: 4px; - line-height: 1; - transition: opacity 0.15s, background 0.15s; + transition: opacity 0.15s, background 0.15s, color 0.15s; flex-shrink: 0; + line-height: 1; } -.chat-item:hover .chat-item-del { opacity: 1; } -.chat-item-del:hover { background: rgba(240,64,64,0.15); } -/* Footer */ +.chat-item:hover .chat-item-del { + opacity: 1; +} + +.chat-item-del:hover { + background: rgba(240,64,64,0.15); + color: var(--danger); +} + +/* ── Footer ── */ .sidebar-footer { - padding: 10px 14px; + padding: 8px 14px; border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; } .footer-version { - font-size: 0.7rem; + font-size: 10px; color: var(--text3); + letter-spacing: 0.02em; } diff --git a/tauri-app/src/components/Sidebar.tsx b/tauri-app/src/components/Sidebar.tsx index b5f564d..fc471a1 100644 --- a/tauri-app/src/components/Sidebar.tsx +++ b/tauri-app/src/components/Sidebar.tsx @@ -37,26 +37,22 @@ export default function Sidebar({ return ( );