diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..021c20a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,148 @@ +# Changelog + +All notable changes to enowX Coder are documented here. + +--- + +## [0.2.5] — 2026-04-23 + +### Excalidraw Canvas — Collaborative Whiteboard +- **New feature**: Full Excalidraw whiteboard integrated as a Canvas tab — switch between Chat and Canvas via segmented control in header +- Drawings persist per project in SQLite — auto-save every 1 second, auto-load on project switch +- Dark/light theme syncs with app theme toggle +- Full Excalidraw toolbar: shapes, arrows, text, freehand, colors, layers, export to disk + +### AI Canvas — Generate Diagrams with AI +- **New feature**: AI prompt bar floating at bottom of canvas — describe what to draw, AI generates Excalidraw elements +- AI generates native Excalidraw JSON (rectangles, ellipses, diamonds, text, arrows with bindings) +- **Context-aware editing**: Existing canvas elements are sent to AI as context — ask "change color of 2021 to yellow" and AI modifies only that element while preserving everything else +- Provider and model selector in canvas prompt bar (shared with chat) +- Merge behavior: AI returns full element set for edits, appends for new drawings + +### Conversation Memory — Smart Context Management +- **New feature**: AI now remembers previous messages in the same chat session +- Loads user messages + agent outputs from DB, interleaved chronologically +- **Sliding window**: Max 20 recent message pairs to prevent token overflow +- **Strip html:preview blocks**: Widget HTML (5-10KB each) compressed to ~300-500 byte summaries preserving chart data, labels, colors, metrics +- **Token budget**: History capped at ~32K chars (~8K tokens), prioritizing recent messages +- **Smart truncation**: User messages max 500 chars, assistant max 1500 chars, cut at word boundary +- `summarize_html_widget()` extracts Chart.js datasets, labels, colors, metric values from HTML for targeted edits + +### Provider System Overhaul +- **Enable/disable toggle**: Per-provider on/off switch in Settings detail panel — disabled providers hidden from chat dropdown +- **Add Provider flow**: "Custom" replaced with "+ Add Provider" button — create unlimited custom providers with name, base URL, API key, model +- User-created providers appear in sidebar list with enable/disable dot indicator +- Delete provider resets selection to enowX Labs +- `is_enabled` column added to providers table (migration) +- **Manual model add**: Input field in Available Models section — type model name, click Add, auto-enabled +- Manual models persist in DB and appear in list even after Settings reopen (merged with API-fetched models) + +### Token Optimization +- **MAX_REACT_ITERATIONS reduced**: 20 → 10 — prevents excessive API calls per request while still allowing complex tasks +- **Auto-select provider + model on app start**: If only 1 provider enabled, auto-selected as default. First available model auto-selected. No manual selection needed. +- Provider dropdown shows as label when only 1 provider (no unnecessary dropdown) + +### Dark Mode — Warm Claude Palette +- Replaced cold pure black (#0a0a0a) with warm Claude-inspired tones +- Background: #1a1816 (warm dark brown), Surface: #201e1b, Text: #e8e4de (warm off-white) +- Accent color: #D4845A (terracotta/copper) matching light mode +- Borders, links, focus rings all use warm tones +- Danger colors updated to Claude red palette (#E24B4A) + +### Markdown Table Fix +- `fixMarkdownTables()` rewritten — fixes malformed tables from streaming +- Auto-corrects separator row column count to match header +- Splits crammed rows (multiple rows on one line) +- Injects missing separator rows without duplicating between data rows +- Applied to both agent streaming output and final chat messages + +### Scroll Bounce Fix +- Removed gradient overlay conditional mount/unmount (caused layout shift loop) +- Added `isAutoScrolling` guard — scroll events from auto-scroll ignored +- Throttled streaming scroll to 80ms intervals +- `requestAnimationFrame` for scroll-to-bottom to avoid layout thrashing + +### UI Polish +- Left sidebar font sizes increased: project names, session titles, labels from 12px to 13px +- Model dropdown: `max-height: 360px` with scroll for long model lists +- Footer bar removed for cleaner layout +- Right sidebar toggle button in header (hidden when sidebar open) +- enowX Flux tooltip on hover with description and token usage info + +--- + +## [0.2.0] — 2026-04-22 + +### enowX Flux — Generative UI System +- **New feature**: enowX Flux — AI generates interactive charts, diagrams, SVG illustrations, dashboards, and widgets inline in chat +- Integrated Claude.ai's reverse-engineered design system (CSS variables, SVG color ramps, pre-styled form elements, light/dark mode) +- `html:preview` code blocks render as live interactive iframes with full JS/CSS support +- Chart.js, D3, Canvas, and CDN libraries supported inside previews +- Debounced iframe rendering during streaming (400ms) to prevent flicker +- Auto-resize iframe to match content height via direct DOM measurement + ResizeObserver +- Re-measure on window resize for responsive behavior +- Expand preview to fullscreen modal with source code view and copy button +- Toggle button in header to enable/disable Flux (saves ~3K tokens per request when off) +- Custom tooltip on hover explaining what enowX Flux does +- Enhanced `PREVIEW_GUIDE` with full Claude guidelines: diagram types, illustrative patterns, interactive examples, UI components, art/illustration + +### Welcome Screen +- Welcome screen with greeting + quick action chips (Write, Learn, Code, Personal, Brainstorm) +- Chips prefill prompt in input bar on click +- Auto-create project + session when user sends first message (no need to open folder first) +- Input bar always enabled — no more "Open a folder to start chatting" + +### Chat Improvements +- **Streaming output**: Agent responses now stream token-by-token with blinking cursor, instead of appearing all at once +- **Auto-rename chat**: After first AI response, LLM generates a short 2-5 word title (e.g. "Greeting", "React Auth Setup") +- **Copy response**: Copy button below each AI response with model name and generation duration +- **Agent info relocated**: Model name and duration moved to bottom of response, agent type stays at top +- **Markdown table fix**: `fixMarkdownTables()` utility fixes malformed tables from streaming — auto-corrects separator column count, splits crammed rows, injects missing separators +- **Scroll bounce fix**: Removed gradient overlay mount/unmount loop, added `isAutoScrolling` guard, throttled streaming scroll (80ms) + +### Sidebar +- **Left sidebar toggle**: Click logo icon to collapse/expand with smooth CSS grid animation (0.25s cubic-bezier) +- **Right sidebar toggle**: Toggle button inside sidebar header + in chat header when closed +- **Sidebar font size**: Increased project names, session titles, and labels from 12px to 13px for readability +- **Footer removed**: Cleaner layout, sidebar toggles moved to header + +### Settings & Providers +- **Custom provider fix**: Added "Default Model" input field for Custom/Ollama providers — fixes silent save failure +- **Error feedback**: Save errors now shown as red inline messages instead of silent `console.error` +- **Base URL save**: Can now save base URL even when provider doesn't exist yet (auto-creates) +- **Model dropdown scroll**: Fixed dropdown clipping — added `max-height: 360px` with scrollbar for long model lists + +### Header & Navigation +- **Rename chat**: Click pencil icon → inline edit with Enter/Escape +- **Three-dot menu**: New Chat + Delete Chat options +- **Delete confirmation**: Custom dark-themed confirm dialog (replaced broken native `window.confirm`) +- **Theme toggle**: Dark/light mode switch in header +- **enowX Flux toggle**: ON/OFF pill button with status tooltip + +### Theme & Design +- **Dark mode warm palette**: Replaced cold pure black (#0a0a0a) with warm Claude-inspired tones (#1a1816 bg, #e8e4de text, #D4845A accent) +- **Light/dark preview sync**: iframe previews now use class-based theme switching (`.light`/`.dark`) instead of `prefers-color-scheme` media query — syncs with app toggle +- **Tauri dialog permissions**: Upgraded from `dialog:allow-open` to `dialog:default` for full dialog support + +### Bug Fixes +- **Agent request fix**: Fixed parameter mismatch — frontend now wraps `run_agent` params in `request` object matching Rust `RunAgentRequest` struct +- **API key pre-flight**: Added validation check before API calls — clear error message instead of cryptic 401 +- **First message missing**: Fixed race condition where `setActiveSessionId` triggered DB reload that wiped optimistic user message +- **Clippy warnings**: Fixed all Rust clippy warnings — dead code removal, parameter struct refactoring, simplified patterns + +### Infrastructure +- **Tauri capabilities**: Added `dialog:default` permission for ask/confirm/message dialogs +- **Generate title command**: New `generate_title` Tauri command — LLM generates chat title via non-streaming API call + +--- + +## [0.1.0] — Initial Release + +- Tauri desktop app with Rust backend + React/TypeScript frontend +- Multi-agent system: Orchestrator, Planner, Coder FE/BE, Security, UX, UI, Tester, Reviewer, Researcher, Librarian +- Provider support: enowX Labs, OpenAI, Anthropic, Gemini, Ollama, Custom (OpenAI-compatible) +- Chat with streaming responses +- Tool execution: read_file, write_file, list_dir, search_files, run_command, web_search +- Settings modal with provider configuration and model management +- Project and session management +- Agent configuration per agent type diff --git a/README.md b/README.md index 102e366..73e083a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,310 @@ -# Tauri + React + Typescript +
-This template should help get you started developing with Tauri, React and Typescript in Vite. +# 🚀 enowX Coder -## Recommended IDE Setup +**Agentic Coding Tools - AI-Powered Development Assistant** -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![Tauri](https://img.shields.io/badge/Tauri-2.0-24C8DB?logo=tauri)](https://tauri.app) +[![React](https://img.shields.io/badge/React-19.1-61DAFB?logo=react)](https://react.dev) +[![Rust](https://img.shields.io/badge/Rust-1.75+-orange?logo=rust)](https://www.rust-lang.org) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.8-3178C6?logo=typescript)](https://www.typescriptlang.org) + +*A powerful desktop application that combines AI agents with an intuitive interface for enhanced coding workflows* + +[Features](#-features) • [Installation](#-installation) • [Usage](#-usage) • [Architecture](#-architecture) • [Contributing](#-contributing) + +
+ +--- + +## ✨ Features + +### 🤖 AI-Powered Chat Interface +- **Multi-Provider Support**: Connect to enowX Labs, OpenAI, Anthropic, Google, or custom providers +- **Conversation Memory**: Smart context management with sliding window (up to 20 message pairs) +- **Agent Execution Timeline**: Visual representation of AI thinking process, tool usage, and results +- **Markdown Rendering**: Full GitHub-flavored markdown with syntax highlighting +- **Code Block Actions**: Copy, execute, or preview code directly from chat + +### 🎨 Excalidraw Canvas Integration +- **Collaborative Whiteboard**: Full-featured drawing canvas with shapes, arrows, text, and freehand tools +- **AI-Generated Diagrams**: Describe what you want to draw, AI generates native Excalidraw elements +- **Context-Aware Editing**: AI understands existing canvas elements for precise modifications +- **Auto-Save**: Drawings persist per project with 1-second auto-save +- **Theme Sync**: Dark/light mode automatically syncs with app theme + +### 🛠️ Advanced Agent System +- **Multiple Specialized Agents**: Orchestrator, Planner, Coder (FE/BE), Tester, Reviewer, Security, and more +- **Tool Execution**: File operations, shell commands, web scraping, and custom tools +- **Permission System**: User approval required for sensitive operations +- **Streaming Responses**: Real-time token streaming for immediate feedback + +### 🎯 Project Management +- **Multi-Project Support**: Switch between projects seamlessly +- **Session History**: Persistent chat sessions per project +- **SQLite Backend**: Fast, reliable local storage for all data + +### 🌓 Beautiful UI/UX +- **Warm Dark Mode**: Claude-inspired color palette with terracotta accents +- **Responsive Layout**: Three-panel layout with collapsible sidebars +- **Smooth Animations**: Polished transitions and micro-interactions +- **Accessibility**: Keyboard shortcuts and screen reader support + +--- + +## 📸 Screenshots + +### enowX Flux - AI Chat Interface +*Powerful AI-powered conversations with enowX Flux integration and warm Claude-inspired dark theme* + +![enowX Flux](screenshots/enowX%20Flux.png) + +### Chat Interface - Light Mode +*Clean and modern light theme for comfortable daytime coding* + +![Light Mode](screenshots/Light%20Moded.png) + +### Excalidraw Canvas +*Collaborative whiteboard with AI diagram generation* + +![Canvas](screenshots/Excalidraw.png) + +### Provider Settings +*Manage multiple AI providers and models with enable/disable toggles* + +![Settings](screenshots/Providers.png) + +--- + +## 🚀 Installation + +### Prerequisites +- **Node.js** 18+ or **Bun** 1.0+ +- **Rust** 1.75+ (for Tauri) +- **Git** + +### Quick Start + +```bash +# Clone the repository +git clone https://github.com/mhmmadazis/enowX-Coder.git +cd enowX-Coder + +# Install dependencies +npm install +# or +bun install + +# Run in development mode +npm run tauri dev +# or +bun run tauri dev +``` + +### Build for Production + +```bash +# Build the application +npm run tauri build +# or +bun run tauri build +``` + +The compiled application will be available in `src-tauri/target/release/bundle/`. + +--- + +## 🎯 Usage + +### Getting Started + +1. **Launch the Application** + - Run `npm run tauri dev` or open the built executable + +2. **Configure AI Provider** + - Click the settings icon (⚙️) in the top-right corner + - Navigate to "Providers" tab + - Add your API key for enowX Labs, OpenAI, Anthropic, or custom provider + - Enable the provider and select a model + +3. **Create a Project** + - Click "New Project" in the left sidebar + - Enter project name and optional path + - Start chatting with AI agents + +### Chat Commands + +- **Ask Questions**: Type naturally, AI understands context +- **Request Code**: "Create a React component for user authentication" +- **Execute Tools**: AI can read/write files, run shell commands (with permission) +- **Generate Diagrams**: Switch to Canvas tab and describe what to draw + +### Canvas Mode + +1. **Switch to Canvas**: Click "Canvas" in the header segmented control +2. **Draw Manually**: Use Excalidraw toolbar for shapes, arrows, text +3. **AI Generation**: Type prompt in bottom bar (e.g., "Draw a system architecture diagram") +4. **Edit with AI**: "Change the color of Database to blue" - AI modifies only that element + +### Keyboard Shortcuts + +- `Ctrl/Cmd + N` - New chat session +- `Ctrl/Cmd + ,` - Open settings +- `Ctrl/Cmd + B` - Toggle left sidebar +- `Ctrl/Cmd + Shift + B` - Toggle right sidebar +- `Esc` - Close dialogs/modals + +--- + +## 🏗️ Architecture + +### Tech Stack + +**Frontend** +- **React 19** - UI framework with concurrent features +- **TypeScript** - Type-safe development +- **Tailwind CSS 4** - Utility-first styling +- **Zustand** - Lightweight state management +- **Excalidraw** - Canvas whiteboard library + +**Backend** +- **Rust** - High-performance, memory-safe backend +- **Tauri 2** - Cross-platform desktop framework +- **SQLite** - Embedded database via rusqlite +- **Tokio** - Async runtime for concurrent operations + +### Project Structure + +``` +enowX-Coder/ +├── src/ # React frontend +│ ├── components/ # UI components +│ │ ├── chat/ # Chat interface components +│ │ ├── canvas/ # Excalidraw canvas +│ │ ├── layout/ # App shell and layout +│ │ ├── settings/ # Settings panels +│ │ └── ui/ # Reusable UI primitives +│ ├── stores/ # Zustand state stores +│ ├── types/ # TypeScript type definitions +│ └── lib/ # Utility functions +│ +├── src-tauri/ # Rust backend +│ ├── src/ +│ │ ├── agents/ # AI agent implementations +│ │ ├── commands/ # Tauri commands (IPC) +│ │ ├── services/ # Business logic layer +│ │ ├── models/ # Data models +│ │ ├── tools/ # Agent tool executors +│ │ └── state.rs # Application state +│ ├── migrations/ # SQLite schema migrations +│ └── Cargo.toml # Rust dependencies +│ +├── public/ # Static assets +├── screenshots/ # Application screenshots +└── CHANGELOG.md # Version history +``` + +### Key Components + +**Agent System** (`src-tauri/src/agents/`) +- `runner.rs` - Main agent execution loop with ReAct pattern +- `prompts/` - Specialized agent prompts (Orchestrator, Coder, Tester, etc.) + +**Services** (`src-tauri/src/services/`) +- `chat_service.rs` - Message handling, streaming, conversation memory +- `provider_service.rs` - AI provider management and API calls +- `drawing_service.rs` - Canvas persistence and AI diagram generation +- `agent_service.rs` - Agent configuration and execution + +**State Management** +- Frontend: Zustand stores for UI state, chat, projects, settings +- Backend: Tauri managed state with Arc> for thread-safe access + +--- + +## 🔧 Configuration + +### Environment Variables + +Create a `.env` file in the project root (optional): + +```env +# Default AI Provider +DEFAULT_PROVIDER=enowx-labs + +# API Keys (can also be set in UI) +ENOWX_API_KEY=your_key_here +OPENAI_API_KEY=your_key_here +ANTHROPIC_API_KEY=your_key_here +``` + +### Custom Providers + +Add custom AI providers via Settings UI: +1. Click "+ Add Provider" +2. Enter provider name, base URL, and API key +3. Add models manually or fetch from API +4. Enable provider and select model + +--- + +## 🤝 Contributing + +Contributions are welcome! Please follow these guidelines: + +1. **Fork the repository** +2. **Create a feature branch**: `git checkout -b feature/amazing-feature` +3. **Commit your changes**: `git commit -m 'feat: add amazing feature'` +4. **Push to the branch**: `git push origin feature/amazing-feature` +5. **Open a Pull Request** + +### Commit Convention + +Follow [Conventional Commits](https://www.conventionalcommits.org/): +- `feat:` - New feature +- `fix:` - Bug fix +- `refactor:` - Code refactoring +- `docs:` - Documentation changes +- `chore:` - Maintenance tasks +- `test:` - Test additions/changes + +### Development Guidelines + +- Run `cargo clippy` before committing Rust code +- Run `npm run build` to check TypeScript compilation +- Test on multiple platforms (Windows, macOS, Linux) +- Update CHANGELOG.md for notable changes + +--- + +## 📝 License + +This project is licensed under the **Apache License 2.0** - see the [LICENSE](LICENSE) file for details. + +--- + +## 🙏 Acknowledgments + +- **Tauri Team** - For the amazing cross-platform framework +- **Excalidraw** - For the collaborative whiteboard library +- **Anthropic** - For Claude AI inspiration and design patterns +- **enowX Labs** - For AI infrastructure and model access + +--- + +## 📞 Support + +- **Issues**: [GitHub Issues](https://github.com/mhmmadazis/enowX-Coder/issues) +- **Discussions**: [GitHub Discussions](https://github.com/mhmmadazis/enowX-Coder/discussions) +- **Email**: support@enowx.dev + +--- + +
+ +**Built with ❤️ by the enowX Community** + +⭐ Star this repo if you find it useful! + +
diff --git a/package.json b/package.json index fc7a71c..72d018c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "tauri": "tauri" }, "dependencies": { + "@excalidraw/excalidraw": "^0.18.1", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", diff --git a/screenshots/.gitkeep b/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/screenshots/Excalidraw.png b/screenshots/Excalidraw.png new file mode 100644 index 0000000..703fdc2 Binary files /dev/null and b/screenshots/Excalidraw.png differ diff --git a/screenshots/Light Moded.png b/screenshots/Light Moded.png new file mode 100644 index 0000000..ab1350a Binary files /dev/null and b/screenshots/Light Moded.png differ diff --git a/screenshots/Providers.png b/screenshots/Providers.png new file mode 100644 index 0000000..a2b83a3 Binary files /dev/null and b/screenshots/Providers.png differ diff --git a/screenshots/enowX Flux.png b/screenshots/enowX Flux.png new file mode 100644 index 0000000..3fbba60 Binary files /dev/null and b/screenshots/enowX Flux.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f41d07d..859be03 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -982,6 +982,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-util", "urlencoding", "uuid", "walkdir", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 33cf77a..d7c3ab4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } futures-util = "0.3" +tokio-util = "0.7" tokio-stream = "0.1" anyhow = "1" log = "0.4" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 62f67cf..778bfb5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,6 @@ "permissions": [ "core:default", "opener:default", - "dialog:allow-open" + "dialog:default" ] } diff --git a/src-tauri/migrations/20260422000_provider_enabled.sql b/src-tauri/migrations/20260422000_provider_enabled.sql new file mode 100644 index 0000000..a66cf32 --- /dev/null +++ b/src-tauri/migrations/20260422000_provider_enabled.sql @@ -0,0 +1,2 @@ +-- Add is_enabled flag to providers (default true) +ALTER TABLE providers ADD COLUMN is_enabled INTEGER NOT NULL DEFAULT 1; diff --git a/src-tauri/migrations/20260422001_drawings.sql b/src-tauri/migrations/20260422001_drawings.sql new file mode 100644 index 0000000..11c1a52 --- /dev/null +++ b/src-tauri/migrations/20260422001_drawings.sql @@ -0,0 +1,11 @@ +-- Drawings table: one drawing per project, stores Excalidraw JSON +CREATE TABLE IF NOT EXISTS drawings ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id); diff --git a/src-tauri/src/agents/runner.rs b/src-tauri/src/agents/runner.rs index 9f72c85..ff92ba2 100644 --- a/src-tauri/src/agents/runner.rs +++ b/src-tauri/src/agents/runner.rs @@ -1,6 +1,8 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::time::Duration; +use futures_util::stream::FuturesUnordered; use futures_util::StreamExt; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; use serde::Serialize; @@ -8,23 +10,234 @@ use serde_json::{json, Value}; use sqlx::SqlitePool; use tauri::ipc::Channel; use tauri::{AppHandle, Emitter}; -use tokio::task::JoinSet; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::agents::prompts::get_prompt; use crate::error::{AppError, AppResult}; use crate::services::{now_rfc3339, provider_service}; +use crate::state::PermissionState; use crate::tools::{ToolCall, ToolExecutor, ToolName}; -const MAX_REACT_ITERATIONS: usize = 20; +const MAX_REACT_ITERATIONS: usize = 10; const SYNTHESIS_REACT_ITERATIONS: usize = 8; const LANGUAGE_GUARD: &str = "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to."; +const PREVIEW_GUIDE: &str = r#"INTERACTIVE PREVIEW — VISUAL CREATION SYSTEM + +When the user asks for a visualization, diagram, chart, interactive demo, UI mockup, or any visual/interactive HTML content, output it directly in your response as a fenced code block with the language tag `html:preview`. Do NOT use write_file — the app renders it as a live interactive preview inline. Only use write_file when the user explicitly asks to save a file on disk. + +Format: ```html:preview followed by a COMPLETE standalone HTML document (DOCTYPE, html, head, body). + +The preview iframe has a full design system pre-loaded: CSS variables, SVG color ramp classes, pre-styled form elements, and light/dark mode support. Your HTML inherits all of this automatically. + +## Core Design Philosophy +- **Seamless**: The widget should feel like a natural extension of the chat, not an embed from somewhere else. +- **Flat**: No gradients, mesh backgrounds, noise textures, drop shadows, blur, glow, or neon effects. Clean flat surfaces only. +- **Transparent background**: Do NOT set background color on `` or outer containers. The host app provides the background. Only set background on inner cards/surfaces using `var(--color-background-secondary)`. +- **Compact**: Show the essential visual inline. All explanatory text goes in your response text OUTSIDE the code block. +- **Text goes in your response, visuals go in the widget** — All explanatory text, descriptions, introductions, and summaries must be written as normal response text OUTSIDE the code block. The widget should contain ONLY the visual element. Never put paragraphs of explanation inside the HTML. +- **Streaming-safe**: `${bridge}`; + + if (/]/i.test(cleaned)) { + let result = cleaned.replace(/]*)>/i, ``); + if (/]/i.test(result)) { + return result.replace(/]*)>/i, `${headContent}`); + } + return result.replace(/]*)>/i, `${headContent}`); + } + + return `${headContent}${cleaned}`; +} + +export const HtmlPreview: React.FC = ({ code, title, onSendPrompt }) => { + const iframeRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [showSource, setShowSource] = useState(false); + const [copied, setCopied] = useState(false); + const [iframeHeight, setIframeHeight] = useState(200); + const theme = useUIStore((s) => s.theme); + + // Debounced srcdoc — only rebuild after code stops changing for 400ms + // This prevents iframe reload on every streaming token + const [debouncedCode, setDebouncedCode] = useState(code); + const debounceTimer = useRef | null>(null); + + useEffect(() => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => { + setDebouncedCode(code); + }, 400); + return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current); }; + }, [code]); + + const srcdoc = React.useMemo(() => buildSrcdoc(debouncedCode, theme), [debouncedCode, theme]); + + // Measure iframe content height directly via contentDocument (same-origin allowed by sandbox) + const pollTimers = useRef[]>([]); + + const measureHeight = useCallback(() => { + const iframe = iframeRef.current; + if (!iframe) return; + try { + const doc = iframe.contentDocument; + if (!doc?.body) return; + // Wrap all body content in a measuring div to get exact height + const h = doc.body.scrollHeight; + if (h > 20) { + setIframeHeight(h); + } + } catch {} + }, []); + + // Poll height after load and at intervals to catch async renders (Chart.js etc) + const startPolling = useCallback(() => { + pollTimers.current.forEach(clearTimeout); + pollTimers.current = []; + [0, 100, 300, 600, 1000, 2000, 4000].forEach((delay) => { + pollTimers.current.push(setTimeout(measureHeight, delay)); + }); + }, [measureHeight]); + + // Re-poll when debounced code changes (iframe reloads) + useEffect(() => { + startPolling(); + return () => pollTimers.current.forEach(clearTimeout); + }, [debouncedCode, startPolling]); + + // Re-measure on window resize so iframe adapts when user resizes the app + useEffect(() => { + let resizeRaf = 0; + const onResize = () => { + cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(() => { + measureHeight(); + // Poll again after content reflows + setTimeout(measureHeight, 200); + setTimeout(measureHeight, 600); + }); + }; + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + cancelAnimationFrame(resizeRaf); + }; + }, [measureHeight]); + + // Listen for sendPrompt/openLink messages from iframe + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== 'object') return; + if (data.type === 'sendPrompt' && typeof data.text === 'string') { + onSendPrompt?.(data.text); + } + if (data.type === 'openLink' && typeof data.url === 'string') { + window.open(data.url, '_blank', 'noopener'); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [onSendPrompt]); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [code]); + + const displayTitle = React.useMemo(() => { + if (title) return title; + const match = /(.*?)<\/title>/i.exec(code); + if (match?.[1]) return match[1]; + return 'Preview'; + }, [title, code]); + + useEffect(() => { + if (!expanded) return; + const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setExpanded(false); }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [expanded]); + + const btnClass = 'p-1.5 rounded-lg transition-all duration-150'; + const btnIdle = 'text-[var(--text-subtle)] hover:text-[var(--text-muted)] hover:bg-[var(--hover-bg)]'; + + return ( + <> + {expanded && ( + <div className="fixed inset-0 z-50 bg-[var(--overlay)] backdrop-blur-sm flex items-center justify-center p-6" onClick={() => setExpanded(false)}> + <div className="w-full max-w-5xl bg-[var(--surface)] border border-[var(--border)] rounded-2xl overflow-hidden shadow-2xl shadow-[var(--shadow)] flex flex-col" style={{ height: EXPANDED_HEIGHT }} onClick={(e) => e.stopPropagation()}> + <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] bg-[var(--surface-2)]"> + <span className="text-[12px] font-medium text-[var(--text-muted)]">{displayTitle}</span> + <div className="flex items-center gap-0.5"> + <button onClick={() => setShowSource(!showSource)} className={cn(btnClass, showSource ? 'text-[var(--accent)]' : btnIdle)} title={showSource ? 'Preview' : 'Source'}><Code size={14} /></button> + <button onClick={handleCopy} className={cn(btnClass, btnIdle)} title="Copy">{copied ? <Check size={14} className="text-[var(--accent)]" /> : <Copy size={14} />}</button> + <button onClick={() => setExpanded(false)} className={cn(btnClass, btnIdle)} title="Close (Esc)"><X size={14} /></button> + </div> + </div> + <div className="flex-1 overflow-hidden"> + {showSource ? ( + <pre className="h-full overflow-auto p-4 text-[13px] leading-relaxed font-mono text-[var(--text-muted)] bg-[var(--bg)] custom-scrollbar">{code}</pre> + ) : ( + <iframe srcDoc={srcdoc} sandbox="allow-scripts allow-same-origin" className="w-full h-full border-0 bg-transparent" title={displayTitle} /> + )} + </div> + </div> + </div> + )} + + {/* Seamless inline preview */} + <div className="group/preview relative my-2"> + <div className="absolute top-2 right-2 z-10 flex items-center gap-0.5 px-1.5 py-1 rounded-lg bg-[var(--surface)]/90 backdrop-blur-md border border-[var(--border)] shadow-sm opacity-0 group-hover/preview:opacity-100 transition-opacity duration-200 pointer-events-none group-hover/preview:pointer-events-auto"> + <button onClick={() => setShowSource(!showSource)} className={cn(btnClass, 'p-1', showSource ? 'text-[var(--accent)]' : btnIdle)} title={showSource ? 'Preview' : 'Source'}><Code size={13} /></button> + <button onClick={handleCopy} className={cn(btnClass, 'p-1', btnIdle)} title="Copy">{copied ? <Check size={13} className="text-[var(--accent)]" /> : <Copy size={13} />}</button> + <button onClick={() => setExpanded(true)} className={cn(btnClass, 'p-1', btnIdle)} title="Expand"><ArrowsOutSimple size={13} /></button> + </div> + + {showSource ? ( + <pre className="overflow-auto p-4 text-[13px] leading-relaxed font-mono text-[var(--text-muted)] bg-[var(--surface-2)] rounded-xl custom-scrollbar" style={{ maxHeight: '500px' }}>{code}</pre> + ) : ( + <iframe + ref={iframeRef} + srcDoc={srcdoc} + sandbox="allow-scripts allow-same-origin" + className="w-full border-0 bg-transparent rounded-xl" + style={{ height: `${iframeHeight}px`, overflow: 'hidden', display: 'block' }} + scrolling="no" + title={displayTitle} + onLoad={startPolling} + /> + )} + </div> + </> + ); +}; diff --git a/src/components/chat/MarkdownCodeBlock.tsx b/src/components/chat/MarkdownCodeBlock.tsx new file mode 100644 index 0000000..03107a6 --- /dev/null +++ b/src/components/chat/MarkdownCodeBlock.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Copy, Check } from '@phosphor-icons/react'; +import { HtmlPreview } from './HtmlPreview'; + +interface MarkdownCodeBlockProps { + inline?: boolean; + className?: string; + children?: React.ReactNode; +} + +/** + * Detect if a language tag is an HTML preview trigger. + * Supports: html:preview, html:artifact, html:viz, html:visual, html:render + */ +const isPreviewLang = (lang: string): boolean => + /^html:(preview|artifact|viz|visual|render)$/i.test(lang); + +/** + * Extract a title hint from the language tag, e.g. "html:preview:My Chart" → "My Chart" + */ +const extractPreviewTitle = (lang: string): string | undefined => { + const parts = lang.split(':'); + return parts.length >= 3 ? parts.slice(2).join(':') : undefined; +}; + +/** + * Detect if HTML code looks like a full interactive page that should be previewed. + */ +const looksLikeFullPage = (code: string): boolean => { + if (code.length < 100) return false; + const hasDoctype = /<!doctype\s+html/i.test(code); + const hasHtmlTag = /<html[\s>]/i.test(code); + const hasBodyTag = /<body[\s>]/i.test(code); + const hasScript = /<script[\s>]/i.test(code); + // Full page: has doctype or html tag, and has script + if ((hasDoctype || hasHtmlTag) && hasScript) return true; + // Canvas + script combo + if (/<canvas/i.test(code) && hasScript) return true; + // Has body + script + if (hasBodyTag && hasScript) return true; + return false; +}; + +export const MarkdownCodeBlock: React.FC<MarkdownCodeBlockProps> = ({ inline, className, children }) => { + const [copied, setCopied] = React.useState(false); + const code = String(children).replace(/\n$/, ''); + const lang = /language-(\S+)/.exec(className ?? '')?.[1] ?? ''; + + if (inline) { + return ( + <code className="px-1.5 py-0.5 rounded text-[0.84em] font-mono bg-[var(--surface-3)] text-[var(--text)] border border-[var(--border)]"> + {children} + </code> + ); + } + + // ── HTML Preview (explicit tag) ─────────────────────────── + if (isPreviewLang(lang)) { + const title = extractPreviewTitle(lang); + return <HtmlPreview code={code} title={title} />; + } + + // ── HTML Preview (auto-detect full page) ────────────────── + if (lang === 'html' && looksLikeFullPage(code)) { + return <HtmlPreview code={code} />; + } + + const handleCopy = async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // ── Normal code block ───────────────────────────────────── + const displayLang = lang.replace(/:.+$/, ''); + + return ( + <div className="my-3 rounded-xl overflow-hidden border border-[var(--border)]"> + <div className="flex items-center justify-between px-4 py-1.5 bg-[var(--surface-3)]"> + <span className="text-[11px] font-mono font-medium text-[var(--text-muted)] uppercase tracking-wider"> + {displayLang || 'code'} + </span> + <button + onClick={handleCopy} + className="flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[11px] font-medium text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)] transition-colors" + > + {copied ? <Check size={12} weight="bold" className="text-[var(--accent)]" /> : <Copy size={12} />} + {copied ? 'Copied' : 'Copy'} + </button> + </div> + <pre className="overflow-x-auto text-[13px] leading-relaxed bg-[var(--surface-2)] p-4 m-0"> + <code className={className}>{children}</code> + </pre> + </div> + ); +}; diff --git a/src/components/chat/PermissionDialog.tsx b/src/components/chat/PermissionDialog.tsx index 5c8d6f4..93afe3e 100644 --- a/src/components/chat/PermissionDialog.tsx +++ b/src/components/chat/PermissionDialog.tsx @@ -13,7 +13,7 @@ export function PermissionDialog({ request, onAllow, onDeny }: PermissionDialogP const agentLabel = AGENT_LABELS[request.agentType] || request.agentType; return ( - <div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center"> + <div className="fixed inset-0 z-50 bg-[var(--overlay)] backdrop-blur-sm flex items-center justify-center"> <div className="bg-[var(--surface)] border border-[var(--border)] rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"> <div className="flex items-center space-x-3 mb-4 text-[var(--text)]"> <Warning size={24} weight="duotone" className="text-yellow-500" /> @@ -46,7 +46,7 @@ export function PermissionDialog({ request, onAllow, onDeny }: PermissionDialogP </button> <button onClick={onAllow} - className="px-4 py-2 rounded-lg bg-white text-black hover:bg-gray-200 transition-colors text-sm font-medium" + className="px-4 py-2 rounded-lg bg-[var(--accent)] text-[var(--accent-fg)] hover:bg-[var(--accent-hover)] transition-colors text-sm font-medium" > Allow </button> diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index 8c462c0..b505d50 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Sparkle } from '@phosphor-icons/react'; import { useChatStore } from '@/stores/useChatStore'; export const StreamingMessage: React.FC = () => { @@ -7,18 +8,18 @@ export const StreamingMessage: React.FC = () => { if (!isStreaming) return null; return ( - <div className="flex flex-col gap-1.5 w-full"> - <div className="flex items-center gap-2 mb-1.5"> - <div className="w-6 h-6 rounded-md bg-[var(--surface-3)] border border-[var(--border)] flex items-center justify-center text-[10px] font-bold text-white shrink-0"> - AI - </div> - <span className="text-[12px] text-[var(--text-muted)] font-semibold tracking-wide">Assistant</span> + <div className="flex gap-3 w-full"> + {/* Avatar */} + <div className="w-7 h-7 rounded-full bg-[var(--accent)] flex items-center justify-center shrink-0 mt-0.5"> + <Sparkle size={14} weight="fill" className="text-[var(--accent-fg)]" /> </div> - <div className="w-full px-5 py-4 rounded-xl text-[15px] leading-relaxed bg-[var(--surface)] text-[var(--text)] border border-[var(--border-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"> + + {/* Content */} + <div className="min-w-0 flex-1 pt-0.5 text-[15px] leading-relaxed text-[var(--text)]"> {streamingText ? ( <> <span className="whitespace-pre-wrap">{streamingText}</span> - <span className="inline-block w-0.5 h-4 bg-white/70 ml-0.5 align-middle animate-pulse" /> + <span className="inline-block w-0.5 h-4 bg-[var(--accent)] ml-0.5 align-middle animate-pulse rounded-full" /> </> ) : ( <span className="text-[var(--text-subtle)]" aria-label="Generating"> diff --git a/src/components/chat/ThinkingBlock.tsx b/src/components/chat/ThinkingBlock.tsx index d75ecbc..9d12936 100644 --- a/src/components/chat/ThinkingBlock.tsx +++ b/src/components/chat/ThinkingBlock.tsx @@ -15,22 +15,18 @@ export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({ const [collapsed, setCollapsed] = useState(defaultCollapsed); return ( - <div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)]/60 overflow-hidden"> + <div> <button onClick={() => setCollapsed((v) => !v)} - className="w-full flex items-center justify-between px-3 py-2 text-left" + className="flex items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors py-1" > - <div className="flex items-center gap-2"> - <Brain size={13} weight="duotone" className="text-[var(--text-muted)]" /> - <span className="text-[11px] uppercase tracking-wider font-bold text-[var(--text-muted)]"> - {title} - </span> - </div> - {collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />} + {collapsed ? <CaretRight size={10} weight="bold" /> : <CaretDown size={10} weight="bold" />} + <Brain size={12} weight="duotone" /> + <span className="font-medium">{title}</span> </button> {!collapsed && ( - <div className="px-3 pb-3 text-[12px] leading-relaxed text-[var(--text)] whitespace-pre-wrap"> + <div className="ml-5 pl-3 border-l-2 border-[var(--border)] text-[12px] leading-relaxed text-[var(--text-muted)] whitespace-pre-wrap mt-1 mb-1"> {content} </div> )} diff --git a/src/components/chat/ToolExecutionBlock.tsx b/src/components/chat/ToolExecutionBlock.tsx index c7ea43e..d161b87 100644 --- a/src/components/chat/ToolExecutionBlock.tsx +++ b/src/components/chat/ToolExecutionBlock.tsx @@ -9,10 +9,10 @@ interface ToolExecutionBlockProps { } const statusLabel = (status: ToolCall['status']) => { - if (status === 'failed') return 'Execute Failed'; - if (status === 'completed') return 'Result'; - if (status === 'running') return 'Running'; - return 'Queued'; + if (status === 'failed') return 'failed'; + if (status === 'completed') return 'done'; + if (status === 'running') return 'running'; + return 'queued'; }; export const ToolExecutionBlock: React.FC<ToolExecutionBlockProps> = ({ @@ -23,49 +23,42 @@ export const ToolExecutionBlock: React.FC<ToolExecutionBlockProps> = ({ const isFailed = tool.status === 'failed'; return ( - <div - className={cn( - 'rounded-lg border bg-[var(--surface-2)]/60 overflow-hidden', - isFailed ? 'border-white/35' : 'border-[var(--border)]' - )} - > + <div> <button onClick={() => setOpen((v) => !v)} - className="w-full flex items-center justify-between px-3 py-2 text-left" + className="flex items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors py-1" > - <div className="flex items-center gap-2"> - <Terminal size={13} weight="duotone" className="text-[var(--text-muted)]" /> - <span className="text-[11px] uppercase tracking-wider font-bold text-[var(--text-muted)]"> - Executed - </span> - <span - className={cn( - 'text-[10px] px-1.5 py-0.5 rounded border', - isFailed - ? 'text-white border-white/40 bg-white/5' - : 'text-[var(--text-subtle)] border-[var(--border)] bg-[var(--surface-3)]' - )} - > - {statusLabel(tool.status)} - </span> - </div> - {open ? <CaretDown size={12} /> : <CaretRight size={12} />} + {open ? <CaretDown size={10} weight="bold" /> : <CaretRight size={10} weight="bold" />} + <Terminal size={12} weight="duotone" /> + <span className="font-medium font-mono">{tool.toolName}</span> + <span + className={cn( + 'text-[10px] px-1.5 py-0.5 rounded-full', + isFailed + ? 'text-[var(--danger)] bg-[var(--danger-bg)]' + : tool.status === 'running' + ? 'text-[var(--accent)] bg-[var(--hover-bg)]' + : 'text-[var(--text-subtle)] bg-[var(--surface-3)]', + )} + > + {statusLabel(tool.status)} + </span> </button> {open && ( - <div className="px-3 pb-3 space-y-2"> - <div className="rounded border border-[var(--border)] bg-[var(--surface-3)] p-2 font-mono text-[11px] text-[var(--text)] whitespace-pre-wrap break-all"> + <div className="ml-5 pl-3 border-l-2 border-[var(--border)] space-y-1.5 mt-1 mb-1"> + <div className="font-mono text-[11px] text-[var(--text-muted)] whitespace-pre-wrap break-all bg-[var(--surface-2)] rounded-lg px-3 py-2"> {`> ${tool.toolName} ${tool.input}`} </div> {tool.output && ( - <div className="rounded border border-[var(--border)] bg-[var(--surface-3)]/70 p-2 font-mono text-[11px] text-[var(--text-muted)] whitespace-pre-wrap break-all"> + <div className="font-mono text-[11px] text-[var(--text-subtle)] whitespace-pre-wrap break-all bg-[var(--surface-2)] rounded-lg px-3 py-2 max-h-48 overflow-y-auto custom-scrollbar"> {tool.output} </div> )} {isFailed && ( - <div className="flex items-start gap-1.5 text-[11px] text-white border border-white/35 bg-white/5 rounded p-2"> + <div className="flex items-start gap-1.5 text-[11px] text-[var(--danger)] bg-[var(--danger-bg)] rounded-lg px-3 py-2"> <WarningCircle size={13} className="mt-0.5 shrink-0" /> <span>{tool.error ?? 'Tool execution failed.'}</span> </div> diff --git a/src/components/chat/preview-styles.ts b/src/components/chat/preview-styles.ts new file mode 100644 index 0000000..5ca0212 --- /dev/null +++ b/src/components/chat/preview-styles.ts @@ -0,0 +1,258 @@ +/** + * Design system CSS injected into every html:preview iframe. + * Theme is controlled by a class on <html>: .dark or .light. + * This is set by the host app based on its theme state, NOT by OS prefers-color-scheme. + */ + +export const PREVIEW_DESIGN_SYSTEM = ` +/* ── CSS Variables (dark mode default) ─────────────────────────────── */ +:root, .dark { + --p: #e0e0e0; + --s: #a0a0a0; + --t: #707070; + --bg2: #2a2a2a; + --b: #404040; + --color-text-primary: #e0e0e0; + --color-text-secondary: #a0a0a0; + --color-text-tertiary: #707070; + --color-text-info: #85B7EB; + --color-text-danger: #F09595; + --color-text-success: #97C459; + --color-text-warning: #EF9F27; + --color-background-primary: #1a1a1a; + --color-background-secondary: #2a2a2a; + --color-background-tertiary: #111111; + --color-background-info: #0C447C; + --color-background-danger: #791F1F; + --color-background-success: #27500A; + --color-background-warning: #633806; + --color-border-primary: rgba(255,255,255,0.4); + --color-border-secondary: rgba(255,255,255,0.3); + --color-border-tertiary: rgba(255,255,255,0.15); + --color-border-info: #85B7EB; + --color-border-danger: #F09595; + --color-border-success: #97C459; + --color-border-warning: #EF9F27; + --font-sans: system-ui, -apple-system, sans-serif; + --font-serif: Georgia, serif; + --font-mono: ui-monospace, monospace; + --border-radius-md: 8px; + --border-radius-lg: 12px; + --border-radius-xl: 16px; +} + +/* ── Light mode overrides (class-based, not media query) ───────────── */ +.light { + --p: #1a1a1a; + --s: #555555; + --t: #888888; + --bg2: #f5f5f0; + --b: #d8d5cc; + --color-text-primary: #1a1a1a; + --color-text-secondary: #555555; + --color-text-tertiary: #888888; + --color-text-info: #185FA5; + --color-text-danger: #A32D2D; + --color-text-success: #3B6D11; + --color-text-warning: #854F0B; + --color-background-primary: #ffffff; + --color-background-secondary: #f5f5f0; + --color-background-tertiary: #eaeae4; + --color-background-info: #E6F1FB; + --color-background-danger: #FCEBEB; + --color-background-success: #EAF3DE; + --color-background-warning: #FAEEDA; + --color-border-primary: rgba(0,0,0,0.4); + --color-border-secondary: rgba(0,0,0,0.2); + --color-border-tertiary: rgba(0,0,0,0.1); + --color-border-info: #378ADD; + --color-border-danger: #E24B4A; + --color-border-success: #639922; + --color-border-warning: #BA7517; +} + +/* ── Base styles ───────────────────────────────────────────────────── */ +* { box-sizing: border-box; } +body { + margin: 0; + padding: 1rem; + font-family: var(--font-sans); + font-size: 16px; + font-weight: 400; + line-height: 1.7; + color: var(--color-text-primary); + background: transparent; + -webkit-font-smoothing: antialiased; +} +h1 { font-size: 22px; font-weight: 500; margin: 0 0 12px; } +h2 { font-size: 18px; font-weight: 500; margin: 0 0 10px; } +h3 { font-size: 16px; font-weight: 500; margin: 0 0 8px; } + +/* ── SVG text classes ──────────────────────────────────────────────── */ +svg .t { font-family: var(--font-sans); font-size: 14px; fill: var(--p); } +svg .ts { font-family: var(--font-sans); font-size: 12px; fill: var(--s); } +svg .th { font-family: var(--font-sans); font-size: 14px; font-weight: 500; fill: var(--p); } + +/* ── SVG shape classes ─────────────────────────────────────────────── */ +svg .box { fill: var(--bg2); stroke: var(--b); } +svg .node { cursor: pointer; transition: opacity 0.15s; } +svg .node:hover { opacity: 0.8; } +svg .arr { stroke: var(--t); stroke-width: 1.5; fill: none; } +svg .leader { stroke: var(--t); stroke-width: 0.5; stroke-dasharray: 4 3; fill: none; } + +/* ── SVG Color Ramp Classes (dark mode default) ────────────────────── */ +svg .c-purple > rect, svg .c-purple > circle, svg .c-purple > ellipse { fill: #3C3489; stroke: #AFA9EC; stroke-width: 0.5; } +svg .c-purple > .th, svg .c-purple > .t { fill: #CECBF6; } +svg .c-purple > .ts { fill: #AFA9EC; } +svg rect.c-purple, svg circle.c-purple, svg ellipse.c-purple { fill: #3C3489; stroke: #AFA9EC; } + +svg .c-teal > rect, svg .c-teal > circle, svg .c-teal > ellipse { fill: #085041; stroke: #5DCAA5; stroke-width: 0.5; } +svg .c-teal > .th, svg .c-teal > .t { fill: #9FE1CB; } +svg .c-teal > .ts { fill: #5DCAA5; } +svg rect.c-teal, svg circle.c-teal, svg ellipse.c-teal { fill: #085041; stroke: #5DCAA5; } + +svg .c-coral > rect, svg .c-coral > circle, svg .c-coral > ellipse { fill: #712B13; stroke: #F0997B; stroke-width: 0.5; } +svg .c-coral > .th, svg .c-coral > .t { fill: #F5C4B3; } +svg .c-coral > .ts { fill: #F0997B; } +svg rect.c-coral, svg circle.c-coral, svg ellipse.c-coral { fill: #712B13; stroke: #F0997B; } + +svg .c-pink > rect, svg .c-pink > circle, svg .c-pink > ellipse { fill: #72243E; stroke: #ED93B1; stroke-width: 0.5; } +svg .c-pink > .th, svg .c-pink > .t { fill: #F4C0D1; } +svg .c-pink > .ts { fill: #ED93B1; } +svg rect.c-pink, svg circle.c-pink, svg ellipse.c-pink { fill: #72243E; stroke: #ED93B1; } + +svg .c-gray > rect, svg .c-gray > circle, svg .c-gray > ellipse { fill: #444441; stroke: #B4B2A9; stroke-width: 0.5; } +svg .c-gray > .th, svg .c-gray > .t { fill: #D3D1C7; } +svg .c-gray > .ts { fill: #B4B2A9; } +svg rect.c-gray, svg circle.c-gray, svg ellipse.c-gray { fill: #444441; stroke: #B4B2A9; } + +svg .c-blue > rect, svg .c-blue > circle, svg .c-blue > ellipse { fill: #0C447C; stroke: #85B7EB; stroke-width: 0.5; } +svg .c-blue > .th, svg .c-blue > .t { fill: #B5D4F4; } +svg .c-blue > .ts { fill: #85B7EB; } +svg rect.c-blue, svg circle.c-blue, svg ellipse.c-blue { fill: #0C447C; stroke: #85B7EB; } + +svg .c-green > rect, svg .c-green > circle, svg .c-green > ellipse { fill: #27500A; stroke: #97C459; stroke-width: 0.5; } +svg .c-green > .th, svg .c-green > .t { fill: #C0DD97; } +svg .c-green > .ts { fill: #97C459; } +svg rect.c-green, svg circle.c-green, svg ellipse.c-green { fill: #27500A; stroke: #97C459; } + +svg .c-amber > rect, svg .c-amber > circle, svg .c-amber > ellipse { fill: #633806; stroke: #EF9F27; stroke-width: 0.5; } +svg .c-amber > .th, svg .c-amber > .t { fill: #FAC775; } +svg .c-amber > .ts { fill: #EF9F27; } +svg rect.c-amber, svg circle.c-amber, svg ellipse.c-amber { fill: #633806; stroke: #EF9F27; } + +svg .c-red > rect, svg .c-red > circle, svg .c-red > ellipse { fill: #791F1F; stroke: #F09595; stroke-width: 0.5; } +svg .c-red > .th, svg .c-red > .t { fill: #F7C1C1; } +svg .c-red > .ts { fill: #F09595; } +svg rect.c-red, svg circle.c-red, svg ellipse.c-red { fill: #791F1F; stroke: #F09595; } + +/* ── Light mode color ramps (class-based) ──────────────────────────── */ +.light svg .c-purple > rect, .light svg .c-purple > circle, .light svg .c-purple > ellipse { fill: #EEEDFE; stroke: #7F77DD; } +.light svg .c-purple > .th, .light svg .c-purple > .t { fill: #3C3489; } +.light svg .c-purple > .ts { fill: #534AB7; } + +.light svg .c-teal > rect, .light svg .c-teal > circle, .light svg .c-teal > ellipse { fill: #E1F5EE; stroke: #1D9E75; } +.light svg .c-teal > .th, .light svg .c-teal > .t { fill: #085041; } +.light svg .c-teal > .ts { fill: #0F6E56; } + +.light svg .c-coral > rect, .light svg .c-coral > circle, .light svg .c-coral > ellipse { fill: #FAECE7; stroke: #D85A30; } +.light svg .c-coral > .th, .light svg .c-coral > .t { fill: #712B13; } +.light svg .c-coral > .ts { fill: #993C1D; } + +.light svg .c-pink > rect, .light svg .c-pink > circle, .light svg .c-pink > ellipse { fill: #FBEAF0; stroke: #D4537E; } +.light svg .c-pink > .th, .light svg .c-pink > .t { fill: #72243E; } +.light svg .c-pink > .ts { fill: #993556; } + +.light svg .c-gray > rect, .light svg .c-gray > circle, .light svg .c-gray > ellipse { fill: #F1EFE8; stroke: #888780; } +.light svg .c-gray > .th, .light svg .c-gray > .t { fill: #444441; } +.light svg .c-gray > .ts { fill: #5F5E5A; } + +.light svg .c-blue > rect, .light svg .c-blue > circle, .light svg .c-blue > ellipse { fill: #E6F1FB; stroke: #378ADD; } +.light svg .c-blue > .th, .light svg .c-blue > .t { fill: #0C447C; } +.light svg .c-blue > .ts { fill: #185FA5; } + +.light svg .c-green > rect, .light svg .c-green > circle, .light svg .c-green > ellipse { fill: #EAF3DE; stroke: #639922; } +.light svg .c-green > .th, .light svg .c-green > .t { fill: #27500A; } +.light svg .c-green > .ts { fill: #3B6D11; } + +.light svg .c-amber > rect, .light svg .c-amber > circle, .light svg .c-amber > ellipse { fill: #FAEEDA; stroke: #BA7517; } +.light svg .c-amber > .th, .light svg .c-amber > .t { fill: #633806; } +.light svg .c-amber > .ts { fill: #854F0B; } + +.light svg .c-red > rect, .light svg .c-red > circle, .light svg .c-red > ellipse { fill: #FCEBEB; stroke: #E24B4A; } +.light svg .c-red > .th, .light svg .c-red > .t { fill: #791F1F; } +.light svg .c-red > .ts { fill: #A32D2D; } + +.light svg rect.c-purple, .light svg circle.c-purple, .light svg ellipse.c-purple { fill: #EEEDFE; stroke: #7F77DD; } +.light svg rect.c-teal, .light svg circle.c-teal, .light svg ellipse.c-teal { fill: #E1F5EE; stroke: #1D9E75; } +.light svg rect.c-coral, .light svg circle.c-coral, .light svg ellipse.c-coral { fill: #FAECE7; stroke: #D85A30; } +.light svg rect.c-pink, .light svg circle.c-pink, .light svg ellipse.c-pink { fill: #FBEAF0; stroke: #D4537E; } +.light svg rect.c-gray, .light svg circle.c-gray, .light svg ellipse.c-gray { fill: #F1EFE8; stroke: #888780; } +.light svg rect.c-blue, .light svg circle.c-blue, .light svg ellipse.c-blue { fill: #E6F1FB; stroke: #378ADD; } +.light svg rect.c-green, .light svg circle.c-green, .light svg ellipse.c-green { fill: #EAF3DE; stroke: #639922; } +.light svg rect.c-amber, .light svg circle.c-amber, .light svg ellipse.c-amber { fill: #FAEEDA; stroke: #BA7517; } +.light svg rect.c-red, .light svg circle.c-red, .light svg ellipse.c-red { fill: #FCEBEB; stroke: #E24B4A; } + +/* ── Pre-styled form elements ──────────────────────────────────────── */ +button { + background: transparent; + border: 0.5px solid var(--color-border-secondary); + border-radius: var(--border-radius-md); + color: var(--color-text-primary); + padding: 6px 14px; + font-size: 14px; + cursor: pointer; + font-family: var(--font-sans); + transition: background 0.15s, transform 0.1s; +} +button:hover { background: var(--color-background-secondary); } +button:active { transform: scale(0.98); } + +input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: var(--color-border-secondary); + border-radius: 2px; + outline: none; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-text-primary); + cursor: pointer; +} + +input[type="text"], input[type="number"], textarea, select { + height: 36px; + background: var(--color-background-primary); + border: 0.5px solid var(--color-border-tertiary); + border-radius: var(--border-radius-md); + color: var(--color-text-primary); + padding: 0 10px; + font-size: 14px; + font-family: var(--font-sans); + outline: none; + transition: border-color 0.15s; +} +input:hover, textarea:hover, select:hover { border-color: var(--color-border-secondary); } +input:focus, textarea:focus, select:focus { border-color: var(--color-border-primary); } + +/* ── Utility classes ───────────────────────────────────────────────── */ +.metric-card { + background: var(--color-background-secondary); + border: 0.5px solid var(--color-border-tertiary); + border-radius: var(--border-radius-lg); + padding: 16px; +} +.metric-value { font-size: 28px; font-weight: 500; color: var(--color-text-primary); } +.metric-label { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; } +.metric-change { font-size: 12px; margin-top: 4px; } +.metric-change.up { color: var(--color-text-success); } +.metric-change.down { color: var(--color-text-danger); } + +/* ── Fade-in animation for new elements ────────────────────────────── */ +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } +`; diff --git a/src/components/layout/AppFooter.tsx b/src/components/layout/AppFooter.tsx index 6f9fee9..08e8a30 100644 --- a/src/components/layout/AppFooter.tsx +++ b/src/components/layout/AppFooter.tsx @@ -4,13 +4,29 @@ import { useUIStore } from '@/stores/useUIStore'; import { cn } from '@/lib/utils'; export const AppFooter: React.FC = () => { - const { rightSidebarOpen, toggleRightSidebar } = useUIStore(); + const { leftSidebarOpen, toggleLeftSidebar, rightSidebarOpen, toggleRightSidebar } = useUIStore(); return ( - <footer className="h-9 flex items-center justify-between px-4 border-t border-[var(--border)] bg-[var(--surface)] col-span-3 shrink-0"> - <span className="text-[10px] text-[var(--text-subtle)] tracking-wide select-none"> - enowX Coder v0.1.0 - </span> + <footer + className="h-9 flex items-center justify-between px-4 border-t border-[var(--border)] bg-[var(--surface)] shrink-0 col-span-3" + > + <div className="flex items-center gap-3"> + <button + onClick={toggleLeftSidebar} + title={leftSidebarOpen ? 'Hide left panel' : 'Show left panel'} + className={cn( + 'flex items-center justify-center w-6 h-6 rounded-md transition-colors', + leftSidebarOpen + ? 'text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)]' + : 'text-[var(--text-subtle)] hover:text-[var(--text-muted)] hover:bg-[var(--hover-bg)]' + )} + > + <SidebarSimple size={14} weight={leftSidebarOpen ? 'fill' : 'regular'} /> + </button> + <span className="text-[10px] text-[var(--text-subtle)] tracking-wide select-none"> + enowX Coder v0.1.0 + </span> + </div> <div className="flex items-center gap-3"> <span className="flex items-center gap-1 text-[10px] text-[var(--text-subtle)] select-none"> @@ -34,8 +50,8 @@ export const AppFooter: React.FC = () => { className={cn( 'flex items-center justify-center w-6 h-6 rounded-md transition-colors', rightSidebarOpen - ? 'text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-white/5' - : 'text-[var(--text-subtle)] hover:text-[var(--text-muted)] hover:bg-white/5' + ? 'text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)]' + : 'text-[var(--text-subtle)] hover:text-[var(--text-muted)] hover:bg-[var(--hover-bg)]' )} > <SidebarSimple size={14} weight={rightSidebarOpen ? 'fill' : 'regular'} className="scale-x-[-1]" /> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 8d8413a..adbdfdf 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -4,9 +4,9 @@ import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { LeftSidebar } from '@/components/layout/LeftSidebar'; import { RightSidebar } from '@/components/layout/RightSidebar'; import { ChatHeader } from '@/components/layout/ChatHeader'; -import { AppFooter } from '@/components/layout/AppFooter'; + import { ChatPanel } from '@/components/chat/ChatPanel'; -import { ChatInputBar } from '@/components/chat/ChatInputBar'; +import { ChatInputBar, ChatInputBarHandle } from '@/components/chat/ChatInputBar'; import { PermissionDialog } from '@/components/chat/PermissionDialog'; import { useChatStore } from '@/stores/useChatStore'; import { useProjectStore } from '@/stores/useProjectStore'; @@ -15,19 +15,25 @@ import { useSettingsStore } from '@/stores/useSettingsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useAgentStore } from '@/stores/useAgentStore'; import { SettingsModal } from '@/components/settings/SettingsModal'; -import { AgentConfig, AgentRunWithTools, AgentType, Message, PermissionRequest, Project, Session, ToolCall } from '@/types'; +import { ExcalidrawCanvas } from '@/components/canvas/ExcalidrawCanvas'; +import { AgentConfig, AgentRunWithTools, AgentType, Message, PermissionRequest, Project, Provider, ProviderModelConfig, Session, ToolCall } from '@/types'; +import { cn } from '@/lib/utils'; export const AppShell: React.FC = () => { const { addMessage, appendStreamToken, setStreaming, clearStreaming, setMessages } = useChatStore(); const setProjects = useProjectStore((s) => s.setProjects); + const addProject = useProjectStore((s) => s.addProject); const setActiveProjectId = useProjectStore((s) => s.setActiveProjectId); - const activeProjectId = useProjectStore((s) => s.activeProjectId); - const projects = useProjectStore((s) => s.projects); const activeSessionId = useSessionStore((s) => s.activeSessionId); const setSessions = useSessionStore((s) => s.setSessions); + const addSession = useSessionStore((s) => s.addSession); const setActiveSessionId = useSessionStore((s) => s.setActiveSessionId); - const { setProviders, setDefaultProviderId, defaultProviderId, selectedModelId } = useSettingsStore(); + const { setProviders, setDefaultProviderId, defaultProviderId, selectedModelId, setSelectedModelId } = useSettingsStore(); + const leftSidebarOpen = useUIStore((s) => s.leftSidebarOpen); + const toggleLeftSidebar = useUIStore((s) => s.toggleLeftSidebar); const rightSidebarOpen = useUIStore((s) => s.rightSidebarOpen); + const mainView = useUIStore((s) => s.mainView); + const { addAgentRun, setAgentRuns, @@ -41,6 +47,39 @@ export const AppShell: React.FC = () => { agentConfigs, } = useAgentStore(); + const chatInputRef = React.useRef<ChatInputBarHandle>(null); + + // Track which sessions have already been auto-renamed to avoid duplicates + const renamedSessionsRef = React.useRef<Set<string>>(new Set()); + + const autoRenameSession = React.useCallback((sessionId: string) => { + if (renamedSessionsRef.current.has(sessionId)) return; + + const session = useSessionStore.getState().sessions.find(s => s.id === sessionId); + if (!session || session.title !== 'New Chat') return; + + renamedSessionsRef.current.add(sessionId); + + // Ask the LLM to generate a short title based on the conversation + const { defaultProviderId: pid, selectedModelId: mid } = useSettingsStore.getState(); + invoke<string>('generate_title', { + sessionId, + providerId: pid ?? null, + modelId: mid ?? null, + }) + .then((title) => { + if (title && title !== 'New Chat') { + useSessionStore.getState().updateSessionTitle(sessionId, title); + invoke('update_session_title', { id: sessionId, title }).catch(console.error); + } + }) + .catch((err) => { + console.error('Auto-rename failed:', err); + // Remove from set so it can retry next time + renamedSessionsRef.current.delete(sessionId); + }); + }, []); + useEffect(() => { const loadPersistedData = async () => { try { @@ -85,11 +124,30 @@ export const AppShell: React.FC = () => { }, [setProjects, setActiveProjectId, setSessions, setActiveSessionId]); useEffect(() => { - invoke<{ id: string; name: string; model: string; isDefault: boolean }[]>('list_providers') - .then((ps) => { - setProviders(ps as Parameters<typeof setProviders>[0]); - const def = ps.find((p) => p.isDefault); - if (def) setDefaultProviderId(def.id); + invoke<Provider[]>('list_providers') + .then(async (ps) => { + setProviders(ps); + // Auto-select provider: default > first enabled > first any + const def = ps.find((p) => p.isDefault && p.isEnabled); + const firstEnabled = ps.find((p) => p.isEnabled); + const picked = def ?? firstEnabled ?? ps[0]; + if (picked) { + setDefaultProviderId(picked.id); + // Auto-select first model if none selected + if (!selectedModelId) { + try { + const models = await invoke<ProviderModelConfig[]>('list_provider_models', { providerId: picked.id }); + const enabled = models.filter((m) => m.enabled); + if (enabled.length > 0) { + setSelectedModelId(enabled[0].modelId); + } else { + // Fallback: fetch all available models + const allModels = await invoke<string[]>('list_models', { providerId: picked.id }); + if (allModels.length > 0) setSelectedModelId(allModels[0]); + } + } catch {} + } + } }) .catch(console.error); }, [setProviders, setDefaultProviderId]); @@ -110,7 +168,10 @@ export const AppShell: React.FC = () => { const sessionId = useSessionStore.getState().activeSessionId; if (sessionId) { invoke<Message[]>('get_messages', { sessionId }) - .then((msgs) => useChatStore.getState().setMessages(msgs)) + .then((msgs) => { + useChatStore.getState().setMessages(msgs); + autoRenameSession(sessionId); + }) .catch(console.error); } }); @@ -224,6 +285,12 @@ export const AppShell: React.FC = () => { output, completedAt: new Date().toISOString(), }); + + // Auto-rename after agent completes + const sessionId = useSessionStore.getState().activeSessionId; + if (sessionId) { + autoRenameSession(sessionId); + } } ); @@ -289,6 +356,14 @@ export const AppShell: React.FC = () => { useEffect(() => { if (!activeSessionId) return; + + // Skip DB reload for sessions we just created — they're empty and we already + // have the optimistic user message in the store. The reload would wipe it. + if (justCreatedSessionRef.current.has(activeSessionId)) { + justCreatedSessionRef.current.delete(activeSessionId); + return; + } + invoke<Message[]>('get_messages', { sessionId: activeSessionId }) .then(setMessages) .catch(console.error); @@ -317,19 +392,52 @@ export const AppShell: React.FC = () => { .catch(console.error); }, [activeSessionId, setMessages, setAgentRuns]); - const handleSend = async (content: string) => { - if (!activeSessionId) { - console.warn('No active session — open a folder first'); - return; + // Track sessions we just created so the activeSessionId effect doesn't wipe messages + const justCreatedSessionRef = React.useRef<Set<string>>(new Set()); + + const ensureSession = async (): Promise<{ sessionId: string; projectPath: string } | null> => { + let currentSessionId = useSessionStore.getState().activeSessionId; + let currentProjectId = useProjectStore.getState().activeProjectId; + const currentProjects = useProjectStore.getState().projects; + + // If we already have an active session, just return it + if (currentSessionId) { + const proj = currentProjects.find(p => p.id === currentProjectId); + return { sessionId: currentSessionId, projectPath: proj?.path ?? '' }; } - const activeProject = projects.find((p) => p.id === activeProjectId); - const projectPath = activeProject?.path ?? ''; + try { + // If no project exists, create a default one + if (!currentProjectId || currentProjects.length === 0) { + const project = await invoke<Project>('create_project', { name: 'Default', path: null }); + addProject(project); + setActiveProjectId(project.id); + currentProjectId = project.id; + } + + // Create a new session + const session = await invoke<Session>('create_session', { projectId: currentProjectId, title: 'New Chat' }); + addSession(session); + // Mark as just-created so the effect doesn't wipe our optimistic messages + justCreatedSessionRef.current.add(session.id); + setActiveSessionId(session.id); + return { sessionId: session.id, projectPath: currentProjects.find(p => p.id === currentProjectId)?.path ?? '' }; + } catch (err) { + console.error('Failed to auto-create session:', err); + return null; + } + }; + + const handleSend = async (content: string) => { + const ctx = await ensureSession(); + if (!ctx) return; + + const { sessionId: currentSessionId, projectPath } = ctx; if (selectedAgentType === 'orchestrator' || selectedAgentType === 'planner') { const userMsg: Message = { id: crypto.randomUUID(), - sessionId: activeSessionId, + sessionId: currentSessionId, role: 'user', content, createdAt: new Date().toISOString(), @@ -345,12 +453,15 @@ export const AppShell: React.FC = () => { try { await invoke('run_agent', { - sessionId: activeSessionId, - agentType: selectedAgentType, - task: content, - projectPath, - providerId: agentProviderId, - modelId: agentModelId, + request: { + sessionId: currentSessionId, + agentType: selectedAgentType, + task: content, + projectPath, + providerId: agentProviderId, + modelId: agentModelId, + fluxEnabled: useUIStore.getState().fluxEnabled, + }, onToken, }); } catch (err) { @@ -361,7 +472,7 @@ export const AppShell: React.FC = () => { const userMsg: Message = { id: crypto.randomUUID(), - sessionId: activeSessionId, + sessionId: currentSessionId, role: 'user', content, createdAt: new Date().toISOString(), @@ -376,7 +487,7 @@ export const AppShell: React.FC = () => { try { await invoke('send_message', { - sessionId: activeSessionId, + sessionId: currentSessionId, content, providerId: defaultProviderId ?? null, modelId: selectedModelId ?? null, @@ -388,6 +499,53 @@ export const AppShell: React.FC = () => { } }; + const handleStop = async () => { + const currentSessionId = useSessionStore.getState().activeSessionId; + + // 1. Stop chat streaming via backend cancellation + const { isStreaming, streamingText, clearStreaming } = useChatStore.getState(); + if (isStreaming && currentSessionId) { + try { + await invoke('cancel_chat', { sessionId: currentSessionId }); + } catch (e) { + console.error('Failed to cancel chat:', e); + } + // Save partial output as assistant message + if (streamingText.trim()) { + const partialMsg: Message = { + id: crypto.randomUUID(), + sessionId: currentSessionId, + role: 'assistant', + content: streamingText.trim(), + createdAt: new Date().toISOString(), + }; + useChatStore.getState().addMessage(partialMsg); + } + clearStreaming(); + } + + // 2. Cancel any running agent runs via backend cancellation + if (currentSessionId) { + const { agentRuns } = useAgentStore.getState(); + const runningAgents = agentRuns.filter((r) => r.status === 'running'); + if (runningAgents.length > 0) { + try { + // Cancel by session ID — this cancels the token that the agent runner uses + await invoke('cancel_agent', { id: currentSessionId }); + } catch (e) { + console.error('Failed to cancel agent:', e); + } + for (const run of runningAgents) { + updateAgentRun(run.id, { + status: 'failed', + error: 'Stopped by user', + completedAt: new Date().toISOString(), + }); + } + } + } + }; + const handlePermissionAllow = () => { if (!pendingPermission) return; invoke('agent_permission_response', { @@ -411,24 +569,36 @@ export const AppShell: React.FC = () => { className="bg-[var(--bg)] text-[var(--text)] h-screen w-screen overflow-hidden" style={{ display: 'grid', - gridTemplateColumns: rightSidebarOpen - ? 'var(--sidebar-width-left) 1fr var(--sidebar-width-right)' - : 'var(--sidebar-width-left) 1fr', - gridTemplateRows: '1fr auto', - transition: 'grid-template-columns 0.2s ease', + gridTemplateColumns: `${leftSidebarOpen ? 'var(--sidebar-width-left)' : '0px'} 1fr ${rightSidebarOpen ? 'var(--sidebar-width-right)' : '0px'}`, + gridTemplateRows: '1fr', + transition: 'grid-template-columns 0.25s cubic-bezier(0.4, 0, 0.2, 1)', }} > - <LeftSidebar /> + <div className={cn( + 'h-full overflow-hidden transition-opacity duration-200', + leftSidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none' + )}> + <LeftSidebar /> + </div> <main className="flex flex-col overflow-hidden min-h-0"> - <ChatHeader /> - <ChatPanel /> - <ChatInputBar onSend={handleSend} /> + <ChatHeader onToggleLeftSidebar={!leftSidebarOpen ? toggleLeftSidebar : undefined} /> + {mainView === 'chat' ? ( + <> + <ChatPanel onChipClick={(text) => chatInputRef.current?.prefill(text)} /> + <ChatInputBar ref={chatInputRef} onSend={handleSend} onStop={handleStop} /> + </> + ) : ( + <ExcalidrawCanvas /> + )} </main> - {rightSidebarOpen && <RightSidebar />} - - <AppFooter /> + <div className={cn( + 'h-full overflow-hidden transition-opacity duration-200', + rightSidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none' + )}> + <RightSidebar /> + </div> <SettingsModal /> diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index 120cb49..df8094f 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -1,37 +1,295 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { useSessionStore } from '@/stores/useSessionStore'; -import { PencilSimple, DotsThreeVertical, ShareNetwork } from '@phosphor-icons/react'; +import { useProjectStore } from '@/stores/useProjectStore'; -export const ChatHeader: React.FC = () => { - const { activeSessionId, sessions } = useSessionStore(); +import { useChatStore } from '@/stores/useChatStore'; +import { useAgentStore } from '@/stores/useAgentStore'; +import { PencilSimple, DotsThreeVertical, Sun, Moon, Trash, Plus, Check, X, SidebarSimple, ChatCircle, PaintBrush } from '@phosphor-icons/react'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; +import { Session } from '@/types'; +import { generateId } from '@/lib/utils'; + +interface ChatHeaderProps { + onToggleLeftSidebar?: () => void; +} + +export const ChatHeader: React.FC<ChatHeaderProps> = ({ onToggleLeftSidebar }) => { + const { activeSessionId, sessions, updateSessionTitle, setActiveSessionId, addSession, removeSession, setSessions } = useSessionStore(); + const rightSidebarOpen = useUIStore((s) => s.rightSidebarOpen); + const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); + const fluxEnabled = useUIStore((s) => s.fluxEnabled); + const toggleFlux = useUIStore((s) => s.toggleFlux); + const mainView = useUIStore((s) => s.mainView); + const setMainView = useUIStore((s) => s.setMainView); + const { activeProjectId } = useProjectStore(); + + const { theme, toggleTheme } = useUIStore(); const activeSession = sessions.find(s => s.id === activeSessionId); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const [menuOpen, setMenuOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const menuRef = useRef<HTMLDivElement>(null); + const renameInputRef = useRef<HTMLInputElement>(null); + + // Close menu on outside click + useEffect(() => { + if (!menuOpen) return; + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [menuOpen]); + + // Focus rename input when entering rename mode + useEffect(() => { + if (isRenaming && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [isRenaming]); + + const handleStartRename = () => { + if (!activeSession) return; + setRenameValue(activeSession.title); + setIsRenaming(true); + }; + + const handleConfirmRename = async () => { + const trimmed = renameValue.trim(); + if (!trimmed || !activeSessionId) { + setIsRenaming(false); + return; + } + updateSessionTitle(activeSessionId, trimmed); + setIsRenaming(false); + try { + await invoke('update_session_title', { id: activeSessionId, title: trimmed }); + } catch (e) { + console.error('Failed to rename session:', e); + } + }; + + const handleCancelRename = () => { + setIsRenaming(false); + }; + + const handleNewChat = async () => { + setMenuOpen(false); + if (!activeProjectId) return; + const now = new Date().toISOString(); + const tempSession = { id: generateId(), projectId: activeProjectId, title: 'New Chat', createdAt: now, updatedAt: now }; + addSession(tempSession); + setActiveSessionId(tempSession.id); + try { + const created = await invoke<Session>('create_session', { projectId: activeProjectId, title: 'New Chat' }); + const currentSessions = useSessionStore.getState().sessions; + setSessions(currentSessions.map(s => s.id === tempSession.id ? created : s)); + setActiveSessionId(created.id); + } catch (e) { + console.error('Failed to create session:', e); + } + }; + + const handleDeleteChat = () => { + setMenuOpen(false); + if (!activeSessionId || !activeSession) return; + setDeleteConfirmOpen(true); + }; + + const confirmDelete = async () => { + setDeleteConfirmOpen(false); + if (!activeSessionId) return; + removeSession(activeSessionId); + useChatStore.getState().setMessages([]); + useAgentStore.getState().setAgentRuns([]); + try { + await invoke('delete_session', { id: activeSessionId }); + } catch (e) { + console.error('Failed to delete session:', e); + } + }; + + + return ( + <> <header className="h-16 border-b border-[var(--border)] bg-[var(--surface)] flex items-center justify-between px-6 backdrop-blur-md sticky top-0 z-10"> <div className="flex items-center gap-3 min-w-0"> - <h1 className="text-sm font-bold truncate tracking-tight"> - {activeSession ? activeSession.title : 'Chat'} - </h1> - <button className="p-1 rounded hover:bg-[var(--surface-2)] text-[var(--text-muted)] transition-colors"> - <PencilSimple size={14} /> - </button> + {onToggleLeftSidebar && ( + <button + onClick={onToggleLeftSidebar} + className="p-1.5 rounded-lg hover:bg-[var(--hover-bg)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors" + title="Open sidebar" + > + <SidebarSimple size={18} /> + </button> + )} + {isRenaming ? ( + <div className="flex items-center gap-1.5"> + <input + ref={renameInputRef} + value={renameValue} + onChange={(e) => setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleConfirmRename(); + if (e.key === 'Escape') handleCancelRename(); + }} + className="text-sm font-bold bg-[var(--surface-2)] border border-[var(--border)] rounded px-2 py-1 w-48 focus:border-[var(--focus-border)] transition-colors" + /> + <button onClick={() => void handleConfirmRename()} className="p-1 rounded hover:bg-[var(--hover-bg)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"> + <Check size={14} /> + </button> + <button onClick={handleCancelRename} className="p-1 rounded hover:bg-[var(--hover-bg)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"> + <X size={14} /> + </button> + </div> + ) : ( + <> + <h1 className="text-sm font-bold truncate tracking-tight"> + {activeSession ? activeSession.title : 'Chat'} + </h1> + {activeSession && ( + <button + onClick={handleStartRename} + className="p-1 rounded hover:bg-[var(--hover-bg)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors" + title="Rename chat" + > + <PencilSimple size={14} /> + </button> + )} + </> + )} </div> - <div className="flex items-center gap-4"> - <div className="flex items-center gap-1.5 bg-[var(--surface-2)]/50 px-3 py-1.5 rounded-full border border-[var(--border)]"> - <div className="w-1.5 h-1.5 rounded-full bg-[var(--text-subtle)]" /> - <span className="text-[10px] font-bold uppercase tracking-widest text-[var(--text-muted)]">Ollama / DeepSeek-V3</span> + <div className="flex items-center gap-3"> + {/* View tabs: Chat / Canvas */} + <div className="flex items-center bg-[var(--surface-2)]/50 rounded-lg border border-[var(--border)] p-0.5"> + <button + onClick={() => setMainView('chat')} + className={cn( + 'flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all', + mainView === 'chat' + ? 'bg-[var(--surface)] text-[var(--text)] shadow-sm' + : 'text-[var(--text-subtle)] hover:text-[var(--text-muted)]' + )} + > + <ChatCircle size={13} weight={mainView === 'chat' ? 'fill' : 'regular'} /> + Chat + </button> + <button + onClick={() => setMainView('canvas')} + className={cn( + 'flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all', + mainView === 'canvas' + ? 'bg-[var(--surface)] text-[var(--text)] shadow-sm' + : 'text-[var(--text-subtle)] hover:text-[var(--text-muted)]' + )} + > + <PaintBrush size={13} weight={mainView === 'canvas' ? 'fill' : 'regular'} /> + Canvas + </button> + </div> + + <div className="relative group/flux"> + <button + onClick={toggleFlux} + className={cn( + 'flex items-center gap-2 px-3 py-1.5 rounded-full border text-[11px] font-bold tracking-wide transition-all duration-200', + fluxEnabled + ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]' + : 'bg-[var(--surface-2)]/50 border-[var(--border)] text-[var(--text-subtle)]' + )} + > + <div className={cn( + 'w-1.5 h-1.5 rounded-full transition-colors', + fluxEnabled ? 'bg-[var(--accent)]' : 'bg-[var(--text-subtle)]' + )} /> + enowX Flux + </button> + <div className="absolute top-full right-0 mt-2 w-64 p-3 rounded-xl bg-[var(--surface-2)] border border-[var(--border)] shadow-xl opacity-0 invisible group-hover/flux:opacity-100 group-hover/flux:visible transition-all duration-200 z-50 pointer-events-none"> + <p className="text-[12px] font-semibold text-[var(--text)] mb-1"> + {fluxEnabled ? 'enowX Flux is ON' : 'enowX Flux is OFF'} + </p> + <p className="text-[11px] text-[var(--text-muted)] leading-relaxed"> + {fluxEnabled + ? 'AI can generate interactive charts, diagrams, and visual widgets inline in chat. Uses ~3K extra tokens per request.' + : 'Text-only mode. AI won\'t generate visuals — saves tokens and speeds up responses.'} + </p> + </div> </div> <div className="flex items-center gap-1"> - <button className="p-2 rounded-lg hover:bg-[var(--surface-2)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"> - <ShareNetwork size={20} /> - </button> - <button className="p-2 rounded-lg hover:bg-[var(--surface-2)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"> - <DotsThreeVertical size={20} weight="bold" /> + <button + onClick={toggleTheme} + className="p-2 rounded-lg hover:bg-[var(--hover-bg)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors" + title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} + > + {theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />} </button> + + {/* Three-dot menu */} + <div className="relative" ref={menuRef}> + <button + onClick={() => setMenuOpen(!menuOpen)} + className="p-2 rounded-lg hover:bg-[var(--surface-2)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors" + title="Chat options" + > + <DotsThreeVertical size={20} weight="bold" /> + </button> + + {menuOpen && ( + <div className="absolute right-0 top-full mt-1 w-44 bg-[var(--surface-2)] border border-[var(--border)] rounded-lg shadow-xl overflow-hidden z-50"> + <button + onClick={() => void handleNewChat()} + className="w-full flex items-center gap-2.5 px-3 py-2.5 text-xs text-[var(--text)] hover:bg-[var(--hover-bg-strong)] transition-colors" + > + <Plus size={14} /> + New Chat + </button> + {activeSession && ( + <button + onClick={() => void handleDeleteChat()} + className="w-full flex items-center gap-2.5 px-3 py-2.5 text-xs text-[var(--danger-hover)] hover:bg-[var(--danger-bg)] transition-colors" + > + <Trash size={14} /> + Delete Chat + </button> + )} + </div> + )} + </div> + + {!rightSidebarOpen && ( + <button + onClick={toggleRightSidebar} + className="p-2 rounded-lg hover:bg-[var(--surface-2)] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors" + title="Show right panel" + > + <SidebarSimple size={20} className="scale-x-[-1]" /> + </button> + )} </div> </div> </header> + + <ConfirmDialog + open={deleteConfirmOpen} + title="Delete Chat" + message={`Are you sure you want to delete "${activeSession?.title ?? 'this chat'}"? This action cannot be undone.`} + confirmLabel="Delete" + cancelLabel="Cancel" + danger + onConfirm={() => void confirmDelete()} + onCancel={() => setDeleteConfirmOpen(false)} + /> + </> ); }; diff --git a/src/components/layout/LeftSidebar.tsx b/src/components/layout/LeftSidebar.tsx index d9fcdce..4dbcd43 100644 --- a/src/components/layout/LeftSidebar.tsx +++ b/src/components/layout/LeftSidebar.tsx @@ -6,21 +6,26 @@ import { useUIStore } from '@/stores/useUIStore'; export const LeftSidebar: React.FC = () => { const setSettingsOpen = useUIStore((s) => s.setSettingsOpen); + const toggleLeftSidebar = useUIStore((s) => s.toggleLeftSidebar); return ( <aside className="h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col w-[var(--sidebar-width-left)]"> <div className="flex items-center gap-2 p-4 border-b border-[var(--border)]"> - <div className="w-7 h-7 rounded-lg bg-[var(--surface-3)] border border-[var(--border)] flex items-center justify-center"> - <SidebarSimple size={15} weight="fill" className="text-white" /> - </div> - <span className="font-bold text-sm tracking-tight text-white">enowX Coder</span> + <button + onClick={toggleLeftSidebar} + className="w-7 h-7 rounded-lg bg-[var(--surface-3)] border border-[var(--border)] flex items-center justify-center hover:bg-[var(--surface-2)] transition-colors" + title="Toggle sidebar" + > + <SidebarSimple size={15} weight="fill" className="text-[var(--text)]" /> + </button> + <span className="font-bold text-sm tracking-tight text-[var(--text)]">enowX Coder</span> </div> <div className="px-3 pt-3 pb-2"> <ProjectSwitcher /> </div> - <div className="px-4 pt-2 pb-1 text-[10px] uppercase tracking-widest font-semibold text-[var(--text-subtle)] select-none"> + <div className="px-4 pt-2 pb-1 text-[11px] uppercase tracking-widest font-semibold text-[var(--text-subtle)] select-none"> History </div> @@ -31,7 +36,7 @@ export const LeftSidebar: React.FC = () => { <div className="p-3 border-t border-[var(--border)]"> <button onClick={() => setSettingsOpen(true)} - className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-white/5 transition-colors text-sm" + className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)] transition-colors text-sm" > <GearSix size={16} /> <span>Settings</span> diff --git a/src/components/layout/RightSidebar.tsx b/src/components/layout/RightSidebar.tsx index f77e9ae..a5945f0 100644 --- a/src/components/layout/RightSidebar.tsx +++ b/src/components/layout/RightSidebar.tsx @@ -1,11 +1,13 @@ import React, { useState } from 'react'; -import { Robot, Code, ChartBar, TerminalWindow, Cpu, Books } from '@phosphor-icons/react'; +import { Robot, Code, ChartBar, TerminalWindow, Cpu, Books, SidebarSimple } from '@phosphor-icons/react'; import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; type Tab = 'agents' | 'skills' | 'metrics'; export const RightSidebar: React.FC = () => { const [activeTab, setActiveTab] = useState<Tab>('agents'); + const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); const tabs = [ { id: 'agents' as Tab, icon: Robot, label: 'Agents' }, @@ -15,7 +17,14 @@ export const RightSidebar: React.FC = () => { return ( <aside className="h-full bg-[var(--surface)] border-l border-[var(--border)] flex flex-col w-[var(--sidebar-width-right)]"> - <div className="flex border-b border-[var(--border)]"> + <div className="flex items-center border-b border-[var(--border)]"> + <button + onClick={toggleRightSidebar} + className="p-2.5 text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--surface-2)] transition-colors shrink-0" + title="Hide panel" + > + <SidebarSimple size={16} weight="fill" className="scale-x-[-1]" /> + </button> {tabs.map((tab) => ( <button key={tab.id} @@ -23,14 +32,14 @@ export const RightSidebar: React.FC = () => { className={cn( "flex-1 flex flex-col items-center gap-1 py-3 transition-all relative group", activeTab === tab.id - ? "text-white" + ? "text-[var(--text)]" : "text-[var(--text-muted)] hover:text-[var(--text)]" )} > <tab.icon size={20} weight={activeTab === tab.id ? "fill" : "regular"} /> <span className="text-[10px] font-bold uppercase tracking-wider">{tab.label}</span> {activeTab === tab.id && ( - <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-white" /> + <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--accent)]" /> )} </button> ))} @@ -45,7 +54,7 @@ export const RightSidebar: React.FC = () => { </h3> <div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--surface-2)]/50 text-center space-y-2"> <div className="w-10 h-10 rounded-full bg-[var(--surface)] border border-[var(--border)] flex items-center justify-center mx-auto"> - <TerminalWindow size={20} weight="duotone" className="text-white" /> + <TerminalWindow size={20} weight="duotone" className="text-[var(--text)]" /> </div> <p className="text-xs font-medium">No agents running</p> <p className="text-[10px] text-[var(--text-muted)]">Spawn an agent from the chat to see progress here.</p> diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index aca2a91..54294eb 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -44,6 +44,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } model: model || preset.model, isDefault: true, isBuiltin: false, + isEnabled: true, createdAt: now, updatedAt: now, }; @@ -67,10 +68,10 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]/90 backdrop-blur-sm"> - <div className="w-full max-w-md mx-4 bg-[var(--surface)] border border-[var(--border)] rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"> + <div className="w-full max-w-md mx-4 bg-[var(--surface)] border border-[var(--border)] rounded-2xl shadow-2xl shadow-[var(--shadow)] overflow-hidden"> <div className="h-1 bg-[var(--surface-2)]"> <div - className="h-full bg-white transition-all duration-500" + className="h-full bg-[var(--accent)] transition-all duration-500" style={{ width: step === 'welcome' ? '0%' : step === 'provider' ? '33%' : step === 'project' ? '66%' : '100%' }} /> </div> @@ -79,7 +80,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } {step === 'welcome' && ( <div className="text-center space-y-6"> <div className="w-16 h-16 rounded-2xl bg-[var(--surface-3)] border border-[var(--border)] flex items-center justify-center mx-auto"> - <Sparkle size={32} weight="duotone" className="text-white" /> + <Sparkle size={32} weight="duotone" className="text-[var(--accent)]" /> </div> <div> <h1 className="text-2xl font-bold mb-2">Welcome to enowX Coder</h1> @@ -89,7 +90,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } </div> <button onClick={() => setStep('provider')} - className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-white hover:bg-[#e5e5e5] text-black font-semibold transition-colors" + className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-[var(--accent-fg)] font-semibold transition-colors" > Get Started <ArrowRight size={18} weight="bold" /> </button> @@ -111,11 +112,11 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } className={cn( 'p-3 rounded-xl border text-left transition-all', selectedPreset === i - ? 'border-white bg-white/5 text-white' + ? 'border-[var(--accent)] bg-[var(--hover-bg)] text-[var(--text)]' : 'border-[var(--border)] hover:border-[var(--accent)]/50 text-[var(--text-muted)]' )} > - <Robot size={18} weight={selectedPreset === i ? 'fill' : 'regular'} className={selectedPreset === i ? 'text-white mb-1' : 'mb-1'} /> + <Robot size={18} weight={selectedPreset === i ? 'fill' : 'regular'} className={selectedPreset === i ? 'text-[var(--accent)] mb-1' : 'mb-1'} /> <p className="text-xs font-semibold">{p.label}</p> </button> ))} @@ -159,7 +160,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } <button onClick={handleProviderNext} - className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-white hover:bg-[#e5e5e5] text-black font-semibold transition-colors" + className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-[var(--accent-fg)] font-semibold transition-colors" > Continue <ArrowRight size={18} weight="bold" /> </button> @@ -188,7 +189,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } <button onClick={handleProjectNext} - className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-white hover:bg-[#e5e5e5] text-black font-semibold transition-colors" + className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-[var(--accent-fg)] font-semibold transition-colors" > Continue <ArrowRight size={18} weight="bold" /> </button> @@ -198,7 +199,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } {step === 'done' && ( <div className="text-center space-y-6"> <div className="w-16 h-16 rounded-full bg-[var(--surface-3)] border border-[var(--border)] flex items-center justify-center mx-auto"> - <Check size={32} weight="bold" className="text-white" /> + <Check size={32} weight="bold" className="text-[var(--accent)]" /> </div> <div> <h2 className="text-xl font-bold mb-2">You're all set!</h2> @@ -208,7 +209,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete } </div> <button onClick={onComplete} - className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-white hover:bg-[#e5e5e5] text-black font-semibold transition-colors" + className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-[var(--accent-fg)] font-semibold transition-colors" > Start Coding <Sparkle size={18} weight="fill" /> </button> diff --git a/src/components/settings/AgentsTab.tsx b/src/components/settings/AgentsTab.tsx index 0145e1d..e00beb3 100644 --- a/src/components/settings/AgentsTab.tsx +++ b/src/components/settings/AgentsTab.tsx @@ -216,7 +216,7 @@ export function AgentsTab() { <button onClick={handleSave} disabled={loading || (localConfig.providerId !== null && localConfig.modelId === null)} - className="px-4 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + className="px-4 py-2 bg-[var(--accent)] text-[var(--accent-fg)] text-sm font-medium rounded-lg hover:bg-[var(--accent-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > {loading ? 'Saving...' : 'Save Configuration'} </button> diff --git a/src/components/settings/ProvidersTab.tsx b/src/components/settings/ProvidersTab.tsx index 1c3e44c..ca89255 100644 --- a/src/components/settings/ProvidersTab.tsx +++ b/src/components/settings/ProvidersTab.tsx @@ -25,16 +25,15 @@ const FIXED_BASE_URL: Record<string, boolean> = { gemini: true, }; -const PROVIDER_PRESETS: Record<ProviderType, { label: string; baseUrl: string; model: string }> = { +const PROVIDER_PRESETS: Record<string, { label: string; baseUrl: string; model: string }> = { enowxlabs: { label: 'enowX Labs', baseUrl: 'https://api.enowxlabs.com/v1', model: 'enowx-default' }, openai: { label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' }, anthropic: { label: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1', model: 'claude-3-5-sonnet-20241022' }, gemini: { label: 'Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash' }, ollama: { label: 'Ollama', baseUrl: 'http://localhost:11434/v1', model: 'llama3.2' }, - custom: { label: 'Custom', baseUrl: '', model: '' }, }; -const SIDEBAR_ITEMS: ProviderType[] = ['enowxlabs', 'openai', 'anthropic', 'gemini', 'ollama', 'custom']; +const BUILTIN_TYPES: ProviderType[] = ['enowxlabs', 'openai', 'anthropic', 'gemini', 'ollama']; interface ModelRowProps { modelName: string; @@ -65,15 +64,15 @@ const ModelRow: React.FC<ModelRowProps> = ({ modelName, config, onUpdate }) => { return ( <div className="border-b border-[var(--border)] last:border-0"> - <div className="flex items-center gap-3 py-2.5 px-3 hover:bg-white/[0.02] transition-colors"> + <div className="flex items-center gap-3 py-2.5 px-3 hover:bg-[var(--hover-bg)] transition-colors"> <div className="relative flex items-center justify-center w-5 h-5 cursor-pointer shrink-0" onClick={toggleEnabled}> <input type="checkbox" checked={config?.enabled ?? false} readOnly - className="peer appearance-none w-4 h-4 rounded border border-[var(--border)] bg-[var(--surface)] checked:bg-white checked:border-white transition-all cursor-pointer" + className="peer appearance-none w-4 h-4 rounded border border-[var(--border)] bg-[var(--surface)] checked:bg-[var(--accent)] checked:border-[var(--accent)] transition-all cursor-pointer" /> - <Check size={10} weight="bold" className="absolute text-black opacity-0 peer-checked:opacity-100 pointer-events-none" /> + <Check size={10} weight="bold" className="absolute text-[var(--accent-fg)] opacity-0 peer-checked:opacity-100 pointer-events-none" /> </div> <span className={cn('text-sm flex-1 truncate', config?.enabled ? 'text-[var(--text)]' : 'text-[var(--text-muted)]')}> @@ -101,7 +100,7 @@ const ModelRow: React.FC<ModelRowProps> = ({ modelName, config, onUpdate }) => { max={2000000} value={contextWindow} onChange={(e) => { setContextWindow(Number(e.target.value)); setIsDirty(true); }} - className="w-full bg-[var(--surface-2)] border border-[var(--border)] rounded px-2 py-1.5 text-xs text-[var(--text)] focus:border-white/30 transition-colors" + className="w-full bg-[var(--surface-2)] border border-[var(--border)] rounded px-2 py-1.5 text-xs text-[var(--text)] focus:border-[var(--focus-border)] transition-colors" /> <p className="text-[9px] text-[var(--text-subtle)] mt-1">Total tokens (input + output)</p> </div> @@ -116,7 +115,7 @@ const ModelRow: React.FC<ModelRowProps> = ({ modelName, config, onUpdate }) => { step={0.1} value={temperature} onChange={(e) => { setTemperature(Number(e.target.value)); setIsDirty(true); }} - className="w-full bg-[var(--surface-2)] border border-[var(--border)] rounded px-2 py-1.5 text-xs text-[var(--text)] focus:border-white/30 transition-colors" + className="w-full bg-[var(--surface-2)] border border-[var(--border)] rounded px-2 py-1.5 text-xs text-[var(--text)] focus:border-[var(--focus-border)] transition-colors" /> </div> </div> @@ -124,7 +123,7 @@ const ModelRow: React.FC<ModelRowProps> = ({ modelName, config, onUpdate }) => { <div className="flex justify-end"> <button onClick={handleSave} - className="text-[10px] font-bold bg-white text-black px-2.5 py-1 rounded hover:bg-[#e5e5e5] transition-colors" + className="text-[10px] font-bold bg-[var(--accent)] text-[var(--accent-fg)] px-2.5 py-1 rounded hover:bg-[var(--accent-hover)] transition-colors" > Save </button> @@ -154,9 +153,26 @@ export const ProvidersTab: React.FC = () => { const [baseUrlEdit, setBaseUrlEdit] = useState(''); const [baseUrlDirty, setBaseUrlDirty] = useState(false); + const [modelEdit, setModelEdit] = useState(''); + const [modelDirty, setModelDirty] = useState(false); + const [isEditingName, setIsEditingName] = useState(false); const [nameEdit, setNameEdit] = useState(''); + const [saveError, setSaveError] = useState<string | null>(null); + + // Manual model add + const [manualModelName, setManualModelName] = useState(''); + + // Add Provider form state + const [addingNew, setAddingNew] = useState(false); + const [newName, setNewName] = useState(''); + const [newBaseUrl, setNewBaseUrl] = useState(''); + const [newApiKey, setNewApiKey] = useState(''); + const [newModel, setNewModel] = useState(''); + const [newSaving, setNewSaving] = useState(false); + const [newError, setNewError] = useState<string | null>(null); + // The provider record for the currently selected type (may be null if not yet created) const selectedProvider = providers.find(p => p.providerType === selectedType) ?? null; @@ -180,12 +196,16 @@ export const ProvidersTab: React.FC = () => { setModelsError(null); setApiKeyDirty(false); setBaseUrlDirty(false); + setModelDirty(false); setIsEditingName(false); + setSaveError(null); const p = providers.find(pr => pr.providerType === selectedType) ?? null; + const preset = PROVIDER_PRESETS[selectedType]; setApiKeyEdit(p?.apiKey ?? ''); - setBaseUrlEdit(p?.baseUrl ?? PROVIDER_PRESETS[selectedType].baseUrl); - setNameEdit(p?.name ?? PROVIDER_PRESETS[selectedType].label); + setBaseUrlEdit(p?.baseUrl ?? preset?.baseUrl ?? ''); + setModelEdit(p?.model ?? preset?.model ?? ''); + setNameEdit(p?.name ?? preset?.label ?? selectedType); if (!p) return; @@ -194,10 +214,14 @@ export const ProvidersTab: React.FC = () => { setModelsError(null); try { const [models, configs] = await Promise.all([ - invoke<string[]>('list_models', { providerId: p.id }), + invoke<string[]>('list_models', { providerId: p.id }).catch(() => [] as string[]), invoke<ProviderModelConfig[]>('list_provider_models', { providerId: p.id }), ]); - setAvailableModels(models); + // Merge: API models + any manually added models from DB configs + const configModelIds = configs.map(c => c.modelId); + const manualModels = configModelIds.filter(id => !models.includes(id)); + const allModels = [...models, ...manualModels]; + setAvailableModels(allModels); const map: Record<string, ProviderModelConfig> = {}; configs.forEach(c => { map[c.modelId] = c; }); setModelConfigs(map); @@ -228,17 +252,25 @@ export const ProvidersTab: React.FC = () => { const ensureProvider = async (): Promise<Provider | null> => { if (selectedProvider) return selectedProvider; const preset = PROVIDER_PRESETS[selectedType]; + const resolvedModel = modelEdit.trim() || preset.model; + if (!resolvedModel) { + setSaveError('Model name is required. Enter a model name first.'); + return null; + } try { + setSaveError(null); const created = await invoke<Provider>('create_provider', { - name: preset.label, + name: nameEdit || preset.label, providerType: selectedType, - baseUrl: preset.baseUrl || baseUrlEdit, + baseUrl: baseUrlEdit || preset.baseUrl, apiKey: apiKeyEdit || null, - model: preset.model, + model: resolvedModel, }); await loadProviders(); return created; } catch (e) { + const msg = typeof e === 'string' ? e : (e as Error)?.message ?? 'Failed to create provider'; + setSaveError(msg); console.error('create_provider error:', e); return null; } @@ -246,6 +278,7 @@ export const ProvidersTab: React.FC = () => { const handleSaveApiKey = async () => { setIsSavingKey(true); + setSaveError(null); try { let p = selectedProvider; if (!p) { @@ -255,13 +288,15 @@ export const ProvidersTab: React.FC = () => { await invoke('update_provider', { id: p.id, name: p.name, - baseUrl: p.baseUrl, + baseUrl: baseUrlEdit || p.baseUrl, apiKey: apiKeyEdit || null, - model: p.model, + model: modelEdit.trim() || p.model, }); await loadProviders(); setApiKeyDirty(false); } catch (e) { + const msg = typeof e === 'string' ? e : (e as Error)?.message ?? 'Failed to save API key'; + setSaveError(msg); console.error('update_provider (apiKey) error:', e); } finally { setIsSavingKey(false); @@ -269,22 +304,58 @@ export const ProvidersTab: React.FC = () => { }; const handleSaveBaseUrl = async () => { - if (!selectedProvider) return; + setSaveError(null); + let p = selectedProvider; + if (!p) { + p = await ensureProvider(); + if (!p) return; + } try { await invoke('update_provider', { - id: selectedProvider.id, - name: selectedProvider.name, + id: p.id, + name: p.name, baseUrl: baseUrlEdit, - apiKey: selectedProvider.apiKey ?? null, - model: selectedProvider.model, + apiKey: p.apiKey ?? null, + model: modelEdit.trim() || p.model, }); await loadProviders(); setBaseUrlDirty(false); } catch (e) { + const msg = typeof e === 'string' ? e : (e as Error)?.message ?? 'Failed to save base URL'; + setSaveError(msg); console.error('update_provider (baseUrl) error:', e); } }; + const handleSaveModel = async () => { + setSaveError(null); + const trimmedModel = modelEdit.trim(); + if (!trimmedModel) { + setSaveError('Model name cannot be empty.'); + return; + } + let p = selectedProvider; + if (!p) { + p = await ensureProvider(); + if (!p) return; + } + try { + await invoke('update_provider', { + id: p.id, + name: p.name, + baseUrl: p.baseUrl, + apiKey: p.apiKey ?? null, + model: trimmedModel, + }); + await loadProviders(); + setModelDirty(false); + } catch (e) { + const msg = typeof e === 'string' ? e : (e as Error)?.message ?? 'Failed to save model'; + setSaveError(msg); + console.error('update_provider (model) error:', e); + } + }; + const handleSaveName = async () => { if (!selectedProvider) return; try { @@ -307,11 +378,53 @@ export const ProvidersTab: React.FC = () => { try { await invoke('delete_provider', { id: selectedProvider.id }); await loadProviders(); + setSelectedType('enowxlabs'); } catch (e) { console.error('delete_provider error:', e); } }; + const handleToggleEnabled = async () => { + if (!selectedProvider) return; + try { + await invoke('toggle_provider_enabled', { id: selectedProvider.id, enabled: !selectedProvider.isEnabled }); + await loadProviders(); + } catch (e) { + console.error('toggle_provider_enabled error:', e); + } + }; + + const handleAddProvider = async () => { + const trimmedName = newName.trim(); + if (!trimmedName) { setNewError('Provider name is required.'); return; } + if (!newBaseUrl.trim()) { setNewError('Base URL is required.'); return; } + if (!newModel.trim()) { setNewError('Default model is required.'); return; } + + setNewSaving(true); + setNewError(null); + try { + // Use a slug of the name as providerType + const providerType = trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const created = await invoke<Provider>('create_provider', { + name: trimmedName, + providerType, + baseUrl: newBaseUrl.trim(), + apiKey: newApiKey.trim() || null, + model: newModel.trim(), + }); + await loadProviders(); + // Select the new provider + setSelectedType(created.providerType as ProviderType); + setAddingNew(false); + setNewName(''); setNewBaseUrl(''); setNewApiKey(''); setNewModel(''); + } catch (e) { + const msg = typeof e === 'string' ? e : (e as Error)?.message ?? 'Failed to create provider'; + setNewError(msg); + } finally { + setNewSaving(false); + } + }; + const handleUpsertConfig = async ( modelId: string, patch: { enabled?: boolean; contextWindow?: number; temperature?: number }, @@ -335,6 +448,18 @@ export const ProvidersTab: React.FC = () => { } }; + const handleAddModelManually = async () => { + const name = manualModelName.trim(); + if (!name || !selectedProvider) return; + // Add to available models list if not already there + if (!availableModels.includes(name)) { + setAvailableModels(prev => [...prev, name]); + } + // Auto-enable it + await handleUpsertConfig(name, { enabled: true }); + setManualModelName(''); + }; + const handleCheckAll = async (enable: boolean) => { if (!selectedProvider) return; await Promise.all( @@ -350,35 +475,79 @@ export const ProvidersTab: React.FC = () => { {/* Sidebar */} <div className="w-52 shrink-0 border-r border-[var(--border)] flex flex-col h-full bg-[var(--surface-2)]/10"> <div className="flex-1 overflow-y-auto py-3 space-y-0.5"> - {SIDEBAR_ITEMS.map(type => { - const exists = providers.some(p => p.providerType === type); - const isBuiltin = providers.find(p => p.providerType === type)?.isBuiltin ?? false; + {/* Built-in provider types */} + {BUILTIN_TYPES.map(type => { + const provider = providers.find(p => p.providerType === type); + const exists = !!provider; return ( <div key={type} - onClick={() => setSelectedType(type)} + onClick={() => { setSelectedType(type); setAddingNew(false); }} className={cn( 'cursor-pointer px-3 py-2.5 mx-2 rounded-lg flex items-center gap-2.5 transition-colors', - selectedType === type - ? 'bg-white/10 text-white' - : 'text-[var(--text-muted)] hover:bg-white/5 hover:text-[var(--text)]' + selectedType === type && !addingNew + ? 'bg-[var(--hover-bg-strong)] text-[var(--text)]' + : 'text-[var(--text-muted)] hover:bg-[var(--hover-bg)] hover:text-[var(--text)]' )} > - {isBuiltin ? ( - <Sparkle size={10} weight="fill" className="shrink-0 text-[var(--text-muted)]" /> - ) : ( - <div className={cn('w-2 h-2 rounded-full shrink-0', exists ? 'bg-white/60' : 'bg-white/20')} /> - )} + <Sparkle size={10} weight="fill" className="shrink-0 text-[var(--text-muted)]" /> <div className="flex-1 min-w-0"> - <div className="font-medium text-xs truncate">{PROVIDER_PRESETS[type].label}</div> + <div className="font-medium text-xs truncate">{PROVIDER_PRESETS[type]?.label ?? type}</div> <div className="text-[9px] uppercase tracking-wider text-[var(--text-subtle)] truncate">{type}</div> </div> - {exists && ( - <div className="w-1.5 h-1.5 rounded-full bg-white/40 shrink-0" title="Configured" /> + {exists && provider.isEnabled && ( + <div className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" title="Active" /> + )} + {exists && !provider.isEnabled && ( + <div className="w-1.5 h-1.5 rounded-full bg-[var(--border)] shrink-0" title="Disabled" /> )} </div> ); })} + + {/* User-created providers */} + {providers.filter(p => !BUILTIN_TYPES.includes(p.providerType as ProviderType) && p.providerType !== 'custom').length > 0 && ( + <div className="mx-4 my-2 border-t border-[var(--border)]" /> + )} + {providers + .filter(p => !BUILTIN_TYPES.includes(p.providerType as ProviderType)) + .map(p => ( + <div + key={p.id} + onClick={() => { setSelectedType(p.providerType as ProviderType); setAddingNew(false); }} + className={cn( + 'cursor-pointer px-3 py-2.5 mx-2 rounded-lg flex items-center gap-2.5 transition-colors', + selectedType === p.providerType && !addingNew + ? 'bg-[var(--hover-bg-strong)] text-[var(--text)]' + : 'text-[var(--text-muted)] hover:bg-[var(--hover-bg)] hover:text-[var(--text)]' + )} + > + <div className={cn('w-2 h-2 rounded-full shrink-0', p.isEnabled ? 'bg-[var(--accent)]' : 'bg-[var(--border)]')} /> + <div className="flex-1 min-w-0"> + <div className="font-medium text-xs truncate">{p.name}</div> + <div className="text-[9px] uppercase tracking-wider text-[var(--text-subtle)] truncate">{p.providerType}</div> + </div> + {p.isEnabled && ( + <div className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" title="Active" /> + )} + </div> + ))} + </div> + + {/* Add Provider button */} + <div className="p-2 border-t border-[var(--border)]"> + <button + onClick={() => setAddingNew(true)} + className={cn( + 'w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-colors', + addingNew + ? 'bg-[var(--hover-bg-strong)] text-[var(--text)]' + : 'text-[var(--text-muted)] hover:bg-[var(--hover-bg)] hover:text-[var(--text)]' + )} + > + <span className="text-sm">+</span> + Add Provider + </button> </div> </div> @@ -386,7 +555,81 @@ export const ProvidersTab: React.FC = () => { <div className="flex-1 flex flex-col h-full bg-[var(--surface)] overflow-hidden"> <div className="flex-1 overflow-y-auto p-6 space-y-6"> - {/* Header */} + {/* ── Add Provider Form ── */} + {addingNew ? ( + <> + <div className="pb-5 border-b border-[var(--border)]"> + <h1 className="text-lg font-bold">Add Provider</h1> + <p className="text-[11px] text-[var(--text-muted)] mt-1">Add a custom OpenAI-compatible provider.</p> + </div> + + <div className="space-y-4 max-w-lg"> + <div> + <label className="block text-[11px] font-semibold text-[var(--text-muted)] mb-1.5">Provider Name</label> + <input + value={newName} + onChange={(e) => setNewName(e.target.value)} + placeholder="e.g. Together AI, Groq, DeepSeek" + className="w-full px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-[var(--focus-border)] transition-colors" + /> + </div> + <div> + <label className="block text-[11px] font-semibold text-[var(--text-muted)] mb-1.5">Base URL</label> + <input + value={newBaseUrl} + onChange={(e) => setNewBaseUrl(e.target.value)} + placeholder="https://api.example.com/v1" + className="w-full px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-[var(--focus-border)] transition-colors" + /> + </div> + <div> + <label className="block text-[11px] font-semibold text-[var(--text-muted)] mb-1.5">API Key</label> + <input + type="password" + value={newApiKey} + onChange={(e) => setNewApiKey(e.target.value)} + placeholder="sk-..." + className="w-full px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-[var(--focus-border)] transition-colors" + /> + </div> + <div> + <label className="block text-[11px] font-semibold text-[var(--text-muted)] mb-1.5">Default Model</label> + <input + value={newModel} + onChange={(e) => setNewModel(e.target.value)} + placeholder="e.g. deepseek-chat, llama-3.1-70b" + className="w-full px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-[var(--focus-border)] transition-colors" + /> + <p className="text-[9px] text-[var(--text-subtle)] mt-1">The model identifier used for API requests</p> + </div> + + {newError && ( + <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--danger-bg)] border border-[var(--danger-border)]"> + <X size={14} className="text-[var(--danger)] shrink-0" /> + <p className="text-xs text-[var(--danger)]">{newError}</p> + </div> + )} + + <div className="flex items-center gap-2 pt-2"> + <button + onClick={() => void handleAddProvider()} + disabled={newSaving} + className="px-4 py-2 rounded-lg bg-[var(--accent)] text-[var(--accent-fg)] text-xs font-bold hover:bg-[var(--accent-hover)] transition-colors disabled:opacity-50" + > + {newSaving ? 'Creating…' : 'Create Provider'} + </button> + <button + onClick={() => { setAddingNew(false); setNewError(null); }} + className="px-4 py-2 rounded-lg text-xs font-medium text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)] transition-colors" + > + Cancel + </button> + </div> + </div> + </> + ) : ( + <> + {/* ── Provider Detail Header ── */} <div className="flex items-start justify-between pb-5 border-b border-[var(--border)]"> <div> <div className="flex items-center gap-3"> @@ -395,18 +638,18 @@ export const ProvidersTab: React.FC = () => { <input value={nameEdit} onChange={(e) => setNameEdit(e.target.value)} - className="bg-[var(--surface-2)] border border-[var(--border)] rounded px-2 py-1 text-base font-bold w-48 focus:border-white/40" + className="bg-[var(--surface-2)] border border-[var(--border)] rounded px-2 py-1 text-base font-bold w-48 focus:border-[var(--focus-border)]" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') setIsEditingName(false); }} /> - <button onClick={handleSaveName} className="p-1 text-[var(--text-muted)] hover:text-white"><Check size={15} /></button> - <button onClick={() => setIsEditingName(false)} className="p-1 text-[var(--text-muted)] hover:text-white"><X size={15} /></button> + <button onClick={handleSaveName} className="p-1 text-[var(--text-muted)] hover:text-[var(--text)]"><Check size={15} /></button> + <button onClick={() => setIsEditingName(false)} className="p-1 text-[var(--text-muted)] hover:text-[var(--text)]"><X size={15} /></button> </div> ) : ( <h1 className="text-lg font-bold flex items-center gap-2"> - {selectedProvider?.name ?? PROVIDER_PRESETS[selectedType].label} + {selectedProvider?.name ?? PROVIDER_PRESETS[selectedType]?.label ?? selectedType} {selectedProvider && !selectedProvider.isBuiltin && ( - <button onClick={() => setIsEditingName(true)} className="text-[var(--text-muted)] hover:text-white transition-colors"> + <button onClick={() => setIsEditingName(true)} className="text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"> <PencilSimple size={13} /> </button> )} @@ -418,7 +661,7 @@ export const ProvidersTab: React.FC = () => { {selectedType} </span> {selectedProvider?.isBuiltin && ( - <span className="text-[10px] uppercase tracking-wider font-semibold text-[var(--text-muted)] bg-white/5 px-1.5 py-0.5 rounded flex items-center gap-1"> + <span className="text-[10px] uppercase tracking-wider font-semibold text-[var(--text-muted)] bg-[var(--hover-bg)] px-1.5 py-0.5 rounded flex items-center gap-1"> <Sparkle size={9} weight="fill" /> Built-in </span> )} @@ -428,15 +671,34 @@ export const ProvidersTab: React.FC = () => { </div> </div> - {selectedProvider && !selectedProvider.isBuiltin && ( - <button - onClick={handleDeleteProvider} - className="p-2 rounded-lg text-[var(--text-muted)] hover:text-white hover:bg-white/5 transition-colors" - title="Remove provider" - > - <Trash size={16} /> - </button> - )} + <div className="flex items-center gap-1"> + {/* Enable/Disable toggle */} + {selectedProvider && ( + <button + onClick={() => void handleToggleEnabled()} + className={cn( + 'px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-colors', + selectedProvider.isEnabled + ? 'bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20' + : 'bg-[var(--surface-2)] text-[var(--text-subtle)] hover:text-[var(--text-muted)]' + )} + title={selectedProvider.isEnabled ? 'Disable this provider' : 'Enable this provider'} + > + {selectedProvider.isEnabled ? 'Enabled' : 'Disabled'} + </button> + )} + + {/* Delete button (non-builtin only) */} + {selectedProvider && !selectedProvider.isBuiltin && ( + <button + onClick={handleDeleteProvider} + className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--danger)] hover:bg-[var(--danger-bg)] transition-colors" + title="Remove provider" + > + <Trash size={16} /> + </button> + )} + </div> </div> {/* Configuration */} @@ -452,17 +714,41 @@ export const ProvidersTab: React.FC = () => { value={baseUrlEdit} onChange={(e) => { setBaseUrlEdit(e.target.value); setBaseUrlDirty(true); }} placeholder="http://localhost:11434/v1" - className="flex-1 px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-white/40 transition-colors" + className="flex-1 px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-[var(--focus-border)] transition-colors" /> - {baseUrlDirty && selectedProvider && ( + {baseUrlDirty && ( <button onClick={handleSaveBaseUrl} - className="px-3 py-2 rounded-lg bg-white text-black text-xs font-bold hover:bg-[#e5e5e5] transition-colors" + className="px-3 py-2 rounded-lg bg-[var(--accent)] text-[var(--accent-fg)] text-xs font-bold hover:bg-[var(--accent-hover)] transition-colors" + > + Save + </button> + )} + </div> + </div> + )} + + {/* Model Name — for custom/ollama */} + {!FIXED_BASE_URL[selectedType] && ( + <div> + <label className="block text-[11px] font-semibold text-[var(--text-muted)] mb-1.5">Default Model</label> + <div className="flex gap-2"> + <input + value={modelEdit} + onChange={(e) => { setModelEdit(e.target.value); setModelDirty(true); setSaveError(null); }} + placeholder="e.g. deepseek-v3, llama3.2, gpt-4o" + className="flex-1 px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-[var(--focus-border)] transition-colors" + /> + {modelDirty && selectedProvider && ( + <button + onClick={handleSaveModel} + className="px-3 py-2 rounded-lg bg-[var(--accent)] text-[var(--accent-fg)] text-xs font-bold hover:bg-[var(--accent-hover)] transition-colors" > Save </button> )} </div> + <p className="text-[9px] text-[var(--text-subtle)] mt-1">The model identifier used for API requests</p> </div> )} @@ -475,13 +761,13 @@ export const ProvidersTab: React.FC = () => { value={apiKeyEdit} onChange={(e) => { setApiKeyEdit(e.target.value); setApiKeyDirty(true); }} placeholder={selectedType === 'ollama' ? 'Not required for local Ollama' : 'sk-...'} - className="flex-1 px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-white/40 transition-colors" + className="flex-1 px-3 py-2 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-sm focus:border-[var(--focus-border)] transition-colors" /> {apiKeyDirty && ( <button onClick={handleSaveApiKey} disabled={isSavingKey} - className="px-3 py-2 rounded-lg bg-white text-black text-xs font-bold hover:bg-[#e5e5e5] transition-colors disabled:opacity-50" + className="px-3 py-2 rounded-lg bg-[var(--accent)] text-[var(--accent-fg)] text-xs font-bold hover:bg-[var(--accent-hover)] transition-colors disabled:opacity-50" > {isSavingKey ? 'Saving…' : 'Save'} </button> @@ -490,12 +776,20 @@ export const ProvidersTab: React.FC = () => { {selectedType === 'enowxlabs' && ( <p className="text-[10px] text-[var(--text-muted)] mt-1.5"> Get your API key at{' '} - <a href="https://api.enowxlabs.com" target="_blank" rel="noreferrer" className="underline hover:text-white"> + <a href="https://api.enowxlabs.com" target="_blank" rel="noreferrer" className="underline hover:text-[var(--text)]"> api.enowxlabs.com </a> </p> )} </div> + + {/* Error message */} + {saveError && ( + <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--danger-bg)] border border-[var(--danger-border)]"> + <X size={14} className="text-[var(--danger)] shrink-0" /> + <p className="text-xs text-[var(--danger)]">{saveError}</p> + </div> + )} </div> {/* Available Models — only shown when provider is configured */} @@ -507,7 +801,7 @@ export const ProvidersTab: React.FC = () => { {availableModels.length > 0 && !modelsLoading && ( <button onClick={() => handleCheckAll(!allEnabled)} - className="flex items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-white transition-colors" + className="flex items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors" > {allEnabled ? <CheckSquare size={13} weight="fill" /> : someEnabled ? <CheckSquare size={13} /> : <Square size={13} />} {allEnabled ? 'Uncheck all' : 'Check all'} @@ -516,7 +810,7 @@ export const ProvidersTab: React.FC = () => { <button onClick={refreshModels} disabled={modelsLoading} - className="flex items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-white transition-colors disabled:opacity-50" + className="flex items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text)] transition-colors disabled:opacity-50" > <ArrowsClockwise size={12} className={modelsLoading ? 'animate-spin' : ''} /> Refresh @@ -524,6 +818,24 @@ export const ProvidersTab: React.FC = () => { </div> </div> + {/* Add model manually */} + <div className="flex gap-2"> + <input + value={manualModelName} + onChange={(e) => setManualModelName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void handleAddModelManually(); }} + placeholder="Add model manually (e.g. gpt-4o, claude-sonnet-4)" + className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--surface-2)] border border-[var(--border)] text-xs focus:border-[var(--focus-border)] transition-colors" + /> + <button + onClick={() => void handleAddModelManually()} + disabled={!manualModelName.trim()} + className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-[var(--surface-2)] border border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg-strong)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed" + > + Add + </button> + </div> + <div className="border border-[var(--border)] rounded-xl overflow-hidden bg-[var(--surface-2)]/20"> {modelsLoading ? ( <div className="p-8 text-center text-[var(--text-muted)] text-sm"> @@ -533,7 +845,7 @@ export const ProvidersTab: React.FC = () => { ) : modelsError ? ( <div className="p-8 text-center space-y-2"> <p className="text-sm text-[var(--text-muted)]">{modelsError}</p> - <button onClick={refreshModels} className="text-xs underline text-[var(--text-muted)] hover:text-white"> + <button onClick={refreshModels} className="text-xs underline text-[var(--text-muted)] hover:text-[var(--text)]"> Try again </button> </div> @@ -564,6 +876,8 @@ export const ProvidersTab: React.FC = () => { <p className="text-[10px] text-[var(--text-subtle)]">Available models will appear once configured.</p> </div> )} + </> + )} </div> </div> diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 7376cb1..ac1250b 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -24,18 +24,18 @@ export const SettingsModal: React.FC = () => { return ( <div - className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" + className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--overlay)] backdrop-blur-sm" onClick={() => setSettingsOpen(false)} > <div - className="bg-[var(--surface)] border border-[var(--border)] rounded-2xl w-full max-w-6xl h-[64vh] flex flex-col overflow-hidden shadow-2xl shadow-black/50" + className="bg-[var(--surface)] border border-[var(--border)] rounded-2xl w-full max-w-6xl h-[64vh] flex flex-col overflow-hidden shadow-2xl shadow-[var(--shadow)]" onClick={(e) => e.stopPropagation()} > <div className="flex items-center justify-between px-5 pt-5 pb-1"> <h2 className="text-sm font-bold text-[var(--text)]">Settings</h2> <button onClick={() => setSettingsOpen(false)} - className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-white/5 transition-colors" + className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)] transition-colors" > <X size={16} /> </button> @@ -47,8 +47,8 @@ export const SettingsModal: React.FC = () => { className={cn( "px-3 py-2 text-xs font-semibold rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-2", activeTab === 'providers' - ? "text-white border-white bg-white/5" - : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-white/5" + ? "text-[var(--text)] border-[var(--accent)] bg-[var(--hover-bg)]" + : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-[var(--hover-bg)]" )} > <Robot size={14} weight={activeTab === 'providers' ? "fill" : "regular"} /> @@ -59,8 +59,8 @@ export const SettingsModal: React.FC = () => { className={cn( "px-3 py-2 text-xs font-semibold rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-2", activeTab === 'agents' - ? "text-white border-white bg-white/5" - : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-white/5" + ? "text-[var(--text)] border-[var(--accent)] bg-[var(--hover-bg)]" + : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-[var(--hover-bg)]" )} > <Robot size={14} weight={activeTab === 'agents' ? "fill" : "regular"} /> @@ -71,8 +71,8 @@ export const SettingsModal: React.FC = () => { className={cn( "px-3 py-2 text-xs font-semibold rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-2", activeTab === 'tools' - ? "text-white border-white bg-white/5" - : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-white/5" + ? "text-[var(--text)] border-[var(--accent)] bg-[var(--hover-bg)]" + : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-[var(--hover-bg)]" )} > <Wrench size={14} weight={activeTab === 'tools' ? "fill" : "regular"} /> @@ -83,8 +83,8 @@ export const SettingsModal: React.FC = () => { className={cn( "px-3 py-2 text-xs font-semibold rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-2", activeTab === 'system' - ? "text-white border-white bg-white/5" - : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-white/5" + ? "text-[var(--text)] border-[var(--accent)] bg-[var(--hover-bg)]" + : "text-[var(--text-muted)] border-transparent hover:text-[var(--text)] hover:bg-[var(--hover-bg)]" )} > <GearSix size={14} weight={activeTab === 'system' ? "fill" : "regular"} /> diff --git a/src/components/sidebar/ProjectSwitcher.tsx b/src/components/sidebar/ProjectSwitcher.tsx index efbc952..0592266 100644 --- a/src/components/sidebar/ProjectSwitcher.tsx +++ b/src/components/sidebar/ProjectSwitcher.tsx @@ -37,14 +37,14 @@ export const ProjectSwitcher: React.FC = () => { <button onClick={handleOpenFolder} disabled={loading} - className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-white/5 transition-colors text-sm border border-dashed border-[var(--border)] hover:border-[var(--border-strong)] disabled:opacity-50 disabled:cursor-not-allowed" + className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)] transition-colors text-sm border border-dashed border-[var(--border)] hover:border-[var(--border-strong)] disabled:opacity-50 disabled:cursor-not-allowed" > {loading ? ( <CircleNotch size={15} className="animate-spin" /> ) : ( <FolderOpen size={15} /> )} - <span className="text-xs">{loading ? 'Opening…' : 'Open folder'}</span> + <span className="text-[13px]">{loading ? 'Opening…' : 'Open folder'}</span> </button> ); }; diff --git a/src/components/sidebar/SessionList.tsx b/src/components/sidebar/SessionList.tsx index 41ec144..fe92bac 100644 --- a/src/components/sidebar/SessionList.tsx +++ b/src/components/sidebar/SessionList.tsx @@ -2,7 +2,10 @@ import React, { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { useSessionStore } from '@/stores/useSessionStore'; import { useProjectStore } from '@/stores/useProjectStore'; +import { useChatStore } from '@/stores/useChatStore'; +import { useAgentStore } from '@/stores/useAgentStore'; import { FolderSimple, ChatCircleText, Trash, CaretRight, Plus } from '@phosphor-icons/react'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { cn } from '@/lib/utils'; import { generateId } from '@/lib/utils'; import { Session } from '@/types'; @@ -13,6 +16,7 @@ export const SessionList: React.FC = () => { const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => { return new Set(activeProjectId ? [activeProjectId] : []); }); + const [deleteTarget, setDeleteTarget] = useState<string | null>(null); useEffect(() => { if (!activeProjectId) return; @@ -49,9 +53,22 @@ export const SessionList: React.FC = () => { } }; - const handleDeleteSession = async (e: React.MouseEvent, sessionId: string) => { + const handleDeleteSession = (e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); + setDeleteTarget(sessionId); + }; + + const confirmDeleteSession = async () => { + if (!deleteTarget) return; + const sessionId = deleteTarget; + setDeleteTarget(null); + + const wasActive = activeSessionId === sessionId; removeSession(sessionId); + if (wasActive) { + useChatStore.getState().setMessages([]); + useAgentStore.getState().setAgentRuns([]); + } try { await invoke('delete_session', { id: sessionId }); @@ -78,7 +95,7 @@ export const SessionList: React.FC = () => { return ( <div key={project.id}> <div - className="group flex items-center gap-1.5 px-2 py-1.5 mx-1 rounded-md cursor-pointer hover:bg-white/5 transition-colors" + className="group flex items-center gap-1.5 px-2 py-1.5 mx-1 rounded-md cursor-pointer hover:bg-[var(--hover-bg)] transition-colors" onClick={() => toggleProject(project.id)} > <CaretRight @@ -90,16 +107,16 @@ export const SessionList: React.FC = () => { )} /> <FolderSimple - size={14} + size={16} weight={isExpanded ? 'fill' : 'regular'} className="text-[var(--text-muted)] shrink-0" /> - <span className="text-xs font-medium text-[var(--text-muted)] truncate flex-1 group-hover:text-[var(--text)]"> + <span className="text-[13px] font-medium text-[var(--text-muted)] truncate flex-1 group-hover:text-[var(--text)]"> {project.name} </span> <button onClick={(e) => handleNewSession(e, project.id)} - className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10 text-[var(--text-subtle)] hover:text-[var(--text)] transition-all" + className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-[var(--hover-bg-strong)] text-[var(--text-subtle)] hover:text-[var(--text)] transition-all" title="New chat" > <Plus size={12} /> @@ -115,15 +132,15 @@ export const SessionList: React.FC = () => { <div key={session.id} className={cn( - 'group flex items-center gap-2 px-2 py-1.5 mx-1 rounded-md cursor-pointer transition-colors text-xs', + 'group flex items-center gap-2 px-2 py-1.5 mx-1 rounded-md cursor-pointer transition-colors text-[13px]', activeSessionId === session.id - ? 'bg-white/10 text-white' - : 'text-[var(--text-subtle)] hover:bg-white/5 hover:text-[var(--text-muted)]' + ? 'bg-[var(--hover-bg-strong)] text-[var(--text)]' + : 'text-[var(--text-subtle)] hover:bg-[var(--hover-bg)] hover:text-[var(--text-muted)]' )} onClick={() => setActiveSessionId(session.id)} > <ChatCircleText - size={13} + size={15} weight={activeSessionId === session.id ? 'fill' : 'regular'} className="shrink-0" /> @@ -132,7 +149,7 @@ export const SessionList: React.FC = () => { onClick={(e) => { void handleDeleteSession(e, session.id); }} - className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10 transition-all" + className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-[var(--hover-bg-strong)] transition-all" > <Trash size={11} /> </button> @@ -144,6 +161,17 @@ export const SessionList: React.FC = () => { </div> ); })} + + <ConfirmDialog + open={deleteTarget !== null} + title="Delete Chat" + message={`Are you sure you want to delete "${sessions.find(s => s.id === deleteTarget)?.title ?? 'this chat'}"? This action cannot be undone.`} + confirmLabel="Delete" + cancelLabel="Cancel" + danger + onConfirm={() => void confirmDeleteSession()} + onCancel={() => setDeleteTarget(null)} + /> </div> ); }; diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..f3fc4da --- /dev/null +++ b/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef } from 'react'; +import { Warning } from '@phosphor-icons/react'; +import { cn } from '@/lib/utils'; + +interface ConfirmDialogProps { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ + open, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + danger = false, + onConfirm, + onCancel, +}) => { + const confirmRef = useRef<HTMLButtonElement>(null); + + useEffect(() => { + if (!open) return; + // Focus confirm button on open + setTimeout(() => confirmRef.current?.focus(), 50); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, onCancel]); + + if (!open) return null; + + return ( + <div + className="fixed inset-0 z-[60] flex items-center justify-center bg-[var(--overlay)] backdrop-blur-sm animate-in fade-in duration-150" + onClick={onCancel} + > + <div + className="bg-[var(--surface)] border border-[var(--border)] rounded-2xl w-full max-w-sm overflow-hidden shadow-2xl shadow-[var(--shadow)] animate-in zoom-in-95 duration-150" + onClick={(e) => e.stopPropagation()} + > + <div className="p-6 space-y-4"> + <div className="flex items-start gap-4"> + <div className={cn( + 'w-10 h-10 rounded-xl flex items-center justify-center shrink-0', + danger ? 'bg-[var(--danger-bg)]' : 'bg-[var(--hover-bg-strong)]' + )}> + <Warning size={20} weight="fill" className={danger ? 'text-[var(--danger)]' : 'text-[var(--text)]'} /> + </div> + <div className="flex-1 min-w-0"> + <h3 className="text-sm font-bold text-[var(--text)] mb-1">{title}</h3> + <p className="text-xs text-[var(--text-muted)] leading-relaxed">{message}</p> + </div> + </div> + </div> + + <div className="flex items-center justify-end gap-2 px-6 pb-5"> + <button + onClick={onCancel} + className="px-4 py-2 rounded-lg text-xs font-semibold text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--hover-bg)] transition-colors" + > + {cancelLabel} + </button> + <button + ref={confirmRef} + onClick={onConfirm} + className={cn( + 'px-4 py-2 rounded-lg text-xs font-bold transition-all active:scale-95', + danger + ? 'bg-[var(--danger)] text-white hover:bg-[var(--danger-hover)]' + : 'bg-[var(--accent)] text-[var(--accent-fg)] hover:bg-[var(--accent-hover)]' + )} + > + {confirmLabel} + </button> + </div> + </div> + </div> + ); +}; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 66b7107..f568e9b 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef< "bg-[var(--surface-3)] border border-[var(--border)]", "text-xs font-medium text-[var(--text-muted)] hover:text-[var(--text)]", "hover:bg-[var(--surface-2)] transition-colors", - "focus:outline-none focus:border-white/20", + "focus:outline-none focus:border-[var(--focus-border)]", "disabled:cursor-not-allowed disabled:opacity-50", "whitespace-nowrap shrink-0", className @@ -71,7 +71,7 @@ const SelectContent = React.forwardRef< className={cn( "relative z-50 min-w-[8rem] overflow-hidden rounded-xl", "bg-[var(--surface-2)] border border-[var(--border)]", - "shadow-xl shadow-black/40", + "shadow-xl shadow-[var(--shadow)]", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", @@ -85,8 +85,8 @@ const SelectContent = React.forwardRef< <SelectScrollUpButton /> <SelectPrimitive.Viewport className={cn( - "p-1", - position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + "p-1 max-h-[min(360px,var(--radix-select-content-available-height))] overflow-y-auto custom-scrollbar", + position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]" )} > {children} @@ -118,8 +118,8 @@ const SelectItem = React.forwardRef< className={cn( "relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pl-3 pr-8", "text-xs text-[var(--text-muted)] outline-none", - "hover:bg-white/5 hover:text-[var(--text)]", - "focus:bg-white/5 focus:text-[var(--text)]", + "hover:bg-[var(--hover-bg)] hover:text-[var(--text)]", + "focus:bg-[var(--hover-bg)] focus:text-[var(--text)]", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className )} diff --git a/src/index.css b/src/index.css index 199d6db..64cf82f 100644 --- a/src/index.css +++ b/src/index.css @@ -2,23 +2,66 @@ @plugin "@tailwindcss/typography"; :root { - --bg: #0a0a0a; - --surface: #111111; - --surface-2: #1a1a1a; - --surface-3: #222222; - --border: #2a2a2a; - --border-strong: #3a3a3a; - --text: #ffffff; - --text-muted: #888888; - --text-subtle: #555555; - --accent: #ffffff; - --accent-hover: #e5e5e5; - --accent-fg: #000000; + --bg: #1a1816; + --surface: #201e1b; + --surface-2: #2a2724; + --surface-3: #33302c; + --border: #3d3a35; + --border-strong: #524e48; + --text: #e8e4de; + --text-muted: #9c9790; + --text-subtle: #6b6560; + --accent: #D4845A; + --accent-hover: #C17348; + --accent-fg: #1a1816; + --shadow: rgba(0, 0, 0, 0.5); + --overlay: rgba(0, 0, 0, 0.6); + --hover-bg: rgba(255, 255, 255, 0.05); + --hover-bg-strong: rgba(255, 255, 255, 0.1); + --focus-ring: rgba(212, 132, 90, 0.3); + --focus-border: rgba(212, 132, 90, 0.25); + --focus-glow: rgba(212, 132, 90, 0.08); + --inset-shadow: rgba(255, 255, 255, 0.03); + --link: #D4845A; + --link-hover: #E09A72; + --danger: #E24B4A; + --danger-hover: #F09595; + --danger-bg: rgba(226, 75, 74, 0.1); + --danger-border: rgba(226, 75, 74, 0.2); --sidebar-width-left: 260px; --sidebar-width-right: 320px; } +:root.light { + --bg: #FFFFFF; + --surface: #F4F3EE; + --surface-2: #EAE8E1; + --surface-3: #E2E0D8; + --border: #D8D5CC; + --border-strong: #B1ADA1; + --text: #1a1816; + --text-muted: #6B6560; + --text-subtle: #9C9790; + --accent: #C15F3C; + --accent-hover: #A8502F; + --accent-fg: #FFFFFF; + --shadow: rgba(0, 0, 0, 0.08); + --overlay: rgba(0, 0, 0, 0.3); + --hover-bg: rgba(0, 0, 0, 0.04); + --hover-bg-strong: rgba(0, 0, 0, 0.08); + --focus-ring: rgba(193, 95, 60, 0.3); + --focus-border: rgba(193, 95, 60, 0.4); + --focus-glow: rgba(193, 95, 60, 0.08); + --inset-shadow: rgba(255, 255, 255, 0.5); + --link: #C15F3C; + --link-hover: #A8502F; + --danger: #dc2626; + --danger-hover: #ef4444; + --danger-bg: rgba(220, 38, 38, 0.08); + --danger-border: rgba(220, 38, 38, 0.15); +} + body { background: var(--bg); color: var(--text); @@ -63,7 +106,7 @@ body { } *:focus-visible { - outline: 1px solid rgba(255,255,255,0.25); + outline: 1px solid var(--focus-ring); outline-offset: 2px; } @@ -98,7 +141,7 @@ body { border-radius: 0.35rem; padding: 0.4rem 0.75rem; margin: 0.9rem 0; - color: #c8c8c8; + color: var(--text-muted); } .ai-prose-readable code { @@ -110,7 +153,7 @@ body { } .ai-prose-readable td { - color: #d0d0d0; + color: var(--text-muted); } .ai-prose p { margin: 0 0 0.75rem; } @@ -157,11 +200,11 @@ body { .ai-prose em { font-style: italic; color: var(--text-muted); } .ai-prose a { - color: #7eb8f7; + color: var(--link); text-decoration: underline; text-underline-offset: 2px; } -.ai-prose a:hover { color: #a8d0ff; } +.ai-prose a:hover { color: var(--link-hover); } .ai-prose table { width: 100%; @@ -184,6 +227,59 @@ body { } .ai-prose tr:nth-child(even) td { background: var(--surface-2); } +/* Light theme syntax highlighting overrides */ +:root.light .hljs { + background: var(--surface-3) !important; + color: var(--text) !important; +} + +:root.light .hljs-comment, +:root.light .hljs-quote { + color: #6a737d; +} + +:root.light .hljs-keyword, +:root.light .hljs-selector-tag, +:root.light .hljs-type { + color: #d73a49; +} + +:root.light .hljs-string, +:root.light .hljs-addition { + color: #22863a; +} + +:root.light .hljs-number, +:root.light .hljs-literal { + color: #005cc5; +} + +:root.light .hljs-title, +:root.light .hljs-section, +:root.light .hljs-attr { + color: #6f42c1; +} + +:root.light .hljs-name, +:root.light .hljs-selector-id, +:root.light .hljs-selector-class { + color: #005cc5; +} + +:root.light .hljs-built_in, +:root.light .hljs-builtin-name { + color: #e36209; +} + +:root.light .hljs-deletion { + color: #b31d28; + background: #ffeef0; +} + +:root.light .hljs-addition { + background: #f0fff4; +} + @keyframes letter-fade { 0%, 100% { opacity: 0.2; } 50% { opacity: 1; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 160ecc2..9b7a75a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -16,3 +16,150 @@ export function formatDate(dateString: string): string { day: 'numeric', }); } + +/** + * Fix markdown tables that are malformed. + * + * Main issues: + * 1. Separator row has wrong number of columns: "|---|---|" but header has 4 cols + * 2. Multiple table rows crammed on one line during streaming + * 3. Missing separator row entirely + * 4. Missing blank line before table + */ +export function fixMarkdownTables(text: string): string { + const lines = text.split('\n'); + const result: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + // Detect crammed rows: multiple table rows on one line + if (trimmed.startsWith('|') && trimmed.endsWith('|') && (trimmed.match(/\|/g) || []).length > 8) { + const split = splitCrammedRows(trimmed); + if (split.length > 1) { + // Ensure blank line before table if needed + const prev = result[result.length - 1]?.trim() ?? ''; + if (prev && !prev.startsWith('|')) result.push(''); + result.push(...split); + continue; + } + } + + result.push(lines[i]); + } + + // Second pass: fix separator rows to match header column count + const fixed: string[] = []; + for (let i = 0; i < result.length; i++) { + const line = result[i].trim(); + const nextLine = result[i + 1]?.trim() ?? ''; + const prevLine = fixed[fixed.length - 1]?.trim() ?? ''; + + // Case 1: Current line is header, next is separator with wrong col count → fix it + if (isTableRow(line) && isSeparatorRow(nextLine)) { + const headerCols = countColumns(line); + const sepCols = countColumns(nextLine); + fixed.push(result[i]); + if (headerCols !== sepCols) { + // Replace separator with correct column count + fixed.push('|' + Array(headerCols).fill('---').join('|') + '|'); + i++; // skip original separator + } + continue; + } + + // Case 2: Current line is header, next is data row (no separator) → inject + // Only if previous line is NOT part of a table (not a row and not a separator) + if ( + isTableRow(line) && + isTableRow(nextLine) && + !isSeparatorRow(nextLine) && + !isTableRow(prevLine) && + !isSeparatorRow(prevLine) && + !isSeparatorRow(line) + ) { + fixed.push(result[i]); + const headerCols = countColumns(line); + fixed.push('|' + Array(headerCols).fill('---').join('|') + '|'); + continue; + } + + // Case 3: Current line is separator but previous is not a table row → remove orphan + if (isSeparatorRow(line) && !isTableRow(prevLine)) { + continue; + } + + // Case 4: Data row has fewer columns than header → pad with empty cells + if (isTableRow(line) && !isSeparatorRow(line) && isTableRow(prevLine)) { + // Find the header (walk back past separator and other data rows) + let headerIdx = fixed.length - 1; + while (headerIdx >= 0 && (isTableRow(fixed[headerIdx].trim()) || isSeparatorRow(fixed[headerIdx].trim()))) { + headerIdx--; + } + headerIdx++; // first table row = header + if (headerIdx >= 0 && headerIdx < fixed.length) { + const headerCols = countColumns(fixed[headerIdx].trim()); + const dataCols = countColumns(line); + if (dataCols < headerCols) { + const padded = line.replace(/\|$/, '') + Array(headerCols - dataCols).fill(' |').join('') ; + fixed.push(padded); + continue; + } + } + } + + fixed.push(result[i]); + } + + // Third pass: ensure blank line before table starts + const final: string[] = []; + for (let i = 0; i < fixed.length; i++) { + const line = fixed[i].trim(); + const prev = final[final.length - 1]?.trim() ?? ''; + if (isTableRow(line) && prev && !prev.startsWith('|') && !isSeparatorRow(prev) && prev !== '') { + final.push(''); + } + final.push(fixed[i]); + } + + return final.join('\n'); +} + +/** Split "| a | b | | c | d | | e | f |" into separate rows */ +function splitCrammedRows(line: string): string[] { + // Strategy: split by "| |" which is the boundary between rows + const parts = line.split(/\|\s*\|/); + if (parts.length <= 2) return [line]; // normal single row has 2 empty parts at edges + + const rows: string[] = []; + let current = ''; + for (let i = 0; i < parts.length; i++) { + const part = parts[i].trim(); + if (part === '' && current) { + // End of a row + rows.push('| ' + current + ' |'); + current = ''; + } else if (part) { + current = current ? current + ' | ' + part : part; + } + } + if (current) rows.push('| ' + current + ' |'); + + return rows.length > 1 ? rows : [line]; +} + +function isTableRow(line: string): boolean { + if (!line) return false; + const t = line.trim(); + return t.startsWith('|') && t.endsWith('|') && t.length > 2 && !isSeparatorRow(t); +} + +function isSeparatorRow(line: string): boolean { + if (!line) return false; + return /^\|[\s\-:|]+\|$/.test(line.trim()); +} + +function countColumns(row: string): number { + const cells = row.split('|').slice(1, -1); // remove first/last empty from split + return Math.max(cells.length, 1); +} diff --git a/src/stores/useUIStore.ts b/src/stores/useUIStore.ts index 09788f6..454de34 100644 --- a/src/stores/useUIStore.ts +++ b/src/stores/useUIStore.ts @@ -1,17 +1,55 @@ import { create } from 'zustand'; +export type Theme = 'dark' | 'light'; +export type MainView = 'chat' | 'canvas'; + interface UIState { + leftSidebarOpen: boolean; + toggleLeftSidebar: () => void; + setLeftSidebarOpen: (open: boolean) => void; rightSidebarOpen: boolean; toggleRightSidebar: () => void; setRightSidebarOpen: (open: boolean) => void; settingsOpen: boolean; setSettingsOpen: (open: boolean) => void; + theme: Theme; + setTheme: (theme: Theme) => void; + toggleTheme: () => void; + fluxEnabled: boolean; + toggleFlux: () => void; + mainView: MainView; + setMainView: (view: MainView) => void; } +const getStoredTheme = (): Theme => { + try { + const stored = localStorage.getItem('enowx-theme'); + if (stored === 'light' || stored === 'dark') return stored; + } catch {} + return 'dark'; +}; + export const useUIStore = create<UIState>((set) => ({ + leftSidebarOpen: true, + toggleLeftSidebar: () => set((s) => ({ leftSidebarOpen: !s.leftSidebarOpen })), + setLeftSidebarOpen: (open) => set({ leftSidebarOpen: open }), rightSidebarOpen: true, toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })), setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }), settingsOpen: false, setSettingsOpen: (open) => set({ settingsOpen: open }), + theme: getStoredTheme(), + setTheme: (theme) => { + localStorage.setItem('enowx-theme', theme); + set({ theme }); + }, + toggleTheme: () => set((s) => { + const next = s.theme === 'dark' ? 'light' : 'dark'; + localStorage.setItem('enowx-theme', next); + return { theme: next }; + }), + fluxEnabled: true, + toggleFlux: () => set((s) => ({ fluxEnabled: !s.fluxEnabled })), + mainView: 'chat' as MainView, + setMainView: (view) => set({ mainView: view }), })); diff --git a/src/types/index.ts b/src/types/index.ts index 44e99f3..4c26385 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,6 +32,7 @@ export interface Provider { model: string; isDefault: boolean; isBuiltin: boolean; + isEnabled: boolean; createdAt: string; updatedAt: string; }