Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af73a5d8fd | ||
|
|
a92c251ef8 | ||
|
|
574cd2cf70 | ||
|
|
d278563e00 | ||
|
|
8cd07d3774 | ||
|
|
959c386d8d | ||
|
|
690f04bff0 | ||
|
|
f5c9f218c4 | ||
|
|
dcb21dfd37 | ||
|
|
59a92e03d8 | ||
|
|
df3de7a543 | ||
|
|
46fdf3513f | ||
|
|
efb7293ae8 | ||
|
|
44aa538b7c | ||
|
|
1b1cd124f6 | ||
|
|
3f9d1da0e2 | ||
|
|
9363d967ed | ||
|
|
2dda99082f | ||
|
|
51bcf8fead | ||
|
|
56526ce502 | ||
|
|
e0a1ab8e03 | ||
|
|
d88419ccfb | ||
|
|
3c95502979 | ||
|
|
66bd84accb | ||
|
|
b8b62722ec | ||
|
|
1c6db07c2b | ||
|
|
d0aef93372 | ||
|
|
67324cc3bc | ||
|
|
e7e09f217a | ||
|
|
0c00dae15a | ||
|
|
685aabab38 | ||
|
|
0f2bd537f1 | ||
|
|
856f5c21e1 | ||
|
|
f3ae8305dc | ||
|
|
a92fff75a3 | ||
|
|
0be7ccde4c | ||
|
|
fcd155be55 | ||
|
|
0c601a9cc8 | ||
|
|
4b326dbdf0 | ||
|
|
fd993c5513 | ||
|
|
d2bcd2b2f7 | ||
|
|
5e4645ee05 | ||
|
|
ae1faa7252 | ||
|
|
6c7fb4ee44 | ||
|
|
e59eb8bb5b |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
tests/
|
||||
.env*
|
||||
135
ARCHITECTURE.md
135
ARCHITECTURE.md
@@ -18,7 +18,7 @@ a central chat area, and a right panel for workspace file browsing.
|
||||
|
||||
The design philosophy is deliberately minimal. There is no build step, no bundler, no
|
||||
frontend framework. The Python server is split into a routing shell (server.py) and
|
||||
business logic modules (api/). The frontend is six vanilla JS modules loaded from static/.
|
||||
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
|
||||
This makes the code easy to modify from a terminal or by an agent.
|
||||
|
||||
---
|
||||
@@ -26,38 +26,40 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
## 2. File Inventory
|
||||
|
||||
<repo>/
|
||||
server.py Thin routing shell + HTTP Handler. ~76 lines. Pure Python.
|
||||
server.py Thin routing shell + HTTP Handler + auth middleware. ~79 lines.
|
||||
Delegates all route handling to api/routes.py.
|
||||
start.sh Discovery script: finds agent dir, Python, starts server.
|
||||
api/
|
||||
__init__.py Package marker
|
||||
routes.py All GET + POST route handlers (~802 lines)
|
||||
config.py Shared configuration, constants, global state, model discovery (~453 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines)
|
||||
models.py Session model + CRUD (~114 lines)
|
||||
auth.py Optional password authentication, signed cookies (~149 lines)
|
||||
routes.py All GET + POST route handlers (~1109 lines)
|
||||
config.py Shared configuration, constants, global state, model discovery (~654 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines)
|
||||
models.py Session model + CRUD (~132 lines)
|
||||
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)
|
||||
upload.py Multipart parser, file upload handler (~77 lines)
|
||||
streaming.py SSE engine, run_agent integration, cancel support (~218 lines)
|
||||
streaming.py SSE engine, run_agent integration, cancel support (~222 lines)
|
||||
static/
|
||||
index.html HTML template (served from disk)
|
||||
style.css All CSS
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown (~671 lines)
|
||||
workspace.js File tree, preview, file ops (~168 lines)
|
||||
sessions.js Session CRUD, list rendering, search (~206 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~310 lines)
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel (~600 lines)
|
||||
boot.js Event wiring + boot IIFE (~154 lines)
|
||||
style.css All CSS (~590 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~957 lines)
|
||||
workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
|
||||
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~532 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~297 lines)
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel, settings (~813 lines)
|
||||
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
|
||||
boot.js Event wiring, keydown handlers, boot IIFE (~208 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
|
||||
test_sprint1-11.py Feature tests per sprint (13 files)
|
||||
test_regressions.py Permanent regression gate
|
||||
test_sprint{1-19}.py Feature tests per sprint (17 files, 327 test functions)
|
||||
test_regressions.py Permanent regression gate (23 tests)
|
||||
AGENTS.md Instruction file for agents working in this directory.
|
||||
ROADMAP.md Feature and product roadmap document.
|
||||
SPRINTS.md Forward sprint plan with CLI + Claude parity targets.
|
||||
ARCHITECTURE.md THIS FILE.
|
||||
TESTING.md Manual browser test plan and automated coverage reference.
|
||||
CHANGELOG.md Release notes per sprint.
|
||||
PORTABILITY.md Portability design spec for download-and-run installs.
|
||||
BUGS.md Bug backlog and fixed items tracker.
|
||||
requirements.txt Python dependencies.
|
||||
.env.example Sample environment variable overrides.
|
||||
|
||||
@@ -67,7 +69,8 @@ State directory (runtime data, separate from source):
|
||||
sessions/ One JSON file per session: {session_id}.json
|
||||
workspaces.json Registered workspaces list
|
||||
last_workspace.txt Last-used workspace path
|
||||
settings.json (future) User settings
|
||||
settings.json User settings (default model, workspace, send key, password hash)
|
||||
projects.json Session project groups (name, color, id)
|
||||
|
||||
Log file:
|
||||
|
||||
@@ -91,6 +94,7 @@ Environment variables controlling behavior:
|
||||
HERMES_WEBUI_STATE_DIR Where sessions/ folder lives
|
||||
HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml
|
||||
HERMES_WEBUI_DEFAULT_MODEL Default LLM model string
|
||||
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
|
||||
|
||||
Test isolation environment variables (set by conftest.py):
|
||||
|
||||
@@ -151,10 +155,14 @@ Session is a plain Python class (not a dataclass, not SQLAlchemy):
|
||||
session_id hex string, 12 chars (uuid4().hex[:12])
|
||||
title string, auto-set from first user message
|
||||
workspace absolute path string, resolved at creation
|
||||
model OpenRouter model ID string (e.g. "anthropic/claude-sonnet-4.6")
|
||||
model model ID string (e.g. "anthropic/claude-sonnet-4.6")
|
||||
messages list of OpenAI-format message dicts
|
||||
created_at float Unix timestamp
|
||||
updated_at float Unix timestamp, updated on every save()
|
||||
pinned bool, default False (Sprint 12)
|
||||
archived bool, default False (Sprint 14)
|
||||
project_id string or null, FK to projects.json (Sprint 15)
|
||||
tool_calls list of tool call dicts (Sprint 10)
|
||||
|
||||
Key methods:
|
||||
path (property) Returns SESSION_DIR/{session_id}.json
|
||||
@@ -326,16 +334,20 @@ read_file_content(workspace, rel):
|
||||
### 5.1 Structure
|
||||
|
||||
The frontend is served from static/ as separate files: one HTML template, one CSS file,
|
||||
and six JavaScript modules (~2,025 lines total). External dependency: Prism.js from CDN
|
||||
(syntax highlighting, loaded async/deferred).
|
||||
and six JavaScript modules (~2,786 lines total). External dependencies: Prism.js (syntax
|
||||
highlighting) and Mermaid.js (diagrams) from CDN, both loaded async/deferred with SRI hashes.
|
||||
|
||||
Six JS modules loaded in order at end of <body>:
|
||||
1. ui.js (~589 lines) DOM helpers, renderMd, tool card rendering, global state
|
||||
2. workspace.js (~168 lines) File tree, preview, file operations
|
||||
3. sessions.js (~206 lines) Session CRUD, list rendering, search
|
||||
4. messages.js (~310 lines) send(), SSE event handlers, approval, transcript
|
||||
5. panels.js (~600 lines) Cron, skills, memory, workspace, todo, switchPanel
|
||||
6. boot.js (~152 lines) Event wiring + boot IIFE
|
||||
1. ui.js (~846 lines) DOM helpers, renderMd, tool card rendering, global state
|
||||
2. workspace.js (~169 lines) File tree, preview, file operations
|
||||
3. sessions.js (~532 lines) Session CRUD, list rendering, search, SVG icons, overlay actions, project picker
|
||||
4. messages.js (~293 lines) send(), SSE event handlers, approval, transcript
|
||||
5. panels.js (~771 lines) Cron, skills, memory, workspace, todo, switchPanel
|
||||
6. boot.js (~175 lines) Event wiring + boot IIFE
|
||||
|
||||
sessions.js defines an `ICONS` constant at module level with hardcoded SVG strings for all
|
||||
session action buttons (pin, unpin, folder, archive, unarchive, duplicate, trash). All icons
|
||||
inherit `currentColor` for consistent theming.
|
||||
|
||||
Three-panel layout (in static/index.html):
|
||||
|
||||
@@ -406,25 +418,43 @@ Boot IIFE:
|
||||
|
||||
### 5.4 Markdown Renderer (renderMd)
|
||||
|
||||
A hand-rolled regex chain. Processes in this order:
|
||||
1. Code blocks (``` lang ... ```) -> <pre><code> with language header
|
||||
2. Inline code (`...`) -> <code>
|
||||
3. Bold+italic (***..***) -> <strong><em>
|
||||
4. Bold (**...**) -> <strong>
|
||||
5. Italic (*...*) -> <em>
|
||||
6. Headings (# ## ###) -> <h1> <h2> <h3>
|
||||
7. Horizontal rules (---+) -> <hr>
|
||||
8. Blockquotes (> ...) -> <blockquote>
|
||||
9. Unordered lists (- or * or + at line start) -> <ul><li>
|
||||
10. Ordered lists (N. at line start) -> <ol><li>
|
||||
11. Links ([text](https://...)) -> <a href target=_blank>
|
||||
12. Paragraph wrapping: remaining double-newline-separated blocks -> <p>
|
||||
A hand-rolled regex chain with HTML safety. Processes in this order:
|
||||
|
||||
Pre-pass (v0.18.1):
|
||||
0a. Stash fenced code blocks and backtick spans (fence_stash array)
|
||||
0b. Convert safe HTML tags to markdown equivalents:
|
||||
<strong>/<b> -> **text**, <em>/<i> -> *text*, <code> -> `text`, <br> -> newline
|
||||
0c. Restore stashed code blocks
|
||||
|
||||
Pipeline:
|
||||
1. Mermaid blocks (```mermaid ... ```) -> <div class="mermaid-block">
|
||||
2. Code blocks (``` lang ... ```) -> <pre><code> with language header
|
||||
3. Inline code (`...`) -> <code>
|
||||
4. Bold+italic (***..***) -> <strong><em>
|
||||
5. Bold (**...**) -> <strong>
|
||||
6. Italic (*...*) -> <em>
|
||||
7. Headings (# ## ###) -> <h1> <h2> <h3> (uses inlineMd() for content)
|
||||
8. Horizontal rules (---+) -> <hr>
|
||||
9. Blockquotes (> ...) -> <blockquote> (uses inlineMd() for content)
|
||||
10. Unordered lists (- or * or + at line start) -> <ul><li> (uses inlineMd())
|
||||
11. Ordered lists (N. at line start) -> <ol><li> (uses inlineMd())
|
||||
12. Links ([text](https://...)) -> <a href target=_blank>
|
||||
13. Tables (| col | col |) -> <table>
|
||||
14. Safety net: escape any HTML tag not in SAFE_TAGS allowlist via esc()
|
||||
15. Paragraph wrapping: remaining double-newline-separated blocks -> <p>
|
||||
|
||||
inlineMd() helper (v0.18.1):
|
||||
Processes inline bold/italic/code/links within list items, blockquotes,
|
||||
and headings. Escapes unknown tags via SAFE_INLINE allowlist. Replaces
|
||||
the old direct esc() calls which would double-escape pre-pass output.
|
||||
|
||||
SAFE_TAGS allowlist:
|
||||
strong, em, code, pre, h1-6, ul, ol, li, table, thead, tbody, tr, th,
|
||||
td, hr, blockquote, p, br, a, div. Everything else is escaped.
|
||||
|
||||
Known gaps:
|
||||
- Tables: not supported, render as plain text
|
||||
- Nested lists: single regex pass, multi-level indentation not handled
|
||||
- Mixed bold+link in same line: may produce garbled output
|
||||
- Inline HTML: not sanitized (esc() only runs on code content)
|
||||
|
||||
### 5.5 Model Chip Label (Fixed in Sprint 1)
|
||||
|
||||
@@ -604,26 +634,27 @@ Split server.py into a proper package. Completed across Sprints 4-10.
|
||||
Current structure:
|
||||
|
||||
<repo>/
|
||||
server.py Entry point + HTTP Handler routing (~704 lines)
|
||||
server.py Entry point + HTTP Handler dispatch (~76 lines)
|
||||
api/
|
||||
__init__.py
|
||||
config.py Configuration, constants, global state (~273 lines)
|
||||
routes.py All GET + POST route handlers (~1016 lines)
|
||||
config.py Configuration, constants, global state, model discovery (~640 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines)
|
||||
models.py Session model + CRUD (~114 lines)
|
||||
models.py Session model + CRUD (~132 lines)
|
||||
workspace.py File ops, workspace management (~77 lines)
|
||||
upload.py Multipart parser, file upload handler (~77 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~218 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~222 lines)
|
||||
static/
|
||||
index.html HTML document (served from disk)
|
||||
style.css All CSS
|
||||
style.css All CSS (~560 lines)
|
||||
ui.js, workspace.js, sessions.js, messages.js, panels.js, boot.js
|
||||
tests/
|
||||
conftest.py Isolated test server on port 8788
|
||||
test_sprint1-10.py Feature tests per sprint (12 files)
|
||||
test_sprint1-16.py Feature tests per sprint (14 files)
|
||||
test_regressions.py Permanent regression gate
|
||||
|
||||
Remaining: server.py still has all 49 route handlers in one do_GET/do_POST class.
|
||||
Sprint 11 plans extracting these to api/routes.py, making server.py a ~50-line shell.
|
||||
Route extraction to api/routes.py completed in Sprint 11. server.py is now a ~76-line
|
||||
thin shell: Handler class with structured logging, dispatch to routes, and main().
|
||||
|
||||
### Phase B: Thread-Safe Request Context (Priority: Critical, Effort: Medium)
|
||||
|
||||
@@ -718,10 +749,10 @@ Optional password gate for non-SSH-tunnel deployments.
|
||||
|
||||
### Phase I: Test Infrastructure -- COMPLETE
|
||||
|
||||
190 tests across 12 test files + regression gate. Isolated test server on port 8788
|
||||
289 tests across 14 test files + regression gate. Isolated test server on port 8788
|
||||
with separate HERMES_HOME, wiped per run. Production data never touched.
|
||||
|
||||
Test files: `test_sprint1.py` through `test_sprint10.py`, `test_regressions.py`.
|
||||
Test files: `test_sprint1.py` through `test_sprint11.py`, `test_sprint16.py`, `test_regressions.py`.
|
||||
Fixtures in `conftest.py`: auto-cleanup, cron isolation, workspace reset.
|
||||
|
||||
Remaining: no CI (GitHub Actions), no frontend tests (browser-based).
|
||||
|
||||
40
BUGS.md
Normal file
40
BUGS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Bugs Backlog
|
||||
|
||||
This file tracks UI bugs and polish items. Fixed items are kept for reference.
|
||||
|
||||
---
|
||||
|
||||
## Open Bugs
|
||||
|
||||
*No open bugs at this time.*
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### ~~Session title truncation / hover actions~~ -- Fixed (Sprint 16)
|
||||
|
||||
- **Was:** Action icons reserved ~30px of space even when invisible, truncating titles.
|
||||
- **Fix:** Wrapped all action buttons in a `.session-actions` overlay container with `position:absolute`. Titles now use full available width. Actions appear on hover with a gradient fade from the right edge.
|
||||
|
||||
### ~~Folder/project assignment interaction feels sticky~~ -- Fixed (Sprint 16)
|
||||
|
||||
- **Was:** Folder icon stayed permanently visible (blue, 60% opacity) when a session belonged to a project.
|
||||
- **Fix:** Replaced `.has-project` persistent button with a colored left border matching the project color. The folder button now only appears in the hover overlay like all other actions.
|
||||
|
||||
### ~~Project picker clipping and width~~ -- Fixed (v0.17.3)
|
||||
|
||||
- **Was:** Picker was clipped by `overflow:hidden` on `.session-item` ancestors. With `position:fixed`, no containing block constrained width -- picker stretched to full viewport.
|
||||
- **Fix:** Dynamic width calculation (min 160px, max 220px). Event listener reordering. Cleanup sequence corrected. (PR #25)
|
||||
|
||||
### ~~NameError crash in model discovery~~ -- Fixed (v0.17.3)
|
||||
|
||||
- **Was:** `logger.debug()` called in custom endpoint `except` block, but `logger` was never imported in `config.py`. Every failed endpoint fetch crashed with `NameError`.
|
||||
- **Fix:** Replaced with silent `pass` -- unreachable endpoints are expected when no local LLM is configured. (PR #24)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sprint 16 replaced all emoji HTML entities with monochrome SVG line icons (`ICONS` constant in `sessions.js`).
|
||||
- All session action buttons now use the overlay pattern for consistent UX.
|
||||
241
CHANGELOG.md
241
CHANGELOG.md
@@ -5,6 +5,245 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.23] Sprint 21 -- Mobile Responsive + Docker
|
||||
*April 3, 2026 | 415 tests*
|
||||
|
||||
### Features
|
||||
- **Mobile responsive layout (Issue #21).** Full mobile experience with
|
||||
hamburger sidebar (slide-in overlay), bottom navigation bar (5-tab iOS
|
||||
pattern), and files slide-over panel. Touch targets minimum 44px. Composer
|
||||
positioned above bottom nav. Session clicks auto-close sidebar. Desktop
|
||||
layout completely unchanged — all mobile elements hidden via `@media`.
|
||||
- **Docker support (Issue #7).** Dockerfile (`python:3.12-slim`), docker-compose.yml
|
||||
with named volume for state persistence, optional `~/.hermes` mount for
|
||||
agent features. Binds to `127.0.0.1` by default for security.
|
||||
|
||||
### Bug Fixes (from review)
|
||||
- **CSS cascade broke mobile slide-in.** `position:relative` rules after the
|
||||
media query overrode `position:fixed` on mobile. Wrapped in `@media(min-width:641px)`.
|
||||
- **mobileSwitchPanel() always reopened sidebar.** Chat tab now closes sidebar
|
||||
instead of reopening it over the main chat area.
|
||||
- **Dockerfile missing pip install.** Added `pip install -r requirements.txt`.
|
||||
- **No .dockerignore.** Added exclusions for `.git`, `tests/`, `.env*`.
|
||||
- **docker-compose tilde expansion.** Changed `~/.hermes` default to
|
||||
`${HOME}/.hermes` (Docker Compose doesn't shell-expand `~`).
|
||||
|
||||
### Architecture
|
||||
- Mobile navigation functions in `boot.js`: `toggleMobileSidebar()`,
|
||||
`closeMobileSidebar()`, `toggleMobileFiles()`, `mobileSwitchPanel()`.
|
||||
- `sessions.js`: `closeMobileSidebar()` called after session click.
|
||||
- 69 new CSS lines in `@media(max-width:640px)` block.
|
||||
- New files: `Dockerfile`, `docker-compose.yml`, `.dockerignore`.
|
||||
|
||||
---
|
||||
|
||||
## [v0.22] Sprint 20 -- Voice Input + Send Button Polish
|
||||
*April 3, 2026 | 415 tests*
|
||||
|
||||
### Features
|
||||
- **Voice input via Web Speech API.** Microphone button in the composer.
|
||||
Tap to start recording, tap again (or send) to stop. Live interim
|
||||
transcription appears in the textarea. Auto-stops after ~2s of silence.
|
||||
Final text stays editable before sending. Appends to existing textarea
|
||||
content rather than replacing it. Button hidden when browser doesn't
|
||||
support Web Speech API. No API keys, no external libraries, no server
|
||||
changes. Works in Chrome, Edge, Safari (partial). Firefox unsupported
|
||||
(button stays hidden).
|
||||
- **Send button polish.** Send button redesigned as a 34px icon-only circle
|
||||
with upward arrow SVG. Hidden by default — appears with pop-in spring
|
||||
animation when textarea has content or files are attached. Disappears
|
||||
on send or when content is cleared. Hidden while agent is responding.
|
||||
Blue fill (#7cb9ff) with glow, scale hover/active for tactile feedback.
|
||||
|
||||
### Architecture
|
||||
- Voice input IIFE in `boot.js`: SpeechRecognition lifecycle with
|
||||
`continuous=false`, `interimResults=true`, error handling via `showToast()`.
|
||||
- `_prefix` variable snapshots existing textarea content on recording start
|
||||
so dictation appends rather than overwrites.
|
||||
- `btnSend.onclick` stops active recognition before sending (send guard).
|
||||
- CSS: `.mic-btn`, `.mic-btn.recording` (red pulse), `.mic-status`,
|
||||
`.mic-dot`, `@keyframes mic-pulse`.
|
||||
- `updateSendBtn()` in `ui.js` tracks textarea content, pending files,
|
||||
and busy state. Hooked into `setBusy()`, `renderTray()`, `autoResize()`,
|
||||
and input event listener.
|
||||
- CSS: `.send-btn` redesigned (circle, glow), `.send-btn.visible` +
|
||||
`@keyframes send-pop-in` (spring animation).
|
||||
|
||||
### Tests
|
||||
- 52 new tests in `test_sprint20.py`: voice input HTML, CSS, JS, append
|
||||
behaviour, error handling, regressions.
|
||||
- 33 new tests in `test_sprint20b.py`: send button HTML, CSS, JS,
|
||||
animation, visibility logic, regressions. Total: **415 tests**.
|
||||
|
||||
---
|
||||
|
||||
## [v0.21] Sprint 19 -- Auth + Security Hardening
|
||||
*April 3, 2026 | 328 tests*
|
||||
|
||||
### Features
|
||||
- **Password authentication (Issue #23).** Optional password auth, off by default.
|
||||
Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel. Password-only
|
||||
(single-user app). Signed HMAC HTTP-only cookie with 24h TTL. Minimal dark-themed
|
||||
login page at `/login`. API calls without auth return 401; page loads redirect.
|
||||
New `api/auth.py` module with hashing, verification, session management.
|
||||
- **Security headers.** All responses now include `X-Content-Type-Options: nosniff`,
|
||||
`X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
|
||||
- **POST body size limit.** Non-upload POST bodies capped at 20MB via `read_body()`.
|
||||
- **Settings panel additions.** "Access Password" field and "Sign Out" button
|
||||
(only visible when auth is active).
|
||||
|
||||
### Architecture
|
||||
- New `api/auth.py`: password hashing (SHA-256 + STATE_DIR salt), signed cookies,
|
||||
auth middleware, public path allowlist.
|
||||
- Auth check in `server.py` do_GET/do_POST before routing.
|
||||
- `password_hash` added to `_SETTINGS_DEFAULTS`.
|
||||
|
||||
### Tests
|
||||
- 10 new tests in `test_sprint19.py`: auth status, login flow, security headers,
|
||||
cache-control, settings password field, request size limit. Total: **328 tests (328 passing)**.
|
||||
|
||||
---
|
||||
|
||||
## [v0.20] Sprint 18 -- File Preview Auto-Close + Thinking Display + Workspace Tree
|
||||
*April 3, 2026 | 318 tests*
|
||||
|
||||
### Features
|
||||
- **File preview auto-close on directory navigation.** When viewing a file in
|
||||
the right panel and navigating directories (breadcrumbs, up button, folder
|
||||
clicks), the preview now automatically closes instead of showing stale
|
||||
content. `clearPreview()` extracted as named function and called from
|
||||
`loadDir()`. Unsaved preview edits prompt for confirmation before discarding.
|
||||
- **Thinking/reasoning display.** Assistant messages with structured content
|
||||
arrays containing `type:'thinking'` or `type:'reasoning'` blocks (Claude
|
||||
extended thinking, o3 reasoning) now render as collapsible gold-themed cards
|
||||
above the response text. Collapsed by default. Click the header to expand and
|
||||
see the model's reasoning process. Uses `esc()` on all content for XSS safety.
|
||||
- **Workspace tree view (Issue #22).** Directories expand/collapse in-place
|
||||
with toggle arrows. Single-click toggles a directory open/closed. Double-click
|
||||
navigates into it (breadcrumb view). Subdirectory contents fetched lazily from
|
||||
the API and cached in `S._dirCache`. Nesting depth shown via indentation.
|
||||
Empty directories show "(empty)" placeholder. Breadcrumb navigation still
|
||||
works alongside the tree view.
|
||||
|
||||
### Bug Fixes
|
||||
- **Stale tree cache on session switch.** `S._dirCache` and `S._expandedDirs`
|
||||
are now cleared when navigating to the root directory, preventing session B
|
||||
from showing session A's cached file listings.
|
||||
- **clearPreview() discards unsaved edits.** Navigation now checks
|
||||
`_previewDirty` and prompts before discarding unsaved preview changes.
|
||||
|
||||
### Architecture
|
||||
- `clearPreview()` extracted from inline handler to named function in `boot.js`.
|
||||
- Thinking card styles added to `style.css` (gold-themed, collapsible).
|
||||
- Tree toggle and empty-directory styles added to `style.css`.
|
||||
|
||||
---
|
||||
|
||||
## [v0.19] Sprint 17 -- Workspace Polish + Slash Commands + Settings
|
||||
*April 3, 2026 | 318 tests*
|
||||
|
||||
### Features
|
||||
- **Workspace breadcrumb navigation.** Clicking into subdirectories now shows a
|
||||
breadcrumb path bar (e.g. `~ / src / components`) with clickable segments to
|
||||
navigate back. An "up" button appears in the panel header when inside a
|
||||
subdirectory. File operations (rename, delete, new file/folder) stay in the
|
||||
current directory instead of jumping back to root. Foundation for Issue #22
|
||||
(tree view).
|
||||
- **Slash commands.** Type `/` in the composer to see an autocomplete dropdown
|
||||
of built-in commands. New `commands.js` module with command registry. Built-in
|
||||
commands: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`.
|
||||
Arrow keys navigate, Tab/Enter select, Escape closes. Unrecognized commands
|
||||
pass through to the agent normally.
|
||||
- **Send key setting (Issue #26).** New setting in Settings panel to choose
|
||||
between Enter (default) and Ctrl/Cmd+Enter as the send key. Persisted to
|
||||
`settings.json` via the existing settings API. Setting loads on boot.
|
||||
Server-side validation ensures only valid values (`enter`, `ctrl+enter`).
|
||||
|
||||
### Architecture
|
||||
- New `static/commands.js` module (7th JS module): command registry, parser,
|
||||
autocomplete dropdown, and built-in command handlers.
|
||||
- `send_key` added to `_SETTINGS_DEFAULTS` in `api/config.py` with enum validation
|
||||
(`_SETTINGS_ENUM_VALUES` rejects unknown values server-side).
|
||||
- `S.currentDir` state tracking added to `ui.js` for workspace navigation.
|
||||
|
||||
### Tests
|
||||
- 6 new tests in `test_sprint17.py`: send_key default, round-trip save with
|
||||
cleanup, invalid value rejection, unknown key ignored, commands.js served,
|
||||
workspace root listing. Total: **318 passed**.
|
||||
|
||||
---
|
||||
|
||||
## [v0.18.1] Safe HTML Rendering + Sprint 16 Tests
|
||||
*April 2, 2026 | 289 tests*
|
||||
|
||||
### Features
|
||||
- **Safe HTML rendering in AI responses.** AI models sometimes emit HTML tags
|
||||
(`<strong>`, `<em>`, `<code>`, `<br>`) in their responses. Previously these
|
||||
showed as literal escaped text. A new pre-pass in `renderMd()` converts safe
|
||||
HTML tags to markdown equivalents before the pipeline runs. Code blocks and
|
||||
backtick spans are stashed first so their content is never touched.
|
||||
- **`inlineMd()` helper.** New function for processing inline formatting inside
|
||||
list items, blockquotes, and headings. The old code called `esc()` directly,
|
||||
which escaped tags that had already been converted by the pre-pass.
|
||||
- **Safety net.** After the full pipeline, any HTML tags not in the output
|
||||
allowlist (`SAFE_TAGS`) are escaped via `esc()`. XSS fully blocked -- 7
|
||||
attack vectors tested.
|
||||
- **Active session gold style.** Active session uses gold/amber (`#e8a030`)
|
||||
instead of blue, matching the logo gradient. Project border-left skipped
|
||||
when active (gold always wins).
|
||||
|
||||
### Tests
|
||||
- **74 new tests** in `test_sprint16.py`: static analysis (6), behavioral (10),
|
||||
exact regression (1), XSS security (7), edge cases (51). Total: 289 passed.
|
||||
|
||||
---
|
||||
|
||||
## [v0.18] Sprint 16 -- Session Sidebar Visual Polish
|
||||
*April 2, 2026 | 237 tests*
|
||||
|
||||
### Features
|
||||
- **SVG action icons.** Replaced all emoji HTML entities (star, folder, box,
|
||||
duplicate, trash) with monochrome SVG line icons that inherit `currentColor`.
|
||||
Consistent rendering across macOS, Linux, and Windows. Defined in a top-level
|
||||
`ICONS` constant in `sessions.js`.
|
||||
- **Action buttons overlay.** All session action buttons (pin, move, archive,
|
||||
duplicate, trash) wrapped in a `.session-actions` container with
|
||||
`position:absolute`. Titles now use full available width instead of being
|
||||
truncated by invisible buttons. Actions appear on hover with a gradient fade
|
||||
from the right edge. Overlay auto-hides during inline rename via
|
||||
`:has(.session-title-input)`.
|
||||
- **Pin indicator.** Small gold filled-star icon rendered inline before the
|
||||
title only when pinned. Unpinned sessions get full title width with zero
|
||||
space reservation.
|
||||
- **Project border indicator.** Sessions assigned to a project show a colored
|
||||
left border matching the project color, replacing the old always-visible
|
||||
blue folder button.
|
||||
|
||||
### Bug Fixes
|
||||
- **Session title truncation.** Action icons reserved ~30px of space even when
|
||||
invisible, truncating titles. Fixed by overlay container approach.
|
||||
- **Folder button felt sticky.** Replaced `.has-project` persistent blue button
|
||||
with colored left border. Folder button now only appears in hover overlay.
|
||||
|
||||
---
|
||||
|
||||
## [v0.17.3] Bug Fixes
|
||||
*April 2, 2026*
|
||||
|
||||
### Bug Fixes
|
||||
- **NameError crash in model discovery.** `logger.debug()` was called in the
|
||||
custom endpoint `except` block in `config.py`, but `logger` was never
|
||||
imported. Every failed custom endpoint fetch crashed with `NameError`,
|
||||
returning HTTP 500 for `/api/models`. Replaced with silent `pass` since
|
||||
unreachable endpoints are expected. (PR #24)
|
||||
- **Project picker clipping and width.** Picker was clipped by
|
||||
`overflow:hidden` on ancestor elements. Width calculation improved with
|
||||
dynamic sizing (min 160px, max 220px). Event listener `close` handler
|
||||
moved after DOM append to fix reference-before-definition. Reordered
|
||||
`picker.remove()` before `removeEventListener` for correct cleanup. (PR #25)
|
||||
|
||||
---
|
||||
|
||||
## [v0.17.2] Model Update
|
||||
*April 2, 2026*
|
||||
|
||||
@@ -509,4 +748,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.16.2, April 1, 2026 | Tests: 247*
|
||||
*Last updated: v0.23, April 3, 2026 | Tests: 415*
|
||||
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer="nesquena"
|
||||
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source
|
||||
COPY . /app
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Default to binding all interfaces (required for container networking)
|
||||
ENV HERMES_WEBUI_HOST=0.0.0.0
|
||||
ENV HERMES_WEBUI_PORT=8787
|
||||
|
||||
# State directory (mount as volume for persistence)
|
||||
ENV HERMES_WEBUI_STATE_DIR=/data
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
31
README.md
31
README.md
@@ -35,6 +35,37 @@ That is it. The script will:
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
Run with Docker Compose (recommended):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or build and run manually:
|
||||
|
||||
```bash
|
||||
docker build -t hermes-webui .
|
||||
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes:ro hermes-webui
|
||||
```
|
||||
|
||||
Open http://localhost:8787 in your browser.
|
||||
|
||||
To enable password protection:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret -v ~/.hermes:/root/.hermes:ro hermes-webui
|
||||
```
|
||||
|
||||
Session data persists in a named volume (`hermes-data`) across restarts.
|
||||
|
||||
> **Note:** By default, Docker Compose binds to `127.0.0.1` (localhost only).
|
||||
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
|
||||
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
|
||||
|
||||
---
|
||||
|
||||
## What start.sh discovers automatically
|
||||
|
||||
| Thing | How it finds it |
|
||||
|
||||
70
ROADMAP.md
70
ROADMAP.md
@@ -3,8 +3,8 @@
|
||||
> 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: Sprint 15 (April 1, 2026)
|
||||
> Tests: 237 passing
|
||||
> Last updated: Sprint 19 / v0.21 (April 3, 2026)
|
||||
> Tests: 328 total (328 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -32,6 +32,10 @@
|
||||
| Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 |
|
||||
| Sprint 14 | Visual polish + workspace ops | Mermaid diagrams, message timestamps, file rename, folder create, session tags, session archive | 233 |
|
||||
| Sprint 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 |
|
||||
| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, safe HTML rendering | 289 |
|
||||
| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 318 |
|
||||
| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 |
|
||||
| Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 |
|
||||
|
||||
---
|
||||
|
||||
@@ -39,10 +43,10 @@
|
||||
|
||||
| Layer | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| Python server | <repo>/server.py (~76 lines) + api/ modules (~1900 lines) | Thin shell + business logic in api/ |
|
||||
| Python server | <repo>/server.py (~79 lines) + api/ modules (~2491 lines) | Thin shell + auth middleware + business logic in api/ |
|
||||
| HTML template | <repo>/static/index.html | Served from disk |
|
||||
| CSS | <repo>/static/style.css | Served from disk |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot}.js | 6 modules, ~2250 lines total |
|
||||
| CSS | <repo>/static/style.css (~590 lines) | Served from disk |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3148 lines total |
|
||||
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
|
||||
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
|
||||
| Production server | Port 8787 | SSH tunnel from Mac |
|
||||
@@ -55,6 +59,7 @@
|
||||
- [x] Send messages, get SSE-streaming responses
|
||||
- [x] Switch models per session (10 models, grouped by provider)
|
||||
- [x] Multi-provider API support: use any Hermes agent API provider (OpenAI, Anthropic, Google, etc.) directly, not just OpenRouter (Sprint 11)
|
||||
- [x] Custom endpoint model discovery: auto-detect models from Ollama, LM Studio, and other local LLM servers via base_url (PR #18)
|
||||
- [x] Upload files to workspace (drag-drop, click, clipboard paste)
|
||||
- [x] File tray with remove button
|
||||
- [x] Tool progress shown in activity bar above composer
|
||||
@@ -146,22 +151,42 @@
|
||||
|
||||
### Configuration
|
||||
- [x] Settings panel (default model, default workspace) (Sprint 12)
|
||||
- [x] Send key preference (Enter or Ctrl+Enter) (Sprint 17)
|
||||
- [x] Password authentication (Sprint 19)
|
||||
- [ ] Enable/disable toolsets per session (deferred)
|
||||
|
||||
### Notifications
|
||||
- [x] Cron job completion alerts (Sprint 13)
|
||||
- [x] Background agent error alerts (Sprint 13)
|
||||
|
||||
### Workspace
|
||||
- [x] Breadcrumb navigation in subdirectories (Sprint 17)
|
||||
- [x] Workspace tree view with expand/collapse (Sprint 18, Issue #22)
|
||||
- [x] File preview auto-close on directory navigation (Sprint 18)
|
||||
|
||||
### Slash Commands
|
||||
- [x] Command registry + autocomplete dropdown (Sprint 17)
|
||||
- [x] Built-in: /help, /clear, /model, /workspace, /new (Sprint 17)
|
||||
|
||||
### Security
|
||||
- [x] Password auth with signed cookies (Sprint 19, Issue #23)
|
||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options) (Sprint 19)
|
||||
- [x] POST body size limit (20MB) (Sprint 19)
|
||||
|
||||
### Thinking / Reasoning
|
||||
- [x] Collapsible thinking cards for extended-thinking models (Sprint 18)
|
||||
|
||||
### Advanced / Future
|
||||
- [ ] Voice input via Whisper (Wave 6)
|
||||
- [ ] TTS playback of responses (Wave 6)
|
||||
- [ ] Subagent delegation cards (Wave 6)
|
||||
- [ ] Voice input via Whisper (Sprint 20)
|
||||
- [ ] TTS playback of responses (Sprint 20)
|
||||
- [ ] Subagent delegation cards (deferred)
|
||||
- [x] Background task cancel (activity bar Cancel button)
|
||||
- [ ] Code execution cell (Wave 6)
|
||||
- [ ] Password authentication (Wave 7)
|
||||
- [ ] HTTPS / reverse proxy (Wave 7)
|
||||
- [ ] Mobile responsive layout (Wave 7)
|
||||
- [ ] Virtual scroll for large lists (Wave 7)
|
||||
- [ ] Code execution cell (deferred)
|
||||
- [ ] Mobile responsive layout (Sprint 21)
|
||||
- [ ] Multi-profile support (Sprint 22, Issue #28)
|
||||
- [ ] Desktop application (Sprint 23)
|
||||
- [ ] Extended slash command / skill integration (Sprint 24)
|
||||
- [ ] Virtual scroll for large lists (deferred)
|
||||
|
||||
---
|
||||
|
||||
@@ -252,8 +277,8 @@ Add more models. Group by provider. Model info tooltip on hover.
|
||||
Both sidebar and workspace panel are drag-resizable with localStorage persistence.
|
||||
|
||||
### Sprint 3.3: Workspace File Actions
|
||||
- [ ] Rename file (inline, double-click) (Wave 3)
|
||||
- [ ] Create folder (Wave 3)
|
||||
- [x] Rename file (inline, double-click) (Sprint 14)
|
||||
- [x] Create folder (Sprint 14)
|
||||
- [x] Syntax highlighted code preview (Prism.js)
|
||||
|
||||
### Sprint 3.4: Conversation Controls
|
||||
@@ -316,3 +341,18 @@ Collapsible sidebar hamburger. Touch-friendly controls. Swipe gestures.
|
||||
|
||||
### Sprint 7.4: Performance and Scale
|
||||
Virtual scroll for session/message lists. Incremental message loading.
|
||||
|
||||
---
|
||||
|
||||
## User Requested Features
|
||||
|
||||
Community-requested enhancements tracked from GitHub issues.
|
||||
|
||||
| Feature | Issue | Description | Complexity |
|
||||
|---------|-------|-------------|-----------|
|
||||
| Workspace tree view | #22 | Accordion/tree view for workspace file browser instead of flat list. Lazy-load subdirectories on expand, no backend changes needed. | Medium |
|
||||
| Docker container | #7 | Docker Compose setup with separate hermes-agent and hermes-webui containers, multi-arch (amd64 + arm64), volume mounts for config. | Medium-High |
|
||||
| Authentication | #23 | Password gate via `HERMES_WEBUI_PASSWORD` env var, login page, signed cookie. Already planned in Sprint 7.1. | Low-Medium |
|
||||
| Send key / personalization | #26 | Toggle send key (Enter vs Ctrl/Cmd+Enter) and queue vs interrupt mode as global settings. | Low |
|
||||
| Multi-profile support | #28 | Profile management UI: create, delete, switch, configure agent profiles. | Medium |
|
||||
| Mobile responsive UI | #21 | Hamburger menu, slide-out sidebar drawer, touch-friendly controls. Already planned in Sprint 7.3. | Medium-High |
|
||||
|
||||
393
SPRINTS.md
393
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.15 | 221 tests | Daily driver ready
|
||||
> Current state: v0.23 | 415 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
|
||||
@@ -14,15 +14,19 @@
|
||||
|
||||
---
|
||||
|
||||
## Where we are now (v0.12.1)
|
||||
## Where we are now (v0.21)
|
||||
|
||||
**CLI parity: ~80% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops, cron/skills/memory CRUD, session management, streaming, cancel --
|
||||
all solid. Gaps are configuration, subagent visibility, and runtime controls.
|
||||
**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops with tree view, cron/skills/memory CRUD, session management, streaming,
|
||||
cancel, multi-provider models, custom endpoint discovery, slash commands,
|
||||
thinking/reasoning display, password auth -- all solid. Gaps are subagent
|
||||
visibility, toolset control, and code execution.
|
||||
|
||||
**Claude parity: ~55% complete.** Chat, streaming, file browser,
|
||||
session management, tool cards, syntax highlighting, model switching -- all
|
||||
present. Gaps are project organization, artifacts, voice, sharing, mobile.
|
||||
**Claude parity: ~70% complete.** Chat, streaming, file browser, session
|
||||
management, tool cards, syntax highlighting, model switching, projects,
|
||||
settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
|
||||
commands, thinking display, auth -- all present. Gaps are artifacts, voice,
|
||||
TTS, sharing, mobile-optimized layout.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,173 +221,275 @@ organizational gap vs. Claude's project folders.
|
||||
**Hermes CLI parity impact:** Low (CLI has no session organization)
|
||||
**Claude parity impact:** Very High (projects are a core Claude concept)
|
||||
|
||||
### Candidates for next sprints
|
||||
- Workspace reorder (drag-and-drop)
|
||||
- View skill linked files
|
||||
### Candidates for later sprints
|
||||
- Artifacts + code execution (HTML/SVG preview, inline Python execution)
|
||||
- Voice input via Whisper
|
||||
- Subagent delegation cards (enhanced tool card rendering)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 16 -- Artifacts + Code Execution
|
||||
## Sprint 16 -- Session Sidebar Visual Polish (COMPLETED)
|
||||
|
||||
**Theme:** See outputs, not just text.
|
||||
**Theme:** Make the session list feel high-quality and delightful.
|
||||
|
||||
**Why now:** Claude's most distinctive feature is the artifact panel --
|
||||
code runs inline, HTML renders in a sandboxed iframe, SVGs show as images.
|
||||
This is the largest single capability gap between what we have and what Claude
|
||||
feels like. It also directly enables the Hermes "code execution cell" feature
|
||||
(Jupyter-style in-browser execution).
|
||||
**Why now:** The session sidebar had two visible UX bugs: titles truncated
|
||||
unnecessarily because action icons reserved space even when hidden, and
|
||||
the project folder icon felt "sticky" and awkward. Emoji icons rendered
|
||||
inconsistently across platforms. These were the most common visual complaints.
|
||||
|
||||
### Track A: Bugs
|
||||
- Prism.js autoloader makes one CDN request per language encountered. On a
|
||||
code-heavy session this causes noticeable latency. Bundle the top 10 languages
|
||||
(Python, JS, bash, JSON, SQL, YAML, TypeScript, CSS, HTML, Rust) locally.
|
||||
- Code blocks in long responses sometimes re-highlight on every renderMessages()
|
||||
call. Debounce highlightCode() with requestAnimationFrame.
|
||||
### Track A: Bugs (from BUGS.md)
|
||||
- **Session title truncation.** Action icons (pin, move, archive, dup, trash)
|
||||
were always in the DOM with `flex-shrink:0`, reserving ~30px even when
|
||||
invisible. Fix: wrapped all actions in a `.session-actions` overlay
|
||||
container with `position:absolute`. Titles now use full available width.
|
||||
Actions appear on hover with a gradient fade from the right edge.
|
||||
- **Folder button feels sticky.** Replaced `.has-project` persistent blue
|
||||
button with a colored left border matching the project color. The folder
|
||||
button now only appears in the hover overlay like all other actions.
|
||||
|
||||
### Track B: Features
|
||||
- **Artifact panel:** When Hermes produces a code block tagged as `html`, `svg`,
|
||||
or `react`, a "Preview" button appears on that code block. Clicking it opens
|
||||
a sandboxed `<iframe>` in the right panel showing the rendered output. The
|
||||
preview updates live if Hermes edits the artifact in a follow-up.
|
||||
- **Code execution cell:** A "Run" button on Python code blocks. Sends the code
|
||||
to a new server endpoint (`POST /api/execute`) which runs it in a subprocess
|
||||
with a 30-second timeout and streams stdout/stderr back as SSE. Output appears
|
||||
below the code block inline. This is the Jupyter cell experience without
|
||||
needing a kernel.
|
||||
- **Mermaid diagram rendering:** Mermaid.js CDN (deferred). Code blocks tagged
|
||||
as `mermaid` render as flow/sequence/gantt diagrams inline.
|
||||
- **SVG action icons.** Replaced all emoji HTML entities (★, 📂, 📦, ⊕, 🗑)
|
||||
with monochrome SVG line icons that inherit `currentColor`. Consistent
|
||||
rendering across macOS, Linux, and Windows. Icons: pin (star), folder,
|
||||
archive (box), duplicate (overlapping squares), trash (bin with lines).
|
||||
- **Pin indicator.** Small gold filled-star icon rendered inline before the
|
||||
title only when the session is actually pinned. Unpinned sessions get
|
||||
full title width with zero space reservation.
|
||||
- **Project border indicator.** Sessions assigned to a project show a
|
||||
colored left border matching the project color, replacing the old
|
||||
always-visible blue folder button.
|
||||
- **Hover overlay polish.** Actions container uses a gradient background
|
||||
that fades from transparent to the sidebar color, creating a smooth
|
||||
emergence effect. Overlay hides automatically during inline rename.
|
||||
|
||||
### Track C: Architecture
|
||||
- Sandbox safety: `/api/execute` runs in a restricted subprocess (no network,
|
||||
limited filesystem via a temp directory). Returns exit code, stdout, stderr,
|
||||
and execution time.
|
||||
- Artifact state: artifacts are tracked in `S.artifacts = {}` (code block hash
|
||||
-> rendered content). Persisted in session JSON as `artifacts` array.
|
||||
### Deferred to Sprint 17
|
||||
- Slash commands (basic set with `commands.js` module)
|
||||
- Thinking/reasoning display for extended-thinking models
|
||||
- Slash command autocomplete popup
|
||||
|
||||
**Tests:** ~18 new. Total: ~259.
|
||||
**Hermes CLI parity impact:** High (code execution closes the Jupyter gap)
|
||||
**Claude parity impact:** Very High (artifacts are Claude's signature feature)
|
||||
**Tests:** 74 new (test_sprint16.py: safe HTML rendering, XSS security, sidebar polish). Total: 289.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** Medium (sidebar polish matches Claude's quality bar)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 17 -- Voice + Multimodal Input
|
||||
## Sprint 17 -- Workspace Polish + Slash Commands + Settings (COMPLETED)
|
||||
|
||||
**Theme:** Input beyond the keyboard.
|
||||
**Theme:** Workspace polish, slash commands, and composer settings.
|
||||
|
||||
**Why now:** Voice is a meaningful quality-of-life feature for longer sessions
|
||||
and is achievable with Whisper. Image input closes the last modality gap with
|
||||
Claude (Claude accepts image paste natively -- we do too, but only as
|
||||
file uploads, not clipboard screenshots into the conversation directly).
|
||||
**Why now:** Three things converge: @nothingmn filed Issue #22 requesting a
|
||||
tree/accordion workspace view (breadcrumb navigation is the foundation for
|
||||
that), slash commands were deferred from Sprint 16, and Issue #26 (send key
|
||||
personalization) fits naturally since we are already touching the keydown
|
||||
handler for slash command autocomplete.
|
||||
|
||||
### Track A: Workspace Breadcrumb Navigation
|
||||
- **Breadcrumb path bar.** When users click into subdirectories, a breadcrumb
|
||||
bar appears showing the path (e.g. `~ / src / components`) with clickable
|
||||
segments to navigate back. Hidden at root level for a clean UI.
|
||||
- **Up button.** Arrow-up button in the panel header navigates to the parent
|
||||
directory. Hidden when already at workspace root.
|
||||
- **Current directory tracking.** `S.currentDir` state property tracks the
|
||||
active directory. File operations (rename, delete, new file, new folder)
|
||||
stay in the current directory instead of jumping back to root.
|
||||
- **New file/folder in subdirectories.** Creating files or folders now respects
|
||||
the current directory, creating them in the viewed subdirectory.
|
||||
|
||||
### Track B: Slash Commands Foundation
|
||||
- **commands.js module.** New 7th JS module with command registry, parser,
|
||||
autocomplete dropdown, and built-in command handlers.
|
||||
- **Built-in commands:** `/help` (list commands), `/clear` (clear conversation),
|
||||
`/model <name>` (switch model with fuzzy match), `/workspace <name>` (switch
|
||||
workspace), `/new` (start new session).
|
||||
- **Autocomplete dropdown.** Typing `/` in the composer shows a filtered
|
||||
dropdown. Arrow keys navigate, Tab/Enter select, Escape closes. Positioned
|
||||
above the composer using the workspace dropdown CSS pattern.
|
||||
- **Transparent pass-through.** Unrecognized `/` commands pass through to the
|
||||
agent normally (not intercepted).
|
||||
|
||||
### Track C: Send Key Setting (Issue #26)
|
||||
- **`send_key` setting.** New setting in Settings panel: "Enter" (default) or
|
||||
"Ctrl+Enter". Persisted to `settings.json`. Loaded on boot.
|
||||
- **Keydown handler rewrite.** Combined handler for autocomplete navigation
|
||||
and send key preference. When `ctrl+enter` is selected, plain Enter inserts
|
||||
a newline and Ctrl/Cmd+Enter sends.
|
||||
|
||||
### Deferred to Sprint 18
|
||||
- Thinking/reasoning display for extended-thinking models
|
||||
- Voice input via Whisper
|
||||
- Workspace tree/accordion view (full implementation of Issue #22)
|
||||
|
||||
**Tests:** 6 new (test_sprint17.py). Total: 318.
|
||||
**Hermes CLI parity impact:** Low (slash commands add convenience)
|
||||
**Claude parity impact:** Medium (workspace nav, slash commands match Claude UX)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 18 -- Thinking Display + Workspace Tree + Preview Fix (COMPLETED)
|
||||
|
||||
**Theme:** Show the model's reasoning, improve workspace navigation, fix UX bug.
|
||||
|
||||
**Why now:** Thinking/reasoning display was deferred twice (Sprint 16 → 17 → 18).
|
||||
Workspace tree view was the #1 community request (Issue #22). File preview
|
||||
staying open on directory navigation was a daily-driver annoyance.
|
||||
|
||||
### Track A: Bugs
|
||||
- Image paste currently requires a click-to-attach flow. Direct paste into the
|
||||
message textarea should embed the image inline (as a preview chip) and queue
|
||||
it for upload on Send. (Partially works -- clean up edge cases.)
|
||||
- Large image uploads (>5MB) time out the upload step silently.
|
||||
- **File preview auto-close.** When viewing a file in the right panel and
|
||||
navigating directories (breadcrumbs, up button, folder clicks), the preview
|
||||
stayed visible with stale content. Fix: extracted `clearPreview()` as a named
|
||||
function in boot.js and call it from `loadDir()` in workspace.js.
|
||||
|
||||
### Track B: Features
|
||||
- **Voice input (Whisper):** A microphone icon in the composer. Hold to record,
|
||||
release to transcribe via `POST /api/transcribe` (calls local Whisper or
|
||||
OpenAI Whisper API). Transcribed text appears in the message input, editable
|
||||
before send. Supports the full "voice -> text -> Hermes response" loop.
|
||||
- **TTS playback:** A speaker icon on assistant messages. Calls a TTS endpoint
|
||||
(ElevenLabs or OpenAI TTS) and plays the audio. Toggle per-message. Optional
|
||||
auto-play mode in settings.
|
||||
- **Vision input improvements:** Paste a screenshot directly from clipboard into
|
||||
the conversation (not just the tray). Shows as an inline preview chip with
|
||||
the image thumbnail. On Send, uploads and includes in the message.
|
||||
- **Thinking/reasoning display.** Assistant messages with structured content
|
||||
arrays containing `type:'thinking'` or `type:'reasoning'` blocks now render
|
||||
as collapsible gold-themed cards above the response text. Collapsed by
|
||||
default, click header to expand. Works with Claude extended thinking and
|
||||
o3 reasoning tokens when preserved in the message array.
|
||||
- **Workspace tree view (Issue #22).** Directories expand/collapse in-place
|
||||
with toggle arrows. Single-click toggles, double-click navigates (breadcrumb
|
||||
view). Subdirectory contents fetched lazily and cached in `S._dirCache`.
|
||||
Nesting depth shown via indentation. Empty directories show "(empty)".
|
||||
|
||||
**Tests:** 0 new (pure CSS/DOM changes). Total: 318.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** High (reasoning display matches Claude's UI)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 19 -- Auth + Security Hardening (COMPLETED)
|
||||
|
||||
**Theme:** Make this safe to leave running beyond localhost.
|
||||
|
||||
**Why now:** Issue #23 requested authentication. Auth is the last production
|
||||
hardening feature before the app is safe to expose to a network.
|
||||
|
||||
### Track A: Bugs
|
||||
- **No request size limit.** POST bodies were unbounded (DoS risk). Added 20MB
|
||||
cap in `read_body()`.
|
||||
|
||||
### Track B: Features
|
||||
- **Password authentication (Issue #23).** Off by default — zero friction for
|
||||
localhost. Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel.
|
||||
Password-only (no username — single-user app). Signed HMAC HTTP-only cookie
|
||||
with 24h TTL. Minimal dark-themed login page at `/login`. API calls without
|
||||
auth return 401; page loads redirect to `/login`. Settings panel gains
|
||||
"Access Password" field and "Sign Out" button.
|
||||
- **Security headers.** All responses now include `X-Content-Type-Options: nosniff`,
|
||||
`X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
|
||||
|
||||
### Track C: Architecture
|
||||
- Audio pipeline: `POST /api/transcribe` streams audio bytes, returns transcript.
|
||||
`GET /api/tts?text=...` returns audio/mpeg. Both use lazy import of Whisper
|
||||
and TTS libraries to keep cold start fast.
|
||||
- New `api/auth.py` module: password hashing (SHA-256 + STATE_DIR salt), signed
|
||||
session cookies, auth middleware, public path allowlist.
|
||||
- Auth check in `server.py` do_GET/do_POST before routing.
|
||||
- `password_hash` added to `_SETTINGS_DEFAULTS` in config.py.
|
||||
- `_set_password` special field in save_settings for secure password updates.
|
||||
|
||||
**Tests:** ~12 new. Total: ~271.
|
||||
**Tests:** 10 new. Total: 328.
|
||||
**Hermes CLI parity impact:** Low (CLI has no auth concerns)
|
||||
**Claude parity impact:** High (Claude is authenticated)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 20 -- Voice Input + Send Button Polish (COMPLETED)
|
||||
|
||||
**Theme:** Input refinements — voice and visual polish.
|
||||
|
||||
**Why now:** Voice input was the next feature on the roadmap. The send button
|
||||
UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Send button always visible.** The old pill-shaped "Send" button was always
|
||||
visible even with an empty textarea, wasting space. Now hidden by default,
|
||||
appears only when there is content to send.
|
||||
|
||||
### Track B: Features
|
||||
- **Voice input (Web Speech API).** Microphone button in composer. Tap to
|
||||
record, tap again to stop. Live interim transcription in textarea. Auto-stops
|
||||
after ~2s of silence. Appends to existing text. Hidden when browser doesn't
|
||||
support Web Speech API. No API keys, no server changes.
|
||||
- **Send button polish.** Icon-only 34px circle with upward arrow SVG. Pop-in
|
||||
spring animation on appear. Scale hover/active for tactile feedback. Hidden
|
||||
while agent is responding.
|
||||
|
||||
### Track C: Architecture
|
||||
- Voice input IIFE in `boot.js` with SpeechRecognition lifecycle.
|
||||
- `updateSendBtn()` in `ui.js` hooked into setBusy, renderTray, autoResize.
|
||||
|
||||
**Tests:** 52 new (voice) + 33 new (send button). Total: 415.
|
||||
**Hermes CLI parity impact:** Medium (voice not in CLI, but adds capability)
|
||||
**Claude parity impact:** High (Claude has native voice mode)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 18 -- Subagent Visibility + Agentic Transparency
|
||||
## Sprint 21 -- Mobile Responsive + Docker (COMPLETED)
|
||||
|
||||
**Theme:** Watch Hermes think, not just respond.
|
||||
**Theme:** Mobile experience + containerized deployment.
|
||||
|
||||
**Why now:** When Hermes delegates to subagents (delegate_task, spawns parallel
|
||||
workstreams), the UI shows nothing. On long multi-agent tasks you have no idea
|
||||
what's happening. This is the last major "CLI feels better" gap for power users.
|
||||
**Why now:** Issue #21 (mobile) was the most-requested UX gap. Issue #7 (Docker)
|
||||
enables deployment beyond localhost. Both were achievable without new dependencies.
|
||||
|
||||
### Track A: Bugs
|
||||
- Tool cards for delegate_task show no information about what the subagent was
|
||||
asked to do or what it returned.
|
||||
- The activity bar text truncates at 55 chars -- tool previews for long terminal
|
||||
commands show nothing useful.
|
||||
### Track A: Bugs (from review)
|
||||
- **CSS cascade broke mobile slide-in.** `position:relative` after the media query
|
||||
overrode `position:fixed`. Wrapped in `@media(min-width:641px)`.
|
||||
- **mobileSwitchPanel() always reopened sidebar.** Chat tab now closes it.
|
||||
- **Dockerfile missing pip install.** Container failed on startup.
|
||||
- **No .dockerignore.** `.git`, `tests/`, `.env*` leaked into images.
|
||||
- **docker-compose tilde expansion.** `~` doesn't expand in Compose defaults.
|
||||
|
||||
### Track B: Features
|
||||
- **Subagent delegation cards:** When `delegate_task` fires, show an expandable
|
||||
card with the subagent's goal, status (pending/running/done), and result
|
||||
summary. Multiple subagents from one call appear as a card group. Uses the
|
||||
existing tool card infrastructure.
|
||||
- **Background task monitor:** A "Tasks" indicator in the topbar (separate from
|
||||
the cron Tasks panel). Shows count of active agent threads. Click opens a
|
||||
popover listing all in-flight streams with session names and elapsed times.
|
||||
Cancel any individual thread. This is the full job queue visibility the CLI
|
||||
implicitly has via `ps aux`.
|
||||
- **Thinking/reasoning display:** When the model emits reasoning tokens (o3,
|
||||
Claude extended thinking), show them in a collapsible "Reasoning" card above
|
||||
the response. Collapsed by default. This matches Claude's reasoning display.
|
||||
- **Hamburger sidebar.** Slide-in overlay on mobile, tap outside to close.
|
||||
- **Bottom navigation bar.** 5-tab iOS-style bar replaces sidebar tabs.
|
||||
- **Files slide-over.** Right panel opens as slide-over from right edge.
|
||||
- **Touch targets.** Minimum 44px on all interactive elements.
|
||||
- **Docker support.** Dockerfile, docker-compose.yml, .dockerignore.
|
||||
|
||||
### Track C: Architecture
|
||||
- Task registry: extend STREAMS to include session name, start time, and task
|
||||
description. New `GET /api/tasks/active` endpoint returns all running streams
|
||||
with metadata.
|
||||
- Mobile nav functions in `boot.js`. Session click auto-closes sidebar.
|
||||
- 69 new CSS lines scoped to `@media(max-width:640px)`.
|
||||
- Desktop layout untouched — all mobile elements `display:none` by default.
|
||||
|
||||
**Tests:** ~14 new. Total: ~285.
|
||||
**Hermes CLI parity impact:** Very High (subagent and task visibility is the
|
||||
last major CLI gap)
|
||||
**Claude parity impact:** High (Claude shows reasoning, tool use visibly)
|
||||
**Tests:** 0 new (CSS/DOM changes). Total: 415.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** High (Claude has mobile layout)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 19 -- Auth, HTTPS, and Production Hardening
|
||||
## Sprint 22 -- Multi-Profile Support (PLANNED, Issue #28)
|
||||
|
||||
**Theme:** Make this safe to leave running.
|
||||
|
||||
**Why now:** Everything else is done. This is the sprint you run when you want
|
||||
to expose the UI beyond localhost -- to a team, a mobile device, or a public
|
||||
address.
|
||||
|
||||
### Track A: Bugs
|
||||
- Server has no request size limit on non-upload endpoints (potential DoS).
|
||||
- Session JSON files have no size cap (a runaway agent could write GBs).
|
||||
**Theme:** Switch between Hermes agent profiles seamlessly.
|
||||
|
||||
### Track B: Features
|
||||
- **Password authentication:** A login page with a configurable password
|
||||
(HERMES_WEBUI_PASSWORD env var). Signed cookie session (24h expiry).
|
||||
Single-user model -- no accounts, no registration.
|
||||
- **HTTPS / reverse proxy guide:** A one-page `DEPLOY.md` with instructions
|
||||
for running behind nginx + Let's Encrypt on a VPS. Configuration snippets
|
||||
for systemd service, nginx config, certbot.
|
||||
- **Mobile responsive layout:** Collapsible sidebar (hamburger). Touch-friendly
|
||||
session list (swipe to delete, tap to navigate). Composer expands on focus.
|
||||
Right panel hidden by default on mobile, accessible via a Files tab.
|
||||
- **Rate limiting:** Simple per-IP token bucket on the chat/start endpoint
|
||||
(configurable, default 10 req/min) to prevent accidental hammering.
|
||||
- **Profile picker.** Sidebar or topbar dropdown to switch profiles.
|
||||
- **Per-profile config.** Each profile has its own skills, memory, config.yaml.
|
||||
- **Seamless switching.** No restart required.
|
||||
|
||||
### Track C: Architecture
|
||||
- Helmet headers: X-Content-Type-Options, X-Frame-Options, HSTS (when served
|
||||
over HTTPS). Simple middleware in the Handler.
|
||||
---
|
||||
|
||||
**Tests:** ~12 new. Total: ~297.
|
||||
**Hermes CLI parity impact:** Low (CLI has no auth/HTTPS concerns)
|
||||
**Claude parity impact:** Very High (Claude is authenticated, HTTPS only)
|
||||
## Sprint 23 -- Desktop Application (PLANNED)
|
||||
|
||||
**Theme:** Native desktop experience.
|
||||
|
||||
### Track B: Features
|
||||
- **Electron or Tauri wrapper.** Native window, menu bar, notifications.
|
||||
- **Auto-start option.** Launch on login.
|
||||
- **Packaged distribution.** .dmg (macOS), .exe (Windows).
|
||||
|
||||
---
|
||||
|
||||
## Sprint 24 -- Extended Command Support (PLANNED)
|
||||
|
||||
**Theme:** Deeper slash command and skill integration.
|
||||
|
||||
### Track B: Features
|
||||
- **Skill-aware autocomplete.** `/skill-name` triggers installed skills.
|
||||
- **Command chaining.** Compose multi-step commands.
|
||||
- **Agent tool exposure.** Surface agent capabilities as slash commands.
|
||||
|
||||
---
|
||||
|
||||
## Feature Parity Summary
|
||||
|
||||
### After Sprint 18 (Hermes CLI parity: complete)
|
||||
### Hermes CLI Parity (as of Sprint 19)
|
||||
|
||||
| CLI Feature | Status |
|
||||
|-------------|--------|
|
||||
@@ -399,15 +505,18 @@ address.
|
||||
| Workspace switching | Done (v0.7) |
|
||||
| Model selection | Done (v0.3) |
|
||||
| Multi-provider model support | Done (Sprint 11) |
|
||||
| Toolset control | Sprint 12 |
|
||||
| Settings persistence | Done (Sprint 12) |
|
||||
| Subagent visibility | Sprint 18 |
|
||||
| Background task monitor | Sprint 18 |
|
||||
| Code execution (Jupyter) | Sprint 16 |
|
||||
| Cron completion alerts | Done (Sprint 13) |
|
||||
| Slash commands | Done (Sprint 17) |
|
||||
| Thinking/reasoning display | Done (Sprint 18) |
|
||||
| Auth / login | Done (Sprint 19) |
|
||||
| Voice input | Sprint 20 |
|
||||
| Subagent visibility | Deferred |
|
||||
| Code execution (Jupyter) | Deferred |
|
||||
| Toolset control | Deferred |
|
||||
| Virtual scroll (perf) | Deferred |
|
||||
|
||||
### After Sprint 19 (Claude parity: ~90% complete)
|
||||
### Claude Parity (as of Sprint 19)
|
||||
|
||||
| Claude Feature | Status |
|
||||
|----------------|--------|
|
||||
@@ -419,19 +528,21 @@ address.
|
||||
| Tool use visibility | Done (v0.11) |
|
||||
| Edit/regenerate messages | Done (v0.10) |
|
||||
| Session management | Done (v0.6) |
|
||||
| Artifacts (HTML/SVG preview) | Sprint 16 |
|
||||
| Code execution inline | Sprint 16 |
|
||||
| Mermaid diagrams | Done (Sprint 14) |
|
||||
| Projects / folders | Done (Sprint 15) |
|
||||
| Pinned/starred sessions | Done (Sprint 12) |
|
||||
| Reasoning display | Sprint 18 |
|
||||
| Voice input | Sprint 17 |
|
||||
| TTS playback | Sprint 17 |
|
||||
| Notifications | Done (Sprint 13) |
|
||||
| Settings panel | Done (Sprint 12) |
|
||||
| Auth / login | Sprint 19 |
|
||||
| HTTPS | Sprint 19 |
|
||||
| Mobile layout | Done (v0.16.1) |
|
||||
| Reasoning display | Done (Sprint 18) |
|
||||
| Auth / login | Done (Sprint 19) |
|
||||
| Mobile layout (basic) | Done (v0.16.1) |
|
||||
| Workspace tree view | Done (Sprint 18) |
|
||||
| Slash commands | Done (Sprint 17) |
|
||||
| Voice input | Sprint 20 |
|
||||
| TTS playback | Sprint 20 |
|
||||
| Artifacts (HTML/SVG preview) | Deferred |
|
||||
| Code execution inline | Deferred |
|
||||
| Mobile-optimized layout | Sprint 21 |
|
||||
| Sharing / public URLs | Not planned (requires server infra) |
|
||||
| Claude-specific features | Not replicable (Projects AI, artifacts sync) |
|
||||
|
||||
@@ -448,6 +559,6 @@ address.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 1, 2026*
|
||||
*Current version: v0.17 | 237 tests*
|
||||
*Next sprint: Sprint 16 (Artifacts + Code Execution)*
|
||||
*Last updated: April 3, 2026*
|
||||
*Current version: v0.23 | 415 tests*
|
||||
*Next sprint: Sprint 22 (Multi-Profile Support)*
|
||||
|
||||
86
TESTING.md
86
TESTING.md
@@ -1,12 +1,15 @@
|
||||
# Hermes Web UI: Browser Testing Plan
|
||||
|
||||
> This document is for manual browser testing by you or by a Claude browser agent.
|
||||
> It covers every user-facing feature of the UI through Sprint 2.
|
||||
> It covers user-facing features of the UI through Sprint 19 (v0.21).
|
||||
> Each section is written as a step-by-step test procedure with expected outcomes.
|
||||
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
|
||||
>
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 328 total (328 passing, 0 failures).
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
|
||||
@@ -1593,8 +1596,81 @@ FAIL: User message gone, blank chat, response lands in wrong session.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Post-Sprint 10 concurrency sweeps, March 31, 2026*
|
||||
*Total automated tests: 190/190*
|
||||
*Regression gate: tests/test_regressions.py (23 tests, one per introduced bug)*
|
||||
*Run: python -m pytest tests/ -v*
|
||||
---
|
||||
|
||||
## Sections Added Post-Sprint 10 (Sprints 11-19)
|
||||
|
||||
The following features were added in Sprints 11-19 and need manual browser testing.
|
||||
Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
### Sprint 11: Multi-Provider Models
|
||||
- Open model dropdown. Verify models grouped by provider (OpenAI, Anthropic, Google, etc.)
|
||||
- If custom `base_url` configured in config.yaml, verify local models appear in dropdown.
|
||||
- Switch model. Send a message. Verify response uses selected model.
|
||||
|
||||
### Sprint 12: Settings + Pin + Import
|
||||
- Click gear icon. Settings overlay opens.
|
||||
- Change default model, save. Restart server. Verify setting persisted.
|
||||
- Pin a session (star icon in hover overlay). Verify it floats to top of list.
|
||||
- Export session as JSON. Import it back. Verify messages restored.
|
||||
|
||||
### Sprint 13: Alerts + Session QoL
|
||||
- Duplicate a session (copy icon in hover overlay). Verify "(copy)" title.
|
||||
- Browser tab title updates to active session name. Switch sessions — title changes.
|
||||
|
||||
### Sprint 14: Visual Polish + Workspace Ops
|
||||
- Create a mermaid code block in a response. Verify diagram renders inline.
|
||||
- Message timestamps visible next to role labels (hover for full date).
|
||||
- Double-click a file in workspace panel to rename. Enter saves, Escape cancels.
|
||||
- Create a folder via folder icon in workspace header.
|
||||
- Add `#tag` to session title. Verify tag chip appears in sidebar. Click to filter.
|
||||
- Archive a session. Verify it disappears. Toggle "Show archived" to see it.
|
||||
|
||||
### Sprint 15: Session Projects
|
||||
- Click "+" in project bar to create a project. Type name, Enter.
|
||||
- Click a project chip to filter sessions.
|
||||
- Hover a session → click folder icon → assign to project via picker.
|
||||
- Verify colored left border appears on assigned session.
|
||||
- Double-click project chip to rename. Right-click to delete.
|
||||
- Code blocks have a "Copy" button. Click → "Copied!" feedback.
|
||||
- Messages with 2+ tool cards show "Expand all / Collapse all" toggle.
|
||||
|
||||
### Sprint 16: Sidebar Visual Polish
|
||||
- Session titles use full sidebar width (no truncated space for hidden icons).
|
||||
- Hover a session → action buttons appear from right with gradient fade.
|
||||
- All icons are monochrome SVGs (not emoji). Consistent across platforms.
|
||||
- Pinned sessions show small gold star inline. Unpinned = no star, full title width.
|
||||
- Active session has gold highlight (not blue). Overlay gradient matches.
|
||||
- Double-click to rename → overlay hides during rename.
|
||||
|
||||
### Sprint 17: Workspace + Slash Commands + Send Key
|
||||
- Navigate into a subdirectory. Breadcrumb bar appears with clickable segments.
|
||||
- Up button in panel header navigates to parent. Hidden at root.
|
||||
- Type `/` in composer → autocomplete dropdown appears. Arrow keys navigate.
|
||||
- Type `/help` → lists all commands. `/clear` clears conversation. `/model` switches.
|
||||
- Settings panel: change send key to Ctrl+Enter. Verify Enter inserts newline.
|
||||
|
||||
### Sprint 18: Thinking + Tree View + Preview Fix
|
||||
- View a file in workspace. Click a breadcrumb or folder → preview closes automatically.
|
||||
- Click a directory toggle arrow (▸) → expands in-place showing children.
|
||||
- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view).
|
||||
- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response.
|
||||
|
||||
### Sprint 19: Auth + Security
|
||||
- No password set: everything works as normal. No login page.
|
||||
- Set `HERMES_WEBUI_PASSWORD=test` env var. Restart. All pages redirect to `/login`.
|
||||
- Login page: minimal card, password field, "Sign in" button.
|
||||
- Enter correct password → redirected to `/`. Cookie set (24h).
|
||||
- Enter wrong password → error message, stay on login page.
|
||||
- Settings panel: set password via "Access Password" field. Auth activates.
|
||||
- "Sign Out" button visible when auth active. Click → redirected to /login.
|
||||
- API calls without auth cookie → 401 JSON response.
|
||||
- Check response headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Sprint 19 / v0.21, April 3, 2026*
|
||||
*Total automated tests: 328 (328 passing, 0 failures)*
|
||||
*Regression gate: tests/test_regressions.py (23 tests)*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
149
api/auth.py
Normal file
149
api/auth.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Hermes Web UI -- Optional password authentication.
|
||||
Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var
|
||||
or configuring a password in the Settings panel.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookies
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from api.config import STATE_DIR, load_settings
|
||||
|
||||
# ── Public paths (no auth required) ─────────────────────────────────────────
|
||||
PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico',
|
||||
'/api/auth/login', '/api/auth/status',
|
||||
})
|
||||
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
SESSION_TTL = 86400 # 24 hours
|
||||
|
||||
# Active sessions: token -> expiry timestamp
|
||||
_sessions = {}
|
||||
|
||||
|
||||
def _signing_key():
|
||||
"""Derive a stable signing key from STATE_DIR."""
|
||||
return hashlib.sha256(str(STATE_DIR).encode()).digest()
|
||||
|
||||
|
||||
def _hash_password(password):
|
||||
"""SHA-256 hash with a salt derived from STATE_DIR."""
|
||||
salt = str(STATE_DIR).encode()
|
||||
return hashlib.sha256(salt + password.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_password_hash():
|
||||
"""Return the active password hash, or None if auth is disabled.
|
||||
Priority: env var > settings.json."""
|
||||
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
|
||||
if env_pw:
|
||||
return _hash_password(env_pw)
|
||||
settings = load_settings()
|
||||
return settings.get('password_hash') or None
|
||||
|
||||
|
||||
def is_auth_enabled():
|
||||
"""True if a password is configured (env var or settings)."""
|
||||
return get_password_hash() is not None
|
||||
|
||||
|
||||
def verify_password(plain):
|
||||
"""Verify a plaintext password against the stored hash."""
|
||||
expected = get_password_hash()
|
||||
if not expected:
|
||||
return False
|
||||
return hmac.compare_digest(_hash_password(plain), expected)
|
||||
|
||||
|
||||
def create_session():
|
||||
"""Create a new auth session. Returns signed cookie value."""
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = time.time() + SESSION_TTL
|
||||
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
|
||||
return f"{token}.{sig}"
|
||||
|
||||
|
||||
def verify_session(cookie_value):
|
||||
"""Verify a signed session cookie. Returns True if valid and not expired."""
|
||||
if not cookie_value or '.' not in cookie_value:
|
||||
return False
|
||||
token, sig = cookie_value.rsplit('.', 1)
|
||||
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
|
||||
if not hmac.compare_digest(sig, expected_sig):
|
||||
return False
|
||||
expiry = _sessions.get(token)
|
||||
if not expiry or time.time() > expiry:
|
||||
_sessions.pop(token, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def invalidate_session(cookie_value):
|
||||
"""Remove a session token."""
|
||||
if cookie_value and '.' in cookie_value:
|
||||
token = cookie_value.rsplit('.', 1)[0]
|
||||
_sessions.pop(token, None)
|
||||
|
||||
|
||||
def parse_cookie(handler):
|
||||
"""Extract the auth cookie from the request headers."""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
return None
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
try:
|
||||
cookie.load(cookie_header)
|
||||
except http.cookies.CookieError:
|
||||
return None
|
||||
morsel = cookie.get(COOKIE_NAME)
|
||||
return morsel.value if morsel else None
|
||||
|
||||
|
||||
def check_auth(handler, parsed):
|
||||
"""Check if request is authorized. Returns True if OK.
|
||||
If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
|
||||
if not is_auth_enabled():
|
||||
return True
|
||||
# Public paths don't require auth
|
||||
if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/'):
|
||||
return True
|
||||
# Check session cookie
|
||||
cookie_val = parse_cookie(handler)
|
||||
if cookie_val and verify_session(cookie_val):
|
||||
return True
|
||||
# Not authorized
|
||||
if parsed.path.startswith('/api/'):
|
||||
handler.send_response(401)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(b'{"error":"Authentication required"}')
|
||||
else:
|
||||
handler.send_response(302)
|
||||
handler.send_header('Location', '/login')
|
||||
handler.end_headers()
|
||||
return False
|
||||
|
||||
|
||||
def set_auth_cookie(handler, cookie_value):
|
||||
"""Set the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = cookie_value
|
||||
cookie[COOKIE_NAME]['httponly'] = True
|
||||
cookie[COOKIE_NAME]['samesite'] = 'Lax'
|
||||
cookie[COOKIE_NAME]['path'] = '/'
|
||||
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
|
||||
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
||||
|
||||
|
||||
def clear_auth_cookie(handler):
|
||||
"""Clear the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = ''
|
||||
cookie[COOKIE_NAME]['httponly'] = True
|
||||
cookie[COOKIE_NAME]['path'] = '/'
|
||||
cookie[COOKIE_NAME]['max-age'] = '0'
|
||||
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
||||
@@ -510,8 +510,8 @@ def get_available_models() -> dict:
|
||||
if model_id and model_name:
|
||||
auto_detected_models.append({'id': model_id, 'label': model_name})
|
||||
detected_providers.add(provider.lower())
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch models from custom endpoint: {e}")
|
||||
except Exception:
|
||||
pass # custom endpoint unreachable or misconfigured -- fail silently
|
||||
|
||||
# 5. Build model groups
|
||||
if detected_providers:
|
||||
@@ -594,6 +594,8 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock:
|
||||
_SETTINGS_DEFAULTS = {
|
||||
'default_model': DEFAULT_MODEL,
|
||||
'default_workspace': str(DEFAULT_WORKSPACE),
|
||||
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||
}
|
||||
|
||||
def load_settings() -> dict:
|
||||
@@ -608,13 +610,28 @@ def load_settings() -> dict:
|
||||
pass
|
||||
return settings
|
||||
|
||||
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys())
|
||||
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
'send_key': {'enter', 'ctrl+enter'},
|
||||
}
|
||||
|
||||
def save_settings(settings: dict) -> dict:
|
||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||
import hashlib as _hl
|
||||
current = load_settings()
|
||||
# Handle _set_password: hash and store as password_hash
|
||||
raw_pw = settings.pop('_set_password', None)
|
||||
if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():
|
||||
salt = str(STATE_DIR).encode()
|
||||
current['password_hash'] = _hl.sha256(salt + raw_pw.strip().encode()).hexdigest()
|
||||
# Handle _clear_password: explicitly disable auth
|
||||
if settings.pop('_clear_password', False):
|
||||
current['password_hash'] = None
|
||||
for k, v in settings.items():
|
||||
if k in _SETTINGS_ALLOWED_KEYS:
|
||||
# Validate enum-constrained keys
|
||||
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
|
||||
continue
|
||||
current[k] = v
|
||||
SETTINGS_FILE.write_text(
|
||||
json.dumps(current, ensure_ascii=False, indent=2),
|
||||
|
||||
@@ -25,6 +25,13 @@ def safe_resolve(root: Path, requested: str) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def _security_headers(handler):
|
||||
"""Add security headers to every response."""
|
||||
handler.send_header('X-Content-Type-Options', 'nosniff')
|
||||
handler.send_header('X-Frame-Options', 'DENY')
|
||||
handler.send_header('Referrer-Policy', 'same-origin')
|
||||
|
||||
|
||||
def j(handler, payload, status=200):
|
||||
"""Send a JSON response."""
|
||||
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
@@ -32,6 +39,7 @@ def j(handler, payload, status=200):
|
||||
handler.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
@@ -43,13 +51,19 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
|
||||
handler.send_header('Content-Type', content_type)
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||
|
||||
|
||||
def read_body(handler):
|
||||
"""Read and JSON-parse a POST request body."""
|
||||
"""Read and JSON-parse a POST request body (capped at 20MB)."""
|
||||
length = int(handler.headers.get('Content-Length', 0))
|
||||
if length > MAX_BODY_BYTES:
|
||||
raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})')
|
||||
raw = handler.rfile.read(length) if length else b'{}'
|
||||
try:
|
||||
return _json.loads(raw)
|
||||
|
||||
106
api/routes.py
106
api/routes.py
@@ -19,7 +19,7 @@ from api.config import (
|
||||
IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES,
|
||||
CHAT_LOCK, load_settings, save_settings,
|
||||
)
|
||||
from api.helpers import require, bad, safe_resolve, j, t, read_body
|
||||
from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_headers
|
||||
from api.models import (
|
||||
Session, get_session, new_session, all_sessions, title_from,
|
||||
_write_session_index, SESSION_INDEX_FILE,
|
||||
@@ -52,6 +52,58 @@ except ImportError:
|
||||
_permanent_approved = set()
|
||||
|
||||
|
||||
# ── Login page (self-contained, no external deps) ────────────────────────────
|
||||
_LOGIN_PAGE_HTML = '''<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hermes — Sign in</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
||||
height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#16213e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:36px 32px;
|
||||
width:320px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3)}
|
||||
.logo{width:48px;height:48px;border-radius:12px;background:linear-gradient(145deg,#e8a030,#e94560);
|
||||
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:20px;color:#fff;
|
||||
margin:0 auto 12px;box-shadow:0 2px 12px rgba(233,69,96,.3)}
|
||||
h1{font-size:18px;font-weight:600;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#8888aa;margin-bottom:24px}
|
||||
input{width:100%;padding:10px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.1);
|
||||
background:rgba(255,255,255,.04);color:#e8e8f0;font-size:14px;outline:none;margin-bottom:14px;
|
||||
transition:border-color .15s}
|
||||
input:focus{border-color:rgba(124,185,255,.5);box-shadow:0 0 0 3px rgba(124,185,255,.1)}
|
||||
button{width:100%;padding:10px;border-radius:10px;border:none;background:rgba(124,185,255,.15);
|
||||
border:1px solid rgba(124,185,255,.3);color:#7cb9ff;font-size:14px;font-weight:600;cursor:pointer;
|
||||
transition:all .15s}
|
||||
button:hover{background:rgba(124,185,255,.25)}
|
||||
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="logo">H</div>
|
||||
<h1>Hermes</h1>
|
||||
<p class="sub">Enter your password to continue</p>
|
||||
<form onsubmit="return doLogin(event)">
|
||||
<input type="password" id="pw" placeholder="Password" autofocus>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<div class="err" id="err"></div>
|
||||
</div>
|
||||
<script>
|
||||
async function doLogin(e){
|
||||
e.preventDefault();
|
||||
const pw=document.getElementById('pw').value;
|
||||
const err=document.getElementById('err');
|
||||
err.style.display='none';
|
||||
try{
|
||||
const res=await fetch('/api/auth/login',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({password:pw}),credentials:'include'});
|
||||
const data=await res.json();
|
||||
if(res.ok&&data.ok){window.location.href='/';}
|
||||
else{err.textContent=data.error||'Invalid password';err.style.display='block';}
|
||||
}catch(ex){err.textContent='Connection failed';err.style.display='block';}
|
||||
}
|
||||
</script></body></html>'''
|
||||
|
||||
# ── GET routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_get(handler, parsed):
|
||||
@@ -61,6 +113,17 @@ def handle_get(handler, parsed):
|
||||
return t(handler, _INDEX_HTML_PATH.read_text(encoding='utf-8'),
|
||||
content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/login':
|
||||
return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/api/auth/status':
|
||||
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
||||
logged_in = False
|
||||
if is_auth_enabled():
|
||||
cv = parse_cookie(handler)
|
||||
logged_in = bool(cv and verify_session(cv))
|
||||
return j(handler, {'auth_enabled': is_auth_enabled(), 'logged_in': logged_in})
|
||||
|
||||
if parsed.path == '/favicon.ico':
|
||||
handler.send_response(204); handler.end_headers(); return True
|
||||
|
||||
@@ -76,7 +139,10 @@ def handle_get(handler, parsed):
|
||||
return j(handler, get_available_models())
|
||||
|
||||
if parsed.path == '/api/settings':
|
||||
return j(handler, load_settings())
|
||||
settings = load_settings()
|
||||
# Never expose the stored password hash to clients
|
||||
settings.pop('password_hash', None)
|
||||
return j(handler, settings)
|
||||
|
||||
if parsed.path.startswith('/static/'):
|
||||
return _serve_static(handler, parsed)
|
||||
@@ -308,7 +374,9 @@ def handle_post(handler, parsed):
|
||||
|
||||
# ── Settings (POST) ──
|
||||
if parsed.path == '/api/settings':
|
||||
return j(handler, save_settings(body))
|
||||
saved = save_settings(body)
|
||||
saved.pop('password_hash', None) # never expose hash to client
|
||||
return j(handler, saved)
|
||||
|
||||
# ── Session pin (POST) ──
|
||||
if parsed.path == '/api/session/pin':
|
||||
@@ -400,6 +468,38 @@ def handle_post(handler, parsed):
|
||||
if parsed.path == '/api/session/import':
|
||||
return _handle_session_import(handler, body)
|
||||
|
||||
# ── Auth endpoints (POST) ──
|
||||
if parsed.path == '/api/auth/login':
|
||||
from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled
|
||||
if not is_auth_enabled():
|
||||
return j(handler, {'ok': True, 'message': 'Auth not enabled'})
|
||||
password = body.get('password', '')
|
||||
if not verify_password(password):
|
||||
return bad(handler, 'Invalid password', 401)
|
||||
cookie_val = create_session()
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
set_auth_cookie(handler, cookie_val)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'ok': True}).encode())
|
||||
return True
|
||||
|
||||
if parsed.path == '/api/auth/logout':
|
||||
from api.auth import clear_auth_cookie, invalidate_session, parse_cookie
|
||||
cookie_val = parse_cookie(handler)
|
||||
if cookie_val:
|
||||
invalidate_session(cookie_val)
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
clear_auth_cookie(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'ok': True}).encode())
|
||||
return True
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
|
||||
|
||||
except Exception as e:
|
||||
put('error', {'message': str(e), 'trace': traceback.format_exc()})
|
||||
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
|
||||
put('error', {'message': str(e)})
|
||||
finally:
|
||||
_clear_thread_env() # TD1: always clear thread-local context
|
||||
with STREAMS_LOCK:
|
||||
|
||||
@@ -74,4 +74,5 @@ def handle_upload(handler):
|
||||
dest.write_bytes(file_bytes)
|
||||
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
|
||||
except Exception as e:
|
||||
return j(handler, {'error': str(e), 'trace': _tb.format_exc()}, status=500)
|
||||
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
|
||||
return j(handler, {'error': 'Upload failed'}, status=500)
|
||||
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
hermes-webui:
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:8787:8787"
|
||||
volumes:
|
||||
# Persist session data, settings, and projects across restarts
|
||||
- hermes-data:/data
|
||||
# Mount hermes-agent for full agent features (optional)
|
||||
- ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes:ro
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/data
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hermes-data:
|
||||
@@ -8,6 +8,7 @@ import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j
|
||||
from api.routes import handle_get, handle_post
|
||||
@@ -34,21 +35,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
result = handle_get(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
def do_POST(self):
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
result = handle_post(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
180
static/boot.js
180
static/boot.js
@@ -8,8 +8,132 @@ async function cancelStream(){
|
||||
}catch(e){setStatus('Cancel failed: '+e.message);}
|
||||
}
|
||||
|
||||
$('btnSend').onclick=send;
|
||||
// ── Mobile navigation ──────────────────────────────────────────────────────
|
||||
function toggleMobileSidebar(){
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(!sidebar)return;
|
||||
const isOpen=sidebar.classList.contains('mobile-open');
|
||||
if(isOpen){closeMobileSidebar();}
|
||||
else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');}
|
||||
}
|
||||
function closeMobileSidebar(){
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(sidebar)sidebar.classList.remove('mobile-open');
|
||||
if(overlay)overlay.classList.remove('visible');
|
||||
}
|
||||
function toggleMobileFiles(){
|
||||
const panel=document.querySelector('.rightpanel');
|
||||
if(!panel)return;
|
||||
panel.classList.toggle('mobile-open');
|
||||
}
|
||||
function mobileSwitchPanel(name){
|
||||
// Switch the panel content view
|
||||
switchPanel(name);
|
||||
// For non-chat panels (tasks, skills, memory, spaces), open the sidebar
|
||||
// so the panel is visible. For 'chat', the content is in the main area —
|
||||
// just close the sidebar so the chat view is unobstructed.
|
||||
if(name==='chat'){
|
||||
closeMobileSidebar();
|
||||
} else {
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(sidebar){
|
||||
sidebar.classList.add('mobile-open');
|
||||
if(overlay)overlay.classList.add('visible');
|
||||
}
|
||||
}
|
||||
// Update bottom nav active state
|
||||
document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{
|
||||
btn.classList.toggle('active',btn.dataset.panel===name);
|
||||
});
|
||||
}
|
||||
|
||||
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
|
||||
$('btnAttach').onclick=()=>$('fileInput').click();
|
||||
|
||||
// ── Voice input (Web Speech API) ─────────────────────────────────────────
|
||||
(function(){
|
||||
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
||||
if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden
|
||||
|
||||
const btn=$('btnMic');
|
||||
const status=$('micStatus');
|
||||
const ta=$('msg');
|
||||
btn.style.display=''; // Show button — browser supports speech
|
||||
|
||||
const recognition=new SpeechRecognition();
|
||||
recognition.continuous=false;
|
||||
recognition.interimResults=true;
|
||||
recognition.lang='en-US';
|
||||
|
||||
let _finalText='';
|
||||
let _prefix='';
|
||||
|
||||
function _setRecording(on){
|
||||
window._micActive=on;
|
||||
btn.classList.toggle('recording',on);
|
||||
status.style.display=on?'':'none';
|
||||
if(!on){ _finalText=''; _prefix=''; }
|
||||
}
|
||||
|
||||
recognition.onstart=()=>{ _finalText=''; };
|
||||
|
||||
recognition.onresult=(event)=>{
|
||||
let interim='';
|
||||
let final=_finalText;
|
||||
for(let i=event.resultIndex;i<event.results.length;i++){
|
||||
const t=event.results[i][0].transcript;
|
||||
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
||||
else{ interim+=t; }
|
||||
}
|
||||
// Append to whatever was already in the textarea before mic started
|
||||
ta.value=_prefix+(final||interim);
|
||||
autoResize();
|
||||
};
|
||||
|
||||
recognition.onend=()=>{
|
||||
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
|
||||
const committed=_finalText
|
||||
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||
? _prefix+' '+_finalText.trimStart()
|
||||
: _prefix+_finalText)
|
||||
: ta.value; // no speech detected — leave whatever is there
|
||||
_setRecording(false);
|
||||
ta.value=committed;
|
||||
autoResize();
|
||||
};
|
||||
|
||||
recognition.onerror=(event)=>{
|
||||
_setRecording(false);
|
||||
const msgs={
|
||||
'not-allowed':'Microphone access denied. Check browser permissions.',
|
||||
'no-speech':'No speech detected. Try again.',
|
||||
'network':'Speech recognition unavailable.',
|
||||
};
|
||||
showToast(msgs[event.error]||'Voice input error: '+event.error);
|
||||
};
|
||||
|
||||
function _stopMic(){
|
||||
if(window._micActive){ recognition.stop(); }
|
||||
}
|
||||
window._stopMic=_stopMic; // expose for send-guard above
|
||||
|
||||
btn.onclick=()=>{
|
||||
if(window._micActive){
|
||||
recognition.stop();
|
||||
// _setRecording(false) will be called by onend
|
||||
} else {
|
||||
_finalText='';
|
||||
// Snapshot existing textarea content so we append rather than replace
|
||||
_prefix=ta.value;
|
||||
recognition.start();
|
||||
_setRecording(true);
|
||||
}
|
||||
};
|
||||
})();
|
||||
window._micActive=window._micActive||false;
|
||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
||||
$('btnDownload').onclick=()=>{
|
||||
@@ -43,14 +167,16 @@ $('importFileInput').onchange=async(e)=>{
|
||||
}
|
||||
};
|
||||
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
|
||||
$('btnClearPreview').onclick=()=>{
|
||||
$('previewArea').classList.remove('visible');
|
||||
$('previewImg').src='';
|
||||
$('previewMd').innerHTML='';
|
||||
$('previewCode').textContent='';
|
||||
$('previewPathText').textContent='';
|
||||
$('fileTree').style.display='';
|
||||
};
|
||||
function clearPreview(){
|
||||
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
|
||||
const pi=$('previewImg');if(pi)pi.src='';
|
||||
const pm=$('previewMd');if(pm)pm.innerHTML='';
|
||||
const pc=$('previewCode');if(pc)pc.textContent='';
|
||||
const pp=$('previewPathText');if(pp)pp.textContent='';
|
||||
const ft=$('fileTree');if(ft)ft.style.display='';
|
||||
_previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
|
||||
}
|
||||
$('btnClearPreview').onclick=clearPreview;
|
||||
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
|
||||
$('modelSelect').onchange=async()=>{
|
||||
if(!S.session)return;
|
||||
@@ -59,8 +185,38 @@ $('modelSelect').onchange=async()=>{
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;syncTopbar();
|
||||
};
|
||||
$('msg').addEventListener('input',autoResize);
|
||||
$('msg').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}});
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
updateSendBtn();
|
||||
const text=$('msg').value;
|
||||
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
||||
const prefix=text.slice(1);
|
||||
const matches=getMatchingCommands(prefix);
|
||||
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
||||
} else {
|
||||
hideCmdDropdown();
|
||||
}
|
||||
});
|
||||
$('msg').addEventListener('keydown',e=>{
|
||||
// Autocomplete navigation when dropdown is open
|
||||
const dd=$('cmdDropdown');
|
||||
const dropdownOpen=dd&&dd.classList.contains('open');
|
||||
if(dropdownOpen){
|
||||
if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;}
|
||||
if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
|
||||
if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
|
||||
if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
|
||||
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();selectCmdDropdownItem();return;}
|
||||
}
|
||||
// Send key: respect user preference
|
||||
if(e.key==='Enter'){
|
||||
if(window._sendKey==='ctrl+enter'){
|
||||
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
|
||||
} else {
|
||||
if(!e.shiftKey){e.preventDefault();send();}
|
||||
}
|
||||
}
|
||||
});
|
||||
// B14: Cmd/Ctrl+K creates a new chat from anywhere
|
||||
document.addEventListener('keydown',async e=>{
|
||||
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
|
||||
@@ -151,6 +307,8 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
})();
|
||||
|
||||
(async()=>{
|
||||
// Load send key preference
|
||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';}
|
||||
// Fetch available models from server and populate dropdown dynamically
|
||||
await populateModelDropdown();
|
||||
// Restore last-used model preference
|
||||
|
||||
156
static/commands.js
Normal file
156
static/commands.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// ── Slash commands ──────────────────────────────────────────────────────────
|
||||
// Built-in commands intercepted before send(). Each command runs locally
|
||||
// (no round-trip to the agent) and shows feedback via toast or local message.
|
||||
|
||||
const COMMANDS=[
|
||||
{name:'help', desc:'List available commands', fn:cmdHelp},
|
||||
{name:'clear', desc:'Clear conversation messages', fn:cmdClear},
|
||||
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'},
|
||||
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
|
||||
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
||||
];
|
||||
|
||||
function parseCommand(text){
|
||||
if(!text.startsWith('/'))return null;
|
||||
const parts=text.slice(1).split(/\s+/);
|
||||
const name=parts[0].toLowerCase();
|
||||
const args=parts.slice(1).join(' ').trim();
|
||||
return {name,args};
|
||||
}
|
||||
|
||||
function executeCommand(text){
|
||||
const parsed=parseCommand(text);
|
||||
if(!parsed)return false;
|
||||
const cmd=COMMANDS.find(c=>c.name===parsed.name);
|
||||
if(!cmd)return false;
|
||||
cmd.fn(parsed.args);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getMatchingCommands(prefix){
|
||||
const q=prefix.toLowerCase();
|
||||
return COMMANDS.filter(c=>c.name.startsWith(q));
|
||||
}
|
||||
|
||||
// ── Command handlers ────────────────────────────────────────────────────────
|
||||
|
||||
function cmdHelp(){
|
||||
const lines=COMMANDS.map(c=>{
|
||||
const usage=c.arg?` <${c.arg}>`:'';
|
||||
return ` /${c.name}${usage} — ${c.desc}`;
|
||||
});
|
||||
const msg={role:'assistant',content:'**Available commands:**\n'+lines.join('\n')};
|
||||
S.messages.push(msg);
|
||||
renderMessages();
|
||||
showToast('Type / to see commands');
|
||||
}
|
||||
|
||||
function cmdClear(){
|
||||
if(!S.session)return;
|
||||
S.messages=[];S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
renderMessages();
|
||||
$('emptyState').style.display='';
|
||||
showToast('Conversation cleared');
|
||||
}
|
||||
|
||||
async function cmdModel(args){
|
||||
if(!args){showToast('Usage: /model <name>');return;}
|
||||
const sel=$('modelSelect');
|
||||
if(!sel)return;
|
||||
const q=args.toLowerCase();
|
||||
// Fuzzy match: find first option whose label or value contains the query
|
||||
let match=null;
|
||||
for(const opt of sel.options){
|
||||
if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){
|
||||
match=opt.value;break;
|
||||
}
|
||||
}
|
||||
if(!match){showToast(`No model matching "${args}"`);return;}
|
||||
sel.value=match;
|
||||
await sel.onchange();
|
||||
showToast(`Switched to ${match}`);
|
||||
}
|
||||
|
||||
async function cmdWorkspace(args){
|
||||
if(!args){showToast('Usage: /workspace <name>');return;}
|
||||
try{
|
||||
const data=await api('/api/workspaces');
|
||||
const q=args.toLowerCase();
|
||||
const ws=(data.workspaces||[]).find(w=>
|
||||
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
|
||||
);
|
||||
if(!ws){showToast(`No workspace matching "${args}"`);return;}
|
||||
if(!S.session)return;
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
||||
session_id:S.session.session_id,workspace:ws.path,model:S.session.model
|
||||
})});
|
||||
S.session.workspace=ws.path;
|
||||
syncTopbar();await loadDir('.');
|
||||
showToast(`Switched to workspace: ${ws.name||ws.path}`);
|
||||
}catch(e){showToast('Workspace switch failed: '+e.message);}
|
||||
}
|
||||
|
||||
async function cmdNew(){
|
||||
await newSession();
|
||||
await renderSessionList();
|
||||
$('msg').focus();
|
||||
showToast('New session created');
|
||||
}
|
||||
|
||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||
|
||||
let _cmdSelectedIdx=-1;
|
||||
|
||||
function showCmdDropdown(matches){
|
||||
const dd=$('cmdDropdown');
|
||||
if(!dd)return;
|
||||
dd.innerHTML='';
|
||||
_cmdSelectedIdx=-1;
|
||||
for(let i=0;i<matches.length;i++){
|
||||
const c=matches[i];
|
||||
const el=document.createElement('div');
|
||||
el.className='cmd-item';
|
||||
el.dataset.idx=i;
|
||||
const usage=c.arg?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
|
||||
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
||||
el.onmousedown=(e)=>{
|
||||
e.preventDefault();
|
||||
$('msg').value='/'+c.name+(c.arg?' ':'');
|
||||
hideCmdDropdown();
|
||||
$('msg').focus();
|
||||
};
|
||||
dd.appendChild(el);
|
||||
}
|
||||
dd.classList.add('open');
|
||||
}
|
||||
|
||||
function hideCmdDropdown(){
|
||||
const dd=$('cmdDropdown');
|
||||
if(dd)dd.classList.remove('open');
|
||||
_cmdSelectedIdx=-1;
|
||||
}
|
||||
|
||||
function navigateCmdDropdown(dir){
|
||||
const dd=$('cmdDropdown');
|
||||
if(!dd)return;
|
||||
const items=dd.querySelectorAll('.cmd-item');
|
||||
if(!items.length)return;
|
||||
items.forEach(el=>el.classList.remove('selected'));
|
||||
_cmdSelectedIdx+=dir;
|
||||
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
|
||||
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
|
||||
items[_cmdSelectedIdx].classList.add('selected');
|
||||
}
|
||||
|
||||
function selectCmdDropdownItem(){
|
||||
const dd=$('cmdDropdown');
|
||||
if(!dd)return;
|
||||
const items=dd.querySelectorAll('.cmd-item');
|
||||
if(_cmdSelectedIdx>=0&&_cmdSelectedIdx<items.length){
|
||||
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
|
||||
} else if(items.length===1){
|
||||
items[0].onmousedown({preventDefault:()=>{}});
|
||||
}
|
||||
hideCmdDropdown();
|
||||
}
|
||||
@@ -13,7 +13,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.17.1</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.23</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>
|
||||
@@ -143,6 +143,9 @@
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
|
||||
<div class="topbar-chips">
|
||||
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
|
||||
@@ -152,6 +155,7 @@
|
||||
</div>
|
||||
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">🗑 Clear</button>
|
||||
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">⚙</button>
|
||||
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
@@ -206,12 +210,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-wrap" id="composerWrap">
|
||||
<div class="cmd-dropdown" id="cmdDropdown"></div>
|
||||
<div class="composer-box" id="composerBox">
|
||||
<div class="drop-hint" id="dropHint">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Drop files to upload to workspace
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
@@ -219,11 +225,18 @@
|
||||
<button class="icon-btn" id="btnAttach" title="Attach files">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn mic-btn" id="btnMic" title="Voice input" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="1" width="6" height="12" rx="3"/>
|
||||
<path d="M5 10a7 7 0 0 0 14 0"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-right">
|
||||
<button class="send-btn" id="btnSend">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
||||
Send
|
||||
<button class="send-btn" id="btnSend" title="Send message" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,12 +249,14 @@
|
||||
<div class="panel-header">
|
||||
<span>Workspace</span>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none">↑</button>
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">+</button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()">📁</button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir('.')">↻</button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)">↻</button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
<div class="file-tree" id="fileTree"></div>
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="preview-path" id="previewPath">
|
||||
@@ -272,14 +287,52 @@
|
||||
<label for="settingsWorkspace">Default Workspace</label>
|
||||
<select id="settingsWorkspace" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsSendKey">Send Key</label>
|
||||
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="enter">Enter (Shift+Enter for newline)</option>
|
||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||
</select>
|
||||
</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>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
|
||||
<nav class="mobile-bottom-nav" id="mobileBottomNav">
|
||||
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="tasks" onclick="mobileSwitchPanel('tasks')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>Tasks</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="skills" onclick="mobileSwitchPanel('skills')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span>Skills</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="memory" onclick="mobileSwitchPanel('memory')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg>
|
||||
<span>Memory</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="workspaces" onclick="mobileSwitchPanel('workspaces')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h8l2 2h10v14H2z"/></svg>
|
||||
<span>Spaces</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/static/ui.js"></script>
|
||||
<script src="/static/workspace.js"></script>
|
||||
<script src="/static/sessions.js"></script>
|
||||
<script src="/static/commands.js"></script>
|
||||
<script src="/static/messages.js"></script>
|
||||
<script src="/static/panels.js"></script>
|
||||
<script src="/static/boot.js"></script>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
async function send(){
|
||||
const text=$('msg').value.trim();
|
||||
if(!text&&!S.pendingFiles.length)return;
|
||||
// Slash command intercept -- local commands handled without agent round-trip
|
||||
if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
// Don't send while an inline message edit is active
|
||||
if(document.querySelector('.msg-edit-area'))return;
|
||||
// If busy, queue the message instead of dropping it
|
||||
@@ -233,7 +237,7 @@ function transcript(){
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';}
|
||||
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';updateSendBtn();}
|
||||
|
||||
|
||||
// ── Approval polling ──
|
||||
|
||||
@@ -646,6 +646,21 @@ async function loadSettingsPanel(){
|
||||
}catch(e){}
|
||||
wsSel.value=settings.default_workspace||'';
|
||||
}
|
||||
// Send key preference
|
||||
const sendKeySel=$('settingsSendKey');
|
||||
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
||||
// Password field: always blank (we don't send hash back)
|
||||
const pwField=$('settingsPassword');
|
||||
if(pwField) pwField.value='';
|
||||
// Show auth buttons only when auth is active
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
const active=authStatus.auth_enabled;
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
||||
const disableBtn=$('btnDisableAuth');
|
||||
if(disableBtn) disableBtn.style.display=active?'':'none';
|
||||
}catch(e){}
|
||||
}catch(e){
|
||||
showToast('Failed to load settings: '+e.message);
|
||||
}
|
||||
@@ -654,11 +669,25 @@ async function loadSettingsPanel(){
|
||||
async function saveSettings(){
|
||||
const model=($('settingsModel')||{}).value;
|
||||
const workspace=($('settingsWorkspace')||{}).value;
|
||||
const sendKey=($('settingsSendKey')||{}).value;
|
||||
const pw=($('settingsPassword')||{}).value;
|
||||
const body={};
|
||||
if(model) body.default_model=model;
|
||||
if(workspace) body.default_workspace=workspace;
|
||||
if(sendKey) body.send_key=sendKey;
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
if(pw && pw.trim()){
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
||||
window._sendKey=sendKey||'enter';
|
||||
showToast('Settings saved (password set — login now required)');
|
||||
toggleSettings();
|
||||
return;
|
||||
}catch(e){showToast('Save failed: '+e.message);return;}
|
||||
}
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
window._sendKey=sendKey||'enter';
|
||||
showToast('Settings saved');
|
||||
toggleSettings();
|
||||
}catch(e){
|
||||
@@ -666,6 +695,30 @@ async function saveSettings(){
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut(){
|
||||
try{
|
||||
await api('/api/auth/logout',{method:'POST',body:'{}'});
|
||||
window.location.href='/login';
|
||||
}catch(e){
|
||||
showToast('Sign out failed: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAuth(){
|
||||
if(!confirm('Disable password protection? Anyone will be able to access this instance.')) return;
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
|
||||
showToast('Auth disabled — password protection removed');
|
||||
// Hide both auth buttons since auth is now off
|
||||
const disableBtn=$('btnDisableAuth');
|
||||
if(disableBtn) disableBtn.style.display='none';
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display='none';
|
||||
}catch(e){
|
||||
showToast('Failed to disable auth: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Close settings on overlay click (not panel click)
|
||||
document.addEventListener('click',e=>{
|
||||
const overlay=$('settingsOverlay');
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
// ── Session action icons (SVG, monochrome, inherit currentColor) ──
|
||||
const ICONS={
|
||||
pin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>',
|
||||
unpin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>',
|
||||
folder:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>',
|
||||
archive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><line x1="6" y1="8.5" x2="10" y2="8.5"/></svg>',
|
||||
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
|
||||
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
|
||||
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
|
||||
};
|
||||
|
||||
async function newSession(flash){
|
||||
MSG_QUEUE.length=0;updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
@@ -242,11 +253,35 @@ function renderSessionListFromCache(){
|
||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||
};
|
||||
|
||||
const pin=document.createElement('span');
|
||||
pin.className='session-pin'+(s.pinned?' pinned':'');
|
||||
pin.innerHTML=s.pinned?'★':'☆';
|
||||
pin.title=s.pinned?'Unpin':'Pin to top';
|
||||
pin.onclick=async(e)=>{
|
||||
// Pin indicator (inline, only when pinned — no space reserved otherwise)
|
||||
if(s.pinned){
|
||||
const pinInd=document.createElement('span');
|
||||
pinInd.className='session-pin-indicator';
|
||||
pinInd.innerHTML=ICONS.pin;
|
||||
el.appendChild(pinInd);
|
||||
}
|
||||
// Project indicator: colored left border (active item keeps its own gold color)
|
||||
if(s.project_id){
|
||||
const proj=_allProjects.find(p=>p.project_id===s.project_id);
|
||||
if(proj){
|
||||
if(!isActive) el.style.borderLeftColor=proj.color||'var(--blue)';
|
||||
const dot=document.createElement('span');
|
||||
dot.className='session-project-dot';
|
||||
dot.style.background=proj.color||'var(--blue)';
|
||||
dot.title=proj.name;
|
||||
title.appendChild(dot);
|
||||
}
|
||||
}
|
||||
el.appendChild(title);
|
||||
// Action buttons overlay (appears on hover with gradient fade)
|
||||
const actions=document.createElement('div');
|
||||
actions.className='session-actions';
|
||||
// Pin toggle
|
||||
const pinBtn=document.createElement('button');
|
||||
pinBtn.className='act-pin'+(s.pinned?' pinned':'');
|
||||
pinBtn.innerHTML=s.pinned?ICONS.pin:ICONS.unpin;
|
||||
pinBtn.title=s.pinned?'Unpin':'Pin to top';
|
||||
pinBtn.onclick=async(e)=>{
|
||||
e.stopPropagation();e.preventDefault();
|
||||
const newPinned=!s.pinned;
|
||||
try{
|
||||
@@ -256,8 +291,15 @@ function renderSessionListFromCache(){
|
||||
renderSessionList();
|
||||
}catch(err){showToast('Pin failed: '+err.message);}
|
||||
};
|
||||
actions.appendChild(pinBtn);
|
||||
// Move to project
|
||||
const move=document.createElement('button');
|
||||
move.className='act-move';move.innerHTML=ICONS.folder;move.title='Move to project';
|
||||
move.onclick=async(e)=>{e.stopPropagation();e.preventDefault();_showProjectPicker(s,move);};
|
||||
actions.appendChild(move);
|
||||
// Archive
|
||||
const archive=document.createElement('button');
|
||||
archive.className='session-action-btn';archive.innerHTML=s.archived?'✉':'📦';
|
||||
archive.className='act-archive';archive.innerHTML=s.archived?ICONS.unarchive:ICONS.archive;
|
||||
archive.title=s.archived?'Unarchive':'Archive';
|
||||
archive.onclick=async(e)=>{
|
||||
e.stopPropagation();e.preventDefault();
|
||||
@@ -269,8 +311,10 @@ function renderSessionListFromCache(){
|
||||
showToast(s.archived?'Session archived':'Session restored');
|
||||
}catch(err){showToast('Archive failed: '+err.message);}
|
||||
};
|
||||
actions.appendChild(archive);
|
||||
// Duplicate
|
||||
const dup=document.createElement('button');
|
||||
dup.className='session-dup';dup.innerHTML='⧉';dup.title='Duplicate';
|
||||
dup.className='act-dup';dup.innerHTML=ICONS.dup;dup.title='Duplicate';
|
||||
dup.onclick=async(e)=>{
|
||||
e.stopPropagation();e.preventDefault();
|
||||
try{
|
||||
@@ -282,25 +326,13 @@ function renderSessionListFromCache(){
|
||||
}
|
||||
}catch(err){showToast('Duplicate failed: '+err.message);}
|
||||
};
|
||||
actions.appendChild(dup);
|
||||
// Trash
|
||||
const trash=document.createElement('button');
|
||||
trash.className='session-trash';trash.innerHTML='🗑';trash.title='Delete';
|
||||
trash.className='act-trash';trash.innerHTML=ICONS.trash;trash.title='Delete';
|
||||
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
|
||||
// Project move button (folder icon)
|
||||
const move=document.createElement('button');
|
||||
move.className='session-action-btn'+(s.project_id?' has-project':'');move.innerHTML='📂';move.title='Move to project';
|
||||
move.onclick=async(e)=>{e.stopPropagation();e.preventDefault();_showProjectPicker(s,move);};
|
||||
// Project dot indicator
|
||||
if(s.project_id){
|
||||
const proj=_allProjects.find(p=>p.project_id===s.project_id);
|
||||
if(proj){
|
||||
const dot=document.createElement('span');
|
||||
dot.className='session-project-dot';
|
||||
dot.style.background=proj.color||'var(--blue)';
|
||||
dot.title=proj.name;
|
||||
title.appendChild(dot);
|
||||
}
|
||||
}
|
||||
el.appendChild(pin);el.appendChild(title);el.appendChild(move);el.appendChild(archive);el.appendChild(dup);el.appendChild(trash);
|
||||
actions.appendChild(trash);
|
||||
el.appendChild(actions);
|
||||
|
||||
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
|
||||
// This prevents loadSession from firing on the first click of a double-click,
|
||||
@@ -308,12 +340,13 @@ function renderSessionListFromCache(){
|
||||
let _clickTimer=null;
|
||||
el.onclick=async(e)=>{
|
||||
if(_renamingSid) return; // ignore while any rename is active
|
||||
if([trash,dup,archive,move].some(b=>e.target===b||b.contains(e.target))) return;
|
||||
if(actions.contains(e.target)) return;
|
||||
clearTimeout(_clickTimer);
|
||||
_clickTimer=setTimeout(async()=>{
|
||||
_clickTimer=null;
|
||||
if(_renamingSid) return;
|
||||
await loadSession(s.session_id);renderSessionListFromCache();
|
||||
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
|
||||
}, 220);
|
||||
};
|
||||
el.ondblclick=async(e)=>{
|
||||
@@ -360,15 +393,13 @@ function _showProjectPicker(session, anchorEl){
|
||||
document.querySelectorAll('.project-picker').forEach(p=>p.remove());
|
||||
const picker=document.createElement('div');
|
||||
picker.className='project-picker';
|
||||
// Close on outside click
|
||||
const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}};
|
||||
// "No project" option
|
||||
const none=document.createElement('div');
|
||||
none.className='project-picker-item'+(!session.project_id?' active':'');
|
||||
none.textContent='No project';
|
||||
none.onclick=async()=>{
|
||||
document.removeEventListener('click',close);
|
||||
picker.remove();
|
||||
document.removeEventListener('click',close);
|
||||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
|
||||
session.project_id=null;
|
||||
renderSessionListFromCache();
|
||||
@@ -389,8 +420,8 @@ function _showProjectPicker(session, anchorEl){
|
||||
name.textContent=p.name;
|
||||
item.appendChild(name);
|
||||
item.onclick=async()=>{
|
||||
document.removeEventListener('click',close);
|
||||
picker.remove();
|
||||
document.removeEventListener('click',close);
|
||||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
|
||||
session.project_id=p.project_id;
|
||||
renderSessionListFromCache();
|
||||
@@ -398,19 +429,21 @@ function _showProjectPicker(session, anchorEl){
|
||||
};
|
||||
picker.appendChild(item);
|
||||
}
|
||||
// "+ New project" item
|
||||
// "+ New project" shortcut at the bottom
|
||||
const createItem=document.createElement('div');
|
||||
createItem.className='project-picker-item project-picker-create';
|
||||
createItem.textContent='+ New project';
|
||||
createItem.onclick=async()=>{
|
||||
picker.remove();
|
||||
document.removeEventListener('click',close);
|
||||
// Prompt for name inline
|
||||
const name=prompt('Project name:');
|
||||
if(!name||!name.trim()) return;
|
||||
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
||||
const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
|
||||
if(res.project){
|
||||
_allProjects.push(res.project);
|
||||
// Now move session into it
|
||||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:res.project.project_id})});
|
||||
session.project_id=res.project.project_id;
|
||||
await renderSessionList();
|
||||
@@ -418,11 +451,13 @@ function _showProjectPicker(session, anchorEl){
|
||||
}
|
||||
};
|
||||
picker.appendChild(createItem);
|
||||
// Position picker on document.body to avoid overflow:hidden clipping
|
||||
// Append to body and position using getBoundingClientRect so it isn't clipped
|
||||
// by overflow:hidden on .session-item ancestors
|
||||
document.body.appendChild(picker);
|
||||
const rect=anchorEl.getBoundingClientRect();
|
||||
picker.style.position='fixed';
|
||||
picker.style.zIndex='999';
|
||||
// Prefer opening below; flip above if too close to bottom of viewport
|
||||
const spaceBelow=window.innerHeight-rect.bottom;
|
||||
if(spaceBelow<160&&rect.top>160){
|
||||
picker.style.bottom=(window.innerHeight-rect.top+4)+'px';
|
||||
@@ -431,10 +466,13 @@ function _showProjectPicker(session, anchorEl){
|
||||
picker.style.top=(rect.bottom+4)+'px';
|
||||
picker.style.bottom='auto';
|
||||
}
|
||||
const pickerW=160;
|
||||
// Align right edge of picker with right edge of button; keep within viewport
|
||||
const pickerW=Math.min(220,Math.max(160,picker.scrollWidth||160));
|
||||
let left=rect.right-pickerW;
|
||||
if(left<8) left=8;
|
||||
picker.style.left=left+'px';
|
||||
// Close on outside click
|
||||
const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}};
|
||||
setTimeout(()=>document.addEventListener('click',close),0);
|
||||
}
|
||||
|
||||
|
||||
167
static/style.css
167
static/style.css
@@ -20,13 +20,21 @@
|
||||
.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-item{padding:8px 10px 8px 8px;border-radius:8px;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;}
|
||||
.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.active{background:rgba(124,185,255,0.1);color:var(--blue);border-left:2px solid var(--blue);padding-left:8px;}
|
||||
.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-trash{flex-shrink:0;opacity:0;font-size:13px;color:var(--muted);background:none;border:none;cursor:pointer;padding:0 2px;line-height:1;transition:opacity .15s,color .15s;}
|
||||
.session-item:hover .session-trash{opacity:1;}
|
||||
.session-trash:hover{color:var(--accent)!important;}
|
||||
/* ── Session action button overlay ── */
|
||||
.session-actions{position:absolute;right:0;top:0;bottom:0;display:flex;align-items:center;gap:2px;padding:0 6px 0 16px;background:linear-gradient(to right,transparent,var(--sidebar) 12px);opacity:0;pointer-events:none;transition:opacity .15s ease;border-radius:0 8px 8px 0;}
|
||||
.session-item:hover .session-actions{opacity:1;pointer-events:auto;}
|
||||
.session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(30,22,8,.95) 12px);}
|
||||
.session-actions button{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px 3px;line-height:1;transition:color .12s;display:flex;align-items:center;}
|
||||
.session-actions button:hover{color:var(--text);}
|
||||
.session-actions .act-trash:hover{color:var(--accent);}
|
||||
.session-actions .act-pin.pinned{color:#f5c542;}
|
||||
.session-actions .act-pin.pinned:hover{color:#d4a017;}
|
||||
/* Hide overlay during inline rename */
|
||||
.session-item:has(.session-title-input) .session-actions{display:none;}
|
||||
@keyframes newflash{0%{background:rgba(124,185,255,0.22);color:var(--blue);}100%{background:transparent;color:var(--muted);}}
|
||||
.session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
|
||||
.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;}
|
||||
@@ -179,11 +187,18 @@
|
||||
.icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;}
|
||||
.icon-btn{opacity:.75;}
|
||||
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;}
|
||||
.mic-btn{transition:color .15s,background .15s;}
|
||||
.mic-btn.recording{color:#e94560;background:rgba(233,69,96,.12);animation:mic-pulse 1.2s ease-in-out infinite;}
|
||||
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(233,69,96,.3);}50%{box-shadow:0 0 0 6px rgba(233,69,96,0);}}
|
||||
.mic-status{font-size:11px;color:#e94560;padding:4px 12px;display:flex;align-items:center;gap:6px;}
|
||||
.mic-dot{width:6px;height:6px;border-radius:50%;background:#e94560;animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
|
||||
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
|
||||
.send-btn{padding:7px 18px;border-radius:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#5ba8f5,#7cb9ff);border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .15s;flex-shrink:0;letter-spacing:.01em;}
|
||||
.send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);}
|
||||
.send-btn:active{transform:translateY(0);}
|
||||
.send-btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||
.send-btn{width:34px;height:34px;border-radius:50%;background:#7cb9ff;border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 8px rgba(124,185,255,.35);}
|
||||
.send-btn:hover{background:#a0d0ff;transform:scale(1.08);box-shadow:0 4px 14px rgba(124,185,255,.5);}
|
||||
.send-btn:active{transform:scale(0.95);box-shadow:0 1px 4px rgba(124,185,255,.25);}
|
||||
.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.active{display:block;}
|
||||
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
|
||||
@@ -197,10 +212,20 @@
|
||||
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
|
||||
.file-action-btn:hover{color:var(--accent);}
|
||||
.close-preview{cursor:pointer;opacity:.6;}.close-preview:hover{opacity:1;}
|
||||
/* Breadcrumb navigation */
|
||||
.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-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;}
|
||||
.file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;}
|
||||
.file-item:hover{background:rgba(255,255,255,.07);color:var(--text);}
|
||||
.file-item.active{background:rgba(124,185,255,.12);color:var(--blue);}
|
||||
.file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:10px;text-align:center;line-height:1;}
|
||||
.file-item.file-empty{color:var(--muted);opacity:.5;font-style:italic;cursor:default;font-size:11px;}
|
||||
.file-item.file-empty:hover{background:none;color:var(--muted);}
|
||||
.preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;}
|
||||
.preview-area.visible{display:flex;opacity:1;}
|
||||
.preview-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;}
|
||||
@@ -235,38 +260,87 @@
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s}
|
||||
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)}
|
||||
@media(max-width:900px){.rightpanel{display:none}}
|
||||
/* ── Desktop: hide mobile-only elements ── */
|
||||
.mobile-hamburger{display:none;}
|
||||
.mobile-files-btn{display:none!important;}
|
||||
.mobile-overlay{display:none;}
|
||||
.mobile-bottom-nav{display:none;}
|
||||
|
||||
@media(max-width:900px){.rightpanel{display:none}.mobile-files-btn{display:inline-flex!important;}}
|
||||
|
||||
@media(max-width:640px){
|
||||
.sidebar{display:none}
|
||||
/* Topbar: stack title + chips vertically, allow wrapping */
|
||||
.topbar{padding:8px 12px;gap:6px;flex-wrap:wrap;}
|
||||
.topbar-left{min-width:0;flex:1 1 100%;}
|
||||
/* ── Sidebar: slide-in overlay instead of hidden ── */
|
||||
.sidebar{position:fixed;left:-300px;top:0;bottom:0;width:280px;z-index:200;
|
||||
transition:left .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.4);}
|
||||
.sidebar.mobile-open{left:0;}
|
||||
.sidebar .resize-handle{display:none;}
|
||||
/* Hamburger button */
|
||||
.mobile-hamburger{display:flex;align-items:center;justify-content:center;
|
||||
background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;
|
||||
flex-shrink:0;-webkit-tap-highlight-color:transparent;}
|
||||
.mobile-hamburger:hover{color:var(--text);}
|
||||
/* Overlay backdrop */
|
||||
.mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);
|
||||
z-index:199;-webkit-tap-highlight-color:transparent;}
|
||||
.mobile-overlay.visible{display:block;}
|
||||
/* Files button in topbar */
|
||||
.mobile-files-btn{display:inline-flex!important;}
|
||||
/* Right panel: slide-over from right */
|
||||
.rightpanel{display:flex!important;position:fixed;right:-320px;top:0;bottom:0;
|
||||
width:300px;z-index:200;transition:right .25s ease;
|
||||
box-shadow:-4px 0 24px rgba(0,0,0,.4);}
|
||||
.rightpanel.mobile-open{right:0;}
|
||||
.rightpanel .resize-handle{display:none;}
|
||||
/* Bottom navigation bar */
|
||||
.mobile-bottom-nav{display:flex;position:fixed;bottom:0;left:0;right:0;
|
||||
background:var(--sidebar);border-top:1px solid var(--border);
|
||||
z-index:150;padding:4px 0 env(safe-area-inset-bottom,0);
|
||||
justify-content:space-around;align-items:center;}
|
||||
.mobile-nav-btn{display:flex;flex-direction:column;align-items:center;gap:2px;
|
||||
background:none;border:none;color:var(--muted);font-size:9px;padding:6px 4px;
|
||||
cursor:pointer;min-width:44px;min-height:44px;justify-content:center;
|
||||
-webkit-tap-highlight-color:transparent;transition:color .15s;}
|
||||
.mobile-nav-btn.active{color:var(--blue);}
|
||||
.mobile-nav-btn:hover{color:var(--text);}
|
||||
.mobile-nav-btn svg{flex-shrink:0;}
|
||||
/* Hide sidebar nav tabs (replaced by bottom nav) */
|
||||
.sidebar-nav{display:none;}
|
||||
/* Hide sidebar bottom section on mobile (model select, workspace) */
|
||||
.sidebar-bottom{display:none;}
|
||||
/* Topbar adjustments */
|
||||
.topbar{padding:8px 12px;gap:8px;}
|
||||
.topbar-title{font-size:14px;}
|
||||
.topbar-meta{font-size:10px;}
|
||||
.topbar-chips{flex-wrap:wrap;gap:4px;}
|
||||
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;}
|
||||
/* Messages area */
|
||||
.topbar-meta{display:none;}
|
||||
.topbar-chips{flex-wrap:nowrap;gap:4px;overflow-x:auto;-webkit-overflow-scrolling:touch;}
|
||||
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;white-space:nowrap;}
|
||||
/* Messages area — account for bottom nav */
|
||||
.messages{padding-bottom:60px;}
|
||||
.messages-inner{padding:12px 10px 20px;}
|
||||
.msg-body{padding-left:0;max-width:100%;}
|
||||
.msg-role{font-size:12px;}
|
||||
/* Composer */
|
||||
.composer-wrap{padding:8px 10px 12px!important;}
|
||||
/* Composer — above bottom nav */
|
||||
.composer-wrap{padding:8px 10px 12px!important;margin-bottom:56px;}
|
||||
.composer-box{border-radius:12px;}
|
||||
.composer-box textarea{font-size:16px;min-height:40px;}
|
||||
.send-btn{padding:6px 14px;font-size:13px;}
|
||||
.send-btn{width:32px;height:32px;}
|
||||
/* Touch targets — minimum 44px */
|
||||
.icon-btn,.mic-btn{min-width:44px;min-height:44px;}
|
||||
.session-item{min-height:44px;padding:10px 12px;}
|
||||
/* Empty state */
|
||||
.empty-state h2{font-size:18px;}
|
||||
.empty-state p{font-size:13px;}
|
||||
.suggestion-grid{max-width:100%!important;}
|
||||
.suggestion-btn{font-size:12px;padding:8px 10px;}
|
||||
.suggestion{font-size:12px;padding:10px 12px;}
|
||||
/* Approval card */
|
||||
.approval-card{padding:0 10px 8px;}
|
||||
.approval-btns{gap:6px;}
|
||||
.approval-btn{padding:5px 10px;font-size:11px;}
|
||||
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
|
||||
/* Tool cards */
|
||||
.tool-card{margin-left:0!important;font-size:12px;}
|
||||
/* Settings modal */
|
||||
.settings-panel{width:95vw;max-width:95vw;}
|
||||
/* Login page responsive */
|
||||
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
||||
}
|
||||
|
||||
/* ── Workspace dropdown (topbar) ── */
|
||||
@@ -289,6 +363,14 @@
|
||||
.ws-row-actions{display:flex;gap:4px;flex-shrink:0;}
|
||||
.ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;}
|
||||
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
|
||||
/* ── 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.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);}
|
||||
.cmd-item-name{font-size:13px;color:var(--text);font-weight:500;}
|
||||
.cmd-item-arg{color:var(--muted);font-weight:400;font-style:italic;}
|
||||
.cmd-item-desc{font-size:11px;color:var(--muted);margin-top:1px;}
|
||||
.ws-action-btn.danger:hover{background:rgba(233,69,96,.12);color:var(--accent);border-color:rgba(233,69,96,.3);}
|
||||
.ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;}
|
||||
/* ── Message action buttons (copy, edit, retry) ── */
|
||||
@@ -344,7 +426,7 @@
|
||||
.msg-role > span{line-height:1;}
|
||||
|
||||
/* Composer wrap: slightly less padding on smaller heights */
|
||||
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;}
|
||||
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;position:relative;}
|
||||
|
||||
/* Cron status badges: pill shape refinement */
|
||||
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
|
||||
@@ -443,9 +525,14 @@
|
||||
transition:background .15s;
|
||||
}
|
||||
.resize-handle:hover,.resize-handle.dragging{background:rgba(124,185,255,.35);}
|
||||
.sidebar{position:relative;}
|
||||
/* Desktop-only: position:relative for sidebar/rightpanel resize handles.
|
||||
Must be scoped to min-width:641px so it doesn't override the mobile
|
||||
position:fixed slide-in overlay set in the max-width:640px @media block above. */
|
||||
@media(min-width:641px){
|
||||
.sidebar{position:relative;}
|
||||
.rightpanel{position:relative;}
|
||||
}
|
||||
.sidebar .resize-handle{right:-2px;}
|
||||
.rightpanel{position:relative;}
|
||||
.rightpanel .resize-handle{left:-2px;}
|
||||
/* Prevent text selection during drag */
|
||||
body.resizing{user-select:none;cursor:col-resize;}
|
||||
@@ -495,17 +582,9 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.gear-btn{font-size:13px;cursor:pointer;transition:color .15s,background .15s;}
|
||||
.gear-btn:hover{color:var(--text);background:rgba(255,255,255,.08);}
|
||||
|
||||
/* ── Session pin star ── */
|
||||
.session-pin{font-size:12px;cursor:pointer;opacity:0;transition:opacity .15s;padding:2px 4px;flex-shrink:0;}
|
||||
.session-item:hover .session-pin,.session-pin.pinned{opacity:1;}
|
||||
.session-pin.pinned{color:#f5c542;}
|
||||
|
||||
/* ── Session duplicate button ── */
|
||||
.session-dup,.session-action-btn{background:none;border:none;color:var(--muted);font-size:11px;cursor:pointer;opacity:0;transition:opacity .15s;padding:2px 4px;flex-shrink:0;}
|
||||
.session-item:hover .session-dup,.session-item:hover .session-action-btn{opacity:1;}
|
||||
.session-dup:hover,.session-action-btn:hover{color:var(--text);}
|
||||
.session-action-btn.has-project{opacity:.6;color:var(--blue);}
|
||||
.session-item:hover .session-action-btn.has-project{opacity:1;}
|
||||
/* ── Session pin indicator (inline, only when pinned) ── */
|
||||
.session-pin-indicator{flex-shrink:0;color:#f5c542;line-height:1;display:flex;align-items:center;}
|
||||
.session-pin-indicator svg{width:10px;height:10px;}
|
||||
|
||||
/* ── Cron alert badge ── */
|
||||
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
|
||||
@@ -540,7 +619,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.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-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:140px;max-width:220px;width:max-content;box-shadow:0 4px 16px rgba(0,0,0,.3);}
|
||||
.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);}
|
||||
.project-picker-item.active{color:var(--blue);}
|
||||
@@ -557,4 +636,16 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.tool-cards-toggle button{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;opacity:.6;padding:0;}
|
||||
.tool-cards-toggle button:hover{opacity:1;text-decoration:underline;}
|
||||
|
||||
/* ── Thinking/reasoning card ── */
|
||||
.thinking-card{background:rgba(201,168,76,.06);border:1px solid rgba(201,168,76,.2);border-radius:10px;margin:4px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
|
||||
.thinking-card:hover{border-color:rgba(201,168,76,.35);}
|
||||
.thinking-card-header{display:flex;align-items:center;gap:6px;padding:6px 12px;cursor:pointer;font-size:12px;color:var(--gold);user-select:none;}
|
||||
.thinking-card-icon{font-size:14px;}
|
||||
.thinking-card-label{font-weight:600;letter-spacing:.02em;}
|
||||
.thinking-card-toggle{margin-left:auto;font-size:10px;transition:transform .15s;}
|
||||
.thinking-card.open .thinking-card-toggle{transform:rotate(90deg);}
|
||||
.thinking-card-body{display:none;padding:0 12px 10px;max-height:300px;overflow-y:auto;}
|
||||
.thinking-card.open .thinking-card-body{display:block;}
|
||||
.thinking-card-body pre{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;line-height:1.5;color:var(--muted);white-space:pre-wrap;word-break:break-word;margin:0;}
|
||||
|
||||
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}
|
||||
|
||||
215
static/ui.js
215
static/ui.js
@@ -1,4 +1,4 @@
|
||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null};
|
||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.'};
|
||||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||||
const MSG_QUEUE=[]; // messages queued while a request is in-flight
|
||||
const $=id=>document.getElementById(id);
|
||||
@@ -81,6 +81,22 @@ function getModelLabel(modelId){
|
||||
|
||||
function renderMd(raw){
|
||||
let s=raw||'';
|
||||
// Pre-pass: convert safe inline HTML tags the model may emit into their
|
||||
// markdown equivalents so the pipeline can render them correctly.
|
||||
// Only runs OUTSIDE fenced code blocks and backtick spans (stash + restore).
|
||||
// Unsafe tags (anything not in the allowlist) are left as-is and will be
|
||||
// HTML-escaped by esc() when they reach an innerHTML assignment -- no XSS risk.
|
||||
const fence_stash=[];
|
||||
s=s.replace(/(```[\s\S]*?```|`[^`\n]+`)/g,m=>{fence_stash.push(m);return '\x00F'+(fence_stash.length-1)+'\x00';});
|
||||
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
|
||||
s=s.replace(/<strong>([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
|
||||
s=s.replace(/<b>([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
|
||||
s=s.replace(/<em>([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
|
||||
s=s.replace(/<i>([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*');
|
||||
s=s.replace(/<code>([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
|
||||
s=s.replace(/<br\s*\/?>/gi,'\n');
|
||||
// Restore stashed code blocks
|
||||
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
|
||||
// Mermaid blocks: render as diagram containers (processed after DOM insertion)
|
||||
s=s.replace(/```mermaid\n?([\s\S]*?)```/g,(_,code)=>{
|
||||
const id='mermaid-'+Math.random().toString(36).slice(2,10);
|
||||
@@ -88,12 +104,27 @@ function renderMd(raw){
|
||||
});
|
||||
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;});
|
||||
s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`);
|
||||
// inlineMd: process bold/italic/code/links within a single line of text.
|
||||
// Used inside list items and blockquotes where the text may already contain
|
||||
// HTML from the pre-pass → bold pipeline, so we cannot call esc() directly.
|
||||
function inlineMd(t){
|
||||
t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`<strong><em>${esc(x)}</em></strong>`);
|
||||
t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`<strong>${esc(x)}</strong>`);
|
||||
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`<em>${esc(x)}</em>`);
|
||||
t=t.replace(/`([^`\n]+)`/g,(_,x)=>`<code>${esc(x)}</code>`);
|
||||
t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>`<a href="${esc(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);
|
||||
// Escape any plain text that isn't already wrapped in a tag we produced
|
||||
// by escaping bare < > that aren't part of our own tags
|
||||
const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i;
|
||||
t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag));
|
||||
return t;
|
||||
}
|
||||
s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`<strong><em>${esc(t)}</em></strong>`);
|
||||
s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`<strong>${esc(t)}</strong>`);
|
||||
s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`<em>${esc(t)}</em>`);
|
||||
s=s.replace(/^### (.+)$/gm,(_,t)=>`<h3>${esc(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${esc(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${esc(t)}</h1>`);
|
||||
s=s.replace(/^### (.+)$/gm,(_,t)=>`<h3>${inlineMd(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${inlineMd(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${inlineMd(t)}</h1>`);
|
||||
s=s.replace(/^---+$/gm,'<hr>');
|
||||
s=s.replace(/^> (.+)$/gm,(_,t)=>`<blockquote>${esc(t)}</blockquote>`);
|
||||
s=s.replace(/^> (.+)$/gm,(_,t)=>`<blockquote>${inlineMd(t)}</blockquote>`);
|
||||
// B8: improved list handling supporting up to 2 levels of indentation
|
||||
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
|
||||
const lines=block.trimEnd().split('\n');
|
||||
@@ -101,8 +132,8 @@ function renderMd(raw){
|
||||
for(const l of lines){
|
||||
const indent=/^ {2,}/.test(l);
|
||||
const text=l.replace(/^ {0,4}[-*+] /,'');
|
||||
if(indent) html+=`<li style="margin-left:16px">${esc(text)}</li>`;
|
||||
else html+=`<li>${esc(text)}</li>`;
|
||||
if(indent) html+=`<li style="margin-left:16px">${inlineMd(text)}</li>`;
|
||||
else html+=`<li>${inlineMd(text)}</li>`;
|
||||
}
|
||||
return html+'</ul>';
|
||||
});
|
||||
@@ -111,7 +142,7 @@ function renderMd(raw){
|
||||
let html='<ol>';
|
||||
for(const l of lines){
|
||||
const text=l.replace(/^ {0,4}\d+\. /,'');
|
||||
html+=`<li>${esc(text)}</li>`;
|
||||
html+=`<li>${inlineMd(text)}</li>`;
|
||||
}
|
||||
return html+'</ol>';
|
||||
});
|
||||
@@ -128,6 +159,12 @@ function renderMd(raw){
|
||||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||||
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
||||
});
|
||||
// Escape any remaining HTML tags that are NOT from our own markdown output.
|
||||
// Our pipeline only emits: <strong>,<em>,<code>,<pre>,<h1-6>,<ul>,<ol>,<li>,
|
||||
// <table>,<thead>,<tbody>,<tr>,<th>,<td>,<hr>,<blockquote>,<p>,<br>,<a>,
|
||||
// <div class="..."> (mermaid/pre-header). Everything else is untrusted input.
|
||||
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div)([\s>]|$)/i;
|
||||
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
|
||||
const parts=s.split(/\n{2,}/);
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
return s;
|
||||
@@ -150,9 +187,25 @@ function setStatus(t){
|
||||
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
|
||||
}
|
||||
}
|
||||
function updateSendBtn(){
|
||||
const btn=$('btnSend');
|
||||
if(!btn) return;
|
||||
const hasContent=$('msg').value.trim().length>0||S.pendingFiles.length>0;
|
||||
const shouldShow=hasContent&&!S.busy;
|
||||
if(shouldShow&&btn.style.display==='none'){
|
||||
btn.style.display='';
|
||||
// Remove then re-add class to retrigger animation each time
|
||||
btn.classList.remove('visible');
|
||||
requestAnimationFrame(()=>btn.classList.add('visible'));
|
||||
} else if(!shouldShow&&btn.style.display!=='none'){
|
||||
btn.style.display='none';
|
||||
btn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
function setBusy(v){
|
||||
S.busy=v;
|
||||
$('btnSend').disabled=v;
|
||||
updateSendBtn();
|
||||
const dots=$('activityDots');
|
||||
if(dots) dots.style.display=v?'flex':'none';
|
||||
if(!v){
|
||||
@@ -328,9 +381,20 @@ function renderMessages(){
|
||||
for(let vi=0;vi<visWithIdx.length;vi++){
|
||||
const {m,rawIdx}=visWithIdx[vi];
|
||||
let content=m.content||'';
|
||||
if(Array.isArray(content))content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
|
||||
// Extract thinking/reasoning blocks from structured content (Claude extended thinking, o3)
|
||||
let thinkingText='';
|
||||
if(Array.isArray(content)){
|
||||
thinkingText=content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n');
|
||||
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
|
||||
}
|
||||
const isUser=m.role==='user';
|
||||
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
|
||||
// Render thinking card before the assistant message (collapsed by default)
|
||||
if(thinkingText&&!isUser){
|
||||
const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row';
|
||||
thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thinking</span><span class="thinking-card-toggle">▸</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`;
|
||||
inner.appendChild(thinkRow);
|
||||
}
|
||||
const row=document.createElement('div');row.className='msg-row';
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
let filesHtml='';
|
||||
@@ -668,27 +732,88 @@ function fileIcon(name, type){
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function renderBreadcrumb(){
|
||||
const bar=$('breadcrumbBar');
|
||||
const upBtn=$('btnUpDir');
|
||||
if(!bar)return;
|
||||
if(S.currentDir==='.'){
|
||||
bar.style.display='none';
|
||||
if(upBtn)upBtn.style.display='none';
|
||||
return;
|
||||
}
|
||||
bar.style.display='flex';
|
||||
if(upBtn)upBtn.style.display='';
|
||||
bar.innerHTML='';
|
||||
// Root segment
|
||||
const root=document.createElement('span');
|
||||
root.className='breadcrumb-seg breadcrumb-link';
|
||||
root.textContent='~';
|
||||
root.onclick=()=>loadDir('.');
|
||||
bar.appendChild(root);
|
||||
// Path segments
|
||||
const parts=S.currentDir.split('/');
|
||||
let accumulated='';
|
||||
for(let i=0;i<parts.length;i++){
|
||||
const sep=document.createElement('span');
|
||||
sep.className='breadcrumb-sep';sep.textContent='/';
|
||||
bar.appendChild(sep);
|
||||
accumulated+=(accumulated?'/':'')+parts[i];
|
||||
const seg=document.createElement('span');
|
||||
seg.textContent=parts[i];
|
||||
if(i<parts.length-1){
|
||||
seg.className='breadcrumb-seg breadcrumb-link';
|
||||
const target=accumulated;
|
||||
seg.onclick=()=>loadDir(target);
|
||||
} else {
|
||||
seg.className='breadcrumb-seg breadcrumb-current';
|
||||
}
|
||||
bar.appendChild(seg);
|
||||
}
|
||||
}
|
||||
|
||||
// Track expanded directories for tree view
|
||||
if(!S._expandedDirs) S._expandedDirs=new Set();
|
||||
// Cache of fetched directory contents: path -> entries[]
|
||||
if(!S._dirCache) S._dirCache={};
|
||||
|
||||
function renderFileTree(){
|
||||
const box=$('fileTree');box.innerHTML='';
|
||||
for(const item of S.entries){
|
||||
// Cache current dir entries
|
||||
S._dirCache[S.currentDir||'.']=S.entries;
|
||||
_renderTreeItems(box, S.entries, 0);
|
||||
}
|
||||
|
||||
function _renderTreeItems(container, entries, depth){
|
||||
for(const item of entries){
|
||||
const el=document.createElement('div');el.className='file-item';
|
||||
el.style.paddingLeft=(8+depth*16)+'px';
|
||||
|
||||
if(item.type==='dir'){
|
||||
// Toggle arrow for directories
|
||||
const arrow=document.createElement('span');
|
||||
arrow.className='file-tree-toggle';
|
||||
const isExpanded=S._expandedDirs.has(item.path);
|
||||
arrow.textContent=isExpanded?'\u25BE':'\u25B8';
|
||||
el.appendChild(arrow);
|
||||
}
|
||||
|
||||
// Icon
|
||||
const iconEl=document.createElement('span');
|
||||
iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type);
|
||||
el.appendChild(iconEl);
|
||||
|
||||
// Name -- takes all remaining space, truncates with ellipsis
|
||||
// Name
|
||||
const nameEl=document.createElement('span');
|
||||
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title='Double-click to rename';
|
||||
// Inline rename on double-click
|
||||
nameEl.ondblclick=(e)=>{
|
||||
e.stopPropagation();
|
||||
// For directories, double-click navigates (breadcrumb view)
|
||||
if(item.type==='dir'){loadDir(item.path);return;}
|
||||
const inp=document.createElement('input');
|
||||
inp.className='file-rename-input';inp.value=item.name;
|
||||
inp.onclick=(e2)=>e2.stopPropagation();
|
||||
const finish=async(save)=>{
|
||||
inp.onblur=null; // prevent double-call: Enter triggers blur after replaceWith
|
||||
inp.onblur=null;
|
||||
if(save){
|
||||
const newName=inp.value.trim();
|
||||
if(newName&&newName!==item.name){
|
||||
@@ -697,7 +822,9 @@ function renderFileTree(){
|
||||
session_id:S.session.session_id,path:item.path,new_name:newName
|
||||
})});
|
||||
showToast(`Renamed to ${newName}`);
|
||||
await loadDir('.');
|
||||
// Invalidate cache and re-render
|
||||
delete S._dirCache[S.currentDir];
|
||||
await loadDir(S.currentDir);
|
||||
}catch(err){showToast('Rename failed: '+err.message);}
|
||||
}
|
||||
}
|
||||
@@ -713,7 +840,7 @@ function renderFileTree(){
|
||||
};
|
||||
el.appendChild(nameEl);
|
||||
|
||||
// Size -- only for files, right-aligned, shrinks but never wraps
|
||||
// Size -- only for files
|
||||
if(item.type==='file'&&item.size){
|
||||
const sizeEl=document.createElement('span');
|
||||
sizeEl.className='file-size';
|
||||
@@ -721,16 +848,52 @@ function renderFileTree(){
|
||||
el.appendChild(sizeEl);
|
||||
}
|
||||
|
||||
// Delete button -- for files, shown on hover
|
||||
// Delete button -- for files
|
||||
if(item.type==='file'){
|
||||
const del=document.createElement('button');
|
||||
del.className='file-del-btn';del.title='Delete';del.textContent='×';
|
||||
del.className='file-del-btn';del.title='Delete';del.textContent='\u00d7';
|
||||
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);};
|
||||
el.appendChild(del);
|
||||
}
|
||||
|
||||
el.onclick=async()=>item.type==='dir'?loadDir(item.path):openFile(item.path);
|
||||
box.appendChild(el);
|
||||
if(item.type==='dir'){
|
||||
// Single-click toggles expand/collapse
|
||||
el.onclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
if(S._expandedDirs.has(item.path)){
|
||||
S._expandedDirs.delete(item.path);
|
||||
renderFileTree();
|
||||
}else{
|
||||
S._expandedDirs.add(item.path);
|
||||
// Fetch children if not cached
|
||||
if(!S._dirCache[item.path]){
|
||||
try{
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(item.path)}`);
|
||||
S._dirCache[item.path]=data.entries||[];
|
||||
}catch(e2){S._dirCache[item.path]=[];}
|
||||
}
|
||||
renderFileTree();
|
||||
}
|
||||
};
|
||||
}else{
|
||||
el.onclick=async()=>openFile(item.path);
|
||||
}
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
// Render children if directory is expanded
|
||||
if(item.type==='dir'&&S._expandedDirs.has(item.path)){
|
||||
const children=S._dirCache[item.path]||[];
|
||||
if(children.length){
|
||||
_renderTreeItems(container, children, depth+1);
|
||||
}else{
|
||||
const empty=document.createElement('div');
|
||||
empty.className='file-item file-empty';
|
||||
empty.style.paddingLeft=(8+(depth+1)*16)+'px';
|
||||
empty.textContent='(empty)';
|
||||
container.appendChild(empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,7 +905,7 @@ async function deleteWorkspaceFile(relPath, name){
|
||||
showToast(`Deleted ${name}`);
|
||||
// Close preview if we just deleted the viewed file
|
||||
if($('previewPathText').textContent===relPath)$('btnClearPreview').onclick();
|
||||
await loadDir('.');
|
||||
await loadDir(S.currentDir);
|
||||
}catch(e){setStatus('Delete failed: '+e.message);}
|
||||
}
|
||||
|
||||
@@ -750,12 +913,12 @@ async function promptNewFile(){
|
||||
if(!S.session)return;
|
||||
const name=prompt('New file name (e.g. notes.md):','');
|
||||
if(!name||!name.trim())return;
|
||||
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
|
||||
try{
|
||||
await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim(),content:''})});
|
||||
await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,content:''})});
|
||||
showToast(`Created ${name.trim()}`);
|
||||
await loadDir('.');
|
||||
// Open the new file immediately
|
||||
openFile(name.trim());
|
||||
await loadDir(S.currentDir);
|
||||
openFile(relPath);
|
||||
}catch(e){setStatus('Create failed: '+e.message);}
|
||||
}
|
||||
|
||||
@@ -763,17 +926,19 @@ async function promptNewFolder(){
|
||||
if(!S.session)return;
|
||||
const name=prompt('New folder name:','');
|
||||
if(!name||!name.trim())return;
|
||||
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
|
||||
try{
|
||||
await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim()})});
|
||||
await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
|
||||
showToast(`Created folder ${name.trim()}`);
|
||||
await loadDir('.');
|
||||
await loadDir(S.currentDir);
|
||||
}catch(e){setStatus('Create folder failed: '+e.message);}
|
||||
}
|
||||
|
||||
function renderTray(){
|
||||
const tray=$('attachTray');tray.innerHTML='';
|
||||
if(!S.pendingFiles.length){tray.classList.remove('has-files');return;}
|
||||
if(!S.pendingFiles.length){tray.classList.remove('has-files');updateSendBtn();return;}
|
||||
tray.classList.add('has-files');
|
||||
updateSendBtn();
|
||||
S.pendingFiles.forEach((f,i)=>{
|
||||
const chip=document.createElement('div');chip.className='attach-chip';
|
||||
chip.innerHTML=`📎 ${esc(f.name)} <button title="Remove">✕</button>`;
|
||||
|
||||
@@ -9,11 +9,27 @@ async function api(path,opts={}){
|
||||
async function loadDir(path){
|
||||
if(!S.session)return;
|
||||
try{
|
||||
if(!path||path==='.'){ S._dirCache={}; if(S._expandedDirs)S._expandedDirs=new Set(); }
|
||||
S.currentDir=path||'.';
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
S.entries=data.entries||[];renderFileTree();
|
||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
||||
if(typeof clearPreview==='function'){
|
||||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
||||
if(confirm('You have unsaved changes in the preview. Discard and navigate?'))clearPreview();
|
||||
}else{
|
||||
clearPreview();
|
||||
}
|
||||
}
|
||||
}catch(e){console.warn('loadDir',e);}
|
||||
}
|
||||
|
||||
function navigateUp(){
|
||||
if(!S.session||S.currentDir==='.')return;
|
||||
const parts=S.currentDir.split('/');
|
||||
parts.pop();
|
||||
loadDir(parts.length?parts.join('/'):'.');
|
||||
}
|
||||
|
||||
// File extension sets for preview routing (must match server-side sets)
|
||||
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
||||
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
||||
|
||||
@@ -438,3 +438,37 @@ def test_newSession_clears_live_tool_cards(cleanup_test_sessions):
|
||||
next_fn = src.find("async function ", new_sess_idx + 10)
|
||||
new_sess_body = src[new_sess_idx:next_fn]
|
||||
assert "clearLiveToolCards" in new_sess_body, "newSession() must call clearLiveToolCards() to clear stale live cards"
|
||||
|
||||
|
||||
# ── R16: Stack traces must not leak to clients in 500 responses ────────────
|
||||
|
||||
def test_500_response_has_no_trace_field():
|
||||
"""R16: HTTP 500 responses must not include a 'trace' field.
|
||||
Leaking tracebacks exposes file paths, module names, and potentially
|
||||
secret values from local variables.
|
||||
"""
|
||||
# POST to /api/chat/start with missing required fields to trigger an error
|
||||
data, status = post("/api/chat/start", {})
|
||||
# Should be an error response (4xx or 5xx)
|
||||
assert "trace" not in data, \
|
||||
"Server must not leak stack traces to clients"
|
||||
|
||||
def test_upload_error_has_no_trace_field():
|
||||
"""R16b: Upload 500 responses must not include a 'trace' field."""
|
||||
# Send a POST to /api/upload with invalid content to trigger the error handler
|
||||
req = urllib.request.Request(
|
||||
BASE + "/api/upload",
|
||||
data=b"not-multipart-data",
|
||||
headers={"Content-Type": "text/plain", "Content-Length": "18"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
body = json.loads(r.read())
|
||||
code = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
body = json.loads(e.read())
|
||||
code = e.code
|
||||
assert code >= 400, "Invalid upload should return an error status"
|
||||
assert "trace" not in body, \
|
||||
"Upload errors must not leak stack traces to clients"
|
||||
assert "error" in body, "Error responses must include an 'error' key"
|
||||
|
||||
709
tests/test_sprint16.py
Normal file
709
tests/test_sprint16.py
Normal file
@@ -0,0 +1,709 @@
|
||||
"""
|
||||
Sprint 16 Tests: safe HTML rendering in renderMd(), active session styling,
|
||||
session sidebar polish (SVG icons, overlay actions).
|
||||
"""
|
||||
import html as _html
|
||||
import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_text(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return r.read().decode("utf-8"), r.status
|
||||
|
||||
|
||||
def esc(s):
|
||||
"""Mirror of esc() in ui.js — HTML-escapes a string."""
|
||||
return _html.escape(str(s), quote=True)
|
||||
|
||||
|
||||
SAFE_TAGS = re.compile(
|
||||
r"^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td"
|
||||
r"|hr|blockquote|p|br|a|div)([\s>]|$)",
|
||||
re.I,
|
||||
)
|
||||
SAFE_INLINE = re.compile(r"^<\/?(strong|em|code|a)([\s>]|$)", re.I)
|
||||
|
||||
|
||||
def inline_md(t):
|
||||
"""Mirror of inlineMd() in ui.js — for use inside list items / blockquotes."""
|
||||
t = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: "<strong><em>" + esc(m.group(1)) + "</em></strong>", t)
|
||||
t = re.sub(r"\*\*(.+?)\*\*", lambda m: "<strong>" + esc(m.group(1)) + "</strong>", t)
|
||||
t = re.sub(r"\*([^*\n]+)\*", lambda m: "<em>" + esc(m.group(1)) + "</em>", t)
|
||||
t = re.sub(r"`([^`\n]+)`", lambda m: "<code>" + esc(m.group(1)) + "</code>", t)
|
||||
t = re.sub(
|
||||
r"\[([^\]]+)\]\((https?://[^\)]+)\)",
|
||||
lambda m: f'<a href="{esc(m.group(2))}" target="_blank" rel="noopener">{esc(m.group(1))}</a>',
|
||||
t,
|
||||
)
|
||||
t = re.sub(r"</?[a-zA-Z][^>]*>", lambda m: m.group() if SAFE_INLINE.match(m.group()) else esc(m.group()), t)
|
||||
return t
|
||||
|
||||
|
||||
def render_md(raw):
|
||||
"""
|
||||
Python mirror of renderMd() in static/ui.js.
|
||||
Kept in sync with the JS implementation so tests catch regressions
|
||||
if the JS logic drifts from the documented behaviour.
|
||||
"""
|
||||
s = raw or ""
|
||||
|
||||
# Pre-pass: stash code blocks/spans, convert safe HTML → markdown equivalents
|
||||
fence_stash = []
|
||||
|
||||
def stash(m):
|
||||
fence_stash.append(m.group())
|
||||
return "\x00F" + str(len(fence_stash) - 1) + "\x00"
|
||||
|
||||
s = re.sub(r"(```[\s\S]*?```|`[^`\n]+`)", stash, s)
|
||||
s = re.sub(r"<strong>([\s\S]*?)</strong>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
|
||||
s = re.sub(r"<b>([\s\S]*?)</b>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
|
||||
s = re.sub(r"<em>([\s\S]*?)</em>", lambda m: "*" + m.group(1) + "*", s, flags=re.I)
|
||||
s = re.sub(r"<i>([\s\S]*?)</i>", lambda m: "*" + m.group(1) + "*", s, flags=re.I)
|
||||
s = re.sub(r"<code>([^<]*?)</code>", lambda m: "`" + m.group(1) + "`", s, flags=re.I)
|
||||
s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
|
||||
s = re.sub(r"\x00F(\d+)\x00", lambda m: fence_stash[int(m.group(1))], s)
|
||||
|
||||
# Fenced code blocks
|
||||
def fenced(m):
|
||||
lang, code = m.group(1), m.group(2).rstrip("\n")
|
||||
h = f'<div class="pre-header">{esc(lang)}</div>' if lang else ""
|
||||
return h + "<pre><code>" + esc(code) + "</code></pre>"
|
||||
s = re.sub(r"```([\w+-]*)\n?([\s\S]*?)```", fenced, s)
|
||||
s = re.sub(r"`([^`\n]+)`", lambda m: "<code>" + esc(m.group(1)) + "</code>", s)
|
||||
|
||||
# Inline formatting (top-level, outside list items)
|
||||
s = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: "<strong><em>" + esc(m.group(1)) + "</em></strong>", s)
|
||||
s = re.sub(r"\*\*(.+?)\*\*", lambda m: "<strong>" + esc(m.group(1)) + "</strong>", s)
|
||||
s = re.sub(r"\*([^*\n]+)\*", lambda m: "<em>" + esc(m.group(1)) + "</em>", s)
|
||||
|
||||
# Block elements using inlineMd for their content
|
||||
s = re.sub(r"^### (.+)$", lambda m: "<h3>" + inline_md(m.group(1)) + "</h3>", s, flags=re.M)
|
||||
s = re.sub(r"^## (.+)$", lambda m: "<h2>" + inline_md(m.group(1)) + "</h2>", s, flags=re.M)
|
||||
s = re.sub(r"^# (.+)$", lambda m: "<h1>" + inline_md(m.group(1)) + "</h1>", s, flags=re.M)
|
||||
s = re.sub(r"^---+$", "<hr>", s, flags=re.M)
|
||||
s = re.sub(r"^> (.+)$", lambda m: "<blockquote>" + inline_md(m.group(1)) + "</blockquote>", s, flags=re.M)
|
||||
|
||||
def handle_ul(block):
|
||||
lines = block.strip().split("\n")
|
||||
out = "<ul>"
|
||||
for l in lines:
|
||||
indent = bool(re.match(r"^ {2,}", l))
|
||||
text = re.sub(r"^ {0,4}[-*+] ", "", l)
|
||||
style = ' style="margin-left:16px"' if indent else ""
|
||||
out += f"<li{style}>{inline_md(text)}</li>"
|
||||
return out + "</ul>"
|
||||
|
||||
s = re.sub(r"((?:^(?: )?[-*+] .+\n?)+)", lambda m: handle_ul(m.group()), s, flags=re.M)
|
||||
|
||||
def handle_ol(block):
|
||||
lines = block.strip().split("\n")
|
||||
out = "<ol>"
|
||||
for l in lines:
|
||||
text = re.sub(r"^ {0,4}\d+\. ", "", l)
|
||||
out += f"<li>{inline_md(text)}</li>"
|
||||
return out + "</ol>"
|
||||
|
||||
s = re.sub(r"((?:^(?: )?\d+\. .+\n?)+)", lambda m: handle_ol(m.group()), s, flags=re.M)
|
||||
|
||||
# Safety net: escape unknown tags in remaining text
|
||||
s = re.sub(r"</?[a-zA-Z][^>]*>", lambda m: m.group() if SAFE_TAGS.match(m.group()) else esc(m.group()), s)
|
||||
|
||||
# Paragraph wrap
|
||||
parts = s.split("\n\n")
|
||||
def wrap(p):
|
||||
p = p.strip()
|
||||
if not p: return ""
|
||||
if re.match(r"^<(h[1-6]|ul|ol|pre|hr|blockquote)", p): return p
|
||||
return "<p>" + p.replace("\n", "<br>") + "</p>"
|
||||
s = "\n".join(wrap(p) for p in parts)
|
||||
return s
|
||||
|
||||
|
||||
# ── Static analysis: verify key structures exist in ui.js ────────────────────
|
||||
|
||||
def test_render_md_pre_pass_converts_strong(cleanup_test_sessions):
|
||||
"""ui.js renderMd() must have pre-pass that converts <strong> to **."""
|
||||
src = REPO_ROOT / "static" / "ui.js"
|
||||
code = src.read_text()
|
||||
assert "<strong>" in code and "**" in code, "pre-pass for <strong> not found"
|
||||
# Verify the specific conversion pattern
|
||||
assert re.search(r"<strong>.*?\*\*", code, re.S), \
|
||||
"renderMd pre-pass should convert <strong>...</strong> to **...**"
|
||||
|
||||
|
||||
def test_render_md_has_safety_net(cleanup_test_sessions):
|
||||
"""ui.js must have a safety-net that escapes unknown HTML tags after the pipeline."""
|
||||
src = REPO_ROOT / "static" / "ui.js"
|
||||
code = src.read_text()
|
||||
assert "SAFE_TAGS" in code, "SAFE_TAGS allowlist regex not found in ui.js"
|
||||
assert "esc(tag)" in code, "safety-net esc(tag) call not found in ui.js"
|
||||
|
||||
|
||||
def test_render_md_stashes_code_blocks(cleanup_test_sessions):
|
||||
"""ui.js pre-pass must stash code blocks before replacing safe HTML tags."""
|
||||
src = REPO_ROOT / "static" / "ui.js"
|
||||
code = src.read_text()
|
||||
assert "fence_stash" in code, "fence_stash not found in renderMd pre-pass"
|
||||
|
||||
|
||||
def test_render_md_handles_br_tag(cleanup_test_sessions):
|
||||
"""ui.js must convert <br> to newline in pre-pass."""
|
||||
src = REPO_ROOT / "static" / "ui.js"
|
||||
code = src.read_text()
|
||||
assert re.search(r"<br\\s\*", code) or "<br" in code, "<br> handling not found"
|
||||
|
||||
|
||||
def test_render_md_no_placeholder_remnants(cleanup_test_sessions):
|
||||
"""Old Unicode placeholder approach (\\uE001-\\uE005) must be gone."""
|
||||
src = REPO_ROOT / "static" / "ui.js"
|
||||
code = src.read_text()
|
||||
for old_ph in ["\\uE001", "\\uE002", "\\uE003", "\\uE004", "\\uE005"]:
|
||||
assert old_ph not in code, \
|
||||
f"Old placeholder {old_ph} still present — broken implementation not cleaned up"
|
||||
|
||||
|
||||
def test_render_md_safe_tag_allowlist_complete(cleanup_test_sessions):
|
||||
"""SAFE_TAGS allowlist must include all tags the pipeline emits."""
|
||||
src = REPO_ROOT / "static" / "ui.js"
|
||||
code = src.read_text()
|
||||
required = ["strong", "em", "code", "pre", "ul", "ol", "li",
|
||||
"table", "blockquote", "hr", "br", "a", "div"]
|
||||
safe_tags_match = re.search(r"SAFE_TAGS\s*=\s*/(.+?)/i", code)
|
||||
assert safe_tags_match, "SAFE_TAGS regex not found"
|
||||
pattern = safe_tags_match.group(1)
|
||||
for tag in required:
|
||||
assert tag in pattern, f"Tag '{tag}' missing from SAFE_TAGS allowlist"
|
||||
|
||||
|
||||
# ── Behavioural: renderMd logic via Python mirror ─────────────────────────────
|
||||
|
||||
def test_render_md_markdown_bold(cleanup_test_sessions):
|
||||
"""**word** markdown renders as <strong>word</strong>."""
|
||||
out = render_md("Hello **world**")
|
||||
assert "<strong>world</strong>" in out
|
||||
|
||||
|
||||
def test_render_md_html_strong_passthrough(cleanup_test_sessions):
|
||||
"""<strong>word</strong> in AI output renders as bold."""
|
||||
out = render_md("Hello <strong>world</strong>")
|
||||
assert "<strong>world</strong>" in out
|
||||
|
||||
|
||||
def test_render_md_html_b_tag(cleanup_test_sessions):
|
||||
"""<b>word</b> renders as <strong>word</strong>."""
|
||||
out = render_md("Hello <b>world</b>")
|
||||
assert "<strong>world</strong>" in out
|
||||
|
||||
|
||||
def test_render_md_html_em_passthrough(cleanup_test_sessions):
|
||||
"""<em>word</em> renders as italic."""
|
||||
out = render_md("Hello <em>world</em>")
|
||||
assert "<em>world</em>" in out
|
||||
|
||||
|
||||
def test_render_md_html_i_tag(cleanup_test_sessions):
|
||||
"""<i>word</i> renders as <em>word</em>."""
|
||||
out = render_md("Hello <i>word</i>")
|
||||
assert "<em>word</em>" in out
|
||||
|
||||
|
||||
def test_render_md_html_code_passthrough(cleanup_test_sessions):
|
||||
"""<code>text</code> renders as inline code."""
|
||||
out = render_md("use <code>print()</code>")
|
||||
assert "<code>print()</code>" in out
|
||||
|
||||
|
||||
def test_render_md_html_br_becomes_newline(cleanup_test_sessions):
|
||||
"""<br> in AI output becomes a newline (rendered as <br> inside <p> later)."""
|
||||
out = render_md("line one<br>line two")
|
||||
assert "line one\nline two" in out or "line one<br>line two" in out
|
||||
|
||||
|
||||
def test_render_md_mixed_markdown_and_html(cleanup_test_sessions):
|
||||
"""Markdown and HTML formatting can coexist in the same response."""
|
||||
out = render_md("**markdown** and <strong>html</strong>")
|
||||
assert "<strong>markdown</strong>" in out
|
||||
assert "<strong>html</strong>" in out
|
||||
|
||||
|
||||
def test_render_md_html_strong_in_list_item(cleanup_test_sessions):
|
||||
"""THE SCREENSHOT BUG: <strong> tags inside list items must render as bold,
|
||||
not as escaped literal text like <strong>."""
|
||||
out = render_md(
|
||||
"- <strong>All items</strong> get `border-radius: 0 8px 8px 0`\n"
|
||||
"- <strong>Active item</strong> uses <code>#e8a030</code>\n"
|
||||
"- <strong>Project items</strong> show their color\n"
|
||||
"- <strong>Regular items</strong> stay muted"
|
||||
)
|
||||
assert "<strong>" not in out, \
|
||||
"Escaped <strong> literal found in list output — bold not rendering"
|
||||
assert "<strong>All items</strong>" in out
|
||||
assert "<strong>Active item</strong>" in out
|
||||
assert "<code>border-radius: 0 8px 8px 0</code>" in out
|
||||
assert "<code>#e8a030</code>" in out
|
||||
|
||||
|
||||
def test_render_md_exact_screenshot_content(cleanup_test_sessions):
|
||||
"""Exact text from the ui-changes-unrendered-html-tags.png screenshot.
|
||||
This is the canonical regression test for the inlineMd fix.
|
||||
All four bullet points must render <strong> and <code> as HTML, not literal text."""
|
||||
out = render_md(
|
||||
"- <strong>All items</strong> now have <code>border-radius: 0 8px 8px 0</code>"
|
||||
" \u2014 straight left edge everywhere, rounded on the right\n"
|
||||
"- <strong>Active item</strong> is now gold/amber (<code>#e8a030</code>)"
|
||||
" \u2014 same warm gold used in the logo \u2014 instead of blue,"
|
||||
" so it stands out distinctly from everything else\n"
|
||||
"- <strong>Project items</strong> still show their project color on the left"
|
||||
" border, but only when they're not the active item (active always wins with gold)\n"
|
||||
"- <strong>Regular items</strong> (no project) still have no left border color"
|
||||
)
|
||||
# None of the safe tags should appear as literal escaped text
|
||||
assert "<strong>" not in out, \
|
||||
"Literal <strong> found — <strong> is not rendering as bold"
|
||||
assert "</strong>" not in out, \
|
||||
"Literal </strong> found — closing tag is not rendering"
|
||||
assert "<code>" not in out, \
|
||||
"Literal <code> found — <code> is not rendering as inline code"
|
||||
# Each item's bold label must render correctly
|
||||
assert "<strong>All items</strong>" in out
|
||||
assert "<strong>Active item</strong>" in out
|
||||
assert "<strong>Project items</strong>" in out
|
||||
assert "<strong>Regular items</strong>" in out
|
||||
# The code spans in items 1 and 2 must render correctly
|
||||
assert "<code>border-radius: 0 8px 8px 0</code>" in out
|
||||
assert "<code>#e8a030</code>" in out
|
||||
# The surrounding prose text must be preserved
|
||||
assert "straight left edge everywhere" in out
|
||||
assert "same warm gold used in the logo" in out
|
||||
assert "active always wins with gold" in out
|
||||
|
||||
|
||||
def test_render_md_markdown_bold_in_list_item(cleanup_test_sessions):
|
||||
"""**bold** markdown inside list items must render as <strong>."""
|
||||
out = render_md("- **First** item\n- **Second** item with `code`")
|
||||
assert "<strong>First</strong>" in out
|
||||
assert "<strong>Second</strong>" in out
|
||||
assert "<code>code</code>" in out
|
||||
|
||||
|
||||
def test_render_md_html_strong_in_blockquote(cleanup_test_sessions):
|
||||
"""<strong> inside blockquote must render as bold."""
|
||||
out = render_md("> <strong>Note:</strong> pay attention")
|
||||
assert "<strong>" not in out
|
||||
assert "<strong>Note:</strong>" in out
|
||||
|
||||
|
||||
def test_render_md_html_strong_in_heading(cleanup_test_sessions):
|
||||
"""<strong> inside a heading must render as bold."""
|
||||
out = render_md("## <strong>Important</strong> Section")
|
||||
assert "<strong>" not in out
|
||||
assert "<strong>Important</strong>" in out
|
||||
|
||||
|
||||
def test_render_md_xss_in_list_still_blocked(cleanup_test_sessions):
|
||||
"""XSS attempts in list items must still be escaped."""
|
||||
out = render_md("- <img src=x onerror=alert(1)> bad")
|
||||
assert "<img" not in out
|
||||
assert "<img" in out
|
||||
|
||||
|
||||
def test_render_md_xss_in_blockquote_still_blocked(cleanup_test_sessions):
|
||||
"""XSS in blockquote must still be escaped."""
|
||||
out = render_md("> <script>alert(1)</script>")
|
||||
assert "<script>" not in out
|
||||
assert "<script" in out
|
||||
|
||||
|
||||
def test_render_md_code_span_in_list_protected(cleanup_test_sessions):
|
||||
"""Backtick code span in list item must escape its content."""
|
||||
out = render_md("- Use `<br>` for breaks")
|
||||
assert "<code><br></code>" in out
|
||||
|
||||
|
||||
def test_render_md_code_block_protects_html(cleanup_test_sessions):
|
||||
"""HTML inside a backtick code span must NOT be converted — shown as literal."""
|
||||
out = render_md("keep `<strong>literal</strong>` safe")
|
||||
assert "<strong>" in out, "HTML inside code span should be escaped"
|
||||
assert "<strong>literal</strong>" not in out, "HTML inside code span should NOT render as bold"
|
||||
|
||||
|
||||
def test_render_md_fenced_code_protects_html(cleanup_test_sessions):
|
||||
"""HTML inside a fenced code block must not be converted by the pre-pass.
|
||||
The fenced block is stashed before tag replacement runs, so the raw HTML
|
||||
is preserved intact for the pipeline's esc() to escape when rendering
|
||||
the <pre><code> block. We verify the stash/restore mechanism works by
|
||||
checking the content is unchanged after the pre-pass (i.e. still contains
|
||||
the original tag text, not converted to **not bold**)."""
|
||||
src = "```\n<strong>not bold</strong>\n```"
|
||||
out = render_md(src)
|
||||
# Pre-pass stash preserves the raw content -- it should NOT have been
|
||||
# converted to **not bold** (which would render as bold outside the fence)
|
||||
assert "**not bold**" not in out, \
|
||||
"Fenced code content was incorrectly converted to markdown by the pre-pass"
|
||||
# The raw content should still be present (stash/restore worked)
|
||||
assert "<strong>not bold</strong>" in out or "<strong>" in out, \
|
||||
"Fenced code content was lost after stash/restore"
|
||||
|
||||
|
||||
# ── Security: XSS must be blocked ─────────────────────────────────────────────
|
||||
|
||||
def test_render_md_xss_img_tag_escaped(cleanup_test_sessions):
|
||||
"""<img src=x onerror=alert(1)> must be HTML-escaped, not rendered."""
|
||||
out = render_md("<img src=x onerror=alert(1)>")
|
||||
assert "<img" not in out, "Raw <img> tag must not appear in output"
|
||||
assert "<img" in out, "<img> must be HTML-escaped"
|
||||
|
||||
|
||||
def test_render_md_xss_script_tag_escaped(cleanup_test_sessions):
|
||||
"""<script>alert(1)</script> must be HTML-escaped."""
|
||||
out = render_md("<script>alert(1)</script>")
|
||||
assert "<script>" not in out, "Raw <script> tag must not appear in output"
|
||||
assert "<script" in out, "<script> must be HTML-escaped"
|
||||
|
||||
|
||||
def test_render_md_xss_iframe_escaped(cleanup_test_sessions):
|
||||
"""<iframe> must be HTML-escaped."""
|
||||
out = render_md("<iframe src='evil.com'></iframe>")
|
||||
assert "<iframe" not in out
|
||||
assert "<iframe" in out
|
||||
|
||||
|
||||
def test_render_md_xss_svg_onerror_escaped(cleanup_test_sessions):
|
||||
"""<svg onload=...> must be HTML-escaped."""
|
||||
out = render_md("<svg onload=alert(1)>")
|
||||
assert "<svg" not in out
|
||||
assert "<svg" in out
|
||||
|
||||
|
||||
def test_render_md_xss_in_bold_text_escaped(cleanup_test_sessions):
|
||||
"""**<img onerror=...>** — XSS inside markdown bold must be escaped."""
|
||||
out = render_md("**<img src=x onerror=alert(1)>**")
|
||||
assert "<img" not in out, "XSS inside **bold** must be escaped"
|
||||
assert "<img" in out
|
||||
|
||||
|
||||
def test_render_md_xss_in_html_strong_escaped(cleanup_test_sessions):
|
||||
"""<strong><img ...></strong> — nested XSS inside HTML strong must be escaped."""
|
||||
out = render_md("<strong><img src=x onerror=alert(1)></strong>")
|
||||
# <strong> converts to ** which then escapes the inner content via esc()
|
||||
assert "<img" not in out, "XSS nested inside <strong> must be escaped"
|
||||
|
||||
|
||||
def test_render_md_xss_object_tag_escaped(cleanup_test_sessions):
|
||||
"""<object data=...> must be HTML-escaped."""
|
||||
out = render_md("<object data='evil.swf'></object>")
|
||||
assert "<object" not in out
|
||||
assert "<object" in out
|
||||
|
||||
|
||||
# ── Sprint 16 sidebar: static structure checks ───────────────────────────────
|
||||
|
||||
# ── Exhaustive inlineMd / renderMd edge-case tests ───────────────────────────
|
||||
|
||||
# --- Unordered list variants ---
|
||||
|
||||
def test_list_bold_only(cleanup_test_sessions):
|
||||
"""Single bold word in list item."""
|
||||
out = render_md("- **bold**")
|
||||
assert "<strong>bold</strong>" in out
|
||||
assert "<" not in out
|
||||
|
||||
def test_list_italic_only(cleanup_test_sessions):
|
||||
"""Single italic word in list item."""
|
||||
out = render_md("- *italic*")
|
||||
assert "<em>italic</em>" in out
|
||||
|
||||
def test_list_code_only(cleanup_test_sessions):
|
||||
"""Single code span in list item."""
|
||||
out = render_md("- `code`")
|
||||
assert "<code>code</code>" in out
|
||||
|
||||
def test_list_bold_and_code_mixed(cleanup_test_sessions):
|
||||
"""Bold and code together in one list item."""
|
||||
out = render_md("- **run** `pip install foo`")
|
||||
assert "<strong>run</strong>" in out
|
||||
assert "<code>pip install foo</code>" in out
|
||||
|
||||
def test_list_html_strong_and_code_mixed(cleanup_test_sessions):
|
||||
"""HTML <strong> and <code> together — the exact screenshot scenario."""
|
||||
out = render_md("- <strong>Key</strong>: use <code>value</code>")
|
||||
assert "<strong>Key</strong>" in out
|
||||
assert "<code>value</code>" in out
|
||||
assert "<strong>" not in out
|
||||
assert "<code>" not in out
|
||||
|
||||
def test_list_html_em(cleanup_test_sessions):
|
||||
"""HTML <em> in list item renders as italic."""
|
||||
out = render_md("- <em>emphasized</em> text")
|
||||
assert "<em>emphasized</em>" in out
|
||||
assert "<em>" not in out
|
||||
|
||||
def test_list_html_b_tag(cleanup_test_sessions):
|
||||
"""HTML <b> in list item renders as bold."""
|
||||
out = render_md("- <b>bold via b tag</b>")
|
||||
assert "<strong>bold via b tag</strong>" in out
|
||||
assert "<b>" not in out
|
||||
|
||||
def test_list_html_i_tag(cleanup_test_sessions):
|
||||
"""HTML <i> in list item renders as italic."""
|
||||
out = render_md("- <i>italic via i tag</i>")
|
||||
assert "<em>italic via i tag</em>" in out
|
||||
assert "<i>" not in out
|
||||
|
||||
def test_list_multiple_items_each_formatted(cleanup_test_sessions):
|
||||
"""Multiple list items each with different formatting."""
|
||||
out = render_md(
|
||||
"- **bold item**\n"
|
||||
"- *italic item*\n"
|
||||
"- `code item`\n"
|
||||
"- plain item"
|
||||
)
|
||||
assert "<strong>bold item</strong>" in out
|
||||
assert "<em>italic item</em>" in out
|
||||
assert "<code>code item</code>" in out
|
||||
assert "<li>plain item</li>" in out
|
||||
|
||||
def test_list_item_bold_mid_sentence(cleanup_test_sessions):
|
||||
"""Bold in middle of a list item sentence."""
|
||||
out = render_md("- Set the **timeout** to 30 seconds")
|
||||
assert "<strong>timeout</strong>" in out
|
||||
assert "Set the" in out
|
||||
assert "to 30 seconds" in out
|
||||
|
||||
def test_list_item_multiple_bold_spans(cleanup_test_sessions):
|
||||
"""Multiple bold spans in one list item."""
|
||||
out = render_md("- **A** and **B** are both important")
|
||||
assert "<strong>A</strong>" in out
|
||||
assert "<strong>B</strong>" in out
|
||||
|
||||
def test_ordered_list_bold(cleanup_test_sessions):
|
||||
"""Bold text inside ordered list items."""
|
||||
out = render_md("1. **First** step\n2. **Second** step\n3. Plain step")
|
||||
assert "<ol>" in out
|
||||
assert "<strong>First</strong>" in out
|
||||
assert "<strong>Second</strong>" in out
|
||||
assert "<li>Plain step</li>" in out
|
||||
|
||||
def test_ordered_list_html_strong(cleanup_test_sessions):
|
||||
"""HTML <strong> inside ordered list items renders correctly."""
|
||||
out = render_md("1. <strong>Install</strong> the package\n2. <strong>Configure</strong> the settings")
|
||||
assert "<ol>" in out
|
||||
assert "<strong>Install</strong>" in out
|
||||
assert "<strong>Configure</strong>" in out
|
||||
assert "<strong>" not in out
|
||||
|
||||
def test_ordered_list_code_spans(cleanup_test_sessions):
|
||||
"""Code spans inside ordered list items."""
|
||||
out = render_md("1. Run `npm install`\n2. Run `npm start`")
|
||||
assert "<code>npm install</code>" in out
|
||||
assert "<code>npm start</code>" in out
|
||||
|
||||
def test_indented_list_item_bold(cleanup_test_sessions):
|
||||
"""Bold inside indented (nested) list item."""
|
||||
out = render_md("- top level\n - **nested bold**")
|
||||
assert "<strong>nested bold</strong>" in out
|
||||
assert "margin-left:16px" in out
|
||||
|
||||
# --- Blockquote variants ---
|
||||
|
||||
def test_blockquote_plain(cleanup_test_sessions):
|
||||
"""Plain blockquote wraps in <blockquote>."""
|
||||
out = render_md("> simple quote")
|
||||
assert "<blockquote>simple quote</blockquote>" in out
|
||||
|
||||
def test_blockquote_bold(cleanup_test_sessions):
|
||||
"""**bold** inside blockquote renders correctly."""
|
||||
out = render_md("> **important** note")
|
||||
assert "<strong>important</strong>" in out
|
||||
|
||||
def test_blockquote_html_strong(cleanup_test_sessions):
|
||||
"""<strong> inside blockquote renders as bold."""
|
||||
out = render_md("> <strong>Warning:</strong> read this")
|
||||
assert "<strong>Warning:</strong>" in out
|
||||
assert "<strong>" not in out
|
||||
|
||||
def test_blockquote_code_span(cleanup_test_sessions):
|
||||
"""Code span inside blockquote renders correctly."""
|
||||
out = render_md("> Use `git commit` to save")
|
||||
assert "<code>git commit</code>" in out
|
||||
|
||||
def test_blockquote_mixed_formatting(cleanup_test_sessions):
|
||||
"""Mixed bold and code in blockquote."""
|
||||
out = render_md("> **Note:** run `pip install foo` first")
|
||||
assert "<strong>Note:</strong>" in out
|
||||
assert "<code>pip install foo</code>" in out
|
||||
|
||||
def test_blockquote_xss_blocked(cleanup_test_sessions):
|
||||
"""XSS in blockquote content must be escaped."""
|
||||
out = render_md("> <img src=x onerror=alert(1)>")
|
||||
assert "<img" in out
|
||||
assert "<img" not in out
|
||||
|
||||
# --- Heading variants ---
|
||||
|
||||
def test_heading_h1_bold(cleanup_test_sessions):
|
||||
"""Bold inside h1 renders correctly."""
|
||||
out = render_md("# **Main** Title")
|
||||
assert "<h1><strong>Main</strong> Title</h1>" in out
|
||||
|
||||
def test_heading_h2_html_strong(cleanup_test_sessions):
|
||||
"""HTML <strong> inside h2 renders correctly."""
|
||||
out = render_md("## <strong>Section</strong> Name")
|
||||
assert "<h2><strong>Section</strong> Name</h2>" in out
|
||||
assert "<strong>" not in out
|
||||
|
||||
def test_heading_h3_code(cleanup_test_sessions):
|
||||
"""Code span inside h3 renders correctly."""
|
||||
out = render_md("### The `renderMd` function")
|
||||
assert "<h3>The <code>renderMd</code> function</h3>" in out
|
||||
|
||||
def test_heading_xss_blocked(cleanup_test_sessions):
|
||||
"""XSS attempt in heading must be escaped."""
|
||||
out = render_md("## <script>alert(1)</script>")
|
||||
assert "<script>" not in out
|
||||
assert "<script" in out
|
||||
|
||||
# --- Paragraph / top-level formatting ---
|
||||
|
||||
def test_paragraph_bold_renders(cleanup_test_sessions):
|
||||
"""Bold in a plain paragraph renders correctly."""
|
||||
out = render_md("The **quick brown fox** jumps.")
|
||||
assert "<strong>quick brown fox</strong>" in out
|
||||
|
||||
def test_paragraph_html_strong_renders(cleanup_test_sessions):
|
||||
"""HTML <strong> in a plain paragraph renders correctly."""
|
||||
out = render_md("The <strong>quick brown fox</strong> jumps.")
|
||||
assert "<strong>quick brown fox</strong>" in out
|
||||
assert "<strong>" not in out
|
||||
|
||||
def test_paragraph_html_code_renders(cleanup_test_sessions):
|
||||
"""HTML <code> in a plain paragraph renders correctly."""
|
||||
out = render_md("Call <code>foo()</code> to start.")
|
||||
assert "<code>foo()</code>" in out
|
||||
assert "<code>" not in out
|
||||
|
||||
def test_paragraph_br_creates_line_break(cleanup_test_sessions):
|
||||
"""<br> in paragraph becomes a line break inside <p>."""
|
||||
out = render_md("Line one<br>Line two")
|
||||
# br converts to \n which inside <p> becomes <br>
|
||||
assert "Line one" in out and "Line two" in out
|
||||
|
||||
def test_multiple_paragraphs_separated(cleanup_test_sessions):
|
||||
"""Double newline creates separate <p> elements."""
|
||||
out = render_md("First paragraph.\n\nSecond paragraph.")
|
||||
assert out.count("<p>") == 2
|
||||
|
||||
# --- Table variants ---
|
||||
|
||||
def test_table_structure_in_ui_js(cleanup_test_sessions):
|
||||
"""ui.js must contain table rendering logic with thead/tbody structure."""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
assert "<table>" in src or "table>" in src, "table rendering not found in ui.js"
|
||||
assert "thead" in src, "thead not found in table renderer"
|
||||
assert "tbody" in src, "tbody not found in table renderer"
|
||||
assert "parseRow" in src, "parseRow helper not found in table renderer"
|
||||
|
||||
# --- br tag specifically ---
|
||||
|
||||
def test_br_in_list_item(cleanup_test_sessions):
|
||||
"""<br> inside a list item becomes a newline."""
|
||||
out = render_md("- Line one<br>Line two")
|
||||
assert "Line one" in out
|
||||
assert "Line two" in out
|
||||
|
||||
def test_br_self_closing_in_paragraph(cleanup_test_sessions):
|
||||
"""<br/> self-closing form is also handled."""
|
||||
out = render_md("Before<br/>After")
|
||||
assert "Before" in out and "After" in out
|
||||
|
||||
# --- No double-escaping ---
|
||||
|
||||
def test_no_double_escaping_ampersand(cleanup_test_sessions):
|
||||
"""A literal & in text must become & exactly once, not &amp;."""
|
||||
out = render_md("foo & bar")
|
||||
assert "&amp;" not in out
|
||||
assert "&" in out or "foo & bar" in out # either fine (paragraph wrap may not escape)
|
||||
|
||||
def test_no_double_escaping_lt_in_code(cleanup_test_sessions):
|
||||
"""< inside a code span must become < exactly once."""
|
||||
out = render_md("`a < b`")
|
||||
assert "<lt;" not in out
|
||||
assert "<" in out
|
||||
|
||||
def test_strong_text_not_double_escaped(cleanup_test_sessions):
|
||||
"""Content of <strong> must not be double-escaped."""
|
||||
out = render_md("<strong>hello & world</strong>")
|
||||
# The & inside strong content should be escaped once
|
||||
assert "&amp;" not in out
|
||||
assert "<strong>" in out
|
||||
|
||||
# --- inlineMd helper present in source ---
|
||||
|
||||
def test_inline_md_helper_in_ui_js(cleanup_test_sessions):
|
||||
"""ui.js must define inlineMd() helper function."""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
assert "function inlineMd(" in src, "inlineMd() helper not found in ui.js"
|
||||
|
||||
def test_inline_md_used_in_list_handler(cleanup_test_sessions):
|
||||
"""List handler in ui.js must call inlineMd() not esc() for item text."""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
# Find the list block handler
|
||||
ul_idx = src.find("html+='<ul>'") or src.find('html+=`<ul>`') or src.find("let html='<ul>'")
|
||||
assert ul_idx >= 0 or "inlineMd(text)" in src, "inlineMd not called in list handler"
|
||||
# Verify inlineMd is called, not bare esc
|
||||
assert "inlineMd(text)" in src, "inlineMd(text) call not found — list items may not render formatting"
|
||||
|
||||
def test_inline_md_used_in_blockquote_handler(cleanup_test_sessions):
|
||||
"""Blockquote handler in ui.js must call inlineMd() not esc() for content."""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
assert "inlineMd(t)" in src, "inlineMd not called in blockquote/heading handler"
|
||||
|
||||
|
||||
def test_sessions_js_has_svg_icons(cleanup_test_sessions):
|
||||
"""sessions.js must define ICONS object with SVG strings for sidebar buttons."""
|
||||
src = REPO_ROOT / "static" / "sessions.js"
|
||||
code = src.read_text()
|
||||
assert "const ICONS=" in code or "const ICONS =" in code, "ICONS constant not found"
|
||||
for icon in ["pin", "folder", "archive", "trash", "dup"]:
|
||||
assert icon + ":" in code or f"'{icon}'" in code, f"ICONS.{icon} not found"
|
||||
assert "<svg" in code, "SVG content not found in ICONS"
|
||||
|
||||
|
||||
def test_sessions_js_has_overlay_actions(cleanup_test_sessions):
|
||||
"""sessions.js must use .session-actions overlay div for action buttons."""
|
||||
src = REPO_ROOT / "static" / "sessions.js"
|
||||
code = src.read_text()
|
||||
assert "session-actions" in code, ".session-actions overlay not found in sessions.js"
|
||||
|
||||
|
||||
def test_style_css_has_session_actions_overlay(cleanup_test_sessions):
|
||||
"""style.css must define .session-actions with position:absolute."""
|
||||
src = REPO_ROOT / "static" / "style.css"
|
||||
code = src.read_text()
|
||||
assert ".session-actions" in code, ".session-actions not found in style.css"
|
||||
assert "position:absolute" in code or "position: absolute" in code, \
|
||||
".session-actions must use position:absolute for overlay"
|
||||
|
||||
|
||||
def test_style_css_active_session_uses_gold(cleanup_test_sessions):
|
||||
"""Active session style should use gold/amber color (#e8a030) not just blue."""
|
||||
src = REPO_ROOT / "static" / "style.css"
|
||||
code = src.read_text()
|
||||
assert "#e8a030" in code, \
|
||||
"Active session gold color (#e8a030) not found in style.css"
|
||||
|
||||
|
||||
def test_sessions_js_active_skips_project_border(cleanup_test_sessions):
|
||||
"""sessions.js must not override active session border-left with project color."""
|
||||
src = REPO_ROOT / "static" / "sessions.js"
|
||||
code = src.read_text()
|
||||
# The fix: only set borderLeftColor if NOT the active session
|
||||
assert "isActive" in code, "isActive check not found in sessions.js"
|
||||
assert "borderLeftColor" in code, "borderLeftColor not found in sessions.js"
|
||||
96
tests/test_sprint17.py
Normal file
96
tests/test_sprint17.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Sprint 17 Tests: send_key setting, commands.js static file, workspace subdir listing.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def make_session(created_list):
|
||||
d, _ = post("/api/session/new", {})
|
||||
sid = d["session"]["session_id"]
|
||||
created_list.append(sid)
|
||||
return sid, d["session"]
|
||||
|
||||
|
||||
# ── Settings: send_key ──────────────────────────────────────────────────────
|
||||
|
||||
def test_settings_send_key_default():
|
||||
"""GET /api/settings returns send_key with default value 'enter'."""
|
||||
data, status = get("/api/settings")
|
||||
assert status == 200
|
||||
assert data.get("send_key") == "enter"
|
||||
|
||||
|
||||
def test_settings_save_send_key():
|
||||
"""POST /api/settings with send_key persists and round-trips."""
|
||||
try:
|
||||
# Save ctrl+enter
|
||||
_, status = post("/api/settings", {"send_key": "ctrl+enter"})
|
||||
assert status == 200
|
||||
# Verify it persisted
|
||||
data, _ = get("/api/settings")
|
||||
assert data["send_key"] == "ctrl+enter"
|
||||
finally:
|
||||
# Always restore default
|
||||
post("/api/settings", {"send_key": "enter"})
|
||||
data, _ = get("/api/settings")
|
||||
assert data["send_key"] == "enter"
|
||||
|
||||
|
||||
def test_settings_invalid_send_key_rejected():
|
||||
"""POST /api/settings with invalid send_key value is silently ignored."""
|
||||
# Set a known good value first
|
||||
post("/api/settings", {"send_key": "enter"})
|
||||
# Try to set an invalid value
|
||||
data, status = post("/api/settings", {"send_key": "invalid_value"})
|
||||
assert status == 200
|
||||
# Should still be 'enter' (invalid value ignored)
|
||||
assert data["send_key"] == "enter"
|
||||
|
||||
|
||||
def test_settings_unknown_key_ignored():
|
||||
"""POST /api/settings ignores unknown keys."""
|
||||
data, status = post("/api/settings", {"unknown_key": "value", "send_key": "enter"})
|
||||
assert status == 200
|
||||
assert "unknown_key" not in data
|
||||
|
||||
|
||||
# ── Static file: commands.js ────────────────────────────────────────────────
|
||||
|
||||
def test_static_commands_js_served():
|
||||
"""GET /static/commands.js returns 200 and contains COMMANDS registry."""
|
||||
req = urllib.request.Request(BASE + "/static/commands.js")
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
body = r.read().decode()
|
||||
assert r.status == 200
|
||||
assert "COMMANDS" in body
|
||||
assert "executeCommand" in body
|
||||
|
||||
|
||||
# ── Workspace: subdir listing ───────────────────────────────────────────────
|
||||
|
||||
def test_list_workspace_root():
|
||||
"""GET /api/list with path=. returns entries for workspace root."""
|
||||
created = []
|
||||
sid, _ = make_session(created)
|
||||
data, status = get(f"/api/list?session_id={sid}&path=.")
|
||||
assert status == 200
|
||||
assert "entries" in data
|
||||
assert isinstance(data["entries"], list)
|
||||
118
tests/test_sprint19.py
Normal file
118
tests/test_sprint19.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Sprint 19 Tests: auth/login, security headers, request size limit.
|
||||
"""
|
||||
import json, urllib.error, urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get(path, headers=None):
|
||||
req = urllib.request.Request(BASE + path)
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status, dict(r.headers)
|
||||
|
||||
|
||||
def post(path, body=None, headers=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status, dict(r.headers)
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code, dict(e.headers)
|
||||
|
||||
|
||||
# ── Auth status (no password configured in test env) ──────────────────────
|
||||
|
||||
def test_auth_status_disabled():
|
||||
"""Auth should be disabled by default (no password set)."""
|
||||
d, status, _ = get("/api/auth/status")
|
||||
assert status == 200
|
||||
assert d["auth_enabled"] is False
|
||||
|
||||
|
||||
def test_login_when_auth_disabled():
|
||||
"""Login should succeed trivially when auth is not enabled."""
|
||||
d, status, _ = post("/api/auth/login", {"password": "anything"})
|
||||
assert status == 200
|
||||
assert d["ok"] is True
|
||||
|
||||
|
||||
def test_all_routes_accessible_without_auth():
|
||||
"""When auth is disabled, all routes should work without cookies."""
|
||||
d, status, _ = get("/api/sessions")
|
||||
assert status == 200
|
||||
assert "sessions" in d
|
||||
|
||||
|
||||
def test_login_page_served():
|
||||
"""GET /login should return the login page HTML."""
|
||||
req = urllib.request.Request(BASE + "/login")
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
html = r.read().decode()
|
||||
assert r.status == 200
|
||||
assert "Sign in" in html
|
||||
assert "Hermes" in html
|
||||
|
||||
|
||||
# ── Security headers ─────────────────────────────────────────────────────
|
||||
|
||||
def test_security_headers_on_json():
|
||||
"""JSON responses should include security headers."""
|
||||
d, status, headers = get("/api/auth/status")
|
||||
assert status == 200
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert headers.get("X-Frame-Options") == "DENY"
|
||||
assert headers.get("Referrer-Policy") == "same-origin"
|
||||
|
||||
|
||||
def test_security_headers_on_health():
|
||||
"""Health endpoint should include security headers."""
|
||||
d, status, headers = get("/health")
|
||||
assert status == 200
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
|
||||
|
||||
def test_cache_control_no_store():
|
||||
"""API responses should have Cache-Control: no-store."""
|
||||
d, status, headers = get("/api/sessions")
|
||||
assert headers.get("Cache-Control") == "no-store"
|
||||
|
||||
|
||||
# ── Settings password field ──────────────────────────────────────────────
|
||||
|
||||
def test_settings_password_hash_not_exposed():
|
||||
"""GET /api/settings must never expose the stored password hash."""
|
||||
d, status, _ = get("/api/settings")
|
||||
assert status == 200
|
||||
assert "password_hash" not in d # security: never send hash to client
|
||||
|
||||
|
||||
def test_settings_save_preserves_other_fields():
|
||||
"""Saving settings should not break existing fields."""
|
||||
# Get current settings
|
||||
current, _, _ = get("/api/settings")
|
||||
# Save with just send_key
|
||||
d, status, _ = post("/api/settings", {"send_key": "enter"})
|
||||
assert status == 200
|
||||
# Verify other fields still present
|
||||
updated, _, _ = get("/api/settings")
|
||||
assert "default_model" in updated
|
||||
assert "default_workspace" in updated
|
||||
|
||||
|
||||
def test_settings_password_hash_not_directly_settable():
|
||||
"""POST /api/settings with password_hash must not overwrite the stored hash."""
|
||||
# Attempt to set a raw hash directly (attack vector)
|
||||
post("/api/settings", {"password_hash": "deadbeef" * 8})
|
||||
# Settings response must not expose it regardless
|
||||
updated, status, _ = get("/api/settings")
|
||||
assert status == 200
|
||||
assert "password_hash" not in updated
|
||||
422
tests/test_sprint20.py
Normal file
422
tests/test_sprint20.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Sprint 20 Tests: Voice input (mic button) via Web Speech API.
|
||||
|
||||
These tests verify the static assets contain the correct HTML structure,
|
||||
CSS rules, and JS logic for the mic feature — all of which runs purely in
|
||||
the browser with no server-side component.
|
||||
"""
|
||||
import re
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get_text(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return r.read().decode(), r.status
|
||||
|
||||
|
||||
# ── index.html ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mic_button_present_in_html():
|
||||
"""index.html must contain the mic button with id='btnMic'."""
|
||||
html, status = get_text("/")
|
||||
assert status == 200
|
||||
assert 'id="btnMic"' in html
|
||||
|
||||
|
||||
def test_mic_button_has_mic_btn_class():
|
||||
"""btnMic must carry the mic-btn CSS class for styling hooks."""
|
||||
html, _ = get_text("/")
|
||||
assert 'class="icon-btn mic-btn"' in html
|
||||
|
||||
|
||||
def test_mic_button_hidden_by_default():
|
||||
"""btnMic starts hidden (display:none) — JS shows it only if supported."""
|
||||
html, _ = get_text("/")
|
||||
# The button element should have display:none in its style attribute
|
||||
assert 'id="btnMic"' in html
|
||||
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
||||
assert btn_match, "btnMic element not found"
|
||||
assert 'display:none' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_mic_button_has_title():
|
||||
"""btnMic should have a descriptive title for accessibility."""
|
||||
html, _ = get_text("/")
|
||||
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
||||
assert btn_match
|
||||
assert 'title=' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_mic_status_div_present():
|
||||
"""index.html must contain the #micStatus listening indicator."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="micStatus"' in html
|
||||
|
||||
|
||||
def test_mic_status_hidden_by_default():
|
||||
"""#micStatus starts hidden — only shown during active recording."""
|
||||
html, _ = get_text("/")
|
||||
status_match = re.search(r'id="micStatus"[^>]*>', html)
|
||||
assert status_match, "#micStatus element not found"
|
||||
assert 'display:none' in status_match.group(0)
|
||||
|
||||
|
||||
def test_mic_status_has_mic_dot():
|
||||
"""#micStatus must contain a .mic-dot element for the pulse animation."""
|
||||
html, _ = get_text("/")
|
||||
# mic-dot should appear after micStatus
|
||||
idx_status = html.find('id="micStatus"')
|
||||
idx_dot = html.find('mic-dot', idx_status)
|
||||
assert idx_status != -1 and idx_dot != -1
|
||||
assert idx_dot > idx_status
|
||||
|
||||
|
||||
def test_mic_status_has_listening_text():
|
||||
"""#micStatus should display a 'Listening' label."""
|
||||
html, _ = get_text("/")
|
||||
assert 'Listening' in html
|
||||
|
||||
|
||||
def test_mic_button_svg_microphone_shape():
|
||||
"""btnMic SVG must include the rect (mic body) and path (mic arc)."""
|
||||
html, _ = get_text("/")
|
||||
# Find mic button section
|
||||
btn_start = html.find('id="btnMic"')
|
||||
btn_end = html.find('</button>', btn_start) + len('</button>')
|
||||
btn_html = html[btn_start:btn_end]
|
||||
assert '<rect' in btn_html, "mic SVG missing rect (mic body)"
|
||||
assert '<path' in btn_html, "mic SVG missing path (arc)"
|
||||
assert '<line' in btn_html, "mic SVG missing line (stand)"
|
||||
|
||||
|
||||
def test_mic_button_inside_composer_left():
|
||||
"""btnMic must be inside .composer-left, next to the attach button."""
|
||||
html, _ = get_text("/")
|
||||
composer_left_start = html.find('class="composer-left"')
|
||||
composer_left_end = html.find('</div>', composer_left_start)
|
||||
section = html[composer_left_start:composer_left_end]
|
||||
assert 'btnAttach' in section
|
||||
assert 'btnMic' in section
|
||||
|
||||
|
||||
# ── style.css ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mic_btn_css_rule_exists():
|
||||
"""style.css must define .mic-btn rule."""
|
||||
css, status = get_text("/static/style.css")
|
||||
assert status == 200
|
||||
assert '.mic-btn' in css
|
||||
|
||||
|
||||
def test_mic_btn_recording_state_css():
|
||||
""".mic-btn.recording must be defined for active recording visual state."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-btn.recording' in css
|
||||
|
||||
|
||||
def test_mic_recording_color_red():
|
||||
""".mic-btn.recording must use the red accent color #e94560."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
recording_idx = css.find('.mic-btn.recording')
|
||||
# Find the rule block after the selector
|
||||
brace_open = css.find('{', recording_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert '#e94560' in rule or 'e94560' in rule
|
||||
|
||||
|
||||
def test_mic_recording_has_animation():
|
||||
""".mic-btn.recording must use an animation for the pulse effect."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
recording_idx = css.find('.mic-btn.recording')
|
||||
brace_open = css.find('{', recording_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'animation' in rule
|
||||
|
||||
|
||||
def test_mic_pulse_keyframes_defined():
|
||||
"""@keyframes mic-pulse must be defined for the pulsing animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert 'mic-pulse' in css
|
||||
assert '@keyframes' in css
|
||||
|
||||
|
||||
def test_mic_status_css_rule_exists():
|
||||
"""style.css must define .mic-status rule."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-status' in css
|
||||
|
||||
|
||||
def test_mic_dot_css_rule_exists():
|
||||
"""style.css must define .mic-dot rule with animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-dot' in css
|
||||
dot_idx = css.find('.mic-dot')
|
||||
brace_open = css.find('{', dot_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'animation' in rule
|
||||
|
||||
|
||||
def test_mic_btn_has_transition():
|
||||
""".mic-btn must define a transition for smooth state changes."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
mic_btn_idx = css.find('.mic-btn{')
|
||||
if mic_btn_idx == -1:
|
||||
mic_btn_idx = css.find('.mic-btn ')
|
||||
brace_open = css.find('{', mic_btn_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'transition' in rule
|
||||
|
||||
|
||||
# ── boot.js ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_boot_js_serves_ok():
|
||||
"""boot.js must be served successfully."""
|
||||
_, status = get_text("/static/boot.js")
|
||||
assert status == 200
|
||||
|
||||
|
||||
def test_boot_js_speech_recognition_check():
|
||||
"""boot.js must check for SpeechRecognition (with webkit fallback)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'SpeechRecognition' in js
|
||||
assert 'webkitSpeechRecognition' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_config():
|
||||
"""boot.js must configure recognition.continuous, interimResults, and lang."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.continuous' in js
|
||||
assert 'recognition.interimResults' in js
|
||||
assert 'recognition.lang' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_not_continuous():
|
||||
"""recognition.continuous must be false (auto-stop after silence)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.continuous=false' in js or 'recognition.continuous = false' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_interim_results():
|
||||
"""recognition.interimResults must be true (live transcription preview)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.interimResults=true' in js or 'recognition.interimResults = true' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_lang_en():
|
||||
"""recognition.lang must be set to en-US."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "recognition.lang='en-US'" in js or 'recognition.lang = "en-US"' in js or "recognition.lang='en-US'" in js
|
||||
|
||||
|
||||
def test_boot_js_onresult_handler():
|
||||
"""boot.js must define recognition.onresult to handle transcription."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onresult' in js
|
||||
|
||||
|
||||
def test_boot_js_onend_handler():
|
||||
"""boot.js must define recognition.onend to reset state when recording stops."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onend' in js
|
||||
|
||||
|
||||
def test_boot_js_onerror_handler():
|
||||
"""boot.js must define recognition.onerror for graceful error handling."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onerror' in js
|
||||
|
||||
|
||||
def test_boot_js_not_allowed_error_message():
|
||||
"""onerror must handle 'not-allowed' with a user-friendly message."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'not-allowed' in js
|
||||
assert 'permission' in js.lower() or 'denied' in js.lower() or 'access' in js.lower()
|
||||
|
||||
|
||||
def test_boot_js_no_speech_error_message():
|
||||
"""onerror must handle 'no-speech' with a user-friendly message."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'no-speech' in js
|
||||
|
||||
|
||||
def test_boot_js_network_error_message():
|
||||
"""onerror must handle 'network' error."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "'network'" in js or '"network"' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_active_flag():
|
||||
"""boot.js must track recording state via _micActive flag."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert '_micActive' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_recording_class_toggle():
|
||||
"""boot.js must toggle 'recording' CSS class on the mic button."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "'recording'" in js or '"recording"' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_status_toggle():
|
||||
"""boot.js must show/hide #micStatus during recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'micStatus' in js
|
||||
|
||||
|
||||
def test_boot_js_send_stops_mic():
|
||||
"""btnSend onclick must stop mic before sending (send guard)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# The send button onclick should check _micActive and stop recording
|
||||
send_onclick_idx = js.find("$('btnSend').onclick")
|
||||
assert send_onclick_idx != -1
|
||||
# Find the handler code — check that _micActive check appears near send assignment
|
||||
handler_end = js.find(';', send_onclick_idx)
|
||||
handler = js[send_onclick_idx:handler_end + 1]
|
||||
assert '_micActive' in handler or 'stopMic' in handler.lower()
|
||||
|
||||
|
||||
def test_boot_js_btn_mic_onclick():
|
||||
"""boot.js must attach an onclick handler to btnMic."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'btn.onclick' in js or "btnMic.onclick" in js or "$('btnMic').onclick" in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_start():
|
||||
"""boot.js must call recognition.start() to begin recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.start()' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_stop():
|
||||
"""boot.js must call recognition.stop() to end recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.stop()' in js
|
||||
|
||||
|
||||
def test_boot_js_iife_guard():
|
||||
"""Mic logic must be wrapped in an IIFE so it doesn't pollute global scope."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# IIFE pattern: (function(){...})() or (() => {...})()
|
||||
assert '(function(){' in js or '(function () {' in js
|
||||
|
||||
|
||||
def test_boot_js_browser_unsupported_return():
|
||||
"""boot.js must bail out (return) early when SpeechRecognition is unavailable."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# The IIFE should have an early return when SpeechRecognition is falsy
|
||||
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' in js
|
||||
|
||||
|
||||
def test_boot_js_shows_mic_button_when_supported():
|
||||
"""boot.js must set display='' on btnMic when SpeechRecognition is available."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js
|
||||
|
||||
|
||||
def test_boot_js_show_toast_on_error():
|
||||
"""boot.js must call showToast() for mic errors."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'showToast' in js
|
||||
|
||||
|
||||
def test_boot_js_autoresize_called():
|
||||
"""boot.js must call autoResize() after updating textarea from transcript."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'autoResize()' in js
|
||||
|
||||
|
||||
# ── Append behaviour (fix: mic appends to existing text, not replace) ────
|
||||
|
||||
|
||||
def test_boot_js_prefix_variable_declared():
|
||||
"""boot.js must declare _prefix variable to snapshot pre-existing textarea content."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "_prefix" in js
|
||||
|
||||
|
||||
def test_boot_js_prefix_captured_on_start():
|
||||
"""_prefix must be set from ta.value when the user starts recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# _prefix assignment must happen in the btn.onclick else branch (before recognition.start)
|
||||
btn_onclick_idx = js.find("btn.onclick")
|
||||
btn_onclick_end = js.find("};", btn_onclick_idx)
|
||||
onclick_body = js[btn_onclick_idx:btn_onclick_end]
|
||||
assert "_prefix=ta.value" in onclick_body or "_prefix = ta.value" in onclick_body
|
||||
|
||||
|
||||
def test_boot_js_onresult_prepends_prefix():
|
||||
"""onresult must include _prefix when writing to textarea (append, not replace)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onresult_idx = js.find("recognition.onresult")
|
||||
onresult_end = js.find("};", onresult_idx)
|
||||
onresult_body = js[onresult_idx:onresult_end]
|
||||
# ta.value must be set to _prefix + something, not just the transcript alone
|
||||
assert "_prefix" in onresult_body
|
||||
|
||||
|
||||
def test_boot_js_onend_commits_with_prefix():
|
||||
"""onend must commit _prefix + _finalText so appended text survives after recognition ends."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onend_idx = js.find("recognition.onend")
|
||||
onend_end = js.find("};", onend_idx)
|
||||
onend_body = js[onend_idx:onend_end]
|
||||
assert "_prefix" in onend_body
|
||||
|
||||
|
||||
def test_boot_js_prefix_reset_on_stop():
|
||||
"""_prefix must be reset when recording stops so next session starts clean."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# _setRecording(false) clears both _finalText and _prefix
|
||||
set_rec_idx = js.find("function _setRecording")
|
||||
set_rec_end = js.find("}", set_rec_idx) + 1
|
||||
fn_body = js[set_rec_idx:set_rec_end]
|
||||
assert "_prefix" in fn_body
|
||||
|
||||
|
||||
def test_boot_js_auto_space_between_prefix_and_transcript():
|
||||
"""onend must insert a space between existing text and new transcript when needed."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onend_idx = js.find("recognition.onend")
|
||||
onend_end = js.find("};", onend_idx)
|
||||
onend_body = js[onend_idx:onend_end]
|
||||
# Should handle spacing — look for trimStart or endsWith(' ') check
|
||||
has_spacing = ("trimStart" in onend_body or "endsWith(' ')" in onend_body
|
||||
or "endsWith(\" \")" in onend_body or "endsWith('\\n')" in onend_body)
|
||||
assert has_spacing, "onend should handle spacing between prefix and new transcript"
|
||||
|
||||
|
||||
# ── Regression: existing behaviour unchanged ──────────────────────────────
|
||||
|
||||
|
||||
def test_attach_button_still_wired():
|
||||
"""btnAttach onclick must still be wired up (no regression)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "$('btnAttach').onclick" in js
|
||||
|
||||
|
||||
def test_file_input_onchange_still_wired():
|
||||
"""fileInput onchange must still be wired up (no regression)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "$('fileInput').onchange" in js
|
||||
|
||||
|
||||
def test_index_html_still_has_send_button():
|
||||
"""btnSend must still be present in index.html (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnSend"' in html
|
||||
|
||||
|
||||
def test_index_html_still_has_attach_button():
|
||||
"""btnAttach must still be present in index.html (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnAttach"' in html
|
||||
343
tests/test_sprint20b.py
Normal file
343
tests/test_sprint20b.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Sprint 21 Tests: Send button polish — hidden until content, pop-in animation,
|
||||
icon-only circle design.
|
||||
"""
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get_text(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return r.read().decode(), r.status
|
||||
|
||||
|
||||
# ── index.html ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_send_button_present():
|
||||
"""btnSend must still exist in the DOM."""
|
||||
html, status = get_text("/")
|
||||
assert status == 200
|
||||
assert 'id="btnSend"' in html
|
||||
|
||||
|
||||
def test_send_button_hidden_by_default():
|
||||
"""btnSend must start hidden (display:none) — only shown when there is content."""
|
||||
html, _ = get_text("/")
|
||||
btn_match = re.search(r'id="btnSend"[^>]*>', html)
|
||||
assert btn_match, "btnSend element not found"
|
||||
assert 'display:none' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_send_button_no_text_label():
|
||||
"""Send button must be icon-only — no visible 'Send' text label."""
|
||||
html, _ = get_text("/")
|
||||
# Find the full button element (from opening tag to closing tag)
|
||||
btn_open_end = html.find('>', html.find('id="btnSend"')) + 1
|
||||
btn_end = html.find('</button>', btn_open_end) + len('</button>')
|
||||
btn_inner = html[btn_open_end:btn_end]
|
||||
# Strip SVG content and any remaining tags; check visible text
|
||||
no_svg = re.sub(r'<svg[^>]*>.*?</svg>', '', btn_inner, flags=re.DOTALL)
|
||||
visible_text = re.sub(r'<[^>]+>', '', no_svg).strip()
|
||||
assert visible_text == '', f"Send button has visible text: {visible_text!r}"
|
||||
|
||||
|
||||
def test_send_button_has_svg_icon():
|
||||
"""Send button must have an SVG icon."""
|
||||
html, _ = get_text("/")
|
||||
btn_start = html.find('id="btnSend"')
|
||||
btn_end = html.find('</button>', btn_start) + len('</button>')
|
||||
btn_html = html[btn_start:btn_end]
|
||||
assert '<svg' in btn_html
|
||||
|
||||
|
||||
def test_send_button_has_title_attribute():
|
||||
"""btnSend must have a title attribute for accessibility (replaces text label)."""
|
||||
html, _ = get_text("/")
|
||||
btn_match = re.search(r'id="btnSend"[^>]*>', html)
|
||||
assert btn_match
|
||||
assert 'title=' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_send_button_svg_arrow_up():
|
||||
"""Send button SVG should use an upward arrow (line + polyline or path)."""
|
||||
html, _ = get_text("/")
|
||||
btn_start = html.find('id="btnSend"')
|
||||
btn_end = html.find('</button>', btn_start) + len('</button>')
|
||||
btn_html = html[btn_start:btn_end]
|
||||
# Must have some directional shape element
|
||||
has_shape = ('<line' in btn_html or '<polyline' in btn_html or
|
||||
'<polygon' in btn_html or '<path' in btn_html)
|
||||
assert has_shape, "Send button SVG missing directional shape"
|
||||
|
||||
|
||||
# ── style.css ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_send_btn_is_circle():
|
||||
"""send-btn must use border-radius:50% for the circle shape."""
|
||||
css, status = get_text("/static/style.css")
|
||||
assert status == 200
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'border-radius:50%' in rule or 'border-radius: 50%' in rule
|
||||
|
||||
|
||||
def test_send_btn_fixed_dimensions():
|
||||
"""send-btn must have explicit width and height (icon-circle, not text-padded)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'width:' in rule or 'width :' in rule
|
||||
assert 'height:' in rule or 'height :' in rule
|
||||
|
||||
|
||||
def test_send_btn_no_old_padding():
|
||||
"""send-btn must not use text padding layout (old pill style removed)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
# Old style used padding:7px 18px — should be gone
|
||||
assert 'padding:7px' not in rule and 'padding: 7px' not in rule
|
||||
|
||||
|
||||
def test_send_btn_blue_background():
|
||||
"""send-btn background must use the blue accent (#7cb9ff or similar)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert '7cb9ff' in rule or '5ba8f5' in rule or 'var(--blue)' in rule
|
||||
|
||||
|
||||
def test_send_btn_has_transition():
|
||||
"""send-btn must have transition for smooth hover/active states."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'transition' in rule
|
||||
|
||||
|
||||
def test_send_btn_has_box_shadow():
|
||||
"""send-btn must have a box-shadow glow effect."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'box-shadow' in rule
|
||||
|
||||
|
||||
def test_send_btn_hover_has_scale():
|
||||
"""send-btn:hover must use transform:scale for a satisfying hover effect."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
hover_idx = css.find('.send-btn:hover{')
|
||||
brace_open = css.find('{', hover_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'scale' in rule
|
||||
|
||||
|
||||
def test_send_btn_active_shrinks():
|
||||
"""send-btn:active must scale down slightly for tactile press feedback."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
active_idx = css.find('.send-btn:active{')
|
||||
brace_open = css.find('{', active_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'scale' in rule
|
||||
|
||||
|
||||
def test_send_btn_disabled_rule_exists():
|
||||
"""send-btn:disabled must still be styled."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.send-btn:disabled' in css
|
||||
|
||||
|
||||
def test_send_btn_visible_class_defined():
|
||||
""".send-btn.visible class must be defined for the pop-in animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.send-btn.visible' in css
|
||||
|
||||
|
||||
def test_send_pop_in_keyframes_defined():
|
||||
"""@keyframes send-pop-in must be defined."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert 'send-pop-in' in css
|
||||
assert '@keyframes' in css
|
||||
|
||||
|
||||
def _extract_keyframe(css, name):
|
||||
"""Extract the full @keyframes block for the given animation name."""
|
||||
# Find '@keyframes <name>' directly (forward search) to avoid hitting
|
||||
# an earlier keyframe when multiple are defined on the same line.
|
||||
kf_start = css.find('@keyframes ' + name)
|
||||
assert kf_start != -1, f"@keyframes {name} not found in CSS"
|
||||
depth = 0
|
||||
kf_end = kf_start
|
||||
for i, ch in enumerate(css[kf_start:], kf_start):
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
kf_end = i
|
||||
break
|
||||
return css[kf_start:kf_end]
|
||||
|
||||
|
||||
def test_send_pop_in_uses_scale():
|
||||
"""send-pop-in keyframe must animate from a scaled-down state."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
kf_rule = _extract_keyframe(css, 'send-pop-in')
|
||||
assert 'scale' in kf_rule
|
||||
|
||||
|
||||
def test_send_pop_in_uses_opacity():
|
||||
"""send-pop-in keyframe must fade in (opacity transition)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
kf_rule = _extract_keyframe(css, 'send-pop-in')
|
||||
assert 'opacity' in kf_rule
|
||||
|
||||
|
||||
def test_send_btn_mobile_override_no_padding():
|
||||
"""Mobile override for send-btn must not add text padding (keeps circle shape)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
# Find the @media block
|
||||
media_idx = css.find('@media')
|
||||
send_mobile_idx = css.find('.send-btn', media_idx)
|
||||
if send_mobile_idx == -1:
|
||||
return # No mobile override, fine
|
||||
brace_open = css.find('{', send_mobile_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'padding:' not in rule and 'font-size' not in rule
|
||||
|
||||
|
||||
# ── ui.js ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ui_js_update_send_btn_function():
|
||||
"""ui.js must define updateSendBtn() function."""
|
||||
js, status = get_text("/static/ui.js")
|
||||
assert status == 200
|
||||
assert 'function updateSendBtn' in js
|
||||
|
||||
|
||||
def test_update_send_btn_checks_content():
|
||||
"""updateSendBtn must check textarea value length."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'msg' in fn_body
|
||||
assert '.value' in fn_body
|
||||
assert '.length' in fn_body or '.trim()' in fn_body
|
||||
|
||||
|
||||
def test_update_send_btn_checks_pending_files():
|
||||
"""updateSendBtn must also show send button when files are attached."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'pendingFiles' in fn_body
|
||||
|
||||
|
||||
def test_update_send_btn_uses_visible_class():
|
||||
"""updateSendBtn must add .visible class to trigger the pop-in animation."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'visible' in fn_body
|
||||
|
||||
|
||||
def test_update_send_btn_uses_display_none():
|
||||
"""updateSendBtn must hide the button with display:none when no content."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'display' in fn_body
|
||||
assert 'none' in fn_body
|
||||
|
||||
|
||||
def test_set_busy_calls_update_send_btn():
|
||||
"""setBusy must call updateSendBtn() so button hides while agent is responding."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
busy_idx = js.find('function setBusy')
|
||||
busy_end = js.find('\n}', busy_idx) + 2
|
||||
busy_body = js[busy_idx:busy_end]
|
||||
assert 'updateSendBtn' in busy_body
|
||||
|
||||
|
||||
def test_render_tray_calls_update_send_btn():
|
||||
"""renderTray must call updateSendBtn() so button appears when files are attached."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
tray_idx = js.find('function renderTray')
|
||||
tray_end = js.find('\n}', tray_idx) + 2
|
||||
tray_body = js[tray_idx:tray_end]
|
||||
assert 'updateSendBtn' in tray_body
|
||||
|
||||
|
||||
# ── boot.js ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_boot_js_input_calls_update_send_btn():
|
||||
"""boot.js input event listener must call updateSendBtn()."""
|
||||
js, status = get_text("/static/boot.js")
|
||||
assert status == 200
|
||||
assert 'updateSendBtn' in js
|
||||
|
||||
|
||||
# ── messages.js ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_auto_resize_calls_update_send_btn():
|
||||
"""autoResize() must call updateSendBtn() so button hides after send clears textarea."""
|
||||
js, status = get_text("/static/messages.js")
|
||||
assert status == 200
|
||||
assert 'updateSendBtn' in js
|
||||
|
||||
|
||||
# ── Regression: existing behaviour unchanged ──────────────────────────────
|
||||
|
||||
|
||||
def test_send_button_still_has_send_btn_class():
|
||||
"""btnSend must still carry class='send-btn' for CSS targeting."""
|
||||
html, _ = get_text("/")
|
||||
assert 'class="send-btn"' in html
|
||||
|
||||
|
||||
def test_ui_js_set_busy_still_disables_btn():
|
||||
"""setBusy must still set btnSend.disabled (not just hide it)."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
busy_idx = js.find('function setBusy')
|
||||
busy_end = js.find('\n}', busy_idx) + 2
|
||||
busy_body = js[busy_idx:busy_end]
|
||||
assert "btnSend" in busy_body
|
||||
assert 'disabled' in busy_body
|
||||
|
||||
|
||||
def test_index_html_attach_button_unchanged():
|
||||
"""btnAttach must still be present (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnAttach"' in html
|
||||
|
||||
|
||||
def test_send_function_still_exists():
|
||||
"""send() function must still be defined in messages.js."""
|
||||
js, _ = get_text("/static/messages.js")
|
||||
assert 'async function send()' in js
|
||||
Reference in New Issue
Block a user