diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9a9530df..258d5428 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -68,4 +68,3 @@ jobs: fi echo "✅ No obvious secret patterns found" - diff --git a/.gitignore b/.gitignore index a7b854b2..6d027539 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,5 @@ __pycache__/ .env.docker .env.bak +.runtime/ +workspace-final-markdown-review.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae04c3cb..630e6c5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,4 +50,3 @@ See `.env.example` for all options. Key ones: - **Describe what you changed** — clear PR title + description - **No secrets** — never commit API keys, tokens, or passwords - **Follow existing patterns** — match the code style you see - diff --git a/FEATURES-INVENTORY.md b/FEATURES-INVENTORY.md new file mode 100644 index 00000000..96ed9646 --- /dev/null +++ b/FEATURES-INVENTORY.md @@ -0,0 +1,735 @@ +# Hermes Workspace — Comprehensive Features Inventory + +> **Version:** 1.0.0 | **Stack:** React 19 + TanStack Start/Router + Vite 7 + Tailwind CSS 4 + Zustand + xterm.js + Monaco Editor +> **Description:** Desktop workspace for Hermes Agent — chat, orchestration, and multi-agent coding pipelines + +--- + +## Table of Contents + +1. [Frontend Screens & Features](#1-frontend-screens--features) +2. [Backend API Endpoints](#2-backend-api-endpoints) +3. [UI Components Library](#3-ui-components-library) +4. [Configuration & Settings](#4-configuration--settings) +5. [Server-Side Architecture](#5-server-side-architecture) +6. [Integrations & Provider Support](#6-integrations--provider-support) +7. [UX Features & Interactions](#7-ux-features--interactions) +8. [Security Features](#8-security-features) +9. [Mobile & PWA Features](#9-mobile--pwa-features) +10. [Deployment Options](#10-deployment-options) + +--- + +## 1. Frontend Screens & Features + +### 1.1 Chat Screen (`/chat`, `/chat/$sessionKey`) + +- **Real-time SSE streaming** with tool call rendering +- **Multi-session management** — create, rename, delete, fork sessions +- **Dual chat backend modes:** + - **Enhanced Hermes** — full session API with persistent history via Hermes gateway + - **Portable** — OpenAI-compatible `/v1/chat/completions` (works with Ollama, LM Studio, vLLM, etc.) +- **Chat sidebar** — session list with search, pin, rename, delete dialogs +- **Message rendering:** + - Markdown with GFM support (`react-markdown` + `remark-gfm` + `remark-breaks`) + - Syntax highlighting via Shiki + - Tool call pill rendering with expandable details + - Thinking/reasoning content display + - Message timestamps + - Message actions bar (copy, etc.) +- **Chat composer** — multi-line input with: + - Slash command menu (`/new`, `/clear`, `/model`, `/save`, `/skills`, `/skin`, `/help`) + - File attachment support (images via base64, multimodal content) + - Voice input (Web Speech API) + - Context meter showing token usage percentage +- **Session management features:** + - Auto-generated session titles + - Session forking + - Session search across history + - Pinned sessions + - Session tombstones for deleted session cleanup +- **Inspector panel** — sidebar showing session activity, memory, and skills +- **Research card** — embedded research display +- **Connection status messaging** — real-time gateway connectivity indicators +- **Scroll-to-bottom button** for long conversations +- **Chat empty state** — onboarding content when no messages exist +- **Provider selection dialog** — model/provider chooser inline in chat +- **Smooth streaming text** — progressive text reveal for streaming responses +- **Context alert system** — warnings when approaching token limits + +### 1.2 Dashboard Screen (`/dashboard`) + +- Overview dashboard for workspace metrics +- Dashboard overflow panel for expanded views + +### 1.3 Files Screen (`/files`) + +- **Full workspace file browser** with directory tree navigation +- **File preview dialog** — inline file viewing +- **Monaco Editor integration** — full code editing +- **File operations:** create, read, write, rename, delete, mkdir +- **File upload** — multipart form upload support +- **Image preview** — base64 rendering for image files +- **Glob pattern support** — filter files by pattern +- **Path traversal prevention** — sandboxed to workspace root +- **Ignored directories:** `node_modules`, `.git`, `.next`, `.turbo`, `.cache`, `__pycache__`, `.venv`, `dist` +- **Max depth/entries limits** — configurable tree depth (default 3), max 20K entries + +### 1.4 Terminal Screen (`/terminal`) + +- **Full PTY terminal** via Python pty-helper +- **xterm.js** with addons: fit, search, web-links +- **256-color support** (TERM=xterm-256color, COLORTERM=truecolor) +- **Persistent shell sessions** — create, input, resize, close +- **SSE-based terminal streaming** — real-time output +- **Keepalive pings** every 8 seconds +- **Terminal workspace** component with debug panel +- **Mobile terminal input** — adapted for touch devices +- **Platform-aware default shell:** zsh (macOS), bash (Linux), PowerShell (Windows) + +### 1.5 Memory Browser Screen (`/memory`) + +- **Browse agent memory files** in `~/.hermes/` (MEMORY.md, memory/, memories/) +- **Search across memory entries** — text search with line-level results (max 200 matches) +- **Markdown preview** with live editing via MemoryEditor +- **Memory file list** — sorted with MEMORY.md first, daily files by date +- **Memory components:** MemoryFileList, MemorySearch, MemoryEditor, MemoryPreview + +### 1.6 Skills Browser Screen (`/skills`) + +- **Browse 2,000+ skills** from the Hermes skill registry +- **Tabbed view:** Installed, Marketplace, Featured +- **Skill categories:** All, Web & Frontend, Coding Agents, Git & GitHub, DevOps & Cloud, Browser & Automation, Image & Video, Search & Research, AI & LLMs, Productivity, Marketing & Sales, Communication, Data & Analytics, Finance & Crypto +- **Search and filter** — by name, description, author, tags, triggers +- **Sort options:** by name, by category +- **Featured skills** curation with groups (Most Popular, New This Week, Developer Tools, Productivity) +- **Security risk display** — safe/low/medium/high levels with flags and scores +- **Workspace skills screen** — per-session skill management + +### 1.7 Jobs Screen (`/jobs`) + +- **Scheduled job management** — cron-style agent automation +- **Create job dialog** — schedule, prompt, name, delivery, skills, repeat config +- **Edit job dialog** — modify existing jobs +- **Job operations:** create, update, delete, pause, resume, trigger +- **Job output viewer** — view execution results +- **Job state tracking** — enabled/disabled, next/last run, success status + +### 1.8 Settings Screens (`/settings`, `/settings/providers`) + +- **Settings dialog** — centralized configuration panel +- **Providers screen** — manage AI provider connections +- **Provider wizard** — guided setup for new providers + +--- + +## 2. Backend API Endpoints + +### 2.1 Chat & Messaging + +| Endpoint | Method | Description | +| -------------------- | ------ | -------------------------------------------------------------------------------- | +| `/api/send-stream` | POST | Main streaming chat endpoint — routes to enhanced Hermes or portable OpenAI mode | +| `/api/send` | POST | Non-streaming chat send | +| `/api/sessions/send` | POST | Session-specific send | +| `/api/chat-events` | GET | SSE chat event stream | +| `/api/events` | GET | Global SSE event bus (keepalive, real-time updates) | +| `/api/history` | GET | Chat history retrieval | + +### 2.2 Sessions + +| Endpoint | Method | Description | +| -------------------------------------- | ------ | ------------------------------------- | +| `/api/sessions` | GET | List all sessions (paginated, max 50) | +| `/api/sessions` | POST | Create new session | +| `/api/sessions` | PATCH | Update session (rename) | +| `/api/sessions` | DELETE | Delete session | +| `/api/sessions/$sessionKey/status` | GET | Session status | +| `/api/sessions/$sessionKey/active-run` | GET | Active run for session | +| `/api/session-status` | GET | Session connection status | + +### 2.3 Files + +| Endpoint | Method | Description | +| ---------------------------- | ------ | ------------------------------------------- | +| `/api/files?action=list` | GET | List directory tree with depth/entry limits | +| `/api/files?action=read` | GET | Read file content (text or base64 image) | +| `/api/files?action=download` | GET | Download file with Content-Disposition | +| `/api/files` | POST | Write/upload/mkdir/rename/delete files | +| `/api/paths` | GET | Path resolution and workspace info | + +### 2.4 Memory + +| Endpoint | Method | Description | +| -------------------- | ------ | -------------------------------- | +| `/api/memory` | GET | Get memory from Hermes gateway | +| `/api/memory/list` | GET | List local memory markdown files | +| `/api/memory/read` | GET | Read specific memory file | +| `/api/memory/search` | GET | Search across memory files | +| `/api/memory/write` | POST | Write/update memory file | + +### 2.5 Skills + +| Endpoint | Method | Description | +| ------------- | ------ | ----------------------------------------- | +| `/api/skills` | GET | List skills (paginated, filtered, sorted) | +| `/api/skills` | POST | Skill installation (currently disabled) | + +### 2.6 Models & Config + +| Endpoint | Method | Description | +| -------------------- | ------ | -------------------------------------------- | +| `/api/models` | GET | List available models (gateway + auth store) | +| `/api/hermes-config` | GET | Read Hermes config.yaml and .env | +| `/api/hermes-config` | PATCH | Update config.yaml and .env | +| `/api/context-usage` | GET | Token/context usage for a session | + +### 2.7 Jobs + +| Endpoint | Method | Description | +| ------------------------- | --------------------- | ---------------------------------------------- | +| `/api/hermes-jobs` | GET | List all jobs | +| `/api/hermes-jobs` | POST | Create new job | +| `/api/hermes-jobs/$jobId` | GET/POST/PATCH/DELETE | Job CRUD and actions (pause/resume/run/output) | + +### 2.8 Terminal + +| Endpoint | Method | Description | +| ---------------------- | ------ | ---------------------------------------- | +| `/api/terminal-stream` | POST | Create PTY session and stream SSE output | +| `/api/terminal-input` | POST | Send input to terminal session | +| `/api/terminal-resize` | POST | Resize terminal dimensions | +| `/api/terminal-close` | POST | Close terminal session | + +### 2.9 Auth & Infrastructure + +| Endpoint | Method | Description | +| ------------------------ | ------ | --------------------------------------------- | +| `/api/auth` | POST | Password authentication (rate-limited: 5/min) | +| `/api/auth-check` | GET | Check authentication status | +| `/api/ping` | GET | Server ping/health | +| `/api/connection-status` | GET | Gateway connection status with capabilities | +| `/api/gateway-status` | GET | Detailed gateway capabilities | +| `/api/start-agent` | POST | Auto-start Hermes agent process | +| `/api/start-hermes` | POST | Start Hermes gateway | +| `/api/workspace` | GET | Workspace auto-detection | + +### 2.10 OAuth + +| Endpoint | Method | Description | +| ------------------------ | ------ | ------------------------------- | +| `/api/oauth/device-code` | POST | Device code flow (Nous Portal) | +| `/api/oauth/poll-token` | POST | Poll for OAuth token completion | + +--- + +## 3. UI Components Library + +### 3.1 Core UI Primitives (`src/components/ui/`) + +- **alert-dialog** — confirmation dialogs +- **autocomplete** — filterable autocomplete input +- **braille-spinner** — loading indicator with braille animation +- **button** — button variants (class-variance-authority) +- **collapsible** — expandable/collapsible sections +- **command** — command palette UI (cmdk-style) +- **dialog** — modal dialogs +- **input** — text input fields +- **menu** — dropdown menus +- **preview-card** — content preview cards +- **scroll-area** — custom scrollable areas +- **switch** — toggle switches +- **tabs** — tabbed interfaces +- **three-dots-spinner** — loading animation +- **toast** — notification toasts +- **tooltip** — hover tooltips + +### 3.2 Prompt Kit (`src/components/prompt-kit/`) + +- **chat-container** — main chat layout wrapper +- **message** — individual message rendering +- **markdown** — rich markdown rendering +- **code-block** — syntax-highlighted code with copy +- **prompt-input** — chat input component +- **tool** — tool call display +- **tool-indicator** — tool execution status +- **thinking** — thinking/reasoning block +- **thinking-indicator** — animated thinking state +- **typing-indicator** — typing animation +- **text-shimmer** — text loading shimmer effect +- **scroll-button** — scroll-to-bottom control + +### 3.3 Feature Components + +- **workspace-shell** — main app layout shell +- **chat-panel** — persistent side chat panel +- **chat-panel-toggle** — show/hide chat panel +- **command-palette** — global `⌘K` command palette +- **slash-command-menu** — `/` command autocomplete in chat +- **attachment-button** — file attachment trigger +- **attachment-preview** — attached file preview +- **export-menu** — export chat as Markdown/JSON/Text +- **context-meter** — token usage visualization +- **mode-selector** — preset mode selection +- **save-mode-dialog** / **apply-mode-dialog** / **rename-mode-dialog** / **manage-modes-modal** — full mode management UI +- **model-suggestion-toast** — smart model recommendations +- **keyboard-shortcuts-modal** — keyboard shortcut reference +- **global-shortcut-listener** — system-wide keyboard shortcuts +- **terminal-shortcut-listener** — terminal-specific shortcuts +- **connection-overlay** — full-screen connection status +- **connection-startup-screen** — initial loading/connection screen +- **backend-unavailable-state** — offline fallback UI +- **hermes-health-banner** — gateway health indicator +- **hermes-reconnect-banner** — reconnection prompt +- **error-boundary** — React error boundary +- **error-toast** — error notification +- **loading-indicator** — generic loading state +- **logo-loader** — branded loading animation +- **status-indicator** — colored status dots +- **empty-state** — empty content placeholder +- **theme-toggle** — light/dark theme switcher + +### 3.4 Navigation & Layout + +- **mobile-tab-bar** — bottom navigation for mobile +- **mobile-hamburger-menu** — hamburger menu for mobile +- **mobile-sessions-panel** — mobile session browser +- **mobile-page-header** — mobile header bar +- **mobile-prompt** — MobileSetupModal, MobilePromptTrigger + +### 3.5 Specialized Components + +- **memory-viewer/** — MemoryFileList, MemorySearch, MemoryEditor, MemoryPreview +- **file-explorer/** — file-explorer-sidebar, file-preview-dialog +- **terminal/** — terminal-panel, terminal-workspace, debug-panel, mobile-terminal-input +- **inspector/** — inspector-panel, activity-store +- **usage-meter/** — usage-meter, usage-meter-compact, usage-details-modal, context-alert-modal +- **search/** — search-modal, search-input, search-results, search-result-item, quick-actions +- **settings-dialog/** — settings-dialog +- **onboarding/** — hermes-onboarding, onboarding-wizard, onboarding-tour, tour-steps, setup-step-content, provider-select-step +- **agent-chat/** — AgentChatModal, AgentChatHeader, AgentChatInput, AgentChatMessages +- **avatars/** — user-avatar, assistant-avatar +- **auth/** — login-screen + +### 3.6 Provider & Model Components + +- **provider-logo** — provider brand logos +- **provider-model-icon** — model-specific icons +- **agent-avatar** — AI agent avatar +- **agent-card** — agent info card + +--- + +## 4. Configuration & Settings + +### 4.1 User Settings (persisted via Zustand + localStorage) + +| Setting | Type | Default | Description | +| ------------------------- | ------------------------------- | -------- | --------------------------- | +| `hermesUrl` | string | `''` | Hermes API URL | +| `hermesToken` | string | `''` | Bearer token | +| `theme` | `system\|light\|dark` | `system` | Color mode | +| `accentColor` | `orange\|purple\|blue\|green` | `blue` | Accent color | +| `editorFontSize` | number | `13` | Monaco editor font size | +| `editorWordWrap` | boolean | `true` | Editor word wrap | +| `editorMinimap` | boolean | `false` | Editor minimap | +| `notificationsEnabled` | boolean | `true` | Sound/notifications | +| `usageThreshold` | number | `80` | Context usage warning % | +| `smartSuggestionsEnabled` | boolean | `false` | Smart model suggestions | +| `preferredBudgetModel` | string | `''` | Preferred cheap model | +| `preferredPremiumModel` | string | `''` | Preferred premium model | +| `onlySuggestCheaper` | boolean | `false` | Only suggest cheaper models | +| `showSystemMetricsFooter` | boolean | `false` | System metrics display | +| `mobileChatNavMode` | `dock\|integrated\|scroll-hide` | `dock` | Mobile nav behavior | + +### 4.2 Theme System — 8 Themes + +| Theme | Description | Mode | +| --------------------- | ------------------------------- | ----- | +| Hermes Official | Navy and indigo flagship | Dark | +| Hermes Official Light | Soft indigo light palette | Light | +| Hermes Classic | Bronze accents on dark charcoal | Dark | +| Classic Light | Warm parchment with bronze | Light | +| Slate | Cool blue developer theme | Dark | +| Slate Light | GitHub-light with blue accents | Light | +| Mono | Clean monochrome grayscale | Dark | +| Mono Light | Bright monochrome grayscale | Light | + +### 4.3 Workspace State (Zustand, persisted) + +- Sidebar collapsed/expanded +- File explorer collapsed/expanded +- Chat focus mode +- Active sub-page route +- Chat panel open/closed + session key +- Mobile keyboard state + +### 4.4 Modes System + +- **Custom presets** — save/load named configurations +- Each mode stores: name, preferred model, smart suggestions toggle, budget/premium model prefs +- Drift detection — alerts when settings diverge from applied mode + +### 4.5 Environment Variables + +| Variable | Description | +| ---------------------- | -------------------------------------------------- | +| `HERMES_API_URL` | Backend API URL (default: `http://127.0.0.1:8642`) | +| `HERMES_PASSWORD` | Optional password protection for web UI | +| `HERMES_WORKSPACE_DIR` | Workspace root directory (default: `~/.hermes`) | +| `HERMES_AGENT_PATH` | Path to hermes-agent directory | +| `HERMES_DEFAULT_MODEL` | Default model override | +| `HERMES_ALLOWED_HOSTS` | Allowed hosts (default: `.ts.net`) | +| `ANTHROPIC_API_KEY` | Anthropic API key passthrough | +| `BEARER_TOKEN` | Bearer token for backend auth | +| `PORT` | Server port (default: 3002 dev, 3000 prod) | + +### 4.6 Hermes Config Management + +- **Read/write `~/.hermes/config.yaml`** — YAML config via web UI +- **Read/write `~/.hermes/.env`** — environment variables +- **Provider status** with masked API keys +- **Auth store integration** — reads from `~/.hermes/auth-profiles.json` and `~/.openclaw/agents/main/agent/auth-profiles.json` + +--- + +## 5. Server-Side Architecture + +### 5.1 Gateway Capability Probing + +- **Two-tier capability model:** + - **Core:** health, chatCompletions, models, streaming + - **Enhanced:** sessions, skills, memory, config, jobs +- **Three chat modes:** + - `enhanced-hermes` — full Hermes session API + - `portable` — OpenAI-compatible /v1/chat/completions + - `disconnected` — no usable backend +- **Auto-detection** with port fallback (8642 → 8643) +- **Probe TTL** — 30 second cache, periodic refresh +- **Feature gates** — graceful degradation per capability + +### 5.2 Chat Event Bus + +- Server-side event bus for real-time updates +- SSE broadcasting to all connected clients +- Chat events: chunk, done, error, thinking, tool calls + +### 5.3 Run Store (Persistence) + +- Persisted run state at `~/.hermes/webui-mvp/runs/` +- Run lifecycle: accepted → active → handoff → stalled → complete → error +- Tool call tracking with phase management +- Lifecycle event logging (max 40 per run) +- Run timeout: 15 minutes + +### 5.4 Terminal Sessions + +- Python PTY helper (`pty-helper.py`) — real PTY without native node-pty addon +- Session management: create, input, resize (SIGWINCH), close (SIGTERM → SIGKILL) +- Event emitter pattern with early buffer for pre-listener output + +### 5.5 Memory Browser (Server) + +- Filesystem-based memory browsing in `~/.hermes/` +- File filters: MEMORY.md, memory/_, memories/_ +- Markdown-only restriction +- Path traversal prevention +- Sort: MEMORY.md first, daily files by date descending, then by modification time + +### 5.6 OpenAI-Compatible API Client + +- Streaming parser for `/v1/chat/completions` SSE +- Support for reasoning/thinking content (DeepSeek, QwQ, etc.) +- Automatic default model detection from `/v1/models` +- Multimodal support (image_url content parts) + +### 5.7 Hermes Agent Auto-Start + +- Auto-detects sibling `hermes-agent/` directory +- Resolves Python virtualenv (`.venv`, `venv`, system `python3`) +- Spawns uvicorn with health polling (15 attempts, 1s interval) +- Reads `~/.hermes/.env` for agent configuration + +### 5.8 Workspace Daemon (Optional) + +- Separate workspace daemon process on port 3099 +- Auto-restart with exponential backoff (max 20 retries) +- Provides workspace-level APIs (checkpoints, agents, etc.) + +--- + +## 6. Integrations & Provider Support + +### 6.1 AI Providers (Provider Catalog) + +| Provider | Auth Types | Description | +| ---------- | ------------------ | ----------------------------------- | +| Anthropic | API Key, CLI Token | Claude models — Haiku, Sonnet, Opus | +| OpenAI | API Key | GPT and reasoning models | +| Google | API Key, OAuth | Gemini models | +| OpenRouter | API Key | Unified multi-provider access | +| MiniMax | API Key | Foundation models | +| Ollama | Local (no auth) | Local models | +| Custom | API Key | Any OpenAI-compatible server | + +### 6.2 Known Gateway Providers (Hermes Config) + +- Nous Portal (OAuth device code flow) +- OpenAI Codex (OAuth) +- Anthropic (API key) +- OpenRouter (API key) +- Z.AI / GLM (API key) +- Kimi / Moonshot (API key) +- MiniMax / MiniMax CN (API key) +- Ollama (local, no auth) +- Custom OpenAI-compatible + +### 6.3 Well-Known Models + +- **Anthropic:** Claude Sonnet 4, Claude Opus 4 +- **OpenAI:** GPT-4o +- **xAI:** Grok 3 +- **Context window database:** Claude 4 (1M), Claude 3.x (200K), GPT-4o (128K), Gemini 2.x (1M), Qwen (32K–131K), Llama 3 (8K–128K), Mistral (32K–128K), DeepSeek (64K–128K) + +### 6.4 OAuth Integration + +- **Device code flow** for Nous Portal +- Token polling mechanism +- Auth profile storage in `~/.hermes/auth-profiles.json` + +### 6.5 Workspace Agents + +- Multi-agent directory with capabilities tracking +- Agent properties: model, provider, status (online/away/offline), avatar, system prompt +- Agent capabilities: repo write, shell commands, git operations, browser, network +- Agent stats: runs/tokens/cost today, success rate, avg response time + +### 6.6 Workspace Checkpoints + +- Code review checkpoint system +- Review actions: approve, approve-and-commit, approve-and-pr, approve-and-merge, reject, revise +- Diff viewing with file-level additions/deletions +- Verification checks: TypeScript (tsc), tests, lint, e2e +- Run event timeline + +--- + +## 7. UX Features & Interactions + +### 7.1 Sound Notification System + +Web Audio API synthesized sounds (no audio files): + +- **Agent Spawned** — ascending C5→E5 chime +- **Agent Complete** — satisfying G5 ding +- **Agent Failed** — low C3→A2 error tone +- **Chat Notification** — soft E5 ping +- **Chat Complete** — gentle E5→C5 descend +- **Alert** — attention-grab A4→E5→A4 +- **Thinking** — subtle C6 tick +- Configurable volume (0–1) and enable/disable + +### 7.2 Keyboard Shortcuts + +- **⌘K** — Command palette +- **Global shortcuts** via `global-shortcut-listener` +- **Terminal shortcuts** via `terminal-shortcut-listener` +- **Session shortcuts** — navigate between sessions +- **Keyboard shortcuts modal** — discoverable reference + +### 7.3 Voice Input + +- Web Speech API integration +- Languages: configurable (default: en-US) +- States: idle, listening, processing, error +- Interim (partial) results support +- Toggle on/off + +### 7.4 Haptic Feedback + +- `navigator.vibrate(8)` for mobile tap feedback + +### 7.5 Search + +- **Global search modal** with quick actions +- **Search input** with keyboard navigation +- **Search results** with highlighted matches +- **Session search** across all chat history + +### 7.6 Onboarding + +- **Onboarding wizard** — first-run setup flow +- **Onboarding tour** — interactive guided tour (react-joyride) +- **Setup steps** — provider selection, connection verification +- **Tour steps** — feature highlights + +### 7.7 Export + +- Export conversations as Markdown, JSON, or Plain Text + +### 7.8 Auto-Generated Session Titles + +- Automatic title generation from conversation content + +### 7.9 Pinned Sessions & Models + +- Pin frequently used sessions for quick access +- Pin preferred models + +### 7.10 Smart Model Suggestions + +- Automatic model recommendations based on task +- Budget vs premium model preferences +- Model suggestion toast notifications + +--- + +## 8. Security Features + +### 8.1 Authentication + +- Optional password protection via `HERMES_PASSWORD` env var +- Timing-safe password comparison +- Cryptographic session tokens (32 bytes hex) +- HTTP-only, SameSite=Strict cookies (30-day expiry) +- Rate-limited login: 5 attempts/minute per IP +- 1-second delay on failed auth (brute force prevention) + +### 8.2 Authorization + +- Auth middleware on all API routes +- Local request detection (127.0.0.1, ::1, Tailscale 100.x, LAN 192.168.x, 10.x) +- `requireLocalOrAuth` for sensitive operations (file delete, terminal) + +### 8.3 Input Validation + +- CSRF protection via `Content-Type: application/json` requirement +- Path traversal prevention on file and memory routes +- Zod schema validation on auth endpoints +- Input sanitization on all user inputs + +### 8.4 Rate Limiting + +- Sliding window rate limiter (in-memory, no external deps) +- Per-endpoint limits: auth (5/min), files (30/min), terminal (10/min) +- Auto-cleanup every 5 minutes +- 429 Too Many Requests responses + +### 8.5 Error Handling + +- Safe error messages in production (hides internals) +- Error boundaries in React +- Graceful degradation on gateway unavailability + +--- + +## 9. Mobile & PWA Features + +### 9.1 Progressive Web App + +- Full PWA with install prompts +- iOS Safari "Add to Home Screen" support +- Android Chrome install support +- Desktop Chrome/Edge install support + +### 9.2 Mobile-Specific Components + +- Mobile tab bar (bottom navigation) +- Mobile hamburger menu +- Mobile sessions panel +- Mobile page header +- Mobile terminal input +- Mobile setup modal & prompt trigger +- Mobile keyboard handling & inset tracking +- Swipe navigation + +### 9.3 Mobile Chat Nav Modes + +- **Dock** — iMessage-style (no nav in chat) +- **Integrated** — chat input in nav pill +- **Scroll-hide** — nav shows on scroll up + +### 9.4 Tailscale Integration + +- First-class support for Tailscale remote access +- Default allowed hosts include `.ts.net` +- End-to-end encrypted mobile access + +--- + +## 10. Deployment Options + +### 10.1 Local Development + +```bash +pnpm dev # Vite dev server with HMR on port 3002 +``` + +### 10.2 Production Build + +```bash +pnpm build && pnpm start # Node.js server on port 3000 +``` + +### 10.3 Stable Mode + +```bash +pnpm start:stable # Background process via scripts/start-stable.sh +pnpm stop:stable # Stop via scripts/stop-stable.sh +``` + +### 10.4 Docker Compose + +- **hermes-agent** container — Python FastAPI gateway on port 8642 +- **hermes-workspace** container — Node.js web UI on port 3000 +- Health checks with retries +- Environment file passthrough + +### 10.5 Auto-Start Features + +- Hermes agent auto-start from sibling directory +- Workspace daemon auto-start with crash recovery +- Port fallback detection (8642 → 8643) + +--- + +## File/Directory Statistics + +| Category | Count | +| ------------------ | ----- | +| Total source files | ~287 | +| API route files | ~35 | +| React components | ~100+ | +| Custom hooks | ~25 | +| Server modules | ~12 | +| Library utilities | ~20 | +| Store files | 3 | +| Screen files | 8 | + +--- + +## Technology Stack + +| Layer | Technology | +| ------------- | ------------------------------- | +| Framework | TanStack Start (React 19 + SSR) | +| Routing | TanStack Router (file-based) | +| Build | Vite 7 | +| Styling | Tailwind CSS 4 | +| State | Zustand 5 (persisted) | +| Data Fetching | TanStack React Query 5 | +| Terminal | xterm.js 5 + Python PTY | +| Editor | Monaco Editor | +| Markdown | react-markdown + Shiki | +| Charts | Recharts 3 | +| Animation | Motion (Framer Motion) | +| Validation | Zod | +| Icons | Hugeicons + Lobehub Icons | +| Tour | react-joyride | +| WebSocket | ws library | +| Config | YAML parser | +| Testing | Vitest + Testing Library | + +--- + +_Generated from codebase analysis of `/Users/aurora/hermes-workspace/`_ diff --git a/FUTURE-FEATURES.md b/FUTURE-FEATURES.md index 1f0bb8ff..ce2b9048 100644 --- a/FUTURE-FEATURES.md +++ b/FUTURE-FEATURES.md @@ -1,4 +1,5 @@ # FUTURE-FEATURES.md — Post-Roadmap Development + _Added: 2026-03-09 | Source: Framework research (Anthropic Skills guide, OpenAI Agents SDK, Google ADK)_ These features are NOT part of the initial roadmap. Build them AFTER the v4 mockup is 100% complete and verified. @@ -8,44 +9,51 @@ These features are NOT part of the initial roadmap. Build them AFTER the v4 mock ## 🔴 High Priority (unlocks "App Factory" overnight runs) ### 1. Iterative Refinement Loop + **What:** Verification doesn't stop at one tsc pass. Loop: run tsc → errors? → send back to agent → fix → re-run. Max 3 iterations before escalating to human review. **Why:** Anthropic explicitly identifies this as the pattern that makes agents reliable. Current single-pass fails silently. **Where:** `workspace-daemon/src/verification.ts` + `checkpoint-builder.ts` **Pattern source:** Anthropic Skills Guide — "Iterative Refinement" design pattern ### 2. Agent Handoffs (Context Passing Between Agents) + **What:** When one agent finishes a wave, it passes structured context (git diff, error log, what it built, what it skipped) to the next agent. No more blind starts. **Why:** Current agents start each task cold. Handoffs are first-class in OpenAI Agents SDK — explicit control transfer with context. This is what keeps overnight runs coherent. **Where:** New `workspace-daemon/src/handoff.ts`, update adapter interfaces **Pattern source:** OpenAI Agents SDK — "Handoffs" primitive ### 3. Specialized Agent Roles + **What:** Replace generic Codex adapter with role-specific agents: + - **Researcher** — reads codebase, produces spec/context doc - **Planner** — takes spec, produces task breakdown with deps - **Builder** — executes tasks (Codex) - **Validator** — runs tsc, tests, reviews diff - **Deployer** — git ops, PR creation, notifications -**Why:** The "App Factory" screenshot runs specialized roles. Generic agents miss domain context. -**Where:** `workspace-daemon/src/adapters/` — one file per role -**Pattern source:** Anthropic Skills — "Domain-specific intelligence" + App Factory pattern + **Why:** The "App Factory" screenshot runs specialized roles. Generic agents miss domain context. + **Where:** `workspace-daemon/src/adapters/` — one file per role + **Pattern source:** Anthropic Skills — "Domain-specific intelligence" + App Factory pattern --- ## 🟡 Medium Priority ### 4. Parallel Guardrails (tsc watcher during agent run) + **What:** Run tsc in watch mode alongside Codex, not just after. Flag errors in real-time without waiting for checkpoint. **Why:** OpenAI SDK runs guardrails in parallel with the agent — catches issues without blocking the main flow. **Where:** New process spawned alongside agent in `agent-runner.ts` **Pattern source:** OpenAI Agents SDK — "Guardrails" primitive ### 5. Rollback on Checkpoint Rejection + **What:** When a checkpoint is rejected, auto-revert to pre-task git state rather than leaving dirty code in tree. **Why:** Currently a rejection leaves broken code that the next agent inherits. **Where:** `workspace-daemon/src/git-ops.ts` — add `revertToCheckpoint()` method ### 6. Context-Aware Tool Selection + **What:** Agent routing logic that picks different tools based on file size, task type, and context. Large refactors → Codex. Small surgical fixes → Claude ACP session. Research tasks → Claude with web search. **Pattern source:** Anthropic Skills — "Context-aware tool selection" pattern @@ -54,15 +62,18 @@ These features are NOT part of the initial roadmap. Build them AFTER the v4 mock ## 🔵 Lower Priority (Enterprise / Scale) ### 7. Session Persistence Surfaced to Agents + **What:** Pass previous run context (what worked, what failed, git history) to agent at start of each task. Agents currently start blind even when re-running. **Where:** Update adapter `buildPrompt()` to include run history from SQLite ### 8. Progressive Skill Loading for Agent Prompts + **What:** Agent system prompts use Anthropic's 3-level progressive disclosure — minimal header always loaded, full instructions only when triggered, reference docs on demand. **Why:** Keeps context lean when running many agents in parallel. **Pattern source:** Anthropic Skills Guide — core architecture ### 9. Skills Marketplace / Agent Skill Definitions + **What:** Define agent "skills" as portable SKILL.md-style files that can be shared, versioned, and swapped. A "React Builder" skill vs "Python API Builder" skill. **Pattern source:** Anthropic agentskills.io open standard @@ -70,14 +81,14 @@ These features are NOT part of the initial roadmap. Build them AFTER the v4 mock ## Summary Table -| Feature | Impact | Effort | Priority | -|---------|--------|--------|----------| -| Iterative refinement loop | 🔥 High | Low | Do first | -| Agent handoffs | 🔥 High | Med | Do second | -| Specialized agent roles | 🔥 High | High | Do third | -| Parallel guardrails | Med | Med | After roles | -| Rollback on rejection | Med | Low | After roles | -| Context-aware tool selection | Med | High | Later | -| Session persistence | Low | Low | Later | -| Progressive skill loading | Low | Med | Later | -| Skills marketplace | Low | High | Much later | +| Feature | Impact | Effort | Priority | +| ---------------------------- | ------- | ------ | ----------- | +| Iterative refinement loop | 🔥 High | Low | Do first | +| Agent handoffs | 🔥 High | Med | Do second | +| Specialized agent roles | 🔥 High | High | Do third | +| Parallel guardrails | Med | Med | After roles | +| Rollback on rejection | Med | Low | After roles | +| Context-aware tool selection | Med | High | Later | +| Session persistence | Low | Low | Later | +| Progressive skill loading | Low | Med | Later | +| Skills marketplace | Low | High | Much later | diff --git a/README.md b/README.md index 9660cdb7..f45e0d32 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Your AI agent's command center — chat, files, memory, skills, and terminal in one place.** -[![Version](https://img.shields.io/badge/version-0.1.0-6366F1.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.0.0-6366F1.svg)](CHANGELOG.md) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Node](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen.svg)](https://nodejs.org/) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-6366F1.svg)](CONTRIBUTING.md) @@ -132,7 +132,7 @@ Route through the Hermes gateway for sessions, memory, skills, jobs, and tools: ```yaml provider: ollama -model: qwen2.5:7b # or any model you have pulled +model: qwen2.5:7b # or any model you have pulled custom_providers: - name: ollama base_url: http://127.0.0.1:11434/v1 diff --git a/SECURITY.md b/SECURITY.md index 26359355..ae4d85c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ If you discover a security vulnerability in Hermes Workspace, please report it r **Do NOT open a public GitHub issue for security vulnerabilities.** -Instead, email: **security@hermesworkspace.app** +Instead, report via [GitHub Security Advisories](https://github.com/outsourc-e/hermes-workspace/security/advisories) or DM [@ericousodev on X](https://x.com/ericousodev). We will acknowledge your report within 48 hours and aim to provide a fix within 7 days for critical issues. @@ -28,17 +28,20 @@ We will acknowledge your report within 48 hours and aim to provide a fix within ## Security Measures (v3.0.0+) **Authentication** + - All API routes require authentication as of v3.0.0 - Session tokens use timing-safe comparison to prevent timing attacks - httpOnly + SameSite=Strict cookies - Token revocation on logout **Network** + - `Access-Control-Allow-Origin` restricted to localhost — no wildcard CORS - Browser proxy and screenshot endpoints locked to same-origin only - Rate limiting on high-risk endpoints (file access, debug, exec) **Data & File Access** + - Path traversal prevention on all file and memory routes (`ensureWorkspacePath()`) - `.md`-only restriction on memory write routes - No API keys or secrets ever exposed to client-side code @@ -46,10 +49,12 @@ We will acknowledge your report within 48 hours and aim to provide a fix within - Diagnostic output scrubbed of sensitive data **Agent Safety** + - Exec approval workflow — sensitive Hermes exec commands require explicit human approval via in-UI modal - Skills security scanning — every skill from the marketplace is scanned for suspicious patterns before install **Configuration** + - Environment files are gitignored - Config endpoints redact credentials in responses - Example configs use placeholder keys only @@ -69,9 +74,8 @@ We will acknowledge your report within 48 hours and aim to provide a fix within ## Supported Versions -| Version | Supported | -|---------|-----------| -| v3.x (main) | ✅ Active | -| v2.x | ⚠️ Security fixes only | -| < v2.0 | ❌ Unsupported | - +| Version | Supported | +| ----------- | ---------------------- | +| v3.x (main) | ✅ Active | +| v2.x | ⚠️ Security fixes only | +| < v2.0 | ❌ Unsupported | diff --git a/docker/agent/Dockerfile b/docker/agent/Dockerfile index 16050b03..7494ba85 100644 --- a/docker/agent/Dockerfile +++ b/docker/agent/Dockerfile @@ -12,4 +12,4 @@ RUN pip install --no-cache-dir -e . EXPOSE 8642 -CMD ["hermes", "--gateway"] +CMD ["hermes", "gateway", "run"] diff --git a/docs/hermes-openai-compat-spec.md b/docs/hermes-openai-compat-spec.md index 0e2fc22e..068fe46d 100644 --- a/docs/hermes-openai-compat-spec.md +++ b/docs/hermes-openai-compat-spec.md @@ -37,7 +37,7 @@ We want to reverse that. This is the decision to lock in: > **Hermes Workspace must work standalone against any OpenAI-compatible backend.** -> +> > Hermes-specific workspace features may enhance the experience when the full Hermes API is available, but the product must remain usable without those endpoints. Non-negotiable implication: @@ -365,9 +365,9 @@ This is not the detailed task plan, but the engineering direction should be: Lock this in: > Hermes Workspace is a standalone frontend for OpenAI-compatible chat backends. -> +> > Hermes-native APIs are an enhancement layer, not a requirement. -> +> > Step 1 is portable compatibility now. -> +> > Step 2 is upstreaming the enhanced Hermes APIs so no fork is needed ever again. diff --git a/eslint.config.js b/eslint.config.js index e5d8e235..46ab5953 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,5 +8,3 @@ export default [ ignores: ['eslint.config.js', 'prettier.config.js', 'vite.config.ts'], }, ] - - diff --git a/package.json b/package.json index 0ebd6871..dac7f3b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-workspace", - "version": "0.1.0", + "version": "1.0.0", "description": "Desktop workspace for Hermes Agent — chat, orchestration, and multi-agent coding pipelines", "author": "Eric (https://github.com/outsourc-e)", "license": "MIT", @@ -67,6 +67,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.4", + "eslint": "^10.2.0", "jsdom": "^27.0.0", "prettier": "^3.5.3", "tsx": "^4.21.0", @@ -76,4 +77,3 @@ "web-vitals": "^5.1.0" } } - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51125c6f..e3b20869 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,7 +131,7 @@ importers: devDependencies: '@tanstack/eslint-config': specifier: ^0.3.0 - version: 0.3.4(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 0.3.4(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) '@testing-library/dom': specifier: ^10.4.0 version: 10.4.1 @@ -150,6 +150,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.4 version: 5.2.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + eslint: + specifier: ^10.2.0 + version: 10.2.0(jiti@2.6.1) jsdom: specifier: ^27.0.0 version: 27.4.0 @@ -683,33 +686,29 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@9.39.4': resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} @@ -1480,66 +1479,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1653,24 +1665,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -2161,41 +2177,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2290,10 +2314,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -2414,10 +2434,6 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2473,13 +2489,6 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} @@ -2888,10 +2897,6 @@ packages: peerDependencies: eslint: '>=8.23.0' - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2908,9 +2913,9 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@10.2.0: + resolution: {integrity: sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -3113,10 +3118,6 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - globals@15.15.0: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} @@ -3149,10 +3150,6 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -3483,24 +3480,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -3537,9 +3538,6 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -4546,10 +4544,6 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -4565,10 +4559,6 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5603,50 +5593,36 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.0(jiti@2.6.1))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.23.5': dependencies: - '@eslint/object-schema': 2.1.7 + '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 3.1.5 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': + '@eslint/config-helpers@0.5.5': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.2.1 - '@eslint/core@0.17.0': + '@eslint/core@1.2.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - '@eslint/js@9.39.4': {} - '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.4.1': + '@eslint/plugin-kit@0.7.1': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.2.1 levn: 0.4.1 '@exodus/bytes@1.15.0': {} @@ -6656,11 +6632,11 @@ snapshots: dependencies: react: 19.2.4 - '@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1))': + '@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@typescript-eslint/types': 8.57.0 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 @@ -6734,16 +6710,16 @@ snapshots: tailwindcss: 4.2.1 vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) - '@tanstack/eslint-config@0.3.4(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@tanstack/eslint-config@0.3.4(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.4 - '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.6.1)) - eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.0(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) + eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) globals: 16.5.0 - typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) + typescript-eslint: 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.2.0(jiti@2.6.1)) transitivePeerDependencies: - '@typescript-eslint/utils' - eslint-import-resolver-node @@ -7207,15 +7183,15 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.57.0 - '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.0 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -7223,14 +7199,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.57.0 '@typescript-eslint/types': 8.57.0 '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.0 debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7253,13 +7229,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.57.0 '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -7282,13 +7258,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.57.0 '@typescript-eslint/types': 8.57.0 '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7457,10 +7433,6 @@ snapshots: ansi-regex@5.0.1: {} - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - ansi-styles@5.2.0: {} ansis@4.2.0: {} @@ -7632,11 +7604,6 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7716,12 +7683,6 @@ snapshots: collapse-white-space@2.1.0: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - colord@2.9.3: {} comma-separated-tokens@2.0.3: {} @@ -8124,9 +8085,9 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)): + eslint-compat-utils@0.5.1(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) semver: 7.7.4 eslint-import-context@0.1.9(unrs-resolver@1.11.1): @@ -8136,20 +8097,20 @@ snapshots: optionalDependencies: unrs-resolver: 1.11.1 - eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-es-x@7.8.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.4(jiti@2.6.1) - eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1)): dependencies: '@package-json/types': 0.0.12 '@typescript-eslint/types': 8.57.0 comment-parser: 1.4.5 debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.2.4 @@ -8157,16 +8118,16 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - supports-color - eslint-plugin-n@17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-n@17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) enhanced-resolve: 5.20.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-es-x: 7.8.0(eslint@9.39.4(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.2.0(jiti@2.6.1)) get-tsconfig: 4.13.6 globals: 15.15.0 globrex: 0.1.2 @@ -8176,11 +8137,6 @@ snapshots: transitivePeerDependencies: - typescript - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -8194,28 +8150,25 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.6.1): + eslint@10.2.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 ajv: 6.14.0 - chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -8226,8 +8179,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -8414,8 +8366,6 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 - globals@14.0.0: {} - globals@15.15.0: {} globals@16.5.0: {} @@ -8435,8 +8385,6 @@ snapshots: hachure-fill@0.5.2: {} - has-flag@4.0.0: {} - hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -8901,8 +8849,6 @@ snapshots: lodash-es@4.17.23: {} - lodash.merge@4.6.2: {} - lodash@4.17.23: {} longest-streak@3.1.0: {} @@ -10311,8 +10257,6 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 - strip-json-comments@3.1.1: {} - strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -10329,10 +10273,6 @@ snapshots: stylis@4.3.6: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} swr@2.4.1(react@19.2.4): @@ -10444,13 +10384,13 @@ snapshots: type-fest@4.41.0: {} - typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.2.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10717,10 +10657,10 @@ snapshots: vscode-uri@3.1.0: {} - vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)): + vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 espree: 11.2.0 diff --git a/prettier.config.js b/prettier.config.js index 7a56bd0e..f7279f7b 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -8,5 +8,3 @@ const config = { } export default config - - diff --git a/public/manifest.json b/public/manifest.json index a53b6eb3..557948b3 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -6,10 +6,7 @@ "display": "standalone", "background_color": "#0A0E1A", "theme_color": "#6366F1", - "categories": [ - "productivity", - "utilities" - ], + "categories": ["productivity", "utilities"], "icons": [ { "src": "/hermes-icon-192.png", @@ -24,4 +21,4 @@ "purpose": "any maskable" } ] -} \ No newline at end of file +} diff --git a/public/sw.js b/public/sw.js index 4de28e8f..8d1adb41 100644 --- a/public/sw.js +++ b/public/sw.js @@ -8,15 +8,16 @@ self.addEventListener('install', () => { self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((names) => - Promise.all(names.map((name) => caches.delete(name))) - ).then(() => self.clients.claim()) - .then(() => { - // Tell all open tabs to reload so they get fresh assets - self.clients.matchAll({ type: 'window' }).then((clients) => { - clients.forEach((client) => client.navigate(client.url)) - }) - }) + caches + .keys() + .then((names) => Promise.all(names.map((name) => caches.delete(name)))) + .then(() => self.clients.claim()) + .then(() => { + // Tell all open tabs to reload so they get fresh assets + self.clients.matchAll({ type: 'window' }).then((clients) => { + clients.forEach((client) => client.navigate(client.url)) + }) + }), ) }) diff --git a/public/test-streaming.html b/public/test-streaming.html index e5a03fbf..7f02fb74 100644 --- a/public/test-streaming.html +++ b/public/test-streaming.html @@ -1,66 +1,82 @@ - + -Streaming Test - -

Direct Streaming Test

- - -
-
-
- - + + diff --git a/scripts/generate-pwa-icons.js b/scripts/generate-pwa-icons.js index 6563be11..e9d0793d 100644 --- a/scripts/generate-pwa-icons.js +++ b/scripts/generate-pwa-icons.js @@ -47,4 +47,3 @@ async function main() { } main().catch(console.error) - diff --git a/scripts/start-stable.sh b/scripts/start-stable.sh new file mode 100755 index 00000000..0e46ffe3 --- /dev/null +++ b/scripts/start-stable.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PORT="${PORT:-3002}" +RUNTIME_DIR="$ROOT/.runtime" +PID_FILE="$RUNTIME_DIR/hermes-workspace.pid" +LOG_FILE="$RUNTIME_DIR/hermes-workspace.log" +BUILD_LOG_FILE="$RUNTIME_DIR/hermes-workspace.build.log" +mkdir -p "$RUNTIME_DIR" + +stop_pid() { + local pid="$1" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + return 0 + fi + sleep 0.25 + done + kill -9 "$pid" 2>/dev/null || true + fi +} + +if [[ -f "$PID_FILE" ]]; then + old_pid="$(cat "$PID_FILE" 2>/dev/null || true)" + if [[ -n "$old_pid" ]]; then + stop_pid "$old_pid" + fi + rm -f "$PID_FILE" +fi + +for pid in $(lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true); do + stop_pid "$pid" +done + +echo "[stable] building Hermes Workspace..." +pnpm build >"$BUILD_LOG_FILE" 2>&1 + +echo "[stable] starting Hermes Workspace on port $PORT..." +nohup env PORT="$PORT" NODE_OPTIONS="--max-old-space-size=2048" node server-entry.js >>"$LOG_FILE" 2>&1 & +new_pid=$! +echo "$new_pid" >"$PID_FILE" + +for _ in {1..40}; do + if curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then + echo "[stable] up on http://127.0.0.1:$PORT" + echo "[stable] pid=$new_pid" + echo "[stable] log=$LOG_FILE" + exit 0 + fi + if ! kill -0 "$new_pid" 2>/dev/null; then + echo "[stable] failed to start, see $LOG_FILE and $BUILD_LOG_FILE" >&2 + exit 1 + fi + sleep 0.25 +done + +echo "[stable] timed out waiting for startup, see $LOG_FILE" >&2 +exit 1 diff --git a/scripts/stop-stable.sh b/scripts/stop-stable.sh new file mode 100755 index 00000000..2ecf58d4 --- /dev/null +++ b/scripts/stop-stable.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PORT="${PORT:-3002}" +PID_FILE="$ROOT/.runtime/hermes-workspace.pid" + +stop_pid() { + local pid="$1" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + return 0 + fi + sleep 0.25 + done + kill -9 "$pid" 2>/dev/null || true + fi +} + +if [[ -f "$PID_FILE" ]]; then + pid="$(cat "$PID_FILE" 2>/dev/null || true)" + if [[ -n "$pid" ]]; then + stop_pid "$pid" + fi + rm -f "$PID_FILE" +fi + +for pid in $(lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true); do + stop_pid "$pid" +done + +echo "[stable] stopped Hermes Workspace on port $PORT" diff --git a/server-entry.js b/server-entry.js index c459df87..cb1cf885 100644 --- a/server-entry.js +++ b/server-entry.js @@ -34,7 +34,10 @@ const MIME_TYPES = { } async function tryServeStatic(req, res) { - const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`) + const url = new URL( + req.url || '/', + `http://${req.headers.host || 'localhost'}`, + ) const pathname = decodeURIComponent(url.pathname) // Prevent directory traversal @@ -141,4 +144,3 @@ const httpServer = createServer(async (req, res) => { httpServer.listen(port, host, () => { console.log(`Hermes Workspace running at http://${host}:${port}`) }) - diff --git a/src/components/agent-avatar.tsx b/src/components/agent-avatar.tsx index 1d8e3da6..a8ee0e77 100644 --- a/src/components/agent-avatar.tsx +++ b/src/components/agent-avatar.tsx @@ -141,7 +141,15 @@ function AgentAvatar({ 🦞 ) : ( - Hermes + Hermes )} Click to switch avatar diff --git a/src/components/agent-card.tsx b/src/components/agent-card.tsx index 2c48806b..865abbb4 100644 --- a/src/components/agent-card.tsx +++ b/src/components/agent-card.tsx @@ -74,7 +74,11 @@ function StatusIndicator({ status }: { status: AgentCardStatus }) { if (status === 'completed') { return ( - + Done ) @@ -112,8 +116,14 @@ export function AgentCard({ }: AgentCardProps) { const avatar = detectProviderAvatar(model) const resolvedRuntime = - runtimeLabel ?? (typeof runtimeSeconds === 'number' ? formatRuntimeCompact(runtimeSeconds) : '') - const hasTokens = typeof tokenCount === 'number' && Number.isFinite(tokenCount) && tokenCount > 0 + runtimeLabel ?? + (typeof runtimeSeconds === 'number' + ? formatRuntimeCompact(runtimeSeconds) + : '') + const hasTokens = + typeof tokenCount === 'number' && + Number.isFinite(tokenCount) && + tokenCount > 0 return (
, -): Array { +function toChatMessages(messages: Array): Array { return messages .map(function mapMessage(message, index) { const text = readMessageText(message) diff --git a/src/components/auth/login-screen.tsx b/src/components/auth/login-screen.tsx index b101f7a3..e0b7f8f7 100644 --- a/src/components/auth/login-screen.tsx +++ b/src/components/auth/login-screen.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { FormEvent } from 'react'; +import type { FormEvent } from 'react' export function LoginScreen() { const [password, setPassword] = useState('') diff --git a/src/components/avatars/user-avatar.tsx b/src/components/avatars/user-avatar.tsx index 28493fe0..d092559d 100644 --- a/src/components/avatars/user-avatar.tsx +++ b/src/components/avatars/user-avatar.tsx @@ -69,7 +69,12 @@ function UserAvatarComponent({ fill="#E6EAF2" /> {/* Collar detail */} - + ) } diff --git a/src/components/backend-unavailable-state.tsx b/src/components/backend-unavailable-state.tsx index 47b1f2a0..6681adfc 100644 --- a/src/components/backend-unavailable-state.tsx +++ b/src/components/backend-unavailable-state.tsx @@ -6,10 +6,7 @@ type Props = { description?: string } -export function BackendUnavailableState({ - feature, - description, -}: Props) { +export function BackendUnavailableState({ feature, description }: Props) { return (
@@ -19,8 +16,8 @@ export function BackendUnavailableState({

{feature}

- Not available on this backend. Connect to a Hermes gateway to - unlock {feature}. + Not available on this backend. Connect to a Hermes gateway to unlock{' '} + {feature}.

{description ? (

{description}

diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx index 041efef0..2c2f6989 100644 --- a/src/components/chat-panel.tsx +++ b/src/components/chat-panel.tsx @@ -143,7 +143,10 @@ export function ChatPanel() { exit={{ x: '100%', opacity: 0 }} transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }} className="fixed right-0 bottom-0 top-[var(--titlebar-h,0px)] h-[calc(100dvh-var(--titlebar-h,0px))] max-h-[calc(100dvh-var(--titlebar-h,0px))] w-[420px] max-w-[100vw] border-l overflow-hidden flex flex-col z-20 shadow-xl" - style={{ background: 'var(--theme-bg)', borderColor: 'var(--theme-border)' }} + style={{ + background: 'var(--theme-bg)', + borderColor: 'var(--theme-border)', + }} > {/* Panel header */}
diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index ee43703b..7e631197 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -46,7 +46,9 @@ type CommandAction = { label: string keywords: string shortcut?: string - icon: React.ComponentProps['icon'] + icon: React.ComponentProps< + typeof import('@hugeicons/react').HugeiconsIcon + >['icon'] onSelect: () => void } @@ -54,7 +56,11 @@ type ScoredAction = CommandAction & { score: number } -const SCREEN_GROUP_ORDER = ['Screens', 'Recent Sessions', 'Slash Commands'] as const +const SCREEN_GROUP_ORDER = [ + 'Screens', + 'Recent Sessions', + 'Slash Commands', +] as const function getSessionLabel(session: SessionMeta) { return ( @@ -73,14 +79,20 @@ function scoreCommandAction(action: CommandAction, query: string) { const haystack = `${action.label} ${action.keywords}`.toLowerCase() const directIndex = haystack.indexOf(normalizedQuery) if (directIndex >= 0) { - return 400 - directIndex - Math.max(0, haystack.length - normalizedQuery.length) + return ( + 400 - directIndex - Math.max(0, haystack.length - normalizedQuery.length) + ) } let queryIndex = 0 let gaps = 0 let lastMatch = -1 - for (let i = 0; i < haystack.length && queryIndex < normalizedQuery.length; i += 1) { + for ( + let i = 0; + i < haystack.length && queryIndex < normalizedQuery.length; + i += 1 + ) { if (haystack[i] !== normalizedQuery[queryIndex]) continue if (lastMatch >= 0) gaps += Math.max(0, i - lastMatch - 1) lastMatch = i @@ -91,10 +103,7 @@ function scoreCommandAction(action: CommandAction, query: string) { return 180 - gaps - Math.max(0, haystack.length - normalizedQuery.length) } -export function CommandPalette({ - pathname, - sessions, -}: CommandPaletteProps) { +export function CommandPalette({ pathname, sessions }: CommandPaletteProps) { const navigate = useNavigate() const [open, setOpen] = useState(false) const [query, setQuery] = useState('') @@ -301,9 +310,15 @@ export function CommandPalette({ } return actions - .map((action) => ({ ...action, score: scoreCommandAction(action, normalizedQuery) })) + .map((action) => ({ + ...action, + score: scoreCommandAction(action, normalizedQuery), + })) .filter((action) => action.score > 0) - .sort((left, right) => right.score - left.score || left.label.localeCompare(right.label)) + .sort( + (left, right) => + right.score - left.score || left.label.localeCompare(right.label), + ) }, [actions, query]) const groupedActions = useMemo( @@ -372,7 +387,8 @@ export function CommandPalette({ event.preventDefault() if (filteredActions.length === 0) return setSelectedIndex( - (current) => (current - 1 + filteredActions.length) % filteredActions.length, + (current) => + (current - 1 + filteredActions.length) % filteredActions.length, ) return } @@ -438,7 +454,11 @@ export function CommandPalette({ isSelected && 'bg-primary-100 text-primary-900', )} > - +
{action.label} @@ -453,7 +473,9 @@ export function CommandPalette({ ) })} - {groupIndex < groupedActions.length - 1 ? : null} + {groupIndex < groupedActions.length - 1 ? ( + + ) : null} ))} @@ -463,8 +485,16 @@ export function CommandPalette({
- - + + Navigate
diff --git a/src/components/connection-overlay.tsx b/src/components/connection-overlay.tsx index 1402c5af..7b0037a4 100644 --- a/src/components/connection-overlay.tsx +++ b/src/components/connection-overlay.tsx @@ -1,10 +1,16 @@ // Stub — connection overlay (not used in Hermes Workspace) export function useConnectionRestart() { return { - triggerRestart: async (fn: () => Promise) => { await fn() }, + triggerRestart: async (fn: () => Promise) => { + await fn() + }, } } -export function ConnectionProvider({ children }: { children: React.ReactNode }) { +export function ConnectionProvider({ + children, +}: { + children: React.ReactNode +}) { return <>{children} } diff --git a/src/components/context-meter.tsx b/src/components/context-meter.tsx index b9eea1bf..0c39fee0 100644 --- a/src/components/context-meter.tsx +++ b/src/components/context-meter.tsx @@ -27,7 +27,10 @@ type ContextMeterProps = { className?: string } -export function ContextMeter({ variant = 'mobile', className }: ContextMeterProps) { +export function ContextMeter({ + variant = 'mobile', + className, +}: ContextMeterProps) { const [pct, setPct] = useState(0) const [warning, setWarning] = useState(null) const rafRef = useRef(null) @@ -40,7 +43,10 @@ export function ContextMeter({ variant = 'mobile', className }: ContextMeterProp try { const res = await fetch('/api/context-usage') if (!res.ok || cancelled) return - const data = (await res.json()) as { ok?: boolean; contextPercent?: unknown } + const data = (await res.json()) as { + ok?: boolean + contextPercent?: unknown + } if (!data?.ok || cancelled) return const next = readPercent(data.contextPercent) prevPctRef.current = next @@ -73,18 +79,28 @@ export function ContextMeter({ variant = 'mobile', className }: ContextMeterProp return (
{/* Thin progress bar */} -
+
{/* Warning banner at 75%+ */} {warning && pct >= 75 && ( -
= 90 ? 'bg-red-500/10 text-red-600' : 'bg-orange-500/10 text-orange-600' - )}> +
= 90 + ? 'bg-red-500/10 text-red-600' + : 'bg-orange-500/10 text-orange-600', + )} + > ⚠ {warning}
)} @@ -100,13 +116,22 @@ export function ContextMeter({ variant = 'mobile', className }: ContextMeterProp {pct >= 90 ? '⚠ Context full' : '⚠ Context high'} )} -
+
- + {Math.round(pct)}%
diff --git a/src/components/dashboard-overflow-panel.tsx b/src/components/dashboard-overflow-panel.tsx index cead2cf4..1ace4d20 100644 --- a/src/components/dashboard-overflow-panel.tsx +++ b/src/components/dashboard-overflow-panel.tsx @@ -12,12 +12,9 @@ import { Sun02Icon, UserGroupIcon, } from '@hugeicons/core-free-icons' -import type {SettingsThemeMode} from '@/hooks/use-settings'; +import type { SettingsThemeMode } from '@/hooks/use-settings' import { cn } from '@/lib/utils' -import { - - useSettingsStore -} from '@/hooks/use-settings' +import { useSettingsStore } from '@/hooks/use-settings' type OverflowItem = { icon: typeof File01Icon @@ -111,11 +108,7 @@ export function DashboardOverflowPanel({ open, onClose }: Props) { document.documentElement.classList.contains('dark')) const themeIcon = resolvedDarkMode ? Moon02Icon : Sun02Icon const themeLabel = - theme === 'system' - ? 'System' - : theme === 'dark' - ? 'Dark' - : 'Light' + theme === 'system' ? 'System' : theme === 'dark' ? 'Dark' : 'Light' return (
@@ -149,8 +142,16 @@ export function DashboardOverflowPanel({ open, onClose }: Props) { - - + +
diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index b0bf128f..ceffe608 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -1,5 +1,5 @@ -import { Component } from 'react' -import type {ErrorInfo, ReactNode} from 'react'; +import { Component } from 'react' +import type { ErrorInfo, ReactNode } from 'react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' diff --git a/src/components/error-toast.tsx b/src/components/error-toast.tsx index 4dab2495..dbd7058a 100644 --- a/src/components/error-toast.tsx +++ b/src/components/error-toast.tsx @@ -13,13 +13,26 @@ type ErrorEntry = { function classifyError(raw: string): string { const lower = raw.toLowerCase() - if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many')) { + if ( + lower.includes('429') || + lower.includes('rate limit') || + lower.includes('too many') + ) { return 'Rate limited — try again in a moment' } - if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('auth')) { + if ( + lower.includes('401') || + lower.includes('403') || + lower.includes('unauthorized') || + lower.includes('auth') + ) { return 'Authentication error — check your API key in Settings' } - if (lower.includes('500') || lower.includes('server error') || lower.includes('model error')) { + if ( + lower.includes('500') || + lower.includes('server error') || + lower.includes('model error') + ) { return 'Model error — the provider is having issues' } if ( @@ -67,7 +80,9 @@ function ToastItem({ entry, onDismiss }: ToastItemProps) { role="alert" > - {entry.message} + + {entry.message} +
{expanded === skill.name && skill.description && ( -

+

{skill.description}

)} @@ -312,7 +364,10 @@ function LogsTab() {
         {events.map((e: ActivityEvent) => JSON.stringify(e)).join('\n')}
       
@@ -356,7 +411,10 @@ export function InspectorPanel() { className="flex items-center justify-between px-4 py-3 shrink-0" style={{ borderBottom: '1px solid var(--theme-border)' }} > - + Inspector ) - })() - ))} + })(), + )}
{/* Content */} diff --git a/src/components/keyboard-shortcuts-modal.tsx b/src/components/keyboard-shortcuts-modal.tsx index 9acffb29..5cac3cd4 100644 --- a/src/components/keyboard-shortcuts-modal.tsx +++ b/src/components/keyboard-shortcuts-modal.tsx @@ -50,7 +50,7 @@ export function KeyboardShortcutsModal() { ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety const tag = (event.target as HTMLElement)?.tagName?.toLowerCase() - + if ( tag === 'input' || tag === 'textarea' || diff --git a/src/components/logo-loader.tsx b/src/components/logo-loader.tsx index c1755702..bb723670 100644 --- a/src/components/logo-loader.tsx +++ b/src/components/logo-loader.tsx @@ -9,7 +9,11 @@ export type LogoLoaderProps = { function LogoLoader({ className }: LogoLoaderProps) { return ( ) } diff --git a/src/components/mobile-hamburger-menu.tsx b/src/components/mobile-hamburger-menu.tsx index e7d575e5..aa18da34 100644 --- a/src/components/mobile-hamburger-menu.tsx +++ b/src/components/mobile-hamburger-menu.tsx @@ -23,17 +23,57 @@ import { } from '@/hooks/use-chat-settings' const NAV_ITEMS = [ - { id: 'chat', label: 'Chat', icon: Chat01Icon, to: '/chat/main', match: (p: string) => p.startsWith('/chat') || p === '/new' || p === '/' }, - { id: 'dashboard', label: 'Dashboard', icon: DashboardSquare01Icon, to: '/dashboard', match: (p: string) => p.startsWith('/dashboard') }, - { id: 'terminal', label: 'Terminal', icon: CommandLineIcon, to: '/terminal', match: (p: string) => p.startsWith('/terminal') }, - { id: 'jobs', label: 'Jobs', icon: Clock01Icon, to: '/jobs', match: (p: string) => p.startsWith('/jobs') }, - { id: 'memory', label: 'Memory', icon: BrainIcon, to: '/memory', match: (p: string) => p.startsWith('/memory') }, - { id: 'skills', label: 'Skills', icon: PuzzleIcon, to: '/skills', match: (p: string) => p.startsWith('/skills') }, - { id: 'profiles', label: 'Profiles', icon: UserGroupIcon, to: '/profiles', match: (p: string) => p.startsWith('/profiles') }, + { + id: 'chat', + label: 'Chat', + icon: Chat01Icon, + to: '/chat/main', + match: (p: string) => p.startsWith('/chat') || p === '/new' || p === '/', + }, + { + id: 'dashboard', + label: 'Dashboard', + icon: DashboardSquare01Icon, + to: '/dashboard', + match: (p: string) => p.startsWith('/dashboard'), + }, + { + id: 'terminal', + label: 'Terminal', + icon: CommandLineIcon, + to: '/terminal', + match: (p: string) => p.startsWith('/terminal'), + }, + { + id: 'jobs', + label: 'Jobs', + icon: Clock01Icon, + to: '/jobs', + match: (p: string) => p.startsWith('/jobs'), + }, + { + id: 'memory', + label: 'Memory', + icon: BrainIcon, + to: '/memory', + match: (p: string) => p.startsWith('/memory'), + }, + { + id: 'skills', + label: 'Skills', + icon: PuzzleIcon, + to: '/skills', + match: (p: string) => p.startsWith('/skills'), + }, + { + id: 'profiles', + label: 'Profiles', + icon: UserGroupIcon, + to: '/profiles', + match: (p: string) => p.startsWith('/profiles'), + }, ] - - /** Shared drawer state — used by both the trigger button and the drawer itself */ let _setOpen: ((v: boolean) => void) | null = null @@ -70,13 +110,16 @@ export function MobileHamburgerMenu() { // Add/remove body class to push main content useEffect(() => { document.body.classList.toggle('nav-drawer-open', open) - return () => { document.body.classList.remove('nav-drawer-open') } + return () => { + document.body.classList.remove('nav-drawer-open') + } }, [open]) const navigate = useNavigate() const pathname = useRouterState({ select: (s) => s.location.pathname }) const profileDisplayName = useChatSettingsStore(selectChatProfileDisplayName) - const isChatRoute = pathname.startsWith('/chat') || pathname === '/new' || pathname === '/' + const isChatRoute = + pathname.startsWith('/chat') || pathname === '/new' || pathname === '/' function handleNav(to: string) { hapticTap() @@ -100,7 +143,9 @@ export function MobileHamburgerMenu() {
open && setOpen(false)} style={open ? { transformOrigin: 'left center' } : undefined} @@ -116,15 +161,35 @@ export function MobileHamburgerMenu() { 'transition-transform duration-300 ease-in-out', open ? 'translate-x-0' : '-translate-x-full', )} - style={{ background: 'var(--color-surface, #fff)', borderColor: 'var(--color-border, #e5e7eb)' }} + style={{ + background: 'var(--color-surface, #fff)', + borderColor: 'var(--color-border, #e5e7eb)', + }} > {/* Header */} -
+
- Hermes + Hermes
- Hermes - Workspace + + Hermes + + + Workspace +
) @@ -164,16 +238,37 @@ export function MobileHamburgerMenu() { {/* Bottom — user profile + settings + theme toggle */} -
+
{/* User avatar + name + status dot */} -
- - - +
+ + +
- + {profileDisplayName} @@ -188,7 +283,11 @@ export function MobileHamburgerMenu() { aria-label="Settings" style={{ color: 'var(--color-ink-muted, #888)' }} > - + {/* Theme toggle — sun/moon */} @@ -204,8 +303,17 @@ export function MobileHamburgerMenu() { aria-label="Toggle theme" style={{ color: 'var(--color-ink-muted, #888)' }} > - - + +
diff --git a/src/components/mobile-page-header.tsx b/src/components/mobile-page-header.tsx index a18afbd8..cc96b934 100644 --- a/src/components/mobile-page-header.tsx +++ b/src/components/mobile-page-header.tsx @@ -12,7 +12,11 @@ type MobilePageHeaderProps = { className?: string } -export function MobilePageHeader({ title, right, className }: MobilePageHeaderProps) { +export function MobilePageHeader({ + title, + right, + className, +}: MobilePageHeaderProps) { return (
- + {title} -
- {right ?? null} -
+
{right ?? null}
) } diff --git a/src/components/mobile-prompt/MobilePromptTrigger.tsx b/src/components/mobile-prompt/MobilePromptTrigger.tsx index 9a05b43b..b2c90f95 100644 --- a/src/components/mobile-prompt/MobilePromptTrigger.tsx +++ b/src/components/mobile-prompt/MobilePromptTrigger.tsx @@ -1,83 +1,84 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { AnimatePresence, motion } from 'motion/react'; -import { HugeiconsIcon } from '@hugeicons/react'; -import { Cancel01Icon } from '@hugeicons/core-free-icons'; -import { MobileSetupModal } from './MobileSetupModal'; +'use client' +import { useEffect, useRef, useState } from 'react' +import { AnimatePresence, motion } from 'motion/react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Cancel01Icon } from '@hugeicons/core-free-icons' +import { MobileSetupModal } from './MobileSetupModal' export function MobilePromptTrigger() { - const [showPrompt, setShowPrompt] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false); - const [dontShowAgain, setDontShowAgain] = useState(false); - const [isDismissedForSession, setIsDismissedForSession] = useState(false); - const mountTimeRef = useRef(null); + const [showPrompt, setShowPrompt] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + const [dontShowAgain, setDontShowAgain] = useState(false) + const [isDismissedForSession, setIsDismissedForSession] = useState(false) + const mountTimeRef = useRef(null) useEffect(() => { - mountTimeRef.current = Date.now(); + mountTimeRef.current = Date.now() // ?mobile-preview forces modal open immediately (dev/review only) // Strip the param from URL so navigation doesn't re-trigger it - if (new URLSearchParams(window.location.search).get('mobile-preview') === '1') { - const url = new URL(window.location.href); - url.searchParams.delete('mobile-preview'); - window.history.replaceState({}, '', url.toString()); - setIsModalOpen(true); - return; + if ( + new URLSearchParams(window.location.search).get('mobile-preview') === '1' + ) { + const url = new URL(window.location.href) + url.searchParams.delete('mobile-preview') + window.history.replaceState({}, '', url.toString()) + setIsModalOpen(true) + return } const isDismissed = localStorage.getItem('hermes-mobile-access-dismissed') === 'true' || - localStorage.getItem('hermes-mobile-prompt-dismissed') === 'true'; - const isSetup = localStorage.getItem('hermes-mobile-setup-seen') === 'true'; + localStorage.getItem('hermes-mobile-prompt-dismissed') === 'true' + const isSetup = localStorage.getItem('hermes-mobile-setup-seen') === 'true' if (isDismissed || isSetup) { - return; + return } const checkPrompt = () => { if (!mountTimeRef.current) { - return; + return } - const elapsedTime = Date.now() - mountTimeRef.current; - const isDesktop = window.innerWidth > 768; - const hasBeenOnPageLongEnough = elapsedTime >= 45_000; + const elapsedTime = Date.now() - mountTimeRef.current + const isDesktop = window.innerWidth > 768 + const hasBeenOnPageLongEnough = elapsedTime >= 45_000 if (isDesktop && hasBeenOnPageLongEnough && !isDismissedForSession) { - setShowPrompt(true); + setShowPrompt(true) } - }; + } - checkPrompt(); - const interval = window.setInterval(checkPrompt, 5_000); - return () => window.clearInterval(interval); - }, [isDismissedForSession]); + checkPrompt() + const interval = window.setInterval(checkPrompt, 5_000) + return () => window.clearInterval(interval) + }, [isDismissedForSession]) const persistDismissalPreference = () => { if (dontShowAgain) { - localStorage.setItem('hermes-mobile-access-dismissed', 'true'); + localStorage.setItem('hermes-mobile-access-dismissed', 'true') } - }; + } const dismissPrompt = () => { - persistDismissalPreference(); - setIsDismissedForSession(true); - setShowPrompt(false); - }; + persistDismissalPreference() + setIsDismissedForSession(true) + setShowPrompt(false) + } const openSetup = () => { - persistDismissalPreference(); - setIsDismissedForSession(true); - setShowPrompt(false); - setIsModalOpen(true); - }; + persistDismissalPreference() + setIsDismissedForSession(true) + setShowPrompt(false) + setIsModalOpen(true) + } const closeSetup = () => { - persistDismissalPreference(); - setIsModalOpen(false); - }; + persistDismissalPreference() + setIsModalOpen(false) + } return ( <> @@ -98,59 +99,130 @@ export function MobilePromptTrigger() { >
-
- Hermes - + -
- - - - - - - - - - - +
+ Hermes + + +
+ + + + + + + + + + + +
+
+ +
+

+ Set up mobile access +

+

+ Connect your phone to this Hermes Workspace instance in a + few steps. +

+
+ +
+ +
-
-

Set up mobile access

-

- Connect your phone to this Hermes Workspace instance in a few steps. -

-
- -
- - -
-
- -
- - + + + + + ) + })} +
+ + ) } diff --git a/src/components/onboarding/onboarding-tour.test.ts b/src/components/onboarding/onboarding-tour.test.ts index 23f896fe..e39e630e 100644 --- a/src/components/onboarding/onboarding-tour.test.ts +++ b/src/components/onboarding/onboarding-tour.test.ts @@ -15,4 +15,3 @@ describe('onboarding tour completion logic', () => { expect(shouldCompleteOnboardingTour('next', 'running')).toBe(false) }) }) - diff --git a/src/components/onboarding/onboarding-tour.tsx b/src/components/onboarding/onboarding-tour.tsx index 68421990..2f779d4a 100644 --- a/src/components/onboarding/onboarding-tour.tsx +++ b/src/components/onboarding/onboarding-tour.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import Joyride, { ACTIONS, STATUS } from 'react-joyride' import { tourSteps } from './tour-steps' -import type { CallBackProps, Styles } from 'react-joyride'; +import type { CallBackProps, Styles } from 'react-joyride' import { useSettingsStore } from '@/hooks/use-settings' import { useResolvedTheme } from '@/hooks/use-chat-settings' @@ -47,7 +47,8 @@ export function OnboardingTour() { // Wait for setup wizard to finish before starting tour const HERMES_SETUP_KEY = 'hermes-configured' const checkAndStart = () => { - const hermesConfigured = localStorage.getItem(HERMES_SETUP_KEY) === 'true' + const hermesConfigured = + localStorage.getItem(HERMES_SETUP_KEY) === 'true' if (hermesConfigured) { setRun(true) return true diff --git a/src/components/onboarding/onboarding-wizard.tsx b/src/components/onboarding/onboarding-wizard.tsx index fa1d2aac..c365a0e4 100644 --- a/src/components/onboarding/onboarding-wizard.tsx +++ b/src/components/onboarding/onboarding-wizard.tsx @@ -65,7 +65,16 @@ export function OnboardingWizard() { prevStep() } }, - [canProceed, isOpen, isLastStep, isFirstStep, skip, handleComplete, nextStep, prevStep], + [ + canProceed, + isOpen, + isLastStep, + isFirstStep, + skip, + handleComplete, + nextStep, + prevStep, + ], ) useEffect(() => { @@ -122,7 +131,11 @@ export function OnboardingWizard() { {step.id === 'welcome' ? ( - Hermes + Hermes ) : ( + @@ -46,10 +54,22 @@ function OpenRouterLogo({ className }: { className?: string }) { function GoogleLogo({ className }: { className?: string }) { return ( - - - - + + + + ) } @@ -66,7 +86,8 @@ const PROVIDERS: Array = [ { id: 'anthropic', name: 'Anthropic (Claude)', - description: 'Best for complex reasoning, long-form writing and precise instructions', + description: + 'Best for complex reasoning, long-form writing and precise instructions', badge: 'Recommended', logo: , placeholder: 'sk-ant-...', @@ -76,7 +97,8 @@ const PROVIDERS: Array = [ { id: 'openrouter', name: 'OpenRouter', - description: 'One Hermes connection to 200+ AI models. Ideal for flexibility and experimentation', + description: + 'One Hermes connection to 200+ AI models. Ideal for flexibility and experimentation', badge: 'Popular', logo: , placeholder: 'sk-or-v1-...', @@ -110,7 +132,10 @@ type ProviderSelectStepProps = { onSkip?: () => void } -export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepProps) { +export function ProviderSelectStep({ + onComplete, + onSkip, +}: ProviderSelectStepProps) { const [selectedId, setSelectedId] = useState(null) const [apiKey, setApiKey] = useState('') const [showKey, setShowKey] = useState(false) @@ -175,7 +200,8 @@ export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepPro Choose AI Provider

- Pick the AI provider you want to start with. You can switch or add more providers later. + Pick the AI provider you want to start with. You can switch or add + more providers later.

@@ -209,9 +235,7 @@ export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepPro : 'border-primary-300', )} > - {isSelected && ( -
- )} + {isSelected &&
}
{/* Logo */} @@ -318,7 +342,11 @@ export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepPro {/* Validation feedback */} {validated === true && (
- + API key is valid!
)} diff --git a/src/components/onboarding/setup-step-content.tsx b/src/components/onboarding/setup-step-content.tsx index 34ac0fe5..9f760285 100644 --- a/src/components/onboarding/setup-step-content.tsx +++ b/src/components/onboarding/setup-step-content.tsx @@ -115,13 +115,17 @@ export function ConnectionCheckStep({

-

1. Enable the API server in ~/.hermes/.env:

+

+ 1. Enable the API server in ~/.hermes/.env: +

API_SERVER_ENABLED=true
-

2. Restart the gateway:

+

+ 2. Restart the gateway: +

cd hermes-agent && hermes --gateway diff --git a/src/components/onboarding/tour-steps.tsx b/src/components/onboarding/tour-steps.tsx index f0bd7490..a6c1627f 100644 --- a/src/components/onboarding/tour-steps.tsx +++ b/src/components/onboarding/tour-steps.tsx @@ -7,10 +7,22 @@ export const tourSteps: Array = [ placement: 'center', title: 'Welcome to Hermes Workspace! ⚕', content: ( -
- Hermes +
+ Hermes

- Your AI-powered command center for managing agents, chats, files, and more. Let's take a quick tour! + Your AI-powered command center for managing agents, chats, files, and + more. Let's take a quick tour!

), diff --git a/src/components/prompt-kit/chat-container.tsx b/src/components/prompt-kit/chat-container.tsx index a472026b..fa2ae009 100644 --- a/src/components/prompt-kit/chat-container.tsx +++ b/src/components/prompt-kit/chat-container.tsx @@ -145,7 +145,10 @@ function ChatContainerContent({ ...props }: ChatContainerContentProps) { return ( -
+
) { - const hasFiles = Array.from(e.clipboardData.items).some( (item) => item.kind === 'file', ) diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index 2d7a607e..31ddab58 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -17,7 +17,10 @@ function useIsLightTheme(): boolean { } check() const observer = new MutationObserver(check) - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) return () => observer.disconnect() }, []) return light @@ -54,7 +57,10 @@ export function ProviderLogo({ if (!file) { return (
{(provider || 'C')[0].toUpperCase()} diff --git a/src/components/provider-model-icon.tsx b/src/components/provider-model-icon.tsx index a51c1f7c..d656196f 100644 --- a/src/components/provider-model-icon.tsx +++ b/src/components/provider-model-icon.tsx @@ -6,7 +6,7 @@ * Uses @lobehub/icons for real provider logos. */ -import type {CSSProperties} from 'react'; +import type { CSSProperties } from 'react' import { cn } from '@/lib/utils' type ProviderModelIconProps = { @@ -22,18 +22,38 @@ type ProviderModelIconProps = { function detectProvider(model: string): string { const m = model.toLowerCase() if (m.includes('anthropic') || m.includes('claude')) return 'anthropic' - if (m.includes('openai') || m.includes('gpt') || m.includes('codex') || m.includes('o1') || m.includes('o3')) return 'openai' - if (m.includes('google') || m.includes('gemini') || m.includes('antigravity')) return 'google' + if ( + m.includes('openai') || + m.includes('gpt') || + m.includes('codex') || + m.includes('o1') || + m.includes('o3') + ) + return 'openai' + if (m.includes('google') || m.includes('gemini') || m.includes('antigravity')) + return 'google' if (m.includes('minimax')) return 'minimax' if (m.includes('mistral') || m.includes('devstral')) return 'mistral' if (m.includes('deepseek')) return 'deepseek' - if (m.includes('ollama') || m.includes('qwen') || m.includes('llama') || m.includes('pc1') || m.includes('pc2')) return 'ollama' + if ( + m.includes('ollama') || + m.includes('qwen') || + m.includes('llama') || + m.includes('pc1') || + m.includes('pc2') + ) + return 'ollama' if (m.includes('openrouter')) return 'openrouter' if (m.includes('nvidia') || m.includes('nemotron')) return 'nvidia' return 'unknown' } -export function ProviderModelIcon({ model, size = 12, className, style }: ProviderModelIconProps) { +export function ProviderModelIcon({ + model, + size = 12, + className, + style, +}: ProviderModelIconProps) { const provider = detectProvider(model) // Use light variant (dark logos on transparent) for light mode @@ -58,7 +78,10 @@ export function ProviderModelIcon({ model, size = 12, className, style }: Provid // Fallback: first letter of provider return ( {provider[0]?.toUpperCase() ?? '?'} diff --git a/src/components/search/search-modal.tsx b/src/components/search/search-modal.tsx index c62022a2..623d803e 100644 --- a/src/components/search/search-modal.tsx +++ b/src/components/search/search-modal.tsx @@ -252,11 +252,7 @@ export function SearchModal() { scope: 'actions', icon: ( diff --git a/src/components/settings-dialog/settings-dialog.tsx b/src/components/settings-dialog/settings-dialog.tsx index f97d319f..615a5070 100644 --- a/src/components/settings-dialog/settings-dialog.tsx +++ b/src/components/settings-dialog/settings-dialog.tsx @@ -8,27 +8,30 @@ import { CloudIcon, ComputerIcon, MessageMultiple01Icon, + Mic01Icon, Moon01Icon, Notification03Icon, PaintBoardIcon, + Settings02Icon, + SparklesIcon, Sun01Icon, + VolumeHighIcon, } from '@hugeicons/core-free-icons' import { Component, useCallback, useEffect, useState } from 'react' import type * as React from 'react' import type { AccentColor, SettingsThemeMode } from '@/hooks/use-settings' import type { LoaderStyle } from '@/hooks/use-chat-settings' import type { BrailleSpinnerPreset } from '@/components/ui/braille-spinner' -import type {ThemeId} from '@/lib/theme'; +import type { ThemeId } from '@/lib/theme' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { applyTheme, useSettings } from '@/hooks/use-settings' import { THEMES, - getTheme, getThemeVariant, isDarkTheme, - setTheme + setTheme, } from '@/lib/theme' import { cn } from '@/lib/utils' import { @@ -57,15 +60,23 @@ import { type SectionId = | 'hermes' + | 'agent' + | 'routing' + | 'voice' + | 'display' | 'appearance' | 'chat' | 'notifications' const SECTIONS: Array<{ id: SectionId; label: string; icon: any }> = [ - { id: 'hermes', label: 'Hermes Agent', icon: CloudIcon }, - { id: 'appearance', label: 'Appearance', icon: PaintBoardIcon }, + { id: 'hermes', label: 'Model & Provider', icon: CloudIcon }, + { id: 'agent', label: 'Agent', icon: Settings02Icon }, + { id: 'routing', label: 'Smart Routing', icon: SparklesIcon }, + { id: 'voice', label: 'Voice', icon: VolumeHighIcon }, + { id: 'display', label: 'Display', icon: PaintBoardIcon }, + { id: 'appearance', label: 'Theme', icon: PaintBoardIcon }, { id: 'chat', label: 'Chat', icon: MessageMultiple01Icon }, - { id: 'notifications', label: 'Notifications', icon: Notification03Icon }, + { id: 'notifications', label: 'Alerts', icon: Notification03Icon }, ] const DARK_ENTERPRISE_THEMES = new Set([ @@ -136,15 +147,75 @@ const SETTINGS_CARD_CLASS = // ── Section components ────────────────────────────────────────────────── -const PROVIDER_CARDS: Array<{ id: string; name: string; logo: string; models: Array; authType: 'oauth' | 'api_key' | 'none'; envKey?: string }> = [ - { id: 'nous', name: 'Nous Portal', logo: '/providers/nous.png', models: ['hermes-3-llama-3.1-405b', 'hermes-3-llama-3.1-70b'], authType: 'oauth' }, - { id: 'openai-codex', name: 'OpenAI Codex', logo: '/providers/openai.png', models: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-4o'], authType: 'oauth' }, - { id: 'anthropic', name: 'Anthropic', logo: '/providers/anthropic.png', models: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-3-5'], authType: 'api_key', envKey: 'ANTHROPIC_API_KEY' }, - { id: 'openrouter', name: 'OpenRouter', logo: '/providers/openrouter.png', models: ['auto', 'deepseek/deepseek-r1', 'google/gemini-2.5-pro'], authType: 'api_key', envKey: 'OPENROUTER_API_KEY' }, - { id: 'zai', name: 'Z.AI / GLM', logo: '/providers/zhipu.png', models: ['glm-4-plus', 'glm-4-air'], authType: 'api_key', envKey: 'GLM_API_KEY' }, - { id: 'kimi-coding', name: 'Kimi', logo: '/providers/kimi.png', models: ['kimi-latest', 'moonshot-v1-128k'], authType: 'api_key', envKey: 'KIMI_API_KEY' }, - { id: 'minimax', name: 'MiniMax', logo: '/providers/minimax.png', models: ['MiniMax-M2.5', 'MiniMax-M2.5-Lightning'], authType: 'api_key', envKey: 'MINIMAX_API_KEY' }, - { id: 'ollama', name: 'Ollama', logo: '/providers/ollama.png', models: ['llama3.1:70b', 'qwen3:32b', 'deepseek-r1:32b'], authType: 'none' }, +const PROVIDER_CARDS: Array<{ + id: string + name: string + logo: string + models: Array + authType: 'oauth' | 'api_key' | 'none' + envKey?: string +}> = [ + { + id: 'nous', + name: 'Nous Portal', + logo: '/providers/nous.png', + models: ['hermes-3-llama-3.1-405b', 'hermes-3-llama-3.1-70b'], + authType: 'oauth', + }, + { + id: 'openai-codex', + name: 'OpenAI Codex', + logo: '/providers/openai.png', + models: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-4o'], + authType: 'oauth', + }, + { + id: 'anthropic', + name: 'Anthropic', + logo: '/providers/anthropic.png', + models: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-3-5'], + authType: 'api_key', + envKey: 'ANTHROPIC_API_KEY', + }, + { + id: 'openrouter', + name: 'OpenRouter', + logo: '/providers/openrouter.png', + models: ['auto', 'deepseek/deepseek-r1', 'google/gemini-2.5-pro'], + authType: 'api_key', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'zai', + name: 'Z.AI / GLM', + logo: '/providers/zhipu.png', + models: ['glm-4-plus', 'glm-4-air'], + authType: 'api_key', + envKey: 'GLM_API_KEY', + }, + { + id: 'kimi-coding', + name: 'Kimi', + logo: '/providers/kimi.png', + models: ['kimi-latest', 'moonshot-v1-128k'], + authType: 'api_key', + envKey: 'KIMI_API_KEY', + }, + { + id: 'minimax', + name: 'MiniMax', + logo: '/providers/minimax.png', + models: ['MiniMax-M2.5', 'MiniMax-M2.5-Lightning'], + authType: 'api_key', + envKey: 'MINIMAX_API_KEY', + }, + { + id: 'ollama', + name: 'Ollama', + logo: '/providers/ollama.png', + models: ['llama3.1:70b', 'qwen3:32b', 'deepseek-r1:32b'], + authType: 'none', + }, { id: 'custom', name: 'Custom', logo: '', models: [], authType: 'api_key' }, ] @@ -157,12 +228,16 @@ function HermesContent() { const [keyInput, setKeyInput] = useState('') const [_saving, setSaving] = useState(false) const [msg, setMsg] = useState(null) - const [configuredKeys, setConfiguredKeys] = useState>({}) + const [configuredKeys, setConfiguredKeys] = useState>( + {}, + ) const [memEnabled, setMemEnabled] = useState(true) const [userProfileEnabled, setUserProfileEnabled] = useState(true) const fetchModelsForProvider = useCallback((providerId: string) => { - fetch(`/api/hermes-proxy/api/available-models?provider=${encodeURIComponent(providerId)}`) + fetch( + `/api/hermes-proxy/api/available-models?provider=${encodeURIComponent(providerId)}`, + ) .then((r) => r.json()) .then((d: { models?: Array<{ id: string }> }) => { setAvailableModels((d.models || []).map((m) => m.id)) @@ -186,30 +261,43 @@ function HermesContent() { setUserProfileEnabled(mem.user_profile_enabled !== false) // Build configured keys map const keys: Record = {} - for (const p of (d.providers || [])) { - if (p.configured && p.envKeys?.[0]) keys[p.envKeys[0]] = p.maskedKeys?.[p.envKeys[0]] || '••••' + for (const p of d.providers || []) { + if (p.configured && p.envKeys?.[0]) + keys[p.envKeys[0]] = p.maskedKeys?.[p.envKeys[0]] || '••••' } setConfiguredKeys(keys) }) .catch(() => {}) }, []) - const save = async (updates: { config?: Record; env?: Record }) => { - setSaving(true); setMsg(null) + const save = async (updates: { + config?: Record + env?: Record + }) => { + setSaving(true) + setMsg(null) try { - const res = await fetch('/api/hermes-config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }) - const r = await res.json() as { message?: string } + const res = await fetch('/api/hermes-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }) + const r = (await res.json()) as { message?: string } setMsg(r.message || 'Saved') const ref = await fetch('/api/hermes-config') const d = await ref.json() - setActiveProvider(d.activeProvider || ''); setActiveModel(d.activeModel || '') + setActiveProvider(d.activeProvider || '') + setActiveModel(d.activeModel || '') const keys: Record = {} - for (const p of (d.providers || [])) { - if (p.configured && p.envKeys?.[0]) keys[p.envKeys[0]] = p.maskedKeys?.[p.envKeys[0]] || '••••' + for (const p of d.providers || []) { + if (p.configured && p.envKeys?.[0]) + keys[p.envKeys[0]] = p.maskedKeys?.[p.envKeys[0]] || '••••' } setConfiguredKeys(keys) setTimeout(() => setMsg(null), 3000) - } catch { setMsg('Failed to save') } + } catch { + setMsg('Failed to save') + } setSaving(false) } @@ -234,25 +322,46 @@ function HermesContent() { ) } - const cardStyle: React.CSSProperties = { backgroundColor: 'var(--theme-card)', border: '1px solid var(--theme-border)', color: 'var(--theme-text)' } + const cardStyle: React.CSSProperties = { + backgroundColor: 'var(--theme-card)', + border: '1px solid var(--theme-border)', + color: 'var(--theme-text)', + } const mutedStyle: React.CSSProperties = { color: 'var(--theme-muted)' } return (
{msg && ( -
+
{msg}
)} {/* Provider Selection */}
-

Provider

-

Select your AI provider. OAuth providers authenticate via browser.

+

+ Provider +

+

+ Select your AI provider. OAuth providers authenticate via browser. +

{PROVIDER_CARDS.map((p) => { const isActive = activeProvider === p.id - const hasKey = p.authType === 'none' || p.authType === 'oauth' || (p.envKey ? !!configuredKeys[p.envKey] : false) + const hasKey = + p.authType === 'none' || + p.authType === 'oauth' || + (p.envKey ? !!configuredKeys[p.envKey] : false) return ( ) @@ -286,16 +409,27 @@ function HermesContent() { {/* Model Selection for active provider */} {activeProvider && (
-

Model

+

+ Model +

- {(availableModels.length > 0 ? availableModels : PROVIDER_CARDS.find((p) => p.id === activeProvider)?.models || []).map((model) => ( + {(availableModels.length > 0 + ? availableModels + : PROVIDER_CARDS.find((p) => p.id === activeProvider)?.models || + [] + ).map((model) => ( - + + ) : ( - )} @@ -361,21 +553,48 @@ function HermesContent() { {/* Memory */}
-

Memory

+

+ Memory +

-
+
Memory
-
Store & recall memories across sessions
+
+ Store & recall memories across sessions +
- { setMemEnabled(c); save({ config: { memory: { memory_enabled: c } } }) }} /> + { + setMemEnabled(c) + save({ config: { memory: { memory_enabled: c } } }) + }} + />
-
+
User Profile
-
Remember preferences & context
+
+ Remember preferences & context +
- { setUserProfileEnabled(c); save({ config: { memory: { user_profile_enabled: c } } }) }} /> + { + setUserProfileEnabled(c) + save({ config: { memory: { user_profile_enabled: c } } }) + }} + />
@@ -384,12 +603,24 @@ function HermesContent() {
- Runtime + + Runtime +
- Model{activeModel || '—'} - Provider{PROVIDER_CARDS.find((p) => p.id === activeProvider)?.name || activeProvider || '—'} - Config~/.hermes/config.yaml + Model + {activeModel || '—'} + Provider + + {PROVIDER_CARDS.find((p) => p.id === activeProvider)?.name || + activeProvider || + '—'} + + Config + ~/.hermes/config.yaml
@@ -493,7 +724,11 @@ function _ProfileContent() { aria-describedby={nameError ? errorId : undefined} /> {nameError && ( - )} @@ -605,7 +840,9 @@ function AppearanceContent() { > updateSettings({ showSystemMetricsFooter: c })} + onCheckedChange={(c) => + updateSettings({ showSystemMetricsFooter: c }) + } aria-label="Show system metrics footer" /> @@ -628,37 +865,105 @@ const ENTERPRISE_THEMES = THEMES.map((theme) => ({ desc: theme.description, preview: theme.id === 'hermes-official' - ? { bg: '#0A0E1A', panel: '#11182A', border: '#24304A', accent: '#6366F1', text: '#E6EAF2' } + ? { + bg: '#0A0E1A', + panel: '#11182A', + border: '#24304A', + accent: '#6366F1', + text: '#E6EAF2', + } : theme.id === 'hermes-official-light' - ? { bg: '#F6F8FC', panel: '#FFFFFF', border: '#D7DEEE', accent: '#4F46E5', text: '#111827' } + ? { + bg: '#F6F8FC', + panel: '#FFFFFF', + border: '#D7DEEE', + accent: '#4F46E5', + text: '#111827', + } : theme.id === 'hermes-classic' - ? { bg: '#0d0f12', panel: '#1a1f26', border: '#2a313b', accent: '#b98a44', text: '#eceff4' } - : theme.id === 'hermes-classic-light' - ? { bg: '#F5F2ED', panel: '#FCFAF7', border: '#D8CCBC', accent: '#b98a44', text: '#1a1f26' } - : theme.id === 'hermes-slate' - ? { bg: '#0d1117', panel: '#1c2128', border: '#30363d', accent: '#7eb8f6', text: '#c9d1d9' } - : theme.id === 'hermes-slate-light' - ? { bg: '#F6F8FA', panel: '#FFFFFF', border: '#D0D7DE', accent: '#3b82f6', text: '#24292f' } - : theme.id === 'hermes-mono' - ? { bg: '#111111', panel: '#222222', border: '#333333', accent: '#aaaaaa', text: '#e6edf3' } - : { bg: '#FAFAFA', panel: '#FFFFFF', border: '#D4D4D4', accent: '#666666', text: '#1a1a1a' }, + ? { + bg: '#0d0f12', + panel: '#1a1f26', + border: '#2a313b', + accent: '#b98a44', + text: '#eceff4', + } + : theme.id === 'hermes-classic-light' + ? { + bg: '#F5F2ED', + panel: '#FCFAF7', + border: '#D8CCBC', + accent: '#b98a44', + text: '#1a1f26', + } + : theme.id === 'hermes-slate' + ? { + bg: '#0d1117', + panel: '#1c2128', + border: '#30363d', + accent: '#7eb8f6', + text: '#c9d1d9', + } + : theme.id === 'hermes-slate-light' + ? { + bg: '#F6F8FA', + panel: '#FFFFFF', + border: '#D0D7DE', + accent: '#3b82f6', + text: '#24292f', + } + : theme.id === 'hermes-mono' + ? { + bg: '#111111', + panel: '#222222', + border: '#333333', + accent: '#aaaaaa', + text: '#e6edf3', + } + : { + bg: '#FAFAFA', + panel: '#FFFFFF', + border: '#D4D4D4', + accent: '#666666', + text: '#1a1a1a', + }, })) -function ThemeSwatch({ colors }: { colors: typeof ENTERPRISE_THEMES[number]['preview'] }) { +function ThemeSwatch({ + colors, +}: { + colors: (typeof ENTERPRISE_THEMES)[number]['preview'] +}) { return (
-
+
{[1, 2, 3].map((i) => ( -
+
))}
-
-
-
+
+
+
) @@ -701,7 +1006,8 @@ function EnterpriseThemePicker() { {currentMode === 'dark' ? 'Dark mode' : 'Light mode'}

- Toggle the current theme family between paired light and dark variants. + Toggle the current theme family between paired light and dark + variants.

- {visibleThemes.map((t) => { - const isActive = current === t.id - return ( -
-

{t.desc}

- - ) - })} + > + +
+ {t.icon} + + {t.label} + + {isActive && ( + + Active + + )} +
+

+ {t.desc} +

+ + ) + })}
) @@ -1041,10 +1353,444 @@ class SettingsErrorBoundary extends Component< } } +// ── Agent Behavior ────────────────────────────────────────────────────── + +function AgentBehaviorContent() { + const [config, setConfig] = useState>({}) + const [msg, setMsg] = useState(null) + + useEffect(() => { + fetch('/api/hermes-config') + .then((r) => r.json()) + .then((d: any) => { + setConfig((d.config?.agent as Record) || {}) + }) + .catch(() => {}) + }, []) + + const save = async (key: string, value: unknown) => { + setMsg(null) + try { + await fetch('/api/hermes-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: { agent: { [key]: value } } }), + }) + setConfig((prev) => ({ ...prev, [key]: value })) + setMsg('Saved') + setTimeout(() => setMsg(null), 2000) + } catch { + setMsg('Failed') + } + } + + return ( +
+ + {msg && ( +
+ {msg} +
+ )} +
+ + save('max_turns', Number(e.target.value))} + className="h-8 w-20 rounded-lg border border-primary-200 bg-primary-50 px-2 text-sm text-center text-primary-900 outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100" + /> + + + save('gateway_timeout', Number(e.target.value))} + className="h-8 w-20 rounded-lg border border-primary-200 bg-primary-50 px-2 text-sm text-center text-primary-900 outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100" + /> + + + + +
+
+ ) +} + +// ── Smart Routing ─────────────────────────────────────────────────────── + +function SmartRoutingContent() { + const [config, setConfig] = useState>({}) + const [models, setModels] = useState>([]) + const [msg, setMsg] = useState(null) + + useEffect(() => { + fetch('/api/hermes-config') + .then((r) => r.json()) + .then((d: any) => { + setConfig( + (d.config?.smart_model_routing as Record) || {}, + ) + }) + .catch(() => {}) + fetch('/api/models') + .then((r) => r.json()) + .then((d: any) => { + setModels(d.models || []) + }) + .catch(() => {}) + }, []) + + const save = async (key: string, value: unknown) => { + setMsg(null) + try { + await fetch('/api/hermes-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: { smart_model_routing: { [key]: value } }, + }), + }) + setConfig((prev) => ({ ...prev, [key]: value })) + setMsg('Saved') + setTimeout(() => setMsg(null), 2000) + } catch { + setMsg('Failed') + } + } + + return ( +
+ + {msg && ( +
+ {msg} +
+ )} +
+ + save('enabled', c)} + /> + + + + + + save('max_simple_chars', Number(e.target.value))} + className="h-8 w-20 rounded-lg border border-primary-200 bg-primary-50 px-2 text-sm text-center text-primary-900 outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100" + /> + + + save('max_simple_words', Number(e.target.value))} + className="h-8 w-20 rounded-lg border border-primary-200 bg-primary-50 px-2 text-sm text-center text-primary-900 outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100" + /> + +
+
+ ) +} + +// ── Voice (TTS + STT) ────────────────────────────────────────────────── + +function VoiceContent() { + const [tts, setTts] = useState>({}) + const [stt, setStt] = useState>({}) + const [msg, setMsg] = useState(null) + + useEffect(() => { + fetch('/api/hermes-config') + .then((r) => r.json()) + .then((d: any) => { + setTts((d.config?.tts as Record) || {}) + setStt((d.config?.stt as Record) || {}) + }) + .catch(() => {}) + }, []) + + const saveTts = async (key: string, value: unknown) => { + setMsg(null) + try { + await fetch('/api/hermes-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: { tts: { [key]: value } } }), + }) + setTts((prev) => ({ ...prev, [key]: value })) + setMsg('Saved') + setTimeout(() => setMsg(null), 2000) + } catch { + setMsg('Failed') + } + } + + const saveStt = async (key: string, value: unknown) => { + setMsg(null) + try { + await fetch('/api/hermes-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: { stt: { [key]: value } } }), + }) + setStt((prev) => ({ ...prev, [key]: value })) + setMsg('Saved') + setTimeout(() => setMsg(null), 2000) + } catch { + setMsg('Failed') + } + } + + const ttsProvider = String(tts.provider || 'edge') + + return ( +
+ + {msg && ( +
+ {msg} +
+ )} +
+

+ Text-to-Speech +

+ + + + {ttsProvider === 'openai' && ( + + + + )} +
+
+

+ Speech-to-Text +

+ + saveStt('enabled', c)} + /> + + + + +
+
+ ) +} + +// ── Display ───────────────────────────────────────────────────────────── + +function DisplayContent() { + const [config, setConfig] = useState>({}) + const [msg, setMsg] = useState(null) + + useEffect(() => { + fetch('/api/hermes-config') + .then((r) => r.json()) + .then((d: any) => { + setConfig((d.config?.display as Record) || {}) + }) + .catch(() => {}) + }, []) + + const save = async (key: string, value: unknown) => { + setMsg(null) + try { + await fetch('/api/hermes-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: { display: { [key]: value } } }), + }) + setConfig((prev) => ({ ...prev, [key]: value })) + setMsg('Saved') + setTimeout(() => setMsg(null), 2000) + } catch { + setMsg('Failed') + } + } + + return ( +
+ + {msg && ( +
+ {msg} +
+ )} +
+ + + + + save('streaming', c)} + /> + + + save('show_reasoning', c)} + /> + + + save('show_cost', c)} + /> + + + save('compact', c)} + /> + +
+
+ ) +} + // ── Main Dialog ───────────────────────────────────────────────────────── const CONTENT_MAP: Record React.JSX.Element> = { hermes: HermesContent, + agent: AgentBehaviorContent, + routing: SmartRoutingContent, + voice: VoiceContent, + display: DisplayContent, appearance: AppearanceContent, chat: ChatContent, notifications: NotificationsContent, @@ -1124,10 +1870,15 @@ export function SettingsDialog({ onClick={() => handleSectionSelect(s.id)} className={cn( 'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-primary-600 transition-colors hover:bg-primary-100', - active === s.id && 'bg-accent-50 font-medium text-accent-700', + active === s.id && + 'bg-accent-50 font-medium text-accent-700', )} > - + {s.label} ))} @@ -1161,7 +1912,13 @@ export function SettingsDialog({
- Changes saved automatically. + Changes saved automatically.{' '} + + All settings → +
diff --git a/src/components/slash-command-menu.tsx b/src/components/slash-command-menu.tsx index e598a5de..83a94915 100644 --- a/src/components/slash-command-menu.tsx +++ b/src/components/slash-command-menu.tsx @@ -1,6 +1,12 @@ 'use client' -import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react' import type { Ref } from 'react' import { useAutocompleteFilter } from '@/components/ui/autocomplete' @@ -95,7 +101,12 @@ const SlashCommandMenu = forwardRef(function SlashCommandMenu( return (
-
+
{ - if (e.key === 'Enter') { e.preventDefault(); send() } - if (e.key === 'Tab') { e.preventDefault(); void sendToActiveTab('\t') } + if (e.key === 'Enter') { + e.preventDefault() + send() + } + if (e.key === 'Tab') { + e.preventDefault() + void sendToActiveTab('\t') + } }} placeholder="Type command…" autoCapitalize="none" diff --git a/src/components/terminal/terminal-panel.tsx b/src/components/terminal/terminal-panel.tsx index f5603f0e..e9264cb3 100644 --- a/src/components/terminal/terminal-panel.tsx +++ b/src/components/terminal/terminal-panel.tsx @@ -163,7 +163,6 @@ export function TerminalPanel({ isMobile }: TerminalPanelProps) { window.addEventListener('mousemove', handleMove) window.addEventListener('mouseup', handleUp) - }, [activeTab?.id, height], ) @@ -377,7 +376,6 @@ export function TerminalPanel({ isMobile }: TerminalPanelProps) { />
-
{tabs.map((tab) => ( @@ -425,7 +423,6 @@ export function TerminalPanel({ isMobile }: TerminalPanelProps) { placeholder="Search output" onKeyDown={(event) => { if (event.key === 'Enter') { - handleSearch( activeTab?.id ?? '', event.currentTarget.value, diff --git a/src/components/terminal/terminal-workspace.tsx b/src/components/terminal/terminal-workspace.tsx index ae3733a0..e95ea1a2 100644 --- a/src/components/terminal/terminal-workspace.tsx +++ b/src/components/terminal/terminal-workspace.tsx @@ -136,7 +136,6 @@ export function TerminalWorkspace({ const [debugLoading, setDebugLoading] = useState(false) const [showDebugPanel, setShowDebugPanel] = useState(false) - const containerMapRef = useRef(new Map()) const terminalMapRef = useRef(new Map()) const fitMapRef = useRef(new Map()) @@ -277,8 +276,6 @@ export function TerminalWorkspace({ [activeTab], ) - - const closeTabResources = useCallback(async function closeTabResources( tabId: string, sessionId: string | null, @@ -400,7 +397,8 @@ export function TerminalWorkspace({ for (let _bi = 0; _bi < blocks.length; _bi++) { // Yield every 10 blocks to let input events through - if (_bi > 0 && _bi % 10 === 0) await new Promise((r) => setTimeout(r, 0)) + if (_bi > 0 && _bi % 10 === 0) + await new Promise((r) => setTimeout(r, 0)) const block = blocks[_bi] if (!block.trim()) continue const lines = block.split('\n') @@ -596,7 +594,11 @@ export function TerminalWorkspace({ // Refit all terminals when becoming visible (e.g. navigating back to terminal route) window.setTimeout(() => { for (const fitAddon of fitMapRef.current.values()) { - try { fitAddon.fit() } catch { /* ignore */ } + try { + fitAddon.fit() + } catch { + /* ignore */ + } } const snapshot = useTerminalPanelStore.getState().tabs for (const tab of snapshot) { @@ -613,7 +615,11 @@ export function TerminalWorkspace({ function fitOnResize() { function refitAll() { for (const fitAddon of fitMapRef.current.values()) { - try { fitAddon.fit() } catch { /* */ } + try { + fitAddon.fit() + } catch { + /* */ + } } const snapshot = useTerminalPanelStore.getState().tabs for (const tab of snapshot) { @@ -668,7 +674,11 @@ export function TerminalWorkspace({ return (
{/* fullscreen header removed — tab bar handles everything */} @@ -776,14 +786,41 @@ export function TerminalWorkspace({ {mode === 'panel' ? ( <> - - - ) : null} diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx index 2b55fea5..92d5ebea 100644 --- a/src/components/theme-toggle.tsx +++ b/src/components/theme-toggle.tsx @@ -1,11 +1,7 @@ import { ComputerIcon, Moon01Icon, Sun01Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' -import type {SettingsThemeMode} from '@/hooks/use-settings'; -import { - - applyTheme, - useSettingsStore -} from '@/hooks/use-settings' +import type { SettingsThemeMode } from '@/hooks/use-settings' +import { applyTheme, useSettingsStore } from '@/hooks/use-settings' import { cn } from '@/lib/utils' function resolvedIsDark(): boolean { diff --git a/src/components/ui/autocomplete.tsx b/src/components/ui/autocomplete.tsx index 5d864edc..2bed6cc3 100644 --- a/src/components/ui/autocomplete.tsx +++ b/src/components/ui/autocomplete.tsx @@ -183,10 +183,7 @@ function AutocompleteGroupLabel({ }: AutocompletePrimitive.GroupLabel.Props) { return ( - + { (e.currentTarget as HTMLElement).style.background = 'var(--theme-card2)' }} - onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent' }} + onMouseEnter={(e) => { + ;(e.currentTarget as HTMLElement).style.background = + 'var(--theme-card2)' + }} + onMouseLeave={(e) => { + ;(e.currentTarget as HTMLElement).style.background = 'transparent' + }} {...props} /> ) diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 0cd8844d..65491570 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -54,7 +54,9 @@ export function Toaster() { const addToast = useCallback((item: ToastItem) => { setToasts((prev) => { // Dedupe: skip if same message + type already visible - if (prev.some((t) => t.message === item.message && t.type === item.type)) { + if ( + prev.some((t) => t.message === item.message && t.type === item.type) + ) { return prev } return [...prev.slice(-4), item] // max 5 diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 16a265de..9fc9925a 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -38,10 +38,7 @@ function TooltipContent({
diff --git a/src/components/usage-meter/usage-details-modal.tsx b/src/components/usage-meter/usage-details-modal.tsx index 4749ce5d..0b6da63d 100644 --- a/src/components/usage-meter/usage-details-modal.tsx +++ b/src/components/usage-meter/usage-details-modal.tsx @@ -387,7 +387,8 @@ export function UsageDetailsModal({
{usage.models.length === 0 ? (
- No model usage reported yet. Send a message to start tracking usage here. + No model usage reported yet. Send a message to start + tracking usage here.
) : ( usage.models.map((model) => ( @@ -418,7 +419,8 @@ export function UsageDetailsModal({
{usage.sessions.length === 0 ? (
- No sessions reported yet. Start a chat to see session history here. + No sessions reported yet. Start a chat to see session + history here.
) : ( usage.sessions.map((session) => ( @@ -487,10 +489,12 @@ export function UsageDetailsModal({ {providerUsage.length === 0 ? (
- No providers connected. Add a provider in Settings to start chatting. + No providers connected. Add a provider in Settings to start + chatting.
- Open Settings -{'>'} Providers to connect Claude CLI or add an API key. + Open Settings -{'>'} Providers to connect Claude CLI or add + an API key.
) : ( diff --git a/src/components/usage-meter/usage-meter-compact.tsx b/src/components/usage-meter/usage-meter-compact.tsx index 6587ee72..5f528786 100644 --- a/src/components/usage-meter/usage-meter-compact.tsx +++ b/src/components/usage-meter/usage-meter-compact.tsx @@ -70,7 +70,10 @@ function readPercent(value: unknown): number { } function parseContextPercent(payload: unknown): number { - const root = payload && typeof payload === 'object' ? (payload as Record) : {} + const root = + payload && typeof payload === 'object' + ? (payload as Record) + : {} const usage = (root.today as Record | undefined) ?? (root.usage as Record | undefined) ?? @@ -78,9 +81,9 @@ function parseContextPercent(payload: unknown): number { (root.totals as Record | undefined) ?? root return readPercent( - (usage)?.contextPercent ?? - (usage)?.context_percent ?? - (usage)?.context ?? + usage?.contextPercent ?? + usage?.context_percent ?? + usage?.context ?? root?.contextPercent ?? root?.context_percent, ) @@ -125,8 +128,12 @@ export function UsageMeterCompact() { const [contextPct, setContextPct] = useState(null) const [progressRows, setProgressRows] = useState>([]) const [providerLabel, setProviderLabel] = useState(null) - const [preferredProvider, setPreferredProvider] = useState(getStoredPreferredProvider) - const [allProviders, setAllProviders] = useState>([]) + const [preferredProvider, setPreferredProvider] = useState( + getStoredPreferredProvider, + ) + const [allProviders, setAllProviders] = useState>( + [], + ) const [expanded, setExpanded] = useState(true) // Flash state: animate provider name on change const [providerFlash, setProviderFlash] = useState(false) @@ -138,11 +145,14 @@ export function UsageMeterCompact() { (providers: Array, preferred: string | null) => { if (preferred) { const match = providers.find( - (p) => p.provider === preferred && p.status === 'ok' && p.lines.length > 0, + (p) => + p.provider === preferred && p.status === 'ok' && p.lines.length > 0, ) if (match) return match } - return providers.find((p) => p.status === 'ok' && p.lines.length > 0) ?? null + return ( + providers.find((p) => p.status === 'ok' && p.lines.length > 0) ?? null + ) }, [], ) @@ -150,9 +160,13 @@ export function UsageMeterCompact() { // ── Cycle to next provider ─────────────────────────────────────────────── const cycleProvider = useCallback(() => { - const okProviders = allProviders.filter((p) => p.status === 'ok' && p.lines.length > 0) + const okProviders = allProviders.filter( + (p) => p.status === 'ok' && p.lines.length > 0, + ) if (okProviders.length < 2) return - const currentIdx = okProviders.findIndex((p) => p.provider === preferredProvider) + const currentIdx = okProviders.findIndex( + (p) => p.provider === preferredProvider, + ) const nextIdx = (currentIdx + 1) % okProviders.length const next = okProviders[nextIdx] if (!next) return @@ -243,9 +257,12 @@ export function UsageMeterCompact() { useEffect(() => { void fetchProvider(preferredProvider) - const id = window.setInterval(() => fetchProvider(preferredProvider), POLL_INTERVAL_MS) + const id = window.setInterval( + () => fetchProvider(preferredProvider), + POLL_INTERVAL_MS, + ) return () => window.clearInterval(id) - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetchProvider]) // Cleanup flash timer on unmount @@ -262,12 +279,12 @@ export function UsageMeterCompact() { // Build the rows to display: session context row + all provider progress rows const ctxRow: UsageRow = { label: 'Ctx', pct: contextPct, resetHint: null } const allRows: Array = - progressRows.length > 0 - ? progressRows - : [ctxRow] + progressRows.length > 0 ? progressRows : [ctxRow] const headerLabel = providerLabel ? `Usage · ${providerLabel}` : 'Usage' - const canCycle = allProviders.filter((p) => p.status === 'ok' && p.lines.length > 0).length > 1 + const canCycle = + allProviders.filter((p) => p.status === 'ok' && p.lines.length > 0).length > + 1 return (
@@ -288,9 +305,7 @@ export function UsageMeterCompact() { aria-label={canCycle ? 'Cycle provider' : undefined} > {headerLabel} - {canCycle && ( - - )} + {canCycle && } {/* Collapse chevron */} @@ -322,7 +337,10 @@ export function UsageMeterCompact() {
diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index e5f0891c..84145fc3 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -7,4 +7,3 @@ describe('workspace shell sidebar backdrop', () => { expect(DESKTOP_SIDEBAR_BACKDROP_CLASS).not.toContain('inset-0') }) }) - diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index bd259b78..72d132ec 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -217,7 +217,8 @@ export function WorkspaceShell() { if (prevIdx !== -1 && currentIdx !== -1 && currentIdx !== prevIdx) { // Navigate right (higher index) = slide left; left = slide right - const direction = currentIdx > prevIdx ? 'slide-enter-left' : 'slide-enter-right' + const direction = + currentIdx > prevIdx ? 'slide-enter-left' : 'slide-enter-right' setSlideClass(direction) // Remove class after animation completes const timer = setTimeout(() => setSlideClass(''), 250) @@ -265,13 +266,23 @@ export function WorkspaceShell() { {isElectron && (
{/* Traffic light spacer (left ~78px for macOS buttons) */}
{/* Centered title */}
- Hermes + + Hermes +
{/* Right spacer to balance */}
@@ -338,17 +349,29 @@ export function WorkspaceShell() { )}
- +
{/* Mobile input bar — sibling to terminal, NOT a child, so SSE re-renders don't freeze it */} {isMobile && }
-
- {isMobile && !isOnChatRoute && !isOnTerminalRoute && mobilePageTitle && ( - - )} +
+ {isMobile && + !isOnChatRoute && + !isOnTerminalRoute && + mobilePageTitle && } s.setMobileKeyboardInset) + const setMobileKeyboardInset = useWorkspaceStore( + (s) => s.setMobileKeyboardInset, + ) const setMobileKeyboardOpen = useWorkspaceStore( (s) => s.setMobileKeyboardOpen, ) diff --git a/src/hooks/use-model-suggestions.ts b/src/hooks/use-model-suggestions.ts index f347fef6..41c8bead 100644 --- a/src/hooks/use-model-suggestions.ts +++ b/src/hooks/use-model-suggestions.ts @@ -174,7 +174,7 @@ export function useModelSuggestions(_opts: { } // -ignore -- disabled, will re-enable after fixing deps - + function _useModelSuggestionsDisabled({ currentModel, sessionKey, @@ -293,7 +293,7 @@ function _useModelSuggestionsDisabled({ } } } - // eslint-disable-next-line react-hooks/exhaustive-deps -- messages.length as stable proxy + // eslint-disable-next-line react-hooks/exhaustive-deps -- messages.length as stable proxy }, [ currentModel, sessionKey, diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts index c3799df9..0cf5d3df 100644 --- a/src/hooks/use-settings.ts +++ b/src/hooks/use-settings.ts @@ -47,7 +47,6 @@ export const defaultStudioSettings: StudioSettings = { mobileChatNavMode: 'dock', } - export const useSettingsStore = create()( persist( function createSettingsStore(set) { diff --git a/src/hooks/use-swipe-navigation.ts b/src/hooks/use-swipe-navigation.ts index 98a587bd..e4e38438 100644 --- a/src/hooks/use-swipe-navigation.ts +++ b/src/hooks/use-swipe-navigation.ts @@ -3,12 +3,7 @@ import { useNavigate, useRouterState } from '@tanstack/react-router' import type { TouchEvent } from 'react' import { useWorkspaceStore } from '@/stores/workspace-store' -const TAB_ORDER = [ - '/chat/main', - '/files', - '/jobs', - '/settings', -] as const +const TAB_ORDER = ['/chat/main', '/files', '/jobs', '/settings'] as const const EDGE_ZONE = 24 const LOCK_THRESHOLD = 12 @@ -59,7 +54,9 @@ function triggerHaptic() { export function useSwipeNavigation() { const navigate = useNavigate() - const pathname = useRouterState({ select: (state) => state.location.pathname }) + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) const gestureRef = useRef(null) const onTouchStart = useCallback((event: TouchEvent) => { diff --git a/src/hooks/use-tap-debug.ts b/src/hooks/use-tap-debug.ts index 4c864d4d..697513d0 100644 --- a/src/hooks/use-tap-debug.ts +++ b/src/hooks/use-tap-debug.ts @@ -125,7 +125,7 @@ export function useTapDebug( eventTarget instanceof Element ? eventTarget : eventTarget instanceof Node - ? (eventTarget).parentElement + ? eventTarget.parentElement : null console.debug(`[tap-debug:${label}]`, { @@ -142,11 +142,7 @@ export function useTapDebug( function handleTouchStart(event: TouchEvent) { const touch = event.touches[0] if (!touch) return - logTap( - { x: touch.clientX, y: touch.clientY }, - 'touchstart', - event.target, - ) + logTap({ x: touch.clientX, y: touch.clientY }, 'touchstart', event.target) } function handlePointerDown(event: PointerEvent) { diff --git a/src/hooks/use-voice-input.ts b/src/hooks/use-voice-input.ts index dbf96f8a..69a882a7 100644 --- a/src/hooks/use-voice-input.ts +++ b/src/hooks/use-voice-input.ts @@ -28,13 +28,13 @@ type UseVoiceInputReturn = { } // Web Speech API types (not available in all TS configs) - + type SpeechRecognitionInstance = any type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance function getSpeechRecognition(): SpeechRecognitionConstructor | null { if (typeof window === 'undefined') return null - + const win = window as any return win.SpeechRecognition ?? win.webkitSpeechRecognition ?? null } diff --git a/src/lib/active-users.ts b/src/lib/active-users.ts index a89df4b7..407c847e 100644 --- a/src/lib/active-users.ts +++ b/src/lib/active-users.ts @@ -1,44 +1,44 @@ -'use client'; +'use client' -const STORAGE_KEY = 'hermes-session-pinged'; +const STORAGE_KEY = 'hermes-session-pinged' export async function generateFingerprint(): Promise { - const data = `${navigator.userAgent}${window.screen.width}${navigator.language}`; - const encoder = new TextEncoder(); - const dataBuffer = encoder.encode(data); - const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - return hashHex.slice(0, 16); + const data = `${navigator.userAgent}${window.screen.width}${navigator.language}` + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(data) + const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + return hashHex.slice(0, 16) } function isAlreadyPingedToday(): boolean { if (typeof window === 'undefined') { - return false; + return false } - const storedDate = localStorage.getItem(STORAGE_KEY); + const storedDate = localStorage.getItem(STORAGE_KEY) if (!storedDate) { - return false; + return false } - const today = new Date().toISOString().split('T')[0]; - return storedDate === today; + const today = new Date().toISOString().split('T')[0] + return storedDate === today } function markAsPingedToday(): void { if (typeof window === 'undefined') { - return; + return } - const today = new Date().toISOString().split('T')[0]; - localStorage.setItem(STORAGE_KEY, today); + const today = new Date().toISOString().split('T')[0] + localStorage.setItem(STORAGE_KEY, today) } async function sendPing(fingerprint: string): Promise { - const pingUrl = process.env.NEXT_PUBLIC_PING_URL; + const pingUrl = process.env.NEXT_PUBLIC_PING_URL if (!pingUrl) { - return; + return } const payload = { @@ -46,7 +46,7 @@ async function sendPing(fingerprint: string): Promise { version: process.env.NEXT_PUBLIC_APP_VERSION ?? '3.1.0', ts: Date.now(), mobile: window.innerWidth < 768, - }; + } await fetch(pingUrl, { method: 'POST', @@ -54,22 +54,22 @@ async function sendPing(fingerprint: string): Promise { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), - }); + }) } export async function pingActiveUser(): Promise { if (typeof window === 'undefined') { - return; + return } if (isAlreadyPingedToday()) { - return; + return } try { - const fingerprint = await generateFingerprint(); - await sendPing(fingerprint); - markAsPingedToday(); + const fingerprint = await generateFingerprint() + await sendPing(fingerprint) + markAsPingedToday() } catch { // Ignore telemetry failures. } diff --git a/src/lib/approvals-store.ts b/src/lib/approvals-store.ts index 552bf0cb..c46d8cff 100644 --- a/src/lib/approvals-store.ts +++ b/src/lib/approvals-store.ts @@ -16,7 +16,9 @@ export interface ApprovalRequest { resolvedAt?: number } -export function addApproval(_approval: Record): ApprovalRequest | null { +export function addApproval( + _approval: Record, +): ApprovalRequest | null { return null } @@ -26,4 +28,7 @@ export function loadApprovals(): Array { export function saveApprovals(_approvals?: Array): void {} -export function respondToApproval(_id: string, _status: 'approved' | 'denied'): void {} +export function respondToApproval( + _id: string, + _status: 'approved' | 'denied', +): void {} diff --git a/src/lib/feature-gates.ts b/src/lib/feature-gates.ts index 04e31bf7..ed5f4c55 100644 --- a/src/lib/feature-gates.ts +++ b/src/lib/feature-gates.ts @@ -1,6 +1,11 @@ import { getCapabilities } from '../server/gateway-capabilities' -export type EnhancedFeature = 'sessions' | 'skills' | 'memory' | 'config' | 'jobs' +export type EnhancedFeature = + | 'sessions' + | 'skills' + | 'memory' + | 'config' + | 'jobs' const FEATURE_LABELS: Record = { sessions: 'Sessions', @@ -10,7 +15,9 @@ const FEATURE_LABELS: Record = { jobs: 'Jobs', } -function normalizeFeature(feature: EnhancedFeature | string): EnhancedFeature | null { +function normalizeFeature( + feature: EnhancedFeature | string, +): EnhancedFeature | null { const normalized = feature.trim().toLowerCase() if ( normalized === 'sessions' || @@ -25,9 +32,7 @@ function normalizeFeature(feature: EnhancedFeature | string): EnhancedFeature | return null } -export function isFeatureAvailable( - feature: EnhancedFeature, -): boolean { +export function isFeatureAvailable(feature: EnhancedFeature): boolean { const caps = getCapabilities() return caps[feature] === true } @@ -38,7 +43,9 @@ export function getFeatureLabel(feature: EnhancedFeature | string): string { return FEATURE_LABELS[normalized] } -export function getUnavailableReason(feature: EnhancedFeature | string): string { +export function getUnavailableReason( + feature: EnhancedFeature | string, +): string { return `${getFeatureLabel(feature)} requires a Hermes gateway with enhanced API support.` } diff --git a/src/lib/format-model-name.ts b/src/lib/format-model-name.ts index e03a859b..6e9a66c1 100644 --- a/src/lib/format-model-name.ts +++ b/src/lib/format-model-name.ts @@ -19,10 +19,10 @@ const MODEL_MAP: Record = { 'gpt-4-turbo': 'GPT-4 Turbo', 'gpt-5.4': 'GPT-5.4', 'gpt-5.3-codex': 'Codex (GPT-5.3)', - 'o1': 'o1', + o1: 'o1', 'o1-mini': 'o1 Mini', 'o1-pro': 'o1 Pro', - 'o3': 'o3', + o3: 'o3', 'o3-mini': 'o3 Mini', 'o3-pro': 'o3 Pro', 'o4-mini': 'o4 Mini', diff --git a/src/lib/format-session-name.ts b/src/lib/format-session-name.ts index 326d95fb..18b51400 100644 --- a/src/lib/format-session-name.ts +++ b/src/lib/format-session-name.ts @@ -57,7 +57,9 @@ export function formatSessionKey(key: string): string { } // Fallback: last meaningful segment - const lastMeaningful = parts.filter((p) => p.length > 8 ? false : true).pop() + const lastMeaningful = parts + .filter((p) => (p.length > 8 ? false : true)) + .pop() return lastMeaningful ? titleCase(lastMeaningful) : key } diff --git a/src/lib/jobs-api.ts b/src/lib/jobs-api.ts index dc55a12a..1e3038dc 100644 --- a/src/lib/jobs-api.ts +++ b/src/lib/jobs-api.ts @@ -76,19 +76,25 @@ export async function deleteJob(jobId: string): Promise { } export async function pauseJob(jobId: string): Promise { - const res = await fetch(`${HERMES_API}/${jobId}?action=pause`, { method: 'POST' }) + const res = await fetch(`${HERMES_API}/${jobId}?action=pause`, { + method: 'POST', + }) if (!res.ok) throw new Error(`Failed to pause job: ${res.status}`) return (await res.json()).job } export async function resumeJob(jobId: string): Promise { - const res = await fetch(`${HERMES_API}/${jobId}?action=resume`, { method: 'POST' }) + const res = await fetch(`${HERMES_API}/${jobId}?action=resume`, { + method: 'POST', + }) if (!res.ok) throw new Error(`Failed to resume job: ${res.status}`) return (await res.json()).job } export async function triggerJob(jobId: string): Promise { - const res = await fetch(`${HERMES_API}/${jobId}?action=run`, { method: 'POST' }) + const res = await fetch(`${HERMES_API}/${jobId}?action=run`, { + method: 'POST', + }) if (!res.ok) throw new Error(`Failed to trigger job: ${res.status}`) return (await res.json()).job } diff --git a/src/lib/local-chat-threads.ts b/src/lib/local-chat-threads.ts index 421166a9..a5295662 100644 --- a/src/lib/local-chat-threads.ts +++ b/src/lib/local-chat-threads.ts @@ -9,7 +9,7 @@ export type LocalThread = { label: string createdAt: number updatedAt: number - messages: LocalThreadMessage[] + messages: Array } const threads = new Map() @@ -24,7 +24,8 @@ function nextThreadLabel(): string { function createThread(id?: string): LocalThread { const timestamp = Date.now() - const threadId = id && id.trim() ? id.trim() : `portable-${crypto.randomUUID()}` + const threadId = + id && id.trim() ? id.trim() : `portable-${crypto.randomUUID()}` const thread: LocalThread = { id: threadId, label: nextThreadLabel(), @@ -55,7 +56,7 @@ export function getThread(id: string): LocalThread | undefined { return threads.get(id) } -export function listThreads(): LocalThread[] { +export function listThreads(): Array { return [...threads.values()].sort((a, b) => b.updatedAt - a.updatedAt) } diff --git a/src/lib/strip-queued-wrapper.ts b/src/lib/strip-queued-wrapper.ts index 06c79a7f..8c442f01 100644 --- a/src/lib/strip-queued-wrapper.ts +++ b/src/lib/strip-queued-wrapper.ts @@ -1,7 +1,6 @@ const QUEUED_WRAPPER_MARKER = '[Queued messages while agent was busy]' const QUEUED_HEADER_REGEX = /---\s*\n?Queued #\d+\s*\n/g -const QUEUED_MARKER_REGEX = - /^\[Queued messages while agent was busy\]\s*\n?/g +const QUEUED_MARKER_REGEX = /^\[Queued messages while agent was busy\]\s*\n?/g export function stripQueuedWrapper(text: string): string { if (!text.includes(QUEUED_WRAPPER_MARKER)) return text diff --git a/src/lib/theme.ts b/src/lib/theme.ts index b4d9dc91..fc7501e9 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -67,13 +67,19 @@ export const THEMES: Array<{ const STORAGE_KEY = 'hermes-theme' const DEFAULT_THEME: ThemeId = 'hermes-official' const THEME_SET = new Set(THEMES.map((theme) => theme.id)) -const LIGHT_THEME_MAP: Record, Extract> = { +const LIGHT_THEME_MAP: Record< + Exclude, + Extract +> = { 'hermes-official': 'hermes-official-light', 'hermes-classic': 'hermes-classic-light', 'hermes-slate': 'hermes-slate-light', 'hermes-mono': 'hermes-mono-light', } -const DARK_THEME_MAP: Record, Exclude> = { +const DARK_THEME_MAP: Record< + Extract, + Exclude +> = { 'hermes-official-light': 'hermes-official', 'hermes-classic-light': 'hermes-classic', 'hermes-slate-light': 'hermes-slate', @@ -87,7 +93,9 @@ const LIGHT_THEMES = new Set([ 'hermes-mono-light', ]) -export function isValidTheme(value: string | null | undefined): value is ThemeId { +export function isValidTheme( + value: string | null | undefined, +): value is ThemeId { return typeof value === 'string' && THEME_SET.has(value as ThemeId) } @@ -95,7 +103,10 @@ export function isDarkTheme(theme: ThemeId): boolean { return !LIGHT_THEMES.has(theme) } -export function getThemeVariant(theme: ThemeId, mode: 'light' | 'dark'): ThemeId { +export function getThemeVariant( + theme: ThemeId, + mode: 'light' | 'dark', +): ThemeId { if (mode === 'light') { return isDarkTheme(theme) ? LIGHT_THEME_MAP[theme as keyof typeof LIGHT_THEME_MAP] diff --git a/src/lib/workspace-agents.ts b/src/lib/workspace-agents.ts index fa0694bc..6ff6c43a 100644 --- a/src/lib/workspace-agents.ts +++ b/src/lib/workspace-agents.ts @@ -58,7 +58,10 @@ function asBoolean(value: unknown): boolean { function asStringArray(value: unknown): Array { return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + ? value.filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ) : [] } @@ -97,7 +100,8 @@ function normalizeAgent(value: unknown): WorkspaceAgentDirectory | null { : 'primary', description: asString(record?.description) ?? '', system_prompt: asString(record?.system_prompt) ?? '', - prompt_updated_at: asString(record?.prompt_updated_at) ?? new Date().toISOString(), + prompt_updated_at: + asString(record?.prompt_updated_at) ?? new Date().toISOString(), limits: { max_tokens: asNumber(limits?.max_tokens), cost_label: asString(limits?.cost_label) ?? 'Unknown', @@ -116,9 +120,13 @@ function normalizeAgent(value: unknown): WorkspaceAgentDirectory | null { } } -export function extractWorkspaceAgents(payload: unknown): Array { +export function extractWorkspaceAgents( + payload: unknown, +): Array { if (Array.isArray(payload)) { - return payload.map(normalizeAgent).filter((value): value is WorkspaceAgentDirectory => Boolean(value)) + return payload + .map(normalizeAgent) + .filter((value): value is WorkspaceAgentDirectory => Boolean(value)) } const record = asRecord(payload) @@ -133,7 +141,9 @@ export function extractWorkspaceAgents(payload: unknown): Array> { +export async function listWorkspaceAgents(): Promise< + Array +> { const payload = await workspaceRequestJson('/api/workspace/agents') return extractWorkspaceAgents(payload) } diff --git a/src/lib/workspace-checkpoints.ts b/src/lib/workspace-checkpoints.ts index a4218700..25624c57 100644 --- a/src/lib/workspace-checkpoints.ts +++ b/src/lib/workspace-checkpoints.ts @@ -61,7 +61,11 @@ export type WorkspaceCheckpointVerificationItem = { checked_at: string | null } -export type WorkspaceCheckpointVerificationKey = 'tsc' | 'tests' | 'lint' | 'e2e' +export type WorkspaceCheckpointVerificationKey = + | 'tsc' + | 'tests' + | 'lint' + | 'e2e' export type WorkspaceCheckpointVerificationMap = Record< WorkspaceCheckpointVerificationKey, @@ -172,7 +176,9 @@ function normalizeRunEvent(value: unknown): WorkspaceCheckpointRunEvent { } } -function normalizeVerificationItem(value: unknown): WorkspaceCheckpointVerificationItem { +function normalizeVerificationItem( + value: unknown, +): WorkspaceCheckpointVerificationItem { const record = asRecord(value) const status = asString(record?.status) return { @@ -295,7 +301,8 @@ export async function getWorkspaceCheckpointDetail( const detailRecord = asRecord(record.checkpoint) ?? record const parsedDiffStat = asRecord(record.parsed_diff_stat) - const verificationRecord = asRecord(record.verification) ?? asRecord(detailRecord.verification) + const verificationRecord = + asRecord(record.verification) ?? asRecord(detailRecord.verification) const fileDiffs = Array.isArray(record.file_diffs) ? record.file_diffs.map((entry) => { const item = asRecord(entry) @@ -304,7 +311,7 @@ export async function getWorkspaceCheckpointDetail( patch: typeof item?.diff === 'string' ? item.diff - : asString(item?.patch) ?? '', + : (asString(item?.patch) ?? ''), } }) : Array.isArray(detailRecord.diff_files) @@ -317,7 +324,8 @@ export async function getWorkspaceCheckpointDetail( }) : [] - const rawDiffStat = typeof parsedDiffStat?.raw === 'string' ? parsedDiffStat.raw : '' + const rawDiffStat = + typeof parsedDiffStat?.raw === 'string' ? parsedDiffStat.raw : '' const checkpoint = normalizeCheckpoint(detailRecord) return { ...checkpoint, @@ -331,7 +339,8 @@ export async function getWorkspaceCheckpointDetail( typeof detailRecord.task_run_attempt === 'number' ? detailRecord.task_run_attempt : null, - task_run_workspace_path: asString(detailRecord.task_run_workspace_path) ?? null, + task_run_workspace_path: + asString(detailRecord.task_run_workspace_path) ?? null, task_run_started_at: asString(detailRecord.task_run_started_at) ?? null, task_run_completed_at: asString(detailRecord.task_run_completed_at) ?? null, task_run_error: asString(detailRecord.task_run_error) ?? null, @@ -394,7 +403,11 @@ function parseDiffLineTotals( const line = raw .split('\n') .map((entry) => entry.trimEnd()) - .find((entry) => entry.trimStart().startsWith(filePath) || entry.includes(` ${filePath} `)) + .find( + (entry) => + entry.trimStart().startsWith(filePath) || + entry.includes(` ${filePath} `), + ) if (!line) return null const match = line.match(/^(.*?)\s+\|\s+(\d+)\s+([+\-]+)$/) @@ -510,9 +523,10 @@ export function getCheckpointActionButtonClass( /** SQLite timestamps come as "2026-03-10 21:40:00" (no tz) — treat as UTC */ export function parseUtcTimestamp(value: string): Date { - const normalized = value.includes('T') || value.endsWith('Z') - ? value - : value.replace(' ', 'T') + 'Z' + const normalized = + value.includes('T') || value.endsWith('Z') + ? value + : value.replace(' ', 'T') + 'Z' return new Date(normalized) } @@ -534,13 +548,18 @@ export function matchesCheckpointProject( return checkpoint.project_name === projectName } -export function getCheckpointSummary(checkpoint: WorkspaceCheckpoint, maxLength = 200): string { +export function getCheckpointSummary( + checkpoint: WorkspaceCheckpoint, + maxLength = 200, +): string { const raw = checkpoint.summary?.trim() || 'No checkpoint summary provided.' if (raw.length <= maxLength) return raw return raw.slice(0, maxLength).trimEnd() + '…' } -export function getCheckpointFullSummary(checkpoint: WorkspaceCheckpoint): string { +export function getCheckpointFullSummary( + checkpoint: WorkspaceCheckpoint, +): string { return checkpoint.summary?.trim() || 'No checkpoint summary provided.' } @@ -550,14 +569,19 @@ export interface ParsedDiffStat { filesChanged: number } -export function getCheckpointDiffStatParsed(checkpoint: WorkspaceCheckpoint): ParsedDiffStat | null { +export function getCheckpointDiffStatParsed( + checkpoint: WorkspaceCheckpoint, +): ParsedDiffStat | null { if (!checkpoint.diff_stat) return null try { const parsed = JSON.parse(checkpoint.diff_stat) as Record return { raw: typeof parsed.raw === 'string' ? parsed.raw : '', - changedFiles: Array.isArray(parsed.changed_files) ? (parsed.changed_files as Array) : [], - filesChanged: typeof parsed.files_changed === 'number' ? parsed.files_changed : 0, + changedFiles: Array.isArray(parsed.changed_files) + ? (parsed.changed_files as Array) + : [], + filesChanged: + typeof parsed.files_changed === 'number' ? parsed.files_changed : 0, } } catch { return null @@ -583,7 +607,8 @@ export function getCheckpointReviewSuccessMessage( action: CheckpointReviewAction, ): string { if (action === 'approve') return 'Checkpoint approved' - if (action === 'approve-and-commit') return 'Checkpoint approved and committed' + if (action === 'approve-and-commit') + return 'Checkpoint approved and committed' if (action === 'approve-and-pr') return 'Checkpoint approved and PR opened' if (action === 'approve-and-merge') return 'Checkpoint approved and merged' if (action === 'revise') return 'Checkpoint sent back for revision' diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3932c34b..da96ef3f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -73,6 +73,7 @@ import { Route as ApiKnowledgeSearchRouteImport } from './routes/api/knowledge/s import { Route as ApiKnowledgeReadRouteImport } from './routes/api/knowledge/read' import { Route as ApiKnowledgeListRouteImport } from './routes/api/knowledge/list' import { Route as ApiKnowledgeGraphRouteImport } from './routes/api/knowledge/graph' +import { Route as ApiHermesProxySplatRouteImport } from './routes/api/hermes-proxy/$' import { Route as ApiHermesJobsJobIdRouteImport } from './routes/api/hermes-jobs.$jobId' import { Route as ApiSessionsSessionKeyStatusRouteImport } from './routes/api/sessions/$sessionKey.status' import { Route as ApiSessionsSessionKeyActiveRunRouteImport } from './routes/api/sessions/$sessionKey.active-run' @@ -397,6 +398,11 @@ const ApiKnowledgeGraphRoute = ApiKnowledgeGraphRouteImport.update({ path: '/api/knowledge/graph', getParentRoute: () => rootRouteImport, } as any) +const ApiHermesProxySplatRoute = ApiHermesProxySplatRouteImport.update({ + id: '/api/hermes-proxy/$', + path: '/api/hermes-proxy/$', + getParentRoute: () => rootRouteImport, +} as any) const ApiHermesJobsJobIdRoute = ApiHermesJobsJobIdRouteImport.update({ id: '/$jobId', path: '/$jobId', @@ -459,6 +465,7 @@ export interface FileRoutesByFullPath { '/chat/': typeof ChatIndexRoute '/settings/': typeof SettingsIndexRoute '/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute + '/api/hermes-proxy/$': typeof ApiHermesProxySplatRoute '/api/knowledge/graph': typeof ApiKnowledgeGraphRoute '/api/knowledge/list': typeof ApiKnowledgeListRoute '/api/knowledge/read': typeof ApiKnowledgeReadRoute @@ -527,6 +534,7 @@ export interface FileRoutesByTo { '/chat': typeof ChatIndexRoute '/settings': typeof SettingsIndexRoute '/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute + '/api/hermes-proxy/$': typeof ApiHermesProxySplatRoute '/api/knowledge/graph': typeof ApiKnowledgeGraphRoute '/api/knowledge/list': typeof ApiKnowledgeListRoute '/api/knowledge/read': typeof ApiKnowledgeReadRoute @@ -597,6 +605,7 @@ export interface FileRoutesById { '/chat/': typeof ChatIndexRoute '/settings/': typeof SettingsIndexRoute '/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute + '/api/hermes-proxy/$': typeof ApiHermesProxySplatRoute '/api/knowledge/graph': typeof ApiKnowledgeGraphRoute '/api/knowledge/list': typeof ApiKnowledgeListRoute '/api/knowledge/read': typeof ApiKnowledgeReadRoute @@ -668,6 +677,7 @@ export interface FileRouteTypes { | '/chat/' | '/settings/' | '/api/hermes-jobs/$jobId' + | '/api/hermes-proxy/$' | '/api/knowledge/graph' | '/api/knowledge/list' | '/api/knowledge/read' @@ -736,6 +746,7 @@ export interface FileRouteTypes { | '/chat' | '/settings' | '/api/hermes-jobs/$jobId' + | '/api/hermes-proxy/$' | '/api/knowledge/graph' | '/api/knowledge/list' | '/api/knowledge/read' @@ -805,6 +816,7 @@ export interface FileRouteTypes { | '/chat/' | '/settings/' | '/api/hermes-jobs/$jobId' + | '/api/hermes-proxy/$' | '/api/knowledge/graph' | '/api/knowledge/list' | '/api/knowledge/read' @@ -871,6 +883,7 @@ export interface RootRouteChildren { ApiWorkspaceRoute: typeof ApiWorkspaceRoute ChatSessionKeyRoute: typeof ChatSessionKeyRoute ChatIndexRoute: typeof ChatIndexRoute + ApiHermesProxySplatRoute: typeof ApiHermesProxySplatRoute ApiKnowledgeGraphRoute: typeof ApiKnowledgeGraphRoute ApiKnowledgeListRoute: typeof ApiKnowledgeListRoute ApiKnowledgeReadRoute: typeof ApiKnowledgeReadRoute @@ -1337,6 +1350,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiKnowledgeGraphRouteImport parentRoute: typeof rootRouteImport } + '/api/hermes-proxy/$': { + id: '/api/hermes-proxy/$' + path: '/api/hermes-proxy/$' + fullPath: '/api/hermes-proxy/$' + preLoaderRoute: typeof ApiHermesProxySplatRouteImport + parentRoute: typeof rootRouteImport + } '/api/hermes-jobs/$jobId': { id: '/api/hermes-jobs/$jobId' path: '/$jobId' @@ -1479,6 +1499,7 @@ const rootRouteChildren: RootRouteChildren = { ApiWorkspaceRoute: ApiWorkspaceRoute, ChatSessionKeyRoute: ChatSessionKeyRoute, ChatIndexRoute: ChatIndexRoute, + ApiHermesProxySplatRoute: ApiHermesProxySplatRoute, ApiKnowledgeGraphRoute: ApiKnowledgeGraphRoute, ApiKnowledgeListRoute: ApiKnowledgeListRoute, ApiKnowledgeReadRoute: ApiKnowledgeReadRoute, diff --git a/src/routes/$.tsx b/src/routes/$.tsx index b69e6945..746b5c71 100644 --- a/src/routes/$.tsx +++ b/src/routes/$.tsx @@ -46,7 +46,7 @@ function NotFoundPage() { Go Back diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 78378964..7b643c18 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -236,7 +236,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { -