Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3ccb0666 | ||
|
|
2f58724863 | ||
|
|
a3f4ad7111 | ||
|
|
0ed2981205 | ||
|
|
5762aaafba | ||
|
|
3294e54e00 | ||
|
|
2eddef3275 | ||
|
|
7bcd6623e9 | ||
|
|
82a942a2b1 | ||
|
|
805fa296c8 | ||
|
|
b8b063f325 | ||
|
|
882fc947e5 | ||
|
|
96137750a4 | ||
|
|
d10871c0e4 | ||
|
|
6d4c258d90 | ||
|
|
c312dd36ca | ||
|
|
bb595afde9 |
75
CHANGELOG.md
75
CHANGELOG.md
@@ -5,6 +5,79 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.34.3] Light theme final polish
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **Light theme: sidebar, role labels, chips, and interactive elements all broken.** Session titles were too faint, active session used washed-out gold, pin stars were near-invisible bright yellow, and all hover/border effects used dark-theme white `rgba(255,255,255,.XX)` values invisible on cream. Fixed with 46 scoped `[data-theme="light"]` selector overrides covering session items, role labels, project chips, topbar chips, composer, suggestions, tool cards, cron list, and more. (#105)
|
||||
- Active session now uses blue accent (`#2d6fa3`) for strong contrast. Pin stars use deep gold (`#996b15`). Role labels are solid and high contrast.
|
||||
|
||||
---
|
||||
|
||||
## [v0.34.2] Theme text colors
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **Light mode text unreadable.** Bold text was hardcoded white (invisible on cream), italic was light purple on cream, inline code had a dark box on a light background. Fixed by introducing 5 new per-theme CSS variables (`--strong`, `--em`, `--code-text`, `--code-inline-bg`, `--pre-text`) defined for every theme. (#102)
|
||||
- Also replaced remaining `rgba(255,255,255,.08)` border references with `var(--border)`, and darkened light theme `--code-bg` slightly for better contrast.
|
||||
|
||||
---
|
||||
|
||||
## [v0.34.1] Theme variable polish
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **All non-dark themes had broken surfaces, topbar, and dropdowns.** 30+ hardcoded dark-navy rgba/hex values in style.css were stuck on the Dark palette regardless of active theme. Fixed by introducing 7 new CSS variables (`--surface`, `--topbar-bg`, `--main-bg`, `--input-bg`, `--hover-bg`, `--focus-ring`, `--focus-glow`) defined per-theme, replacing every hardcoded reference. (#100)
|
||||
|
||||
---
|
||||
|
||||
## [v0.34] Sprint 26 -- Pluggable UI Themes
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Features
|
||||
- **6 built-in themes.** Dark (default), Light, Slate, Solarized Dark, Monokai,
|
||||
Nord. Defined as CSS variable overrides on `:root[data-theme="name"]` — the
|
||||
entire UI adapts automatically.
|
||||
- **Theme picker in Settings.** Dropdown with instant live preview. Changes
|
||||
apply immediately as you click through options.
|
||||
- **`/theme` slash command.** `/theme dark`, `/theme light`, etc.
|
||||
- **Theme persistence.** Saved server-side in `settings.json` and client-side
|
||||
in `localStorage` for flicker-free loading on page refresh.
|
||||
- **Flash prevention.** Inline `<script>` in `<head>` reads localStorage before
|
||||
the stylesheet loads — no flash of the wrong theme.
|
||||
- **Custom theme support.** Any theme name is accepted (no enum gate). Create a
|
||||
`:root[data-theme="name"]` CSS block and it works. See `THEMES.md`.
|
||||
- **Unsaved changes guard.** Settings panel now tracks dirty state and shows a
|
||||
"You have unsaved changes" bar with Save/Discard buttons when closing with
|
||||
unpersisted changes. Theme preview reverts on discard.
|
||||
|
||||
### Architecture
|
||||
- `static/style.css`: 6 theme blocks using CSS variable overrides. Light theme
|
||||
includes scrollbar and selection overrides.
|
||||
- `static/commands.js`: `/theme` command with validation.
|
||||
- `static/panels.js`: Settings dirty tracking, revert-on-discard, unsaved bar.
|
||||
- `static/boot.js`: Theme applied from server settings on boot.
|
||||
- `api/config.py`: `theme` field in `_SETTINGS_DEFAULTS` (no enum gate).
|
||||
- `THEMES.md`: Full documentation for creating custom themes.
|
||||
|
||||
### Tests
|
||||
- 9 new tests in `test_sprint26.py`: default theme, round-trip persistence for
|
||||
all 6 built-in themes, custom theme acceptance, settings isolation.
|
||||
Total: **433 tests**.
|
||||
|
||||
---
|
||||
|
||||
## [v0.33] /insights Sync + state.db Bridge Fix
|
||||
*April 5, 2026 | 424 tests*
|
||||
|
||||
### Features
|
||||
- **Opt-in `/insights` sync.** New "Sync usage to /insights" setting (default: off). When enabled, after each turn the WebUI mirrors session token usage, cost, model, and title into `state.db` so `hermes /insights` includes browser session activity. (#92, #93)
|
||||
|
||||
### Bug Fixes
|
||||
- **state_sync.py correctness fixes.** Three bugs in the initial implementation caught during code review: wrong class name (`HermesState` → `SessionDB`), wrong constructor argument type (`str` → `Path`), wrong title update method (`_execute_write` with bad signature → `set_session_title`). Also fixed a SQLite connection leak (persistent connection opened per call, never closed). (#95)
|
||||
|
||||
---
|
||||
|
||||
## [v0.32] Auto-Compaction Handling + /compact Command (Issue #90)
|
||||
*April 5, 2026 | 424 tests*
|
||||
|
||||
@@ -1141,4 +1214,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.32, April 5, 2026 | Tests: 424*
|
||||
*Last updated: v0.34, April 5, 2026 | Tests: 433*
|
||||
|
||||
12
README.md
12
README.md
@@ -314,17 +314,24 @@ across 22 test files.
|
||||
- 20MB POST body size limit
|
||||
- CDN resources pinned with SRI integrity hashes
|
||||
|
||||
### Themes
|
||||
- 6 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord
|
||||
- Switch via Settings panel dropdown (instant live preview) or `/theme` command
|
||||
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
|
||||
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
|
||||
|
||||
### Settings and configuration
|
||||
- Settings panel (gear icon) -- default model, default workspace, send key preference
|
||||
- Settings panel (gear icon) -- default model, default workspace, send key, theme
|
||||
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
||||
- Show/hide CLI sessions toggle (enabled by default)
|
||||
- Token usage display toggle (off by default, also via `/usage` command)
|
||||
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
|
||||
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
|
||||
- Background agent error alerts -- banner when a non-active session encounters an error
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`
|
||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`, `/compact`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
@@ -393,6 +400,7 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
- `TESTING.md` -- manual browser test plan and automated coverage reference
|
||||
- `CHANGELOG.md` -- release notes per sprint
|
||||
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
|
||||
- `THEMES.md` -- theme system documentation, custom theme guide
|
||||
|
||||
## Repo
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.31.2 (April 5, 2026)
|
||||
> Last updated: v0.33 (April 5, 2026)
|
||||
> Tests: 424 total (424 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
|
||||
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
|
||||
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
|
||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||
|
||||
---
|
||||
|
||||
@@ -206,7 +208,8 @@
|
||||
- [ ] TTS playback of responses (deferred)
|
||||
- [x] Background task cancel (activity bar Cancel button)
|
||||
- [ ] Code execution cell (deferred)
|
||||
- [ ] Desktop application (deferred)
|
||||
- [ ] Desktop application (Sprint 25, PLANNED)
|
||||
- [ ] Pluggable UI themes -- light, dark, Solarized, Monokai, Nord (Sprint 26, PLANNED)
|
||||
- [ ] Extended slash command / skill integration (deferred)
|
||||
- [ ] Virtual scroll for large lists (deferred)
|
||||
|
||||
|
||||
293
SPRINTS.md
293
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.32 | 424 tests | Daily driver ready
|
||||
> Current state: v0.34 | 433 tests | Daily driver ready
|
||||
> This document plans the path from here to two targets:
|
||||
>
|
||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||
@@ -75,7 +75,7 @@ heavy agentic work.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 12 -- Settings Panel + Reliability + Session QoL
|
||||
## Sprint 12 -- Settings Panel + Reliability + Session QoL (COMPLETED)
|
||||
|
||||
**Theme:** Persist your preferences, survive network blips, and organize sessions.
|
||||
|
||||
@@ -118,7 +118,7 @@ to keep important conversations accessible.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 13 -- Alerts, Session QoL, Polish
|
||||
## Sprint 13 -- Alerts, Session QoL, Polish (COMPLETED)
|
||||
|
||||
**Theme:** Know what Hermes is doing, and small quality-of-life wins.
|
||||
|
||||
@@ -511,15 +511,14 @@ single default profile, blocking multi-persona workflows.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 23 -- Profile/Workspace/Model Coherence (COMPLETED)
|
||||
## Sprint 23 -- Agentic Transparency + Context Visibility (COMPLETED)
|
||||
|
||||
**Theme:** Make profiles, workspaces, models, and sessions coherent across
|
||||
profile switches.
|
||||
**Theme:** Surface what the agent is doing and how much context it's using.
|
||||
|
||||
**Why now:** Sprint 22 added profile switching but five coherence bugs remained:
|
||||
the model picker ignored the profile's default, workspaces were a global file,
|
||||
DEFAULT_WORKSPACE was a startup singleton, the session list showed all profiles,
|
||||
and switchToProfile() didn't refresh workspaces or sessions.
|
||||
**Why now:** Users had no visibility into tool call arguments, session token
|
||||
usage, or context window fill. Sprint 22 left five coherence bugs in the
|
||||
profile/workspace/model flow that also needed closing before the UI felt
|
||||
reliable.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Model picker ignores profile on switch.** `populateModelDropdown()` skipped
|
||||
@@ -611,12 +610,9 @@ the app to others.
|
||||
CSS `contain: strict` + IntersectionObserver approach, no library needed.
|
||||
|
||||
### Track C: Code Quality
|
||||
- **SPRINTS.md + ROADMAP.md + CHANGELOG.md updated** to reflect Sprint 23
|
||||
completion (agentic transparency) and correct test counts.
|
||||
- **Remove stale Sprint 23 description** from SPRINTS.md (the "Profile/Workspace
|
||||
coherence" text is from an older plan; Sprint 23 actually shipped agentic
|
||||
transparency features).
|
||||
- **CHANGELOG entry for v0.29** covering Sprint 23 deliverables.
|
||||
- Audit and remove any remaining dead code introduced by Sprint 23 (e.g. `S.lastUsage` assignment in messages.js that nothing reads).
|
||||
- Verify tool call args render correctly in settled history cards on session reload.
|
||||
- Update test count in all docs to match actual pytest output after sprint merges.
|
||||
|
||||
**Estimated tests:** ~10 new. Target total: ~435.
|
||||
**Hermes CLI parity impact:** Low
|
||||
@@ -897,6 +893,269 @@ genuinely differentiating for an open-source project
|
||||
|
||||
---
|
||||
|
||||
## Sprint 26 -- Pluggable UI Themes (PLANNED)
|
||||
|
||||
**Theme:** Let users choose how the app looks -- light, dark, and custom color
|
||||
schemes. One-click switching, persistent preference, zero flicker on load.
|
||||
|
||||
**Difficulty: Low-Medium.** The existing CSS is already 100% CSS-variable-driven
|
||||
off a single `:root` block. Every color, background, and accent in the entire UI
|
||||
is already a variable. Adding themes is mostly a matter of defining alternative
|
||||
`:root` overrides and wiring a picker -- not a rewrite. The main engineering
|
||||
work is flicker prevention on load and the settings UI.
|
||||
|
||||
**Estimated effort:** 1 sprint, ~2 days of implementation. 8-12 new tests.
|
||||
|
||||
---
|
||||
|
||||
### Why now
|
||||
|
||||
The UI ships only one dark theme. Contributors have asked for light mode. Power
|
||||
users want to match their terminal colorscheme. This is low-risk, high-value
|
||||
polish that makes the app feel more finished and more personal. It's also a
|
||||
good precedent-setter: once the theme system exists, community members can
|
||||
contribute new themes as a pure CSS addition with no Python changes needed.
|
||||
|
||||
---
|
||||
|
||||
### Design decisions
|
||||
|
||||
**Themes are CSS-variable overrides, not separate stylesheets.** Each theme is
|
||||
a named `:root[data-theme="name"]` block. The base stylesheet stays untouched.
|
||||
Switching themes sets `document.documentElement.dataset.theme = name` in JS.
|
||||
No FOUC (flash of unstyled content), no stylesheet swap latency.
|
||||
|
||||
**Theme preference persists server-side in `settings.json`.** Same mechanism
|
||||
as `send_key` and `show_token_usage`. The server includes `theme` in the
|
||||
`GET /api/settings` response. Boot.js reads it and applies before first paint.
|
||||
|
||||
**Flicker prevention.** A tiny inline `<script>` in `<head>` (before the
|
||||
stylesheet link) reads `localStorage.getItem('hermes-theme')` and sets
|
||||
`document.documentElement.dataset.theme` synchronously. This prevents a
|
||||
dark-flash on light-mode users during the round-trip to `/api/settings`.
|
||||
The localStorage value is kept in sync whenever the user changes themes.
|
||||
|
||||
**No third-party dependencies.** Pure CSS + vanilla JS. No theme library.
|
||||
|
||||
---
|
||||
|
||||
### Track A: Core theme system
|
||||
|
||||
**1. CSS variable blocks in `static/style.css`**
|
||||
|
||||
The existing `:root` block becomes the `dark` (default) theme. Add named
|
||||
theme blocks immediately after:
|
||||
|
||||
```css
|
||||
/* ── Default (dark) theme ── already in :root ── */
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f5f7;
|
||||
--sidebar: #e8e8ed;
|
||||
--border: rgba(0,0,0,0.10);
|
||||
--border2: rgba(0,0,0,0.16);
|
||||
--text: #1c1c1e;
|
||||
--muted: #6e6e80;
|
||||
--accent: #c0392b;
|
||||
--blue: #0a6dc2;
|
||||
--gold: #a07a20;
|
||||
--code-bg: #f0f0f5;
|
||||
}
|
||||
|
||||
:root[data-theme="solarized"] {
|
||||
--bg: #002b36;
|
||||
--sidebar: #073642;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border2: rgba(255,255,255,0.13);
|
||||
--text: #839496;
|
||||
--muted: #657b83;
|
||||
--accent: #dc322f;
|
||||
--blue: #268bd2;
|
||||
--gold: #b58900;
|
||||
--code-bg: #073642;
|
||||
}
|
||||
|
||||
:root[data-theme="monokai"] {
|
||||
--bg: #272822;
|
||||
--sidebar: #1e1f1c;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--border2: rgba(255,255,255,0.12);
|
||||
--text: #f8f8f2;
|
||||
--muted: #75715e;
|
||||
--accent: #f92672;
|
||||
--blue: #66d9e8;
|
||||
--gold: #e6db74;
|
||||
--code-bg: #1e1f1c;
|
||||
}
|
||||
|
||||
:root[data-theme="nord"] {
|
||||
--bg: #2e3440;
|
||||
--sidebar: #272c36;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--border2: rgba(255,255,255,0.12);
|
||||
--text: #eceff4;
|
||||
--muted: #9099aa;
|
||||
--accent: #bf616a;
|
||||
--blue: #81a1c1;
|
||||
--gold: #ebcb8b;
|
||||
--code-bg: #272c36;
|
||||
}
|
||||
```
|
||||
|
||||
Additional theming notes:
|
||||
- `syntax-highlight` colors (Prism.js) are theme-independent (they come from the
|
||||
CDN stylesheet) -- acceptable for v1.
|
||||
- The logo gradient (`linear-gradient(145deg,#e8a030,var(--accent))`) uses
|
||||
`--accent` already so it adapts automatically.
|
||||
- Scrollbar colors and `::selection` backgrounds need explicit overrides in the
|
||||
light theme to avoid dark scrollbars on a light background.
|
||||
|
||||
**2. Flicker-prevention inline script in `static/index.html`**
|
||||
|
||||
Immediately after `<head>` opens, before the stylesheet `<link>`:
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('hermes-theme');
|
||||
if(t && t!=='dark') document.documentElement.dataset.theme=t;
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
This runs synchronously before the stylesheet parses. Zero flicker.
|
||||
|
||||
**3. Theme loading in `static/boot.js`**
|
||||
|
||||
In the existing `api('/api/settings')` call, read and apply the theme:
|
||||
|
||||
```js
|
||||
const s = await api('/api/settings');
|
||||
window._sendKey = s.send_key || 'enter';
|
||||
window._showTokenUsage = !!s.show_token_usage;
|
||||
window._showCliSessions = !!s.show_cli_sessions;
|
||||
// Theme: apply server preference, update localStorage for flicker prevention
|
||||
const theme = s.theme || 'dark';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('hermes-theme', theme);
|
||||
```
|
||||
|
||||
**4. Theme setting in `api/config.py`**
|
||||
|
||||
```python
|
||||
_SETTINGS_DEFAULTS = {
|
||||
...
|
||||
'theme': 'dark', # active UI theme name
|
||||
...
|
||||
}
|
||||
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
||||
```
|
||||
|
||||
No enum constraint on `theme` -- allows user-defined theme names to work
|
||||
without server changes.
|
||||
|
||||
---
|
||||
|
||||
### Track B: Theme picker UI
|
||||
|
||||
**Settings panel addition (`static/index.html` + `static/panels.js`)**
|
||||
|
||||
A `<select>` in the Settings panel, below the send-key picker:
|
||||
|
||||
```html
|
||||
<div class="settings-field">
|
||||
<label for="settingsTheme">Theme</label>
|
||||
<select id="settingsTheme" ...>
|
||||
<option value="dark">Dark (default)</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="solarized">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="nord">Nord</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
In `loadSettingsPanel()`:
|
||||
```js
|
||||
const themeSel = $('settingsTheme');
|
||||
if(themeSel) themeSel.value = settings.theme || 'dark';
|
||||
```
|
||||
|
||||
In `saveSettings()`:
|
||||
```js
|
||||
body.theme = $('settingsTheme').value;
|
||||
```
|
||||
|
||||
**Live preview on select change (no save required):**
|
||||
```js
|
||||
$('settingsTheme').addEventListener('change', e => {
|
||||
document.documentElement.dataset.theme = e.target.value;
|
||||
localStorage.setItem('hermes-theme', e.target.value);
|
||||
});
|
||||
```
|
||||
|
||||
This gives instant visual feedback as the user clicks through options.
|
||||
The full settings save then persists it server-side.
|
||||
|
||||
**`/theme` slash command (`static/commands.js`)**
|
||||
|
||||
```js
|
||||
async function cmdTheme(arg) {
|
||||
const themes = ['dark','light','solarized','monokai','nord'];
|
||||
if(!arg || !themes.includes(arg)) {
|
||||
showToast('Usage: /theme dark|light|solarized|monokai|nord');
|
||||
return;
|
||||
}
|
||||
document.documentElement.dataset.theme = arg;
|
||||
localStorage.setItem('hermes-theme', arg);
|
||||
try { await api('/api/settings', {method:'POST', body: JSON.stringify({theme: arg})}); } catch(e) {}
|
||||
showToast('Theme: ' + arg);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Track C: Tests
|
||||
|
||||
New test cases in `tests/test_sprint26.py`:
|
||||
|
||||
1. `GET /api/settings` returns `theme: 'dark'` by default
|
||||
2. `POST /api/settings` with `{theme: 'light'}` persists and round-trips
|
||||
3. `POST /api/settings` with `{theme: 'nord'}` accepts any string (no enum gate)
|
||||
4. Theme value survives server restart (reads from `settings.json`)
|
||||
5. `/theme` command fires without error for each named theme
|
||||
6. `loadSettingsPanel()` populates the select with the current theme value
|
||||
7. Settings save includes theme in the POST body
|
||||
8. `data-theme` attribute is set on `<html>` before first paint (inline script)
|
||||
|
||||
**Estimated new tests:** 8. Target total after sprint: ~443.
|
||||
|
||||
---
|
||||
|
||||
### What's out of scope
|
||||
|
||||
- **Custom color editors** (hex pickers for each variable): saves that for v2.
|
||||
The five shipped themes cover the main use cases. A custom theme can always
|
||||
be added by dropping a CSS block with no code changes.
|
||||
- **Per-session themes**: single global preference is the right call for v1.
|
||||
- **System `prefers-color-scheme` sync**: nice-to-have, low priority. The
|
||||
flicker-prevention script could be extended to read the media query if no
|
||||
explicit preference is set.
|
||||
- **Prism.js theme switching**: the code-block syntax highlighting comes from
|
||||
a CDN stylesheet. Swapping it requires a `<link>` swap and SRI re-check.
|
||||
Defer to a future sprint; the default Prism Tomorrow theme works on all
|
||||
current dark themes and is acceptable on light.
|
||||
|
||||
---
|
||||
|
||||
**Estimated tests:** 8 new. Target total: ~443.
|
||||
**Hermes CLI parity impact:** None
|
||||
**Claude parity impact:** Medium (Claude.ai has light/dark/system sync)
|
||||
**User-facing value:** High -- first thing many users ask for
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 5, 2026*
|
||||
*Current version: v0.32 | 424 tests*
|
||||
*Current version: v0.34 | 433 tests*
|
||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||
*Horizon sprint: Sprint 26 (Pluggable UI Themes)*
|
||||
|
||||
144
THEMES.md
Normal file
144
THEMES.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Hermes Web UI — Themes
|
||||
|
||||
Hermes Web UI supports pluggable color themes. Five themes ship built-in, and
|
||||
you can create your own with pure CSS — no Python changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Switching Themes
|
||||
|
||||
**Settings panel:** Click the gear icon, select a theme from the dropdown. The
|
||||
preview is instant — the UI updates as you click through options.
|
||||
|
||||
**Slash command:** Type `/theme dark` or `/theme light` in the composer.
|
||||
|
||||
**Themes persist** across page reloads and server restarts (stored in
|
||||
`settings.json` server-side, with `localStorage` for flicker-free loading).
|
||||
|
||||
---
|
||||
|
||||
## Built-in Themes
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| **Dark** (default) | Deep navy/indigo with muted blue accents. Easy on the eyes for long sessions. |
|
||||
| **Light** | Warm off-white with dark text. High contrast for bright environments. |
|
||||
| **Slate** | Warm charcoal, lighter than Dark. Easier on the eyes for extended use. |
|
||||
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
|
||||
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
|
||||
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
|
||||
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
|
||||
|
||||
---
|
||||
|
||||
## Creating a Custom Theme
|
||||
|
||||
A theme is a CSS block that overrides the color variables. Add it to
|
||||
`static/style.css` (or a separate file that you link after the main stylesheet).
|
||||
|
||||
### Step 1: Define your theme block
|
||||
|
||||
Every color in the UI comes from these CSS variables:
|
||||
|
||||
```css
|
||||
:root[data-theme="your-theme-name"] {
|
||||
/* Core palette */
|
||||
--bg: #1a1a2e; /* Main background */
|
||||
--sidebar: #16213e; /* Sidebar background */
|
||||
--border: rgba(255,255,255,0.08); /* Subtle borders */
|
||||
--border2: rgba(255,255,255,0.14); /* Stronger borders */
|
||||
--text: #e8e8f0; /* Primary text color */
|
||||
--muted: #8888aa; /* Secondary/muted text */
|
||||
--accent: #e94560; /* Accent color (errors, warnings, delete) */
|
||||
--blue: #7cb9ff; /* Primary action color (links, active states) */
|
||||
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
|
||||
--code-bg: #0d1117; /* Code block background */
|
||||
|
||||
/* Surface and chrome (required for full theme polish) */
|
||||
--surface: #1a2535; /* Dropdowns, popups, toast, approval card */
|
||||
--topbar-bg: rgba(22,33,62,.98); /* Topbar background */
|
||||
--main-bg: rgba(26,26,46,0.5); /* Main chat area background */
|
||||
--input-bg: rgba(255,255,255,.04); /* Input/button subtle backgrounds */
|
||||
--hover-bg: rgba(255,255,255,.06); /* Hover state backgrounds */
|
||||
--focus-ring: rgba(124,185,255,.35); /* Focus border color */
|
||||
--focus-glow: rgba(124,185,255,.08); /* Focus box-shadow glow */
|
||||
|
||||
/* Typography (required for readable text across themes) */
|
||||
--strong: #fff; /* Bold text in messages */
|
||||
--em: #c9c9e8; /* Italic text in messages */
|
||||
--code-text: #f0c27f; /* Inline code text color */
|
||||
--code-inline-bg: rgba(0,0,0,.35); /* Inline code background */
|
||||
--pre-text: #e2e8f0; /* Code block text color */
|
||||
}
|
||||
```
|
||||
|
||||
The **core palette** controls the overall mood. The **surface/chrome** and
|
||||
**typography** variables are part of the standard theme contract — define all
|
||||
of them for a complete theme.
|
||||
|
||||
For **light themes**, you also need `:root[data-theme="name"]` overrides
|
||||
for elements that use `rgba(255,255,255,.XX)` hover/border effects (these
|
||||
are invisible on light backgrounds). See the built-in light theme for the
|
||||
full pattern — it overrides ~45 selectors for proper dark-on-light contrast
|
||||
on hover states, borders, chips, role labels, session items, and
|
||||
interactive elements.
|
||||
|
||||
### Step 2: Add it to the theme picker (optional)
|
||||
|
||||
To make your theme appear in the Settings dropdown, add an `<option>` to the
|
||||
theme `<select>` in `static/index.html`:
|
||||
|
||||
```html
|
||||
<option value="your-theme-name">Your Theme Name</option>
|
||||
```
|
||||
|
||||
And update the `/theme` command's valid theme list in `static/commands.js`.
|
||||
|
||||
### Step 3: Test it
|
||||
|
||||
Switch to your theme via `/theme your-theme-name` or the Settings panel.
|
||||
Check these areas:
|
||||
- Sidebar session list (hover states, active state, project borders)
|
||||
- Message bubbles (user vs assistant styling)
|
||||
- Code blocks (background contrast, copy button visibility)
|
||||
- Tool cards (running indicator, expand/collapse)
|
||||
- Settings panel and login page
|
||||
- Mobile layout (hamburger sidebar, bottom nav)
|
||||
|
||||
### Tips
|
||||
|
||||
- **Light themes** need scrollbar and selection overrides, plus the full
|
||||
text/code set (`--strong`, `--em`, `--code-text`, `--code-inline-bg`,
|
||||
`--pre-text`) or they will look broken.
|
||||
- The **logo gradient** uses `--accent` automatically, so it adapts to your
|
||||
theme without extra work.
|
||||
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
|
||||
It works well on dark themes; on light themes the contrast is acceptable but
|
||||
not perfect. Custom Prism theme support is planned for a future update.
|
||||
- **No server changes needed.** The `theme` setting in `settings.json` accepts
|
||||
any string — your custom theme name will persist without code changes.
|
||||
|
||||
---
|
||||
|
||||
## How Themes Work Internally
|
||||
|
||||
1. Each theme is a `:root[data-theme="name"]` CSS block that overrides variables.
|
||||
2. Switching themes sets `document.documentElement.dataset.theme = name` in JS.
|
||||
3. A tiny inline `<script>` in `<head>` reads `localStorage` before the
|
||||
stylesheet loads — this prevents a flash of the wrong theme on page load.
|
||||
4. The theme preference is saved server-side via `POST /api/settings` and
|
||||
loaded on boot via `GET /api/settings`.
|
||||
5. The `/theme` command and Settings dropdown both update the DOM, localStorage,
|
||||
and server settings simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Contributing a Theme
|
||||
|
||||
To contribute a new built-in theme:
|
||||
|
||||
1. Add your `:root[data-theme="name"]` block to `static/style.css`
|
||||
2. Add the `<option>` to the Settings panel in `static/index.html`
|
||||
3. Add the theme name to the valid list in `cmdTheme()` in `static/commands.js`
|
||||
4. Test on desktop and mobile
|
||||
5. Open a PR — themes are pure CSS additions with no backend changes needed
|
||||
@@ -652,6 +652,8 @@ _SETTINGS_DEFAULTS = {
|
||||
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
||||
'show_token_usage': False, # show input/output token badge below assistant messages
|
||||
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
||||
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
||||
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||
}
|
||||
|
||||
@@ -671,7 +673,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
'send_key': {'enter', 'ctrl+enter'},
|
||||
}
|
||||
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions'}
|
||||
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'}
|
||||
|
||||
def save_settings(settings: dict) -> dict:
|
||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||
|
||||
@@ -995,6 +995,20 @@ def _handle_chat_sync(handler, body):
|
||||
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
|
||||
s.messages = result.get('messages') or s.messages
|
||||
s.title = title_from(s.messages, s.title); s.save()
|
||||
# Sync to state.db for /insights (opt-in setting)
|
||||
try:
|
||||
if load_settings().get('sync_to_insights'):
|
||||
from api.state_sync import sync_session_usage
|
||||
sync_session_usage(
|
||||
session_id=s.session_id,
|
||||
input_tokens=s.input_tokens or 0,
|
||||
output_tokens=s.output_tokens or 0,
|
||||
estimated_cost=s.estimated_cost,
|
||||
model=s.model,
|
||||
title=s.title,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return j(handler, {
|
||||
'answer': result.get('final_response') or '',
|
||||
'status': 'done' if result.get('completed', True) else 'partial',
|
||||
|
||||
101
api/state_sync.py
Normal file
101
api/state_sync.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Hermes Web UI -- Optional state.db sync bridge.
|
||||
|
||||
Mirrors WebUI session metadata (token usage, title, model) into the
|
||||
hermes-agent state.db so that /insights, session lists, and cost
|
||||
tracking include WebUI activity.
|
||||
|
||||
This is opt-in via the 'sync_to_insights' setting (default: off).
|
||||
All operations are wrapped in try/except -- if state.db is unavailable,
|
||||
locked, or the schema doesn't match, the WebUI continues normally.
|
||||
|
||||
The bridge uses absolute token counts (not deltas) because the WebUI
|
||||
Session object already accumulates totals across turns. This avoids
|
||||
any double-counting risk.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _get_state_db():
|
||||
"""Get a SessionDB instance for the active profile's state.db.
|
||||
Returns None if hermes_state is not importable or DB is unavailable.
|
||||
Each caller is responsible for calling db.close() when done.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||||
except Exception:
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
return SessionDB(db_path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def sync_session_start(session_id, model=None):
|
||||
"""Register a WebUI session in state.db (idempotent).
|
||||
Called when a session's first message is sent.
|
||||
"""
|
||||
db = _get_state_db()
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
db.ensure_session(
|
||||
session_id=session_id,
|
||||
source='webui',
|
||||
model=model,
|
||||
)
|
||||
except Exception:
|
||||
pass # never crash the WebUI for sync failures
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def sync_session_usage(session_id, input_tokens=0, output_tokens=0,
|
||||
estimated_cost=None, model=None, title=None):
|
||||
"""Update token usage and title for a WebUI session in state.db.
|
||||
Called after each turn completes. Uses absolute=True to set totals
|
||||
(the WebUI Session already accumulates across turns).
|
||||
"""
|
||||
db = _get_state_db()
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
# Ensure session exists first (idempotent)
|
||||
db.ensure_session(session_id=session_id, source='webui', model=model)
|
||||
# Set absolute token counts
|
||||
db.update_token_counts(
|
||||
session_id=session_id,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
model=model,
|
||||
absolute=True,
|
||||
)
|
||||
# Update title if we have one, using the public API
|
||||
if title:
|
||||
try:
|
||||
db.set_session_title(session_id, title)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass # never crash the WebUI for sync failures
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -309,6 +309,21 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
m['attachments'] = attachments
|
||||
break
|
||||
s.save()
|
||||
# Sync to state.db for /insights (opt-in setting)
|
||||
try:
|
||||
from api.config import load_settings as _load_settings
|
||||
if _load_settings().get('sync_to_insights'):
|
||||
from api.state_sync import sync_session_usage
|
||||
sync_session_usage(
|
||||
session_id=s.session_id,
|
||||
input_tokens=s.input_tokens or 0,
|
||||
output_tokens=s.output_tokens or 0,
|
||||
estimated_cost=s.estimated_cost,
|
||||
model=model,
|
||||
title=s.title,
|
||||
)
|
||||
except Exception:
|
||||
pass # never crash the stream for sync failures
|
||||
usage = {'input_tokens': input_tokens, 'output_tokens': output_tokens, 'estimated_cost': estimated_cost}
|
||||
# Include context window data from the agent's compressor for the UI indicator
|
||||
_cc = getattr(agent, 'context_compressor', None)
|
||||
|
||||
@@ -226,7 +226,7 @@ document.addEventListener('keydown',async e=>{
|
||||
if(e.key==='Escape'){
|
||||
// Close settings overlay if open
|
||||
const settingsOverlay=$('settingsOverlay');
|
||||
if(settingsOverlay&&settingsOverlay.style.display!=='none'){toggleSettings();return;}
|
||||
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;}
|
||||
// Close workspace dropdown
|
||||
closeWsDropdown();
|
||||
// Clear session search
|
||||
@@ -308,7 +308,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
|
||||
(async()=>{
|
||||
// Load send key preference
|
||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
||||
// Fetch active profile
|
||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||
// Update profile chip label immediately
|
||||
|
||||
@@ -10,6 +10,7 @@ const COMMANDS=[
|
||||
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
|
||||
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
||||
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
|
||||
{name:'theme', desc:'Switch theme (dark/light/slate/solarized/monokai/nord)', fn:cmdTheme, arg:'name'},
|
||||
];
|
||||
|
||||
function parseCommand(text){
|
||||
@@ -122,6 +123,22 @@ async function cmdUsage(){
|
||||
showToast('Token usage '+(next?'on':'off'));
|
||||
}
|
||||
|
||||
async function cmdTheme(args){
|
||||
const themes=['dark','light','slate','solarized','monokai','nord'];
|
||||
if(!args||!themes.includes(args.toLowerCase())){
|
||||
showToast('Usage: /theme '+themes.join('|'));
|
||||
return;
|
||||
}
|
||||
const t=args.toLowerCase();
|
||||
document.documentElement.dataset.theme=t;
|
||||
localStorage.setItem('hermes-theme',t);
|
||||
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:t})});}catch(e){}
|
||||
// Update settings dropdown if panel is open
|
||||
const sel=$('settingsTheme');
|
||||
if(sel)sel.value=t;
|
||||
showToast('Theme: '+t);
|
||||
}
|
||||
|
||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||
|
||||
let _cmdSelectedIdx=-1;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hermes</title>
|
||||
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||
@@ -13,7 +14,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.32</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.34.3</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
@@ -311,7 +312,7 @@
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<h3 style="margin:0;font-size:16px">Settings</h3>
|
||||
<button class="panel-icon-btn" onclick="toggleSettings()" title="Close">✕</button>
|
||||
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-field">
|
||||
@@ -329,6 +330,17 @@
|
||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsTheme">Theme</label>
|
||||
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
|
||||
<option value="dark">Dark (default)</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="slate">Slate (charcoal)</option>
|
||||
<option value="solarized">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="nord">Nord</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
@@ -343,6 +355,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
Sync usage to /insights
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
|
||||
@@ -908,17 +908,70 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
|
||||
|
||||
// ── Settings panel ───────────────────────────────────────────────────────────
|
||||
|
||||
let _settingsDirty = false;
|
||||
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
|
||||
|
||||
function toggleSettings(){
|
||||
const overlay=$('settingsOverlay');
|
||||
if(!overlay) return;
|
||||
if(overlay.style.display==='none'){
|
||||
_settingsDirty = false;
|
||||
_settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark';
|
||||
overlay.style.display='';
|
||||
loadSettingsPanel();
|
||||
} else {
|
||||
overlay.style.display='none';
|
||||
_closeSettingsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// Close with unsaved-changes check. If dirty, show a confirm dialog.
|
||||
function _closeSettingsPanel(){
|
||||
if(!_settingsDirty){
|
||||
// Nothing changed -- revert any live preview and close
|
||||
_revertSettingsPreview();
|
||||
$('settingsOverlay').style.display='none';
|
||||
return;
|
||||
}
|
||||
// Dirty -- show inline confirm bar
|
||||
_showSettingsUnsavedBar();
|
||||
}
|
||||
|
||||
// Revert live DOM/localStorage to what they were when the panel opened
|
||||
function _revertSettingsPreview(){
|
||||
if(_settingsThemeOnOpen){
|
||||
document.documentElement.dataset.theme = _settingsThemeOnOpen;
|
||||
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
|
||||
}
|
||||
}
|
||||
|
||||
// Show the "Unsaved changes" bar inside the settings panel
|
||||
function _showSettingsUnsavedBar(){
|
||||
let bar = $('settingsUnsavedBar');
|
||||
if(bar){ bar.style.display=''; return; }
|
||||
// Create it
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'settingsUnsavedBar';
|
||||
bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;';
|
||||
bar.innerHTML = '<span style="color:var(--text)">You have unsaved changes.</span>'
|
||||
+ '<span style="display:flex;gap:8px">'
|
||||
+ '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>'
|
||||
+ '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save</button>'
|
||||
+ '</span>';
|
||||
const body = document.querySelector('.settings-body') || document.querySelector('.settings-panel');
|
||||
if(body) body.prepend(bar);
|
||||
}
|
||||
|
||||
function _discardSettings(){
|
||||
_revertSettingsPreview();
|
||||
_settingsDirty = false;
|
||||
$('settingsOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
// Mark settings as dirty whenever anything changes
|
||||
function _markSettingsDirty(){
|
||||
_settingsDirty = true;
|
||||
}
|
||||
|
||||
async function loadSettingsPanel(){
|
||||
try{
|
||||
const settings=await api('/api/settings');
|
||||
@@ -940,6 +993,7 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
}catch(e){}
|
||||
modelSel.value=settings.default_model||'';
|
||||
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
// Populate workspace dropdown from /api/workspaces
|
||||
const wsSel=$('settingsWorkspace');
|
||||
@@ -954,17 +1008,23 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
}catch(e){}
|
||||
wsSel.value=settings.default_workspace||'';
|
||||
wsSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
// Send key preference
|
||||
const sendKeySel=$('settingsSendKey');
|
||||
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
||||
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
// Theme preference
|
||||
const themeSel=$('settingsTheme');
|
||||
if(themeSel){themeSel.value=settings.theme||'dark';themeSel.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
|
||||
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const showCliCb=$('settingsShowCliSessions');
|
||||
if(showCliCb) showCliCb.checked=!!settings.show_cli_sessions;
|
||||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const syncCb=$('settingsSyncInsights');
|
||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
// Password field: always blank (we don't send hash back)
|
||||
const pwField=$('settingsPassword');
|
||||
if(pwField) pwField.value='';
|
||||
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||
// Show auth buttons only when auth is active
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
@@ -979,19 +1039,22 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(){
|
||||
async function saveSettings(andClose){
|
||||
const model=($('settingsModel')||{}).value;
|
||||
const workspace=($('settingsWorkspace')||{}).value;
|
||||
const sendKey=($('settingsSendKey')||{}).value;
|
||||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||
const pw=($('settingsPassword')||{}).value;
|
||||
const theme=($('settingsTheme')||{}).value||'dark';
|
||||
const body={};
|
||||
if(model) body.default_model=model;
|
||||
if(workspace) body.default_workspace=workspace;
|
||||
if(sendKey) body.send_key=sendKey;
|
||||
body.theme=theme;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
if(pw && pw.trim()){
|
||||
try{
|
||||
@@ -999,7 +1062,9 @@ async function saveSettings(){
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
showToast('Settings saved (password set — login now required)');
|
||||
toggleSettings();
|
||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
||||
$('settingsOverlay').style.display='none';
|
||||
return;
|
||||
}catch(e){showToast('Save failed: '+e.message);return;}
|
||||
}
|
||||
@@ -1008,10 +1073,12 @@ async function saveSettings(){
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
window._showCliSessions=showCliSessions;
|
||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
||||
renderMessages();
|
||||
if(typeof renderSessionList==='function') renderSessionList();
|
||||
showToast('Settings saved');
|
||||
toggleSettings();
|
||||
$('settingsOverlay').style.display='none';
|
||||
}catch(e){
|
||||
showToast('Save failed: '+e.message);
|
||||
}
|
||||
@@ -1041,10 +1108,10 @@ async function disableAuth(){
|
||||
}
|
||||
}
|
||||
|
||||
// Close settings on overlay click (not panel click)
|
||||
// Close settings on overlay click (not panel click) -- with unsaved-changes check
|
||||
document.addEventListener('click',e=>{
|
||||
const overlay=$('settingsOverlay');
|
||||
if(overlay&&e.target===overlay) toggleSettings();
|
||||
if(overlay&&e.target===overlay) _closeSettingsPanel();
|
||||
});
|
||||
|
||||
// ── Cron completion alerts ────────────────────────────────────────────────────
|
||||
|
||||
188
static/style.css
188
static/style.css
@@ -2,8 +2,104 @@
|
||||
:root {
|
||||
--bg:#1a1a2e;--sidebar:#16213e;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.14);
|
||||
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
|
||||
--surface:#1a2535;--topbar-bg:rgba(22,33,62,.98);--main-bg:rgba(26,26,46,0.5);
|
||||
--focus-ring:rgba(124,185,255,.35);--focus-glow:rgba(124,185,255,.08);
|
||||
--input-bg:rgba(255,255,255,.04);--hover-bg:rgba(255,255,255,.06);
|
||||
--strong:#fff;--em:#c9c9e8;--code-text:#f0c27f;--code-inline-bg:rgba(0,0,0,.35);--pre-text:#e2e8f0;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
|
||||
}
|
||||
/* ── Slate theme ── */
|
||||
:root[data-theme="slate"]{
|
||||
--bg:#2b2d30;--sidebar:#25272b;--border:rgba(255,255,255,0.09);--border2:rgba(255,255,255,0.16);
|
||||
--text:#d4d4d8;--muted:#8a8a9a;--accent:#e06c75;--blue:#82aaff;--gold:#d4a85a;--code-bg:#1e2023;
|
||||
--surface:#2f3134;--topbar-bg:rgba(37,39,43,.98);--main-bg:rgba(43,45,48,0.5);
|
||||
--focus-ring:rgba(130,170,255,.35);--focus-glow:rgba(130,170,255,.08);
|
||||
--strong:#f0f0f3;--em:#b0b0c0;--code-text:#dca06a;--code-inline-bg:rgba(0,0,0,.3);--pre-text:#d0d0d6;
|
||||
}
|
||||
/* ── Light theme ── */
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f0ede8;--sidebar:#e4e0d8;--border:rgba(0,0,0,0.09);--border2:rgba(0,0,0,0.15);
|
||||
--text:#2c2825;--muted:#7a746a;--accent:#b5451b;--blue:#2d6fa3;--gold:#8a6520;--code-bg:#ddd8d0;
|
||||
--surface:#e0dcd4;--topbar-bg:rgba(228,224,216,.98);--main-bg:rgba(240,237,232,0.5);
|
||||
--focus-ring:rgba(45,111,163,.35);--focus-glow:rgba(45,111,163,.1);
|
||||
--input-bg:rgba(0,0,0,.03);--hover-bg:rgba(0,0,0,.05);
|
||||
--strong:#1a1715;--em:#5a544a;--code-text:#8b4513;--code-inline-bg:rgba(0,0,0,.06);--pre-text:#2c2825;
|
||||
}
|
||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);}
|
||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3);}
|
||||
:root[data-theme="light"] ::selection{background:rgba(45,111,163,.2);}
|
||||
:root[data-theme="light"] *{scrollbar-color:rgba(0,0,0,.15) transparent;}
|
||||
:root[data-theme="light"] .settings-overlay{background:rgba(0,0,0,.3);}
|
||||
/* ── Light theme: sidebar, roles, chips, active states ── */
|
||||
:root[data-theme="light"] .session-item{color:#5a544a;}
|
||||
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
|
||||
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;border-left-color:#2d6fa3;}
|
||||
:root[data-theme="light"] .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(228,224,216,.95) 12px);}
|
||||
:root[data-theme="light"] .session-pin-indicator{color:#996b15;}
|
||||
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
|
||||
:root[data-theme="light"] .session-actions .act-pin.pinned{color:#996b15;}
|
||||
:root[data-theme="light"] .msg-role.user{color:#2d6fa3;}
|
||||
:root[data-theme="light"] .msg-role.assistant{color:#8a6520;}
|
||||
:root[data-theme="light"] .role-icon.user{background:rgba(45,111,163,.12);color:#2d6fa3;border-color:rgba(45,111,163,.25);}
|
||||
:root[data-theme="light"] .role-icon.assistant{background:rgba(138,101,32,.12);color:#8a6520;border-color:rgba(138,101,32,.25);}
|
||||
:root[data-theme="light"] .project-chip{border-color:rgba(0,0,0,.12);background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .project-chip:hover{background:rgba(0,0,0,.08);color:#2c2825;}
|
||||
:root[data-theme="light"] .project-chip.active{background:rgba(45,111,163,.1);color:#1a5a8a;border-color:rgba(45,111,163,.3);}
|
||||
:root[data-theme="light"] .chip{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .chip.model{color:#2d6fa3;border-color:rgba(45,111,163,.3);background:rgba(45,111,163,.08);}
|
||||
:root[data-theme="light"] .new-chat-btn{border-color:rgba(45,111,163,.25);color:#2d6fa3;}
|
||||
:root[data-theme="light"] .new-chat-btn:hover{background:rgba(45,111,163,.08);}
|
||||
:root[data-theme="light"] .session-search input{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.03);}
|
||||
:root[data-theme="light"] .session-search input:focus{border-color:rgba(45,111,163,.4);background:rgba(0,0,0,.02);}
|
||||
:root[data-theme="light"] .cron-item{border-color:rgba(0,0,0,.08);background:rgba(0,0,0,.02);}
|
||||
:root[data-theme="light"] .sm-btn{border-color:rgba(0,0,0,.1);}
|
||||
:root[data-theme="light"] .sm-btn:hover{background:rgba(0,0,0,.06);border-color:rgba(0,0,0,.15);}
|
||||
:root[data-theme="light"] select{border-color:rgba(0,0,0,.12);}
|
||||
:root[data-theme="light"] .composer-box{border-color:rgba(0,0,0,.12);}
|
||||
:root[data-theme="light"] .composer-box:focus-within{border-color:rgba(45,111,163,.5);box-shadow:0 0 0 3px rgba(45,111,163,.08);}
|
||||
:root[data-theme="light"] .suggestion{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .suggestion:hover{background:rgba(45,111,163,.06);border-color:rgba(45,111,163,.2);}
|
||||
:root[data-theme="light"] .tool-card{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .tool-card:hover{border-color:rgba(0,0,0,.15);}
|
||||
:root[data-theme="light"] .icon-btn:hover{background:rgba(0,0,0,.06);}
|
||||
:root[data-theme="light"] .panel-icon-btn:hover{background:rgba(0,0,0,.06);}
|
||||
:root[data-theme="light"] .file-item:hover{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .preview-md th{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .preview-md td{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .preview-badge.code{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .ctx-bar-wrap{background:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .ws-opt:hover{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .profile-opt:hover{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .profile-opt.active{background:rgba(45,111,163,.06);}
|
||||
:root[data-theme="light"] .profile-chip{color:#7a5a90!important;}
|
||||
:root[data-theme="light"] .nav-tab:hover::after{background:var(--surface);border-color:rgba(45,111,163,.25);color:#2d6fa3;}
|
||||
:root[data-theme="light"] .cron-status.disabled{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .cron-btn{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .cron-btn:hover{background:rgba(0,0,0,.08);}
|
||||
/* ── Solarized Dark theme ── */
|
||||
:root[data-theme="solarized"]{
|
||||
--bg:#002b36;--sidebar:#073642;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.13);
|
||||
--text:#839496;--muted:#657b83;--accent:#dc322f;--blue:#268bd2;--gold:#b58900;--code-bg:#073642;
|
||||
--surface:#0a3c48;--topbar-bg:rgba(7,54,66,.98);--main-bg:rgba(0,43,54,0.5);
|
||||
--focus-ring:rgba(38,139,210,.35);--focus-glow:rgba(38,139,210,.08);
|
||||
--strong:#fdf6e3;--em:#93a1a1;--code-text:#cb4b16;--code-inline-bg:rgba(0,0,0,.25);--pre-text:#93a1a1;
|
||||
}
|
||||
/* ── Monokai theme ── */
|
||||
:root[data-theme="monokai"]{
|
||||
--bg:#272822;--sidebar:#1e1f1c;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
|
||||
--text:#f8f8f2;--muted:#75715e;--accent:#f92672;--blue:#66d9e8;--gold:#e6db74;--code-bg:#1e1f1c;
|
||||
--surface:#2d2e28;--topbar-bg:rgba(30,31,28,.98);--main-bg:rgba(39,40,34,0.5);
|
||||
--focus-ring:rgba(102,217,232,.35);--focus-glow:rgba(102,217,232,.08);
|
||||
--strong:#f8f8f0;--em:#a6a28c;--code-text:#e6db74;--code-inline-bg:rgba(0,0,0,.3);--pre-text:#f8f8f2;
|
||||
}
|
||||
/* ── Nord theme ── */
|
||||
:root[data-theme="nord"]{
|
||||
--bg:#2e3440;--sidebar:#272c36;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
|
||||
--text:#eceff4;--muted:#9099aa;--accent:#bf616a;--blue:#81a1c1;--gold:#ebcb8b;--code-bg:#272c36;
|
||||
--surface:#333a47;--topbar-bg:rgba(39,44,54,.98);--main-bg:rgba(46,52,64,0.5);
|
||||
--focus-ring:rgba(129,161,193,.35);--focus-glow:rgba(129,161,193,.08);
|
||||
--strong:#eceff4;--em:#b8c0cc;--code-text:#a3be8c;--code-inline-bg:rgba(0,0,0,.2);--pre-text:#d8dee9;
|
||||
}
|
||||
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
|
||||
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
|
||||
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
|
||||
@@ -15,13 +111,13 @@
|
||||
.new-chat-btn:hover{background:rgba(124,185,255,0.13);border-color:rgba(124,185,255,.3);}
|
||||
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;}
|
||||
.session-search{padding:4px 10px 8px;flex-shrink:0;}
|
||||
.session-search input{width:100%;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;}
|
||||
.session-search input:focus{border-color:rgba(124,185,255,.35);background:rgba(255,255,255,.06);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
|
||||
.session-search input{width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;}
|
||||
.session-search input:focus{border-color:rgba(124,185,255,.35);background:var(--hover-bg);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
|
||||
.session-search input::placeholder{color:var(--muted);opacity:.7;}
|
||||
/* Inline session title edit */
|
||||
.session-title-input{flex:1;background:rgba(20,32,60,.9);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
|
||||
.session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
|
||||
.session-item{padding:8px 10px 8px 8px;border-radius:0 8px 8px 0;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;position:relative;}
|
||||
.session-item:hover{background:rgba(255,255,255,0.06);color:var(--text);}
|
||||
.session-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;border-left:2px solid #e8a030;padding-left:8px;}
|
||||
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
/* ── Session action button overlay ── */
|
||||
@@ -43,21 +139,21 @@
|
||||
.session-date-header.pinned{color:#f5c542;}
|
||||
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
|
||||
.session-date-caret.collapsed{transform:rotate(-90deg);}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(20,30,50,.95);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--surface);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
|
||||
.reconnect-banner{display:none;background:#1a2535;border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||
.reconnect-banner.visible{display:flex;}
|
||||
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
||||
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
|
||||
/* ── Approval card ── */
|
||||
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
|
||||
.approval-card.visible{display:block;}
|
||||
.approval-inner{background:rgba(20,30,50,.95);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
|
||||
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
|
||||
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;}
|
||||
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:#e2e8f0;white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
|
||||
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;}
|
||||
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,0.06);color:var(--text);cursor:pointer;transition:all .15s;}
|
||||
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;}
|
||||
.approval-btn:hover{background:rgba(255,255,255,0.12);}
|
||||
.approval-btn.once{border-color:rgba(124,185,255,0.5);color:var(--blue);}
|
||||
.approval-btn.once:hover{background:rgba(124,185,255,0.15);}
|
||||
@@ -69,7 +165,7 @@
|
||||
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
|
||||
.nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;}
|
||||
.nav-tab:hover{color:var(--text);}
|
||||
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:rgba(15,22,40,.98);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
|
||||
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
|
||||
.nav-tab.active{color:var(--blue);}
|
||||
.nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--blue);border-radius:2px 2px 0 0;}
|
||||
/* Panel content areas (swapped by tab) */
|
||||
@@ -77,7 +173,7 @@
|
||||
.panel-view.active{display:flex;}
|
||||
/* Cron panel */
|
||||
.cron-list{flex:1;overflow-y:auto;padding:8px;}
|
||||
.cron-item{border-radius:10px;border:1px solid rgba(255,255,255,.08);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
|
||||
.cron-item{border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
|
||||
.cron-item:hover{border-color:var(--border2);}
|
||||
.cron-header{display:flex;align-items:center;gap:8px;padding:9px 11px;cursor:pointer;}
|
||||
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
@@ -100,14 +196,14 @@
|
||||
.cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;}
|
||||
/* Skills panel */
|
||||
.skills-search{padding:8px;flex-shrink:0;}
|
||||
.skills-search input{width:100%;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;}
|
||||
.skills-search input{width:100%;background:var(--hover-bg);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;}
|
||||
.skills-search input::placeholder{color:var(--muted);}
|
||||
.skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;}
|
||||
.skills-category{margin-bottom:4px;}
|
||||
.skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;}
|
||||
.skills-cat-header:hover{color:var(--text);}
|
||||
.skill-item{padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;}
|
||||
.skill-item:hover{background:rgba(255,255,255,.06);color:var(--text);}
|
||||
.skill-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.skill-item.active{background:rgba(124,185,255,.1);color:var(--blue);}
|
||||
.skill-name{font-weight:500;flex-shrink:0;}
|
||||
.skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;}
|
||||
@@ -121,19 +217,19 @@
|
||||
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
|
||||
.sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;position:relative;z-index:10;overflow:visible;}
|
||||
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
|
||||
select{width:100%;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);}
|
||||
optgroup{color:var(--muted);font-size:11px;font-weight:700;}
|
||||
option{background:#1a1a2e;color:var(--text);padding:6px;}
|
||||
option{background:var(--bg);color:var(--text);padding:6px;}
|
||||
.sidebar-actions{display:flex;gap:6px;}
|
||||
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
||||
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
||||
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:rgba(26,26,46,0.5);}
|
||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(22,33,62,.98);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);}
|
||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
||||
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;}
|
||||
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,.1);color:var(--muted);font-weight:500;}
|
||||
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid var(--border2);color:var(--muted);font-weight:500;}
|
||||
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;}
|
||||
.messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
|
||||
@@ -150,11 +246,11 @@
|
||||
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
|
||||
.msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;}
|
||||
.msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;}
|
||||
.msg-body strong{color:#fff;font-weight:600;}.msg-body em{color:#c9c9e8;font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:#e2e8f0;font-size:13px;line-height:1.6;}
|
||||
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:rgba(255,255,255,.04);border-radius:10px 10px 0 0;border:1px solid rgba(255,255,255,.08);border-bottom:1px solid rgba(255,255,255,.05);display:flex;align-items:center;gap:6px;}
|
||||
.msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:13px;line-height:1.6;}
|
||||
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:var(--input-bg);border-radius:10px 10px 0 0;border:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;}
|
||||
.pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;}
|
||||
.pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;}
|
||||
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
|
||||
@@ -171,11 +267,11 @@
|
||||
.empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;}
|
||||
.empty-state p{font-size:14px;text-align:center;max-width:320px;}
|
||||
.suggestion-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px;width:100%;max-width:380px;}
|
||||
.suggestion{padding:11px 14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
|
||||
.suggestion{padding:11px 14px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
|
||||
.suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);}
|
||||
/* ── Composer ── */
|
||||
.composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
|
||||
.composer-box{max-width:780px;margin:0 auto;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.12);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
|
||||
.composer-box{max-width:780px;margin:0 auto;background:var(--input-bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
|
||||
.composer-box:focus-within{border-color:rgba(124,185,255,0.5);box-shadow:0 0 0 3px rgba(124,185,255,0.08);}
|
||||
.composer-wrap.drag-over .composer-box{border-color:var(--blue);background:rgba(124,185,255,0.06);}
|
||||
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(124,185,255,0.08);border:2px dashed var(--blue);border-radius:14px;font-size:14px;color:var(--blue);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
|
||||
@@ -212,12 +308,12 @@
|
||||
.send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;}
|
||||
.send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;}
|
||||
@keyframes send-pop-in{from{opacity:0;transform:scale(.55);}to{opacity:1;transform:scale(1);}}
|
||||
.upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;}
|
||||
.upload-bar-wrap{display:none;height:3px;background:var(--hover-bg);border-radius:0 0 16px 16px;overflow:hidden;}
|
||||
.upload-bar-wrap.active{display:block;}
|
||||
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
|
||||
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
|
||||
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
|
||||
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;}
|
||||
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
|
||||
.panel-actions{display:flex;gap:4px;}
|
||||
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
@@ -231,7 +327,7 @@
|
||||
.breadcrumb-bar{display:flex;align-items:center;gap:2px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border);flex-shrink:0;overflow:hidden;white-space:nowrap;}
|
||||
.breadcrumb-seg{padding:1px 3px;border-radius:3px;}
|
||||
.breadcrumb-link{color:var(--muted);cursor:pointer;transition:color .12s;}
|
||||
.breadcrumb-link:hover{color:var(--text);background:rgba(255,255,255,.06);}
|
||||
.breadcrumb-link:hover{color:var(--text);background:var(--hover-bg);}
|
||||
.breadcrumb-current{color:var(--text);font-weight:500;}
|
||||
.breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;}
|
||||
.file-tree{flex:1;overflow-y:auto;padding:8px;}
|
||||
@@ -251,15 +347,15 @@
|
||||
/* Markdown rendered preview */
|
||||
.preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;}
|
||||
.preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;}
|
||||
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:#fff;border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:#fff;}
|
||||
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:var(--strong);border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:var(--strong);}
|
||||
.preview-md h3{font-size:13px;font-weight:600;margin:12px 0 4px;color:#e8e8f0;}
|
||||
.preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;}
|
||||
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
|
||||
.preview-md pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
|
||||
.preview-md pre code{background:none;padding:0;color:#e2e8f0;font-size:11.5px;line-height:1.55;}
|
||||
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.preview-md pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
|
||||
.preview-md pre code{background:none;padding:0;color:var(--pre-text);font-size:11.5px;line-height:1.55;}
|
||||
.preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;}
|
||||
.preview-md strong{color:#fff;font-weight:600;}.preview-md em{color:#c9c9e8;}
|
||||
.preview-md strong{color:var(--strong);font-weight:600;}.preview-md em{color:var(--em);}
|
||||
.preview-md a{color:var(--blue);text-decoration:underline;}
|
||||
.preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
|
||||
.preview-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
|
||||
@@ -353,14 +449,14 @@
|
||||
/* Tool cards */
|
||||
.tool-card{margin-left:0!important;font-size:12px;}
|
||||
/* Settings modal */
|
||||
.settings-panel{width:95vw;max-width:95vw;}
|
||||
.settings-panel{width:95vw;max-width:95vw;min-height:min(580px,88vh);max-height:92vh;}
|
||||
/* Login page responsive */
|
||||
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
||||
}
|
||||
|
||||
/* ── Workspace dropdown (topbar) ── */
|
||||
.ws-chip{user-select:none;}
|
||||
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown.open{display:block;}
|
||||
.ws-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:4px;align-items:flex-start;}
|
||||
.ws-opt:hover{background:rgba(255,255,255,.07);}
|
||||
@@ -380,7 +476,7 @@
|
||||
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
|
||||
/* ── Profile dropdown + management panel ── */
|
||||
.profile-chip{user-select:none;color:rgba(168,139,250,.9)!important;}
|
||||
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
|
||||
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
|
||||
.profile-dropdown.open{display:block;}
|
||||
.profile-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
||||
.profile-opt:hover{background:rgba(255,255,255,.07);}
|
||||
@@ -398,7 +494,7 @@
|
||||
.profile-card-meta{font-size:11px;color:var(--muted);margin-top:3px;padding-left:12px;}
|
||||
.profile-card-actions{display:flex;gap:4px;flex-shrink:0;}
|
||||
/* ── Slash command autocomplete dropdown ── */
|
||||
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
|
||||
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
|
||||
.cmd-dropdown.open{display:block;}
|
||||
.cmd-item{padding:8px 14px;cursor:pointer;transition:background .12s;}
|
||||
.cmd-item:hover,.cmd-item.selected{background:rgba(255,255,255,.07);}
|
||||
@@ -418,7 +514,7 @@
|
||||
.msg-edit-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;}
|
||||
.msg-edit-send{background:var(--blue);color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;}
|
||||
.msg-edit-send:hover{opacity:.85;}
|
||||
.msg-edit-cancel{background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
|
||||
.msg-edit-cancel{background:var(--hover-bg);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
|
||||
.msg-edit-cancel:hover{background:rgba(255,255,255,.1);}
|
||||
|
||||
/* ── Clear conversation chip ── */
|
||||
@@ -596,7 +692,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.skill-linked-section{margin-bottom:8px;}
|
||||
.skill-linked-section h4{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:4px;}
|
||||
.skill-linked-file{display:block;font-size:12px;padding:3px 6px;border-radius:4px;cursor:pointer;color:var(--blue);text-decoration:none;}
|
||||
.skill-linked-file:hover{background:rgba(255,255,255,.06);}
|
||||
.skill-linked-file:hover{background:var(--hover-bg);}
|
||||
.tool-card-row{margin:0;padding:1px 0;}
|
||||
.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
|
||||
.tool-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
@@ -624,7 +720,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
|
||||
/* ── Settings overlay ── */
|
||||
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;}
|
||||
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:380px;max-width:90vw;max-height:80vh;overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
|
||||
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:420px;max-width:92vw;max-height:92vh;min-height:min(680px,90vh);overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
|
||||
.settings-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid var(--border);}
|
||||
.settings-body{padding:20px;overflow-y:auto;flex:1;}
|
||||
.settings-field{margin-bottom:16px;}
|
||||
@@ -666,13 +762,13 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
|
||||
/* ── Session projects ── */
|
||||
.project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;}
|
||||
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:rgba(255,255,255,.04);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
|
||||
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
|
||||
.project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
.project-chip.active{background:rgba(124,185,255,.12);color:var(--blue);border-color:rgba(124,185,255,.4);}
|
||||
.project-chip .color-dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0;}
|
||||
.project-create-btn{font-size:10px;padding:3px 6px;border-radius:12px;cursor:pointer;border:1px dashed var(--border2);background:none;color:var(--muted);opacity:.6;transition:all .15s;}
|
||||
.project-create-btn:hover{opacity:1;border-color:var(--blue);color:var(--blue);}
|
||||
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:rgba(20,32,60,.9);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
|
||||
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:var(--surface);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
|
||||
.project-picker{position:absolute;right:0;top:100%;background:var(--sidebar);border:1px solid var(--border2);border-radius:8px;padding:4px;z-index:30;min-width:160px;max-width:220px;width:max-content;box-shadow:0 4px 16px rgba(0,0,0,.3);}
|
||||
.project-picker-item{padding:5px 10px;font-size:11px;border-radius:6px;cursor:pointer;color:var(--muted);transition:all .1s;display:flex;align-items:center;gap:6px;}
|
||||
.project-picker-item:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
@@ -682,7 +778,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;margin-left:4px;vertical-align:middle;}
|
||||
|
||||
/* ── Code copy button ── */
|
||||
.code-copy-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
|
||||
.code-copy-btn{background:var(--hover-bg);border:1px solid var(--border2);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
|
||||
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
|
||||
|
||||
/* ── Tool card expand/collapse toggle ── */
|
||||
|
||||
119
tests/test_sprint26.py
Normal file
119
tests/test_sprint26.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Sprint 26 Tests: pluggable UI themes — settings persistence, theme default,
|
||||
custom theme names accepted.
|
||||
"""
|
||||
import json, urllib.error, urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
# ── Theme settings ───────────────────────────────────────────────────────
|
||||
|
||||
def test_settings_default_theme():
|
||||
"""Default theme should be 'dark'."""
|
||||
d, status = get("/api/settings")
|
||||
assert status == 200
|
||||
assert d.get("theme") == "dark"
|
||||
|
||||
|
||||
def test_settings_set_theme_light():
|
||||
"""Setting theme to 'light' should persist and round-trip."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"theme": "light"})
|
||||
assert status == 200
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("theme") == "light"
|
||||
finally:
|
||||
# Reset to dark
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
|
||||
|
||||
def test_settings_set_theme_solarized():
|
||||
"""Setting theme to 'solarized' should persist."""
|
||||
try:
|
||||
post("/api/settings", {"theme": "solarized"})
|
||||
d, _ = get("/api/settings")
|
||||
assert d.get("theme") == "solarized"
|
||||
finally:
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
|
||||
|
||||
def test_settings_set_theme_monokai():
|
||||
"""Setting theme to 'monokai' should persist."""
|
||||
try:
|
||||
post("/api/settings", {"theme": "monokai"})
|
||||
d, _ = get("/api/settings")
|
||||
assert d.get("theme") == "monokai"
|
||||
finally:
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
|
||||
|
||||
def test_settings_set_theme_nord():
|
||||
"""Setting theme to 'nord' should persist."""
|
||||
try:
|
||||
post("/api/settings", {"theme": "nord"})
|
||||
d, _ = get("/api/settings")
|
||||
assert d.get("theme") == "nord"
|
||||
finally:
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
|
||||
|
||||
def test_settings_set_theme_slate():
|
||||
"""Setting theme to 'slate' should persist."""
|
||||
try:
|
||||
post("/api/settings", {"theme": "slate"})
|
||||
d, _ = get("/api/settings")
|
||||
assert d.get("theme") == "slate"
|
||||
finally:
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
|
||||
|
||||
def test_settings_custom_theme_accepted():
|
||||
"""Custom theme names should be accepted (no enum gate)."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"theme": "my-custom-theme"})
|
||||
assert status == 200
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("theme") == "my-custom-theme"
|
||||
finally:
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
|
||||
|
||||
def test_theme_does_not_break_other_settings():
|
||||
"""Setting theme should not disturb other settings."""
|
||||
d_before, _ = get("/api/settings")
|
||||
send_key_before = d_before.get("send_key")
|
||||
try:
|
||||
post("/api/settings", {"theme": "nord"})
|
||||
d_after, _ = get("/api/settings")
|
||||
assert d_after.get("send_key") == send_key_before
|
||||
assert d_after.get("theme") == "nord"
|
||||
finally:
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
|
||||
|
||||
def test_theme_survives_round_trip():
|
||||
"""Theme set via POST should appear in subsequent GET."""
|
||||
try:
|
||||
post("/api/settings", {"theme": "monokai"})
|
||||
d, status = get("/api/settings")
|
||||
assert status == 200
|
||||
assert d["theme"] == "monokai"
|
||||
finally:
|
||||
post("/api/settings", {"theme": "dark"})
|
||||
Reference in New Issue
Block a user