Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a92fff75a3 | ||
|
|
0be7ccde4c | ||
|
|
fcd155be55 | ||
|
|
0c601a9cc8 | ||
|
|
4b326dbdf0 | ||
|
|
fd993c5513 | ||
|
|
d2bcd2b2f7 | ||
|
|
5e4645ee05 | ||
|
|
ae1faa7252 | ||
|
|
6c7fb4ee44 | ||
|
|
e59eb8bb5b | ||
|
|
ca06fd5533 | ||
|
|
7ddd896b36 | ||
|
|
f109910b58 | ||
|
|
b784fff104 | ||
|
|
d9293c6097 | ||
|
|
669412cbc9 | ||
|
|
c1a9324f35 | ||
|
|
cbf82898cb | ||
|
|
fb916d1f7e | ||
|
|
9452f56821 | ||
|
|
95645d651e | ||
|
|
2f281cbbd7 | ||
|
|
537337a158 | ||
|
|
8075442200 | ||
|
|
ebdd955578 | ||
|
|
1a4793848e | ||
|
|
8ed206657c | ||
|
|
06e1f11070 | ||
|
|
089dd7e3de | ||
|
|
0875dddbff |
@@ -31,25 +31,25 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
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)
|
||||
routes.py All GET + POST route handlers (~1016 lines)
|
||||
config.py Shared 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: 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)
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown (~809 lines)
|
||||
workspace.js File tree, preview, file ops (~169 lines)
|
||||
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~532 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~293 lines)
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel (~771 lines)
|
||||
boot.js Event wiring + boot IIFE (~175 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_sprint1-11.py Feature tests per sprint (13 files, Sprints 1-11)
|
||||
test_regressions.py Permanent regression gate
|
||||
AGENTS.md Instruction file for agents working in this directory.
|
||||
ROADMAP.md Feature and product roadmap document.
|
||||
@@ -151,10 +151,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 +330,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,750 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 (~809 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):
|
||||
|
||||
@@ -604,26 +612,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-11.py Feature tests per sprint (13 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 +727,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
|
||||
237 tests across 13 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_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.
|
||||
116
CHANGELOG.md
116
CHANGELOG.md
@@ -5,6 +5,120 @@
|
||||
|
||||
---
|
||||
|
||||
## [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*
|
||||
|
||||
### Enhancements
|
||||
- **GLM-5.1 added to Z.AI model list.** New model available in the dropdown
|
||||
for Z.AI provider users. (Fixes #17)
|
||||
|
||||
---
|
||||
|
||||
## [v0.17.1] Security + Bug Fixes
|
||||
*April 2, 2026 | 237 tests*
|
||||
|
||||
### Security
|
||||
- **Path traversal in static file server.** `_serve_static()` now sandboxes
|
||||
resolved paths inside `static/` via `.relative_to()`. Previously
|
||||
`GET /static/../../.hermes/config.yaml` could expose API keys.
|
||||
- **XSS in markdown renderer.** All captured groups in bold, italic, headings,
|
||||
blockquotes, list items, table cells, and link labels now run through `esc()`
|
||||
before `innerHTML` insertion.
|
||||
- **Skill category path traversal.** Category param validated to reject `/`
|
||||
and `..` to prevent writing outside `~/.hermes/skills/`.
|
||||
- **Debug endpoint locked to localhost.** `/api/approval/inject_test` returns
|
||||
404 to any non-loopback client.
|
||||
- **CDN resources pinned with SRI hashes.** PrismJS and Mermaid tags now have
|
||||
`integrity` + `crossorigin` attributes. Mermaid pinned to `@10.9.3`.
|
||||
- **Project color CSS injection.** Color field validated against
|
||||
`^#[0-9a-fA-F]{3,8}$` to prevent `style.background` injection.
|
||||
- **Project name length limit.** Capped at 128 chars, empty-after-strip rejected.
|
||||
|
||||
### Bug Fixes
|
||||
- **OpenRouter model routing regression.** `resolve_model_provider()` was
|
||||
incorrectly stripping provider prefixes from OpenRouter model IDs (e.g.
|
||||
`openai/gpt-5.4-mini` became `gpt-5.4-mini` with provider `openai`),
|
||||
causing AIAgent to look for OPENAI_API_KEY and crash. Fix: only strip
|
||||
prefix when `config.provider` explicitly matches that direct-API provider.
|
||||
- **Project picker invisible.** Dropdown was clipped by `.session-item`
|
||||
`overflow:hidden`. Now appended to `document.body` with `position:fixed`.
|
||||
- **Project picker stretched full width.** Added `max-width:220px;
|
||||
width:max-content` to constrain the fixed-positioned picker.
|
||||
- **No way to create project from picker.** Added "+ New project" item at
|
||||
the bottom of the picker dropdown.
|
||||
- **Folder button undiscoverable.** Now shows persistently (blue, 60%
|
||||
opacity) when session belongs to a project.
|
||||
- **Picker event listener leak.** `removeEventListener` added to all picker
|
||||
item onclick handlers.
|
||||
- **Redundant sys.path.insert calls removed.** Two cron handler imports no
|
||||
longer prepend the agent dir (already on sys.path via config.py).
|
||||
|
||||
---
|
||||
|
||||
## [v0.17] Sprint 15 -- Session Projects + Code Copy + Tool Card Toggle
|
||||
*April 1, 2026 | 237 tests*
|
||||
|
||||
### Features
|
||||
- **Session projects.** Named groups for organizing sessions. A project filter
|
||||
bar (subtle chips) sits between the search input and the session list. Each
|
||||
project has a name and color. Click a chip to filter; "All" shows everything.
|
||||
Create inline (+), rename (double-click), delete (right-click). Assign sessions
|
||||
via folder icon button with dropdown picker. Projects stored in `projects.json`.
|
||||
Session model gains `project_id` field. 5 new API endpoints.
|
||||
- **Code block copy button.** Every code block gets a "Copy" button in the
|
||||
language header bar (or top-right for plain blocks). Click copies to clipboard,
|
||||
shows "Copied!" for 1.5s.
|
||||
- **Tool card expand/collapse.** When a message has 2+ tool cards, "Expand all /
|
||||
Collapse all" toggle appears above the card group.
|
||||
|
||||
---
|
||||
|
||||
## [v0.16.2] Model List Updates + base_url Passthrough
|
||||
*April 1, 2026 | 247 tests*
|
||||
|
||||
@@ -441,4 +555,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.16.2, April 1, 2026 | Tests: 247*
|
||||
*Last updated: v0.18, April 2, 2026 | Tests: 237*
|
||||
|
||||
33
ROADMAP.md
33
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 14 (March 30, 2026)
|
||||
> Tests: 226/226 passing
|
||||
> Last updated: Sprint 16 / v0.18 (April 2, 2026)
|
||||
> Tests: 237 passing
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -30,6 +30,9 @@
|
||||
| Sprint 11 | Multi-provider models + streaming | Dynamic model dropdown (any Hermes provider), smooth scroll pinning, routes extracted to api/routes.py (server.py 704→76 lines) | 201 |
|
||||
| Sprint 12 | Settings + reliability + session QoL | Settings panel (gear icon, settings.json), SSE auto-reconnect, pin sessions, import session from JSON | 211 |
|
||||
| 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, custom model discovery, GLM-5.1 | 237 |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,10 +40,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 (~76 lines) + api/ modules (~2145 lines) | Thin shell + 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 (~560 lines) | Served from disk |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot}.js | 6 modules, ~2750 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 |
|
||||
@@ -53,6 +56,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
|
||||
@@ -103,6 +107,7 @@
|
||||
- [x] Import session from JSON (Sprint 12)
|
||||
- [x] Pin/star sessions to top of list (Sprint 12)
|
||||
- [x] Duplicate session (Sprint 13)
|
||||
- [x] Session projects / folders (Sprint 15)
|
||||
|
||||
### Workspace Management
|
||||
- [x] Add workspace with path validation (must be existing directory)
|
||||
@@ -249,8 +254,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
|
||||
@@ -313,3 +318,17 @@ 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 |
|
||||
| Mobile responsive UI | #21 | Hamburger menu, slide-out sidebar drawer, touch-friendly controls. Already planned in Sprint 7.3. | Medium-High |
|
||||
|
||||
208
SPRINTS.md
208
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.15 | 221 tests | Daily driver ready
|
||||
> Current state: v0.18 | 237 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,17 @@
|
||||
|
||||
---
|
||||
|
||||
## Where we are now (v0.12.1)
|
||||
## Where we are now (v0.18)
|
||||
|
||||
**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: ~85% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops, cron/skills/memory CRUD, session management, streaming, cancel,
|
||||
multi-provider models, custom endpoint discovery -- 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: ~65% complete.** Chat, streaming, file browser, session
|
||||
management, tool cards, syntax highlighting, model switching, projects,
|
||||
settings, Mermaid diagrams, mobile layout -- all present. Gaps are
|
||||
artifacts, voice, reasoning display, sharing.
|
||||
|
||||
---
|
||||
|
||||
@@ -146,7 +148,7 @@ to daily friction.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 14 -- Visual Polish + Workspace Ops + Session Organization
|
||||
## Sprint 14 -- Visual Polish + Workspace Ops + Session Organization (COMPLETED)
|
||||
|
||||
**Theme:** Polish the visual experience, close workspace file gaps, and
|
||||
organize sessions properly.
|
||||
@@ -169,103 +171,107 @@ organize sessions properly.
|
||||
sessions hidden from sidebar by default. "Show N archived" toggle at top
|
||||
of list. `POST /api/session/archive` endpoint.
|
||||
|
||||
### Candidates for next sprints
|
||||
- Workspace reorder (drag-and-drop)
|
||||
- View skill linked files
|
||||
- Voice input via Whisper
|
||||
- Subagent delegation cards (enhanced tool card rendering)
|
||||
|
||||
**Tests:** ~12 new. Total: ~233.
|
||||
**Hermes CLI parity impact:** Medium (file rename, folder create)
|
||||
**Claude parity impact:** Medium (Mermaid, tags, archive)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 15 -- Project Organization + Session Management
|
||||
## Sprint 15 -- Session Projects + Code Copy + Tool Card Toggle (COMPLETED)
|
||||
|
||||
**Theme:** Organize work the way you think, not just chronologically.
|
||||
Plus two quick UX wins for code and agentic workflows.
|
||||
|
||||
**Why now:** After 100+ sessions the sidebar is a flat chronological list.
|
||||
Finding sessions from 2 weeks ago, or keeping a "MyProject" workspace separate
|
||||
from personal work, requires the search box. This is the biggest remaining
|
||||
daily organizational gap vs. Claude's project folders.
|
||||
Finding sessions from 2 weeks ago, or keeping work separated by project,
|
||||
requires the search box. Session projects are the single biggest remaining
|
||||
organizational gap vs. Claude's project folders.
|
||||
|
||||
### Track A: Bugs
|
||||
- Session search content scan (depth=5) is slow on large session histories.
|
||||
Add server-side caching of search index.
|
||||
- Date group headers ("Today / Yesterday / Earlier") use updated_at which can
|
||||
be misleading for sessions touched by automated title-setting. Use created_at
|
||||
for initial grouping, updated_at for sort order.
|
||||
- None.
|
||||
|
||||
### Track B: Features
|
||||
- **Session folders / projects:** A "Projects" section above the session list.
|
||||
Each project is a named group. Sessions can be dragged into projects or
|
||||
assigned via right-click. Stored in `projects.json`. Projects collapse/expand.
|
||||
This is the single biggest Claude parity feature missing.
|
||||
- ~~Pin sessions~~ (DONE Sprint 12)
|
||||
- ~~Import session from JSON~~ (DONE Sprint 12)
|
||||
|
||||
### Deferred to later sprints
|
||||
- Session tags / labels
|
||||
- Archive sessions
|
||||
- Rename file / Create folder (can be done through the agent)
|
||||
- Toolset control per session
|
||||
- Virtual scroll for session list
|
||||
- **Session projects:** Named groups for organizing sessions. A project
|
||||
filter bar (subtle chips) sits between the search input and the session
|
||||
list. Each project has a name and color. Click a chip to filter sessions
|
||||
to that project; "All" shows everything. Create projects inline (+
|
||||
button), rename (double-click chip), delete (right-click). Assign
|
||||
sessions via folder icon button (hover-reveal) with a dropdown picker.
|
||||
Projects stored in `projects.json`. Session model gains `project_id`
|
||||
field (null = unassigned). Fully backward-compatible with existing
|
||||
sessions. Endpoints: `GET /api/projects`, `POST /api/projects/create`,
|
||||
`POST /api/projects/rename`, `POST /api/projects/delete`,
|
||||
`POST /api/session/move`.
|
||||
- **Code block copy button:** Every code block gets a "Copy" button.
|
||||
Positioned in the language header bar (or top-right corner for plain
|
||||
code blocks). Click copies code to clipboard, shows "Copied!" for 1.5s.
|
||||
- **Tool card expand/collapse:** When a message has 2+ tool cards, an
|
||||
"Expand all / Collapse all" toggle appears above the card group.
|
||||
Scoped per message group, not global.
|
||||
|
||||
### Track C: Architecture
|
||||
- Session index v2: extend `_index.json` to include `project_id` field.
|
||||
Rebuild on session save. Enables fast client-side filtering without disk reads.
|
||||
- `projects.json` flat file storage for project list (same pattern as
|
||||
`workspaces.json` and `settings.json`).
|
||||
- `project_id` field on Session model with backward-compatible null default.
|
||||
- `_index.json` includes `project_id` for fast client-side filtering.
|
||||
|
||||
**Tests:** ~16 new. Total: ~241.
|
||||
**Tests:** 13 new. Total: ~237.
|
||||
**Hermes CLI parity impact:** Low (CLI has no session organization)
|
||||
**Claude parity impact:** Very High (projects are a core Claude concept)
|
||||
|
||||
### 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 15 -- 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:** 0 new (pure CSS/DOM changes). Total: 237.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** Medium (sidebar polish matches Claude's quality bar)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 16 -- Voice + Multimodal Input
|
||||
## Sprint 17 -- Voice + Multimodal Input
|
||||
|
||||
**Theme:** Input beyond the keyboard.
|
||||
|
||||
@@ -303,7 +309,7 @@ file uploads, not clipboard screenshots into the conversation directly).
|
||||
|
||||
---
|
||||
|
||||
## Sprint 17 -- Subagent Visibility + Agentic Transparency
|
||||
## Sprint 18 -- Subagent Visibility + Agentic Transparency
|
||||
|
||||
**Theme:** Watch Hermes think, not just respond.
|
||||
|
||||
@@ -343,7 +349,7 @@ what's happening. This is the last major "CLI feels better" gap for power users.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 18 -- Auth, HTTPS, and Production Hardening
|
||||
## Sprint 19 -- Auth, HTTPS, and Production Hardening
|
||||
|
||||
**Theme:** Make this safe to leave running.
|
||||
|
||||
@@ -380,7 +386,7 @@ address.
|
||||
|
||||
## Feature Parity Summary
|
||||
|
||||
### After Sprint 17 (Hermes CLI parity: complete)
|
||||
### After Sprint 18 (Hermes CLI parity: complete)
|
||||
|
||||
| CLI Feature | Status |
|
||||
|-------------|--------|
|
||||
@@ -395,16 +401,16 @@ address.
|
||||
| Session history | Done (v0.3) |
|
||||
| Workspace switching | Done (v0.7) |
|
||||
| Model selection | Done (v0.3) |
|
||||
| Multi-provider model support | Sprint 11 |
|
||||
| Multi-provider model support | Done (Sprint 11) |
|
||||
| Toolset control | Sprint 12 |
|
||||
| Settings persistence | Sprint 12 |
|
||||
| Subagent visibility | Sprint 17 |
|
||||
| Background task monitor | Sprint 17 |
|
||||
| Code execution (Jupyter) | Sprint 15 |
|
||||
| Cron completion alerts | Sprint 13 |
|
||||
| Virtual scroll (perf) | Sprint 13 |
|
||||
| Settings persistence | Done (Sprint 12) |
|
||||
| Subagent visibility | Sprint 18 |
|
||||
| Background task monitor | Sprint 18 |
|
||||
| Code execution (Jupyter) | Sprint 17+ |
|
||||
| Cron completion alerts | Done (Sprint 13) |
|
||||
| Virtual scroll (perf) | Deferred |
|
||||
|
||||
### After Sprint 18 (Claude parity: ~90% complete)
|
||||
### After Sprint 19 (Claude parity: ~90% complete)
|
||||
|
||||
| Claude Feature | Status |
|
||||
|----------------|--------|
|
||||
@@ -416,19 +422,19 @@ address.
|
||||
| Tool use visibility | Done (v0.11) |
|
||||
| Edit/regenerate messages | Done (v0.10) |
|
||||
| Session management | Done (v0.6) |
|
||||
| Artifacts (HTML/SVG preview) | Sprint 15 |
|
||||
| Code execution inline | Sprint 15 |
|
||||
| Mermaid diagrams | Sprint 15 |
|
||||
| Projects / folders | Sprint 14 |
|
||||
| Pinned/starred sessions | Sprint 14 |
|
||||
| Reasoning display | Sprint 17 |
|
||||
| Voice input | Sprint 16 |
|
||||
| TTS playback | Sprint 16 |
|
||||
| Notifications | Sprint 13 |
|
||||
| Settings panel | Sprint 12 |
|
||||
| Auth / login | Sprint 18 |
|
||||
| HTTPS | Sprint 18 |
|
||||
| Mobile layout | Sprint 18 |
|
||||
| Artifacts (HTML/SVG preview) | Sprint 17+ |
|
||||
| Code execution inline | Sprint 17+ |
|
||||
| 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) |
|
||||
| Sharing / public URLs | Not planned (requires server infra) |
|
||||
| Claude-specific features | Not replicable (Projects AI, artifacts sync) |
|
||||
|
||||
@@ -445,6 +451,6 @@ address.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: March 30, 2026*
|
||||
*Current version: v0.13 | 201 tests*
|
||||
*Next sprint: Sprint 14 (visual polish + small QoL)*
|
||||
*Last updated: April 2, 2026*
|
||||
*Current version: v0.18 | 237 tests*
|
||||
*Next sprint: Sprint 17 (Voice + Multimodal Input)*
|
||||
|
||||
101
api/config.py
101
api/config.py
@@ -39,6 +39,7 @@ WORKSPACES_FILE = STATE_DIR / 'workspaces.json'
|
||||
SESSION_INDEX_FILE = SESSION_DIR / '_index.json'
|
||||
SETTINGS_FILE = STATE_DIR / 'settings.json'
|
||||
LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'
|
||||
PROJECTS_FILE = STATE_DIR / 'projects.json'
|
||||
|
||||
# ── Hermes agent directory discovery ─────────────────────────────────────────
|
||||
def _discover_agent_dir() -> Path:
|
||||
@@ -262,6 +263,7 @@ _PROVIDER_DISPLAY = {
|
||||
'zai': 'Z.AI / GLM', 'kimi-coding': 'Kimi / Moonshot', 'deepseek': 'DeepSeek',
|
||||
'minimax': 'MiniMax', 'google': 'Google', 'meta-llama': 'Meta Llama',
|
||||
'huggingface': 'HuggingFace', 'alibaba': 'Alibaba',
|
||||
'ollama': 'Ollama', 'lmstudio': 'LM Studio',
|
||||
}
|
||||
|
||||
# Well-known models per provider (used to populate dropdown for direct API providers)
|
||||
@@ -295,6 +297,7 @@ _PROVIDER_MODELS = {
|
||||
{'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro (via Nous)'},
|
||||
],
|
||||
'zai': [
|
||||
{'id': 'glm-5.1', 'label': 'GLM-5.1'},
|
||||
{'id': 'glm-5', 'label': 'GLM-5'},
|
||||
{'id': 'glm-5-turbo', 'label': 'GLM-5 Turbo'},
|
||||
{'id': 'glm-4.7', 'label': 'GLM-4.7'},
|
||||
@@ -342,12 +345,14 @@ def resolve_model_provider(model_id: str):
|
||||
|
||||
if '/' in model_id:
|
||||
prefix, bare = model_id.split('/', 1)
|
||||
# If prefix matches config provider, strip it
|
||||
# If prefix matches config provider, strip it and use that provider directly
|
||||
if config_provider and prefix == config_provider:
|
||||
return bare, config_provider, config_base_url
|
||||
# If prefix is a known direct-API provider, use it
|
||||
# (base_url only applies when matching config provider)
|
||||
if prefix in _PROVIDER_MODELS:
|
||||
# If the config provider is openrouter (or unset/None), pass the full
|
||||
# provider/model string through -- OpenRouter uses this as its model ID.
|
||||
# Only strip the prefix and switch to a direct-API provider when the
|
||||
# config is explicitly set to that direct provider.
|
||||
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
|
||||
return bare, prefix, None
|
||||
|
||||
return model_id, config_provider, config_base_url
|
||||
@@ -360,7 +365,8 @@ def get_available_models() -> dict:
|
||||
Discovery order:
|
||||
1. Read config.yaml 'model' section for active provider info
|
||||
2. Check for known API keys in env or ~/.hermes/.env
|
||||
3. Fall back to hardcoded model list (OpenRouter-style)
|
||||
3. Fetch models from custom endpoint if base_url is configured
|
||||
4. Fall back to hardcoded model list (OpenRouter-style)
|
||||
|
||||
Returns: {
|
||||
'active_provider': str|None,
|
||||
@@ -379,6 +385,7 @@ def get_available_models() -> dict:
|
||||
elif isinstance(model_cfg, dict):
|
||||
active_provider = model_cfg.get('provider')
|
||||
cfg_default = model_cfg.get('default', '')
|
||||
cfg_base_url = model_cfg.get('base_url', '')
|
||||
if cfg_default:
|
||||
default_model = cfg_default
|
||||
|
||||
@@ -439,6 +446,73 @@ def get_available_models() -> dict:
|
||||
if all_env.get('DEEPSEEK_API_KEY'):
|
||||
detected_providers.add('deepseek')
|
||||
|
||||
# 3. Fetch models from custom endpoint if base_url is configured
|
||||
auto_detected_models = []
|
||||
if cfg_base_url:
|
||||
try:
|
||||
import ipaddress
|
||||
import urllib.request
|
||||
|
||||
# Normalize the base_url and build models endpoint
|
||||
base_url = cfg_base_url.strip()
|
||||
if base_url.endswith('/v1'):
|
||||
endpoint_url = base_url[:-3] + '/models'
|
||||
else:
|
||||
endpoint_url = base_url + '/v1/models'
|
||||
|
||||
# Detect provider from base_url
|
||||
provider = 'custom'
|
||||
parsed = urlparse(base_url if '://' in base_url else f'http://{base_url}')
|
||||
host = (parsed.netloc or parsed.path).lower()
|
||||
|
||||
if parsed.hostname:
|
||||
try:
|
||||
addr = ipaddress.ip_address(parsed.hostname)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
if 'ollama' in host or '127.0.0.1' in host or 'localhost' in host:
|
||||
provider = 'ollama'
|
||||
elif 'lmstudio' in host or 'lm-studio' in host:
|
||||
provider = 'lmstudio'
|
||||
else:
|
||||
provider = 'local'
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Resolve API key from environment
|
||||
headers = {}
|
||||
api_key_vars = ('HERMES_API_KEY', 'HERMES_OPENAI_API_KEY', 'OPENAI_API_KEY',
|
||||
'LOCAL_API_KEY', 'OPENROUTER_API_KEY', 'API_KEY')
|
||||
for key in api_key_vars:
|
||||
api_key = os.getenv(key)
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
break
|
||||
|
||||
# Fetch model list from endpoint
|
||||
req = urllib.request.Request(endpoint_url, method='GET')
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
# Handle both OpenAI-compatible and llama.cpp response formats
|
||||
models_list = []
|
||||
if 'data' in data and isinstance(data['data'], list):
|
||||
models_list = data['data']
|
||||
elif 'models' in data and isinstance(data['models'], list):
|
||||
models_list = data['models']
|
||||
|
||||
for model in models_list:
|
||||
if not isinstance(model, dict):
|
||||
continue
|
||||
model_id = model.get('id', '') or model.get('name', '') or model.get('model', '')
|
||||
model_name = model.get('name', '') or model.get('model', '') or model_id
|
||||
if model_id and model_name:
|
||||
auto_detected_models.append({'id': model_id, 'label': model_name})
|
||||
detected_providers.add(provider.lower())
|
||||
except Exception:
|
||||
pass # custom endpoint unreachable or misconfigured -- fail silently
|
||||
|
||||
# 5. Build model groups
|
||||
if detected_providers:
|
||||
for pid in sorted(detected_providers):
|
||||
@@ -455,11 +529,18 @@ def get_available_models() -> dict:
|
||||
'models': _PROVIDER_MODELS[pid],
|
||||
})
|
||||
else:
|
||||
# Unknown provider with key -- add a placeholder with the default model
|
||||
groups.append({
|
||||
'provider': provider_name,
|
||||
'models': [{'id': default_model, 'label': default_model.split('/')[-1]}],
|
||||
})
|
||||
# Unknown provider -- use auto-detected models if available,
|
||||
# otherwise fall back to default model placeholder
|
||||
if auto_detected_models:
|
||||
groups.append({
|
||||
'provider': provider_name,
|
||||
'models': auto_detected_models,
|
||||
})
|
||||
else:
|
||||
groups.append({
|
||||
'provider': provider_name,
|
||||
'models': [{'id': default_model, 'label': default_model.split('/')[-1]}],
|
||||
})
|
||||
else:
|
||||
# No providers detected -- use fallback grouped list
|
||||
by_provider = {}
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
import api.config as _cfg
|
||||
from api.config import (
|
||||
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE
|
||||
)
|
||||
from api.workspace import get_last_workspace
|
||||
|
||||
@@ -34,8 +34,8 @@ def _write_session_index():
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived)
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None
|
||||
@property
|
||||
def path(self): return SESSION_DIR / f'{self.session_id}.json'
|
||||
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
|
||||
@@ -44,7 +44,7 @@ class Session:
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
if not p.exists(): return None
|
||||
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived}
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id}
|
||||
|
||||
def get_session(sid):
|
||||
with LOCK:
|
||||
@@ -114,3 +114,19 @@ def title_from(messages, fallback='Untitled'):
|
||||
if text:
|
||||
return text[:64]
|
||||
return fallback
|
||||
|
||||
|
||||
# ── Project helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def load_projects():
|
||||
"""Load project list from disk. Returns list of project dicts."""
|
||||
if not PROJECTS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(PROJECTS_FILE.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def save_projects(projects):
|
||||
"""Write project list to disk."""
|
||||
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
@@ -23,6 +23,7 @@ from api.helpers import require, bad, safe_resolve, j, t, read_body
|
||||
from api.models import (
|
||||
Session, get_session, new_session, all_sessions, title_from,
|
||||
_write_session_index, SESSION_INDEX_FILE,
|
||||
load_projects, save_projects,
|
||||
)
|
||||
from api.workspace import (
|
||||
load_workspaces, save_workspaces, get_last_workspace, set_last_workspace,
|
||||
@@ -93,6 +94,9 @@ def handle_get(handler, parsed):
|
||||
if parsed.path == '/api/sessions':
|
||||
return j(handler, {'sessions': all_sessions()})
|
||||
|
||||
if parsed.path == '/api/projects':
|
||||
return j(handler, {'projects': load_projects()})
|
||||
|
||||
if parsed.path == '/api/session/export':
|
||||
return _handle_session_export(handler, parsed)
|
||||
|
||||
@@ -129,11 +133,13 @@ def handle_get(handler, parsed):
|
||||
return _handle_approval_pending(handler, parsed)
|
||||
|
||||
if parsed.path == '/api/approval/inject_test':
|
||||
# Loopback-only: used by automated tests; blocked from any remote client
|
||||
if handler.client_address[0] != '127.0.0.1':
|
||||
return j(handler, {'error': 'not found'}, status=404)
|
||||
return _handle_approval_inject(handler, parsed)
|
||||
|
||||
# ── Cron API (GET) ──
|
||||
if parsed.path == '/api/crons':
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from cron.jobs import list_jobs
|
||||
return j(handler, {'jobs': list_jobs(include_disabled=True)})
|
||||
|
||||
@@ -324,6 +330,72 @@ def handle_post(handler, parsed):
|
||||
s.save()
|
||||
return j(handler, {'ok': True, 'session': s.compact()})
|
||||
|
||||
# ── Session move to project (POST) ──
|
||||
if parsed.path == '/api/session/move':
|
||||
try: require(body, 'session_id')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
try: s = get_session(body['session_id'])
|
||||
except KeyError: return bad(handler, 'Session not found', 404)
|
||||
s.project_id = body.get('project_id') or None
|
||||
s.save()
|
||||
return j(handler, {'ok': True, 'session': s.compact()})
|
||||
|
||||
# ── Project CRUD (POST) ──
|
||||
if parsed.path == '/api/projects/create':
|
||||
try: require(body, 'name')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
import re as _re
|
||||
name = body['name'].strip()[:128]
|
||||
if not name: return bad(handler, 'name required')
|
||||
color = body.get('color')
|
||||
if color and not _re.match(r'^#[0-9a-fA-F]{3,8}$', color):
|
||||
return bad(handler, 'Invalid color format')
|
||||
projects = load_projects()
|
||||
proj = {'project_id': uuid.uuid4().hex[:12], 'name': name, 'color': color, 'created_at': time.time()}
|
||||
projects.append(proj)
|
||||
save_projects(projects)
|
||||
return j(handler, {'ok': True, 'project': proj})
|
||||
|
||||
if parsed.path == '/api/projects/rename':
|
||||
try: require(body, 'project_id', 'name')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
import re as _re
|
||||
projects = load_projects()
|
||||
proj = next((p for p in projects if p['project_id'] == body['project_id']), None)
|
||||
if not proj: return bad(handler, 'Project not found', 404)
|
||||
proj['name'] = body['name'].strip()[:128]
|
||||
if 'color' in body:
|
||||
color = body['color']
|
||||
if color and not _re.match(r'^#[0-9a-fA-F]{3,8}$', color):
|
||||
return bad(handler, 'Invalid color format')
|
||||
proj['color'] = color
|
||||
save_projects(projects)
|
||||
return j(handler, {'ok': True, 'project': proj})
|
||||
|
||||
if parsed.path == '/api/projects/delete':
|
||||
try: require(body, 'project_id')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
projects = load_projects()
|
||||
proj = next((p for p in projects if p['project_id'] == body['project_id']), None)
|
||||
if not proj: return bad(handler, 'Project not found', 404)
|
||||
projects = [p for p in projects if p['project_id'] != body['project_id']]
|
||||
save_projects(projects)
|
||||
# Unassign all sessions that belonged to this project
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
try:
|
||||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
for entry in index:
|
||||
if entry.get('project_id') == body['project_id']:
|
||||
try:
|
||||
s = get_session(entry['session_id'])
|
||||
s.project_id = None
|
||||
s.save()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return j(handler, {'ok': True})
|
||||
|
||||
# ── Session import from JSON (POST) ──
|
||||
if parsed.path == '/api/session/import':
|
||||
return _handle_session_import(handler, body)
|
||||
@@ -334,7 +406,14 @@ def handle_post(handler, parsed):
|
||||
# ── GET route helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _serve_static(handler, parsed):
|
||||
static_file = Path(__file__).parent.parent / parsed.path.lstrip('/')
|
||||
static_root = (Path(__file__).parent.parent / 'static').resolve()
|
||||
# Strip the leading '/static/' prefix, then resolve and sandbox
|
||||
rel = parsed.path[len('/static/'):]
|
||||
static_file = (static_root / rel).resolve()
|
||||
try:
|
||||
static_file.relative_to(static_root)
|
||||
except ValueError:
|
||||
return j(handler, {'error': 'not found'}, status=404)
|
||||
if not static_file.exists() or not static_file.is_file():
|
||||
return j(handler, {'error': 'not found'}, status=404)
|
||||
ext = static_file.suffix.lower()
|
||||
@@ -486,6 +565,7 @@ def _handle_approval_pending(handler, parsed):
|
||||
|
||||
|
||||
def _handle_approval_inject(handler, parsed):
|
||||
"""Inject a fake pending approval -- loopback-only, used by automated tests."""
|
||||
qs = parse_qs(parsed.query)
|
||||
sid = qs.get('session_id', [''])[0]
|
||||
key = qs.get('pattern_key', ['test_pattern'])[0]
|
||||
@@ -524,7 +604,6 @@ def _handle_cron_recent(handler, parsed):
|
||||
qs = parse_qs(parsed.query)
|
||||
since = float(qs.get('since', ['0'])[0])
|
||||
try:
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from cron.jobs import list_jobs
|
||||
jobs = list_jobs(include_disabled=True)
|
||||
completions = []
|
||||
@@ -871,6 +950,8 @@ def _handle_skill_save(handler, body):
|
||||
if not skill_name or '/' in skill_name or '..' in skill_name:
|
||||
return bad(handler, 'Invalid skill name')
|
||||
category = body.get('category', '').strip()
|
||||
if category and ('/' in category or '..' in category):
|
||||
return bad(handler, 'Invalid category')
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
if category:
|
||||
skill_dir = SKILLS_DIR / category / skill_name
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
<title>Hermes</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" defer></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
|
||||
</head>
|
||||
<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.2</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.17.1</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,7 +143,7 @@
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div class="topbar-left" 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 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>
|
||||
<div id="wsChipWrap" style="position:relative">
|
||||
|
||||
@@ -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=[];
|
||||
@@ -56,12 +67,18 @@ async function loadSession(sid){
|
||||
let _allSessions = []; // cached for search filter
|
||||
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
|
||||
let _showArchived = false; // toggle to show archived sessions
|
||||
let _allProjects = []; // cached project list
|
||||
let _activeProject = null; // project_id filter (null = show all)
|
||||
|
||||
async function renderSessionList(){
|
||||
try{
|
||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||
const data=await api('/api/sessions');
|
||||
_allSessions = data.sessions||[];
|
||||
const [sessData, projData] = await Promise.all([
|
||||
api('/api/sessions'),
|
||||
api('/api/projects'),
|
||||
]);
|
||||
_allSessions = sessData.sessions||[];
|
||||
_allProjects = projData.projects||[];
|
||||
renderSessionListFromCache(); // no-ops if rename is in progress
|
||||
}catch(e){console.warn('renderSessionList',e);}
|
||||
}
|
||||
@@ -94,10 +111,49 @@ function renderSessionListFromCache(){
|
||||
// Merge content matches (deduped): content matches appended after title matches
|
||||
const titleIds=new Set(titleMatches.map(s=>s.session_id));
|
||||
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
||||
// Filter by active project
|
||||
const projectFiltered=_activeProject?allMatched.filter(s=>s.project_id===_activeProject):allMatched;
|
||||
// Filter archived unless toggle is on
|
||||
const sessions=_showArchived?allMatched:allMatched.filter(s=>!s.archived);
|
||||
const archivedCount=allMatched.filter(s=>s.archived).length;
|
||||
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||
const list=$('sessionList');list.innerHTML='';
|
||||
// Project filter bar (only when projects exist)
|
||||
if(_allProjects.length>0){
|
||||
const bar=document.createElement('div');
|
||||
bar.className='project-bar';
|
||||
// "All" chip
|
||||
const allChip=document.createElement('span');
|
||||
allChip.className='project-chip'+(!_activeProject?' active':'');
|
||||
allChip.textContent='All';
|
||||
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
|
||||
bar.appendChild(allChip);
|
||||
// Project chips
|
||||
for(const p of _allProjects){
|
||||
const chip=document.createElement('span');
|
||||
chip.className='project-chip'+(p.project_id===_activeProject?' active':'');
|
||||
if(p.color){
|
||||
const dot=document.createElement('span');
|
||||
dot.className='color-dot';
|
||||
dot.style.background=p.color;
|
||||
chip.appendChild(dot);
|
||||
}
|
||||
const nameSpan=document.createElement('span');
|
||||
nameSpan.textContent=p.name;
|
||||
chip.appendChild(nameSpan);
|
||||
chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();};
|
||||
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);};
|
||||
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
|
||||
bar.appendChild(chip);
|
||||
}
|
||||
// Create button
|
||||
const addBtn=document.createElement('button');
|
||||
addBtn.className='project-create-btn';
|
||||
addBtn.textContent='+';
|
||||
addBtn.title='New project';
|
||||
addBtn.onclick=(e)=>{e.stopPropagation();_startProjectCreate(bar,addBtn);};
|
||||
bar.appendChild(addBtn);
|
||||
list.appendChild(bar);
|
||||
}
|
||||
// Show/hide archived toggle if there are archived sessions
|
||||
if(archivedCount>0){
|
||||
const toggle=document.createElement('div');
|
||||
@@ -106,6 +162,13 @@ function renderSessionListFromCache(){
|
||||
toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionListFromCache();};
|
||||
list.appendChild(toggle);
|
||||
}
|
||||
// Empty state for active project filter
|
||||
if(_activeProject&&sessions.length===0){
|
||||
const empty=document.createElement('div');
|
||||
empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;';
|
||||
empty.textContent='No sessions in this project yet.';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
// Separate pinned from unpinned
|
||||
const pinned=sessions.filter(s=>s.pinned);
|
||||
const unpinned=sessions.filter(s=>!s.pinned);
|
||||
@@ -190,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{
|
||||
@@ -204,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();
|
||||
@@ -217,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{
|
||||
@@ -230,10 +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);};
|
||||
el.appendChild(pin);el.appendChild(title);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,
|
||||
@@ -241,7 +340,7 @@ function renderSessionListFromCache(){
|
||||
let _clickTimer=null;
|
||||
el.onclick=async(e)=>{
|
||||
if(_renamingSid) return; // ignore while any rename is active
|
||||
if([trash,dup,archive].some(b=>e.target===b||b.contains(e.target))) return;
|
||||
if(actions.contains(e.target)) return;
|
||||
clearTimeout(_clickTimer);
|
||||
_clickTimer=setTimeout(async()=>{
|
||||
_clickTimer=null;
|
||||
@@ -284,4 +383,150 @@ async function deleteSession(sid){
|
||||
await renderSessionList();
|
||||
}
|
||||
|
||||
// ── Project helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const PROJECT_COLORS=['#7cb9ff','#f5c542','#e94560','#50c878','#c084fc','#fb923c','#67e8f9','#f472b6'];
|
||||
|
||||
function _showProjectPicker(session, anchorEl){
|
||||
// Close any existing picker
|
||||
document.querySelectorAll('.project-picker').forEach(p=>p.remove());
|
||||
const picker=document.createElement('div');
|
||||
picker.className='project-picker';
|
||||
// "No project" option
|
||||
const none=document.createElement('div');
|
||||
none.className='project-picker-item'+(!session.project_id?' active':'');
|
||||
none.textContent='No project';
|
||||
none.onclick=async()=>{
|
||||
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();
|
||||
showToast('Removed from project');
|
||||
};
|
||||
picker.appendChild(none);
|
||||
// Project options
|
||||
for(const p of _allProjects){
|
||||
const item=document.createElement('div');
|
||||
item.className='project-picker-item'+(session.project_id===p.project_id?' active':'');
|
||||
if(p.color){
|
||||
const dot=document.createElement('span');
|
||||
dot.className='color-dot';
|
||||
dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;';
|
||||
item.appendChild(dot);
|
||||
}
|
||||
const name=document.createElement('span');
|
||||
name.textContent=p.name;
|
||||
item.appendChild(name);
|
||||
item.onclick=async()=>{
|
||||
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();
|
||||
showToast('Moved to '+p.name);
|
||||
};
|
||||
picker.appendChild(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();
|
||||
showToast('Created "'+res.project.name+'" and moved session');
|
||||
}
|
||||
};
|
||||
picker.appendChild(createItem);
|
||||
// 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';
|
||||
picker.style.top='auto';
|
||||
}else{
|
||||
picker.style.top=(rect.bottom+4)+'px';
|
||||
picker.style.bottom='auto';
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
function _startProjectCreate(bar, addBtn){
|
||||
const inp=document.createElement('input');
|
||||
inp.className='project-create-input';
|
||||
inp.placeholder='Project name';
|
||||
const finish=async(save)=>{
|
||||
if(save&&inp.value.trim()){
|
||||
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
||||
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
|
||||
await renderSessionList();
|
||||
showToast('Project created');
|
||||
}else{
|
||||
inp.replaceWith(addBtn);
|
||||
}
|
||||
};
|
||||
inp.onkeydown=(e)=>{
|
||||
if(e.key==='Enter'){e.preventDefault();finish(true);}
|
||||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||||
};
|
||||
inp.onblur=()=>finish(false);
|
||||
addBtn.replaceWith(inp);
|
||||
setTimeout(()=>inp.focus(),10);
|
||||
}
|
||||
|
||||
function _startProjectRename(proj, chip){
|
||||
const inp=document.createElement('input');
|
||||
inp.className='project-create-input';
|
||||
inp.value=proj.name;
|
||||
const finish=async(save)=>{
|
||||
if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
|
||||
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
|
||||
await renderSessionList();
|
||||
showToast('Project renamed');
|
||||
}else{
|
||||
renderSessionListFromCache();
|
||||
}
|
||||
};
|
||||
inp.onkeydown=(e)=>{
|
||||
if(e.key==='Enter'){e.preventDefault();finish(true);}
|
||||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||||
};
|
||||
inp.onblur=()=>finish(false);
|
||||
inp.onclick=(e)=>e.stopPropagation();
|
||||
chip.replaceWith(inp);
|
||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||
}
|
||||
|
||||
async function _confirmDeleteProject(proj){
|
||||
if(!confirm('Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.')){return;}
|
||||
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
|
||||
if(_activeProject===proj.project_id) _activeProject=null;
|
||||
await renderSessionList();
|
||||
showToast('Project deleted');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;}
|
||||
@@ -250,21 +258,21 @@
|
||||
.msg-body{padding-left:0;max-width:100%;}
|
||||
.msg-role{font-size:12px;}
|
||||
/* Composer */
|
||||
.composer-wrap{padding:8px 10px 12px;}
|
||||
.composer-wrap{padding:8px 10px 12px!important;}
|
||||
.composer-box{border-radius:12px;}
|
||||
.composer-box textarea{font-size:16px;min-height:40px;}
|
||||
.send-btn{padding:6px 14px;font-size:13px;}
|
||||
/* Empty state */
|
||||
.empty-state h2{font-size:18px;}
|
||||
.empty-state p{font-size:13px;}
|
||||
.suggestion-grid{max-width:100%;}
|
||||
.suggestion-grid{max-width:100%!important;}
|
||||
.suggestion-btn{font-size:12px;padding:8px 10px;}
|
||||
/* Approval card */
|
||||
.approval-card{padding:0 10px 8px;}
|
||||
.approval-btns{gap:6px;}
|
||||
.approval-btn{padding:5px 10px;font-size:11px;}
|
||||
/* Tool cards */
|
||||
.tool-card{margin-left:0;font-size:12px;}
|
||||
.tool-card{margin-left:0!important;font-size:12px;}
|
||||
/* Settings modal */
|
||||
.settings-panel{width:95vw;max-width:95vw;}
|
||||
}
|
||||
@@ -495,15 +503,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 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;}
|
||||
@@ -529,4 +531,30 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.mermaid-rendered{background:transparent;padding:8px 0;}
|
||||
.mermaid-rendered svg{max-width:100%;height:auto;}
|
||||
|
||||
/* ── Session projects ── */
|
||||
.project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;}
|
||||
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:rgba(255,255,255,.04);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
|
||||
.project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
.project-chip.active{background:rgba(124,185,255,.12);color:var(--blue);border-color:rgba(124,185,255,.4);}
|
||||
.project-chip .color-dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0;}
|
||||
.project-create-btn{font-size:10px;padding:3px 6px;border-radius:12px;cursor:pointer;border:1px dashed var(--border2);background:none;color:var(--muted);opacity:.6;transition:all .15s;}
|
||||
.project-create-btn:hover{opacity:1;border-color:var(--blue);color:var(--blue);}
|
||||
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:rgba(20,32,60,.9);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
|
||||
.project-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);}
|
||||
.project-picker-create{color:var(--blue);opacity:.7;border-top:1px solid var(--border2);margin-top:2px;padding-top:6px;}
|
||||
.project-picker-create:hover{opacity:1;background:rgba(124,185,255,.08);}
|
||||
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;margin-left:4px;vertical-align:middle;}
|
||||
|
||||
/* ── Code copy button ── */
|
||||
.code-copy-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
|
||||
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
|
||||
|
||||
/* ── Tool card expand/collapse toggle ── */
|
||||
.tool-cards-toggle{margin:4px 0 2px 40px;display:flex;gap:8px;}
|
||||
.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;}
|
||||
|
||||
.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;}
|
||||
|
||||
111
static/ui.js
111
static/ui.js
@@ -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>`);
|
||||
s=s.replace(/\*\*\*(.+?)\*\*\*/g,'<strong><em>$1</em></strong>');
|
||||
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
|
||||
s=s.replace(/\*([^*\n]+)\*/g,'<em>$1</em>');
|
||||
s=s.replace(/^### (.+)$/gm,'<h3>$1</h3>').replace(/^## (.+)$/gm,'<h2>$1</h2>').replace(/^# (.+)$/gm,'<h1>$1</h1>');
|
||||
// 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>${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,'<blockquote>$1</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">${text}</li>`;
|
||||
else html+=`<li>${text}</li>`;
|
||||
if(indent) html+=`<li style="margin-left:16px">${inlineMd(text)}</li>`;
|
||||
else html+=`<li>${inlineMd(text)}</li>`;
|
||||
}
|
||||
return html+'</ul>';
|
||||
});
|
||||
@@ -111,23 +142,29 @@ function renderMd(raw){
|
||||
let html='<ol>';
|
||||
for(const l of lines){
|
||||
const text=l.replace(/^ {0,4}\d+\. /,'');
|
||||
html+=`<li>${text}</li>`;
|
||||
html+=`<li>${inlineMd(text)}</li>`;
|
||||
}
|
||||
return html+'</ol>';
|
||||
});
|
||||
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
|
||||
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
|
||||
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
|
||||
const rows=block.trim().split('\n').filter(r=>r.trim());
|
||||
if(rows.length<2)return block;
|
||||
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
|
||||
if(!isSep(rows[1]))return block;
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${c.trim()}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${c.trim()}</th>`).join('');
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${esc(c.trim())}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${esc(c.trim())}</th>`).join('');
|
||||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||||
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;
|
||||
@@ -374,13 +411,29 @@ function renderMessages(){
|
||||
}
|
||||
const frag=document.createDocumentFragment();
|
||||
for(const tc of cards){frag.appendChild(buildToolCard(tc));}
|
||||
// Add expand/collapse toggle for groups with 2+ cards
|
||||
if(cards.length>=2){
|
||||
const toggle=document.createElement('div');
|
||||
toggle.className='tool-cards-toggle';
|
||||
// Collect card elements before they get moved to DOM
|
||||
const cardEls=Array.from(frag.querySelectorAll('.tool-card'));
|
||||
const expandBtn=document.createElement('button');
|
||||
expandBtn.textContent='Expand all';
|
||||
expandBtn.onclick=()=>cardEls.forEach(c=>c.classList.add('open'));
|
||||
const collapseBtn=document.createElement('button');
|
||||
collapseBtn.textContent='Collapse all';
|
||||
collapseBtn.onclick=()=>cardEls.forEach(c=>c.classList.remove('open'));
|
||||
toggle.appendChild(expandBtn);
|
||||
toggle.appendChild(collapseBtn);
|
||||
frag.insertBefore(toggle,frag.firstChild);
|
||||
}
|
||||
if(insertBefore) inner.insertBefore(frag,insertBefore);
|
||||
else inner.appendChild(frag);
|
||||
}
|
||||
}
|
||||
scrollToBottom();
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();renderMermaidBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();});
|
||||
// Refresh todo panel if it's currently open
|
||||
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||||
loadTodos();
|
||||
@@ -558,6 +611,36 @@ function highlightCode(container) {
|
||||
Prism.highlightAllUnder(el);
|
||||
}
|
||||
|
||||
function addCopyButtons(container){
|
||||
const el=container||$('msgInner');
|
||||
if(!el) return;
|
||||
el.querySelectorAll('pre > code').forEach(codeEl=>{
|
||||
const pre=codeEl.parentElement;
|
||||
if(pre.querySelector('.code-copy-btn')) return;
|
||||
const btn=document.createElement('button');
|
||||
btn.className='code-copy-btn';
|
||||
btn.textContent='Copy';
|
||||
btn.onclick=(e)=>{
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(codeEl.textContent).then(()=>{
|
||||
btn.textContent='Copied!';
|
||||
setTimeout(()=>{btn.textContent='Copy';},1500);
|
||||
});
|
||||
};
|
||||
const header=pre.previousElementSibling;
|
||||
if(header&&header.classList.contains('pre-header')){
|
||||
header.style.display='flex';
|
||||
header.style.justifyContent='space-between';
|
||||
header.style.alignItems='center';
|
||||
header.appendChild(btn);
|
||||
}else{
|
||||
pre.style.position='relative';
|
||||
btn.style.cssText='position:absolute;top:6px;right:6px;';
|
||||
pre.appendChild(btn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _mermaidLoading=false;
|
||||
let _mermaidReady=false;
|
||||
|
||||
@@ -568,7 +651,9 @@ function renderMermaidBlocks(){
|
||||
if(!_mermaidLoading){
|
||||
_mermaidLoading=true;
|
||||
const script=document.createElement('script');
|
||||
script.src='https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
|
||||
script.src='https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js';
|
||||
script.integrity='sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT';
|
||||
script.crossOrigin='anonymous';
|
||||
script.onload=()=>{
|
||||
if(typeof mermaid!=='undefined'){
|
||||
mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{
|
||||
|
||||
234
tests/test_sprint15.py
Normal file
234
tests/test_sprint15.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Sprint 15 Tests: session projects (CRUD, move, backward compat).
|
||||
"""
|
||||
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"]
|
||||
|
||||
|
||||
def make_project(created_list, name="Test Project", color=None):
|
||||
body = {"name": name}
|
||||
if color:
|
||||
body["color"] = color
|
||||
d, status = post("/api/projects/create", body)
|
||||
assert status == 200
|
||||
pid = d["project"]["project_id"]
|
||||
created_list.append(pid)
|
||||
return pid, d["project"]
|
||||
|
||||
|
||||
def cleanup_projects(project_ids):
|
||||
for pid in project_ids:
|
||||
try:
|
||||
post("/api/projects/delete", {"project_id": pid})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Project CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_project():
|
||||
"""Creating a project returns a valid project dict."""
|
||||
pids = []
|
||||
try:
|
||||
pid, proj = make_project(pids, "My Project", "#7cb9ff")
|
||||
assert pid and len(pid) == 12
|
||||
assert proj["name"] == "My Project"
|
||||
assert proj["color"] == "#7cb9ff"
|
||||
assert "created_at" in proj
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
|
||||
|
||||
def test_list_projects_empty():
|
||||
"""Listing projects when none exist returns empty list."""
|
||||
d, status = get("/api/projects")
|
||||
assert status == 200
|
||||
assert isinstance(d["projects"], list)
|
||||
|
||||
|
||||
def test_list_projects():
|
||||
"""Listing projects returns created projects."""
|
||||
pids = []
|
||||
try:
|
||||
make_project(pids, "Alpha")
|
||||
make_project(pids, "Beta")
|
||||
d, status = get("/api/projects")
|
||||
assert status == 200
|
||||
names = [p["name"] for p in d["projects"]]
|
||||
assert "Alpha" in names
|
||||
assert "Beta" in names
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
|
||||
|
||||
def test_rename_project():
|
||||
"""Renaming a project updates its name."""
|
||||
pids = []
|
||||
try:
|
||||
pid, _ = make_project(pids, "Old Name")
|
||||
d, status = post("/api/projects/rename", {"project_id": pid, "name": "New Name"})
|
||||
assert status == 200
|
||||
assert d["project"]["name"] == "New Name"
|
||||
# Verify via list
|
||||
dl, _ = get("/api/projects")
|
||||
names = [p["name"] for p in dl["projects"]]
|
||||
assert "New Name" in names
|
||||
assert "Old Name" not in names
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
|
||||
|
||||
def test_delete_project():
|
||||
"""Deleting a project removes it from the list."""
|
||||
pids = []
|
||||
try:
|
||||
pid, _ = make_project(pids, "Doomed")
|
||||
d, status = post("/api/projects/delete", {"project_id": pid})
|
||||
assert status == 200
|
||||
assert d["ok"] is True
|
||||
dl, _ = get("/api/projects")
|
||||
assert all(p["project_id"] != pid for p in dl["projects"])
|
||||
pids.clear() # already deleted
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
|
||||
|
||||
def test_delete_project_unassigns_sessions():
|
||||
"""Deleting a project unassigns all sessions that belonged to it."""
|
||||
pids = []
|
||||
sids = []
|
||||
try:
|
||||
pid, _ = make_project(pids, "Temp Project")
|
||||
sid, _ = make_session(sids)
|
||||
# Assign session to project
|
||||
post("/api/session/move", {"session_id": sid, "project_id": pid})
|
||||
# Verify assigned
|
||||
sd, _ = get(f"/api/session?session_id={sid}")
|
||||
assert sd["session"].get("project_id") == pid
|
||||
# Delete project
|
||||
post("/api/projects/delete", {"project_id": pid})
|
||||
pids.clear()
|
||||
# Verify session is unassigned
|
||||
sd2, _ = get(f"/api/session?session_id={sid}")
|
||||
assert sd2["session"].get("project_id") is None
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
for s in sids:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
def test_create_project_requires_name():
|
||||
"""Creating a project without a name returns 400."""
|
||||
d, status = post("/api/projects/create", {})
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_delete_nonexistent_project():
|
||||
"""Deleting a project that doesn't exist returns 404."""
|
||||
d, status = post("/api/projects/delete", {"project_id": "nonexistent99"})
|
||||
assert status == 404
|
||||
|
||||
|
||||
# ── Session move ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_session_move_to_project():
|
||||
"""Moving a session to a project sets its project_id."""
|
||||
pids = []
|
||||
sids = []
|
||||
try:
|
||||
pid, _ = make_project(pids, "Work")
|
||||
sid, _ = make_session(sids)
|
||||
d, status = post("/api/session/move", {"session_id": sid, "project_id": pid})
|
||||
assert status == 200
|
||||
assert d["session"]["project_id"] == pid
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
for s in sids:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
def test_session_move_to_unassigned():
|
||||
"""Moving a session to null project unassigns it."""
|
||||
pids = []
|
||||
sids = []
|
||||
try:
|
||||
pid, _ = make_project(pids, "Temp")
|
||||
sid, _ = make_session(sids)
|
||||
# Assign then unassign
|
||||
post("/api/session/move", {"session_id": sid, "project_id": pid})
|
||||
d, status = post("/api/session/move", {"session_id": sid, "project_id": None})
|
||||
assert status == 200
|
||||
assert d["session"]["project_id"] is None
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
for s in sids:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
def test_session_project_in_list():
|
||||
"""Session list includes project_id for assigned sessions."""
|
||||
pids = []
|
||||
sids = []
|
||||
try:
|
||||
pid, _ = make_project(pids, "Listed")
|
||||
sid, _ = make_session(sids)
|
||||
# Give it a title so it shows in list (non-empty Untitled sessions are hidden)
|
||||
post("/api/session/rename", {"session_id": sid, "title": "Project Test Session"})
|
||||
post("/api/session/move", {"session_id": sid, "project_id": pid})
|
||||
dl, _ = get("/api/sessions")
|
||||
match = [s for s in dl["sessions"] if s["session_id"] == sid]
|
||||
assert len(match) == 1
|
||||
assert match[0]["project_id"] == pid
|
||||
finally:
|
||||
cleanup_projects(pids)
|
||||
for s in sids:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
# ── Backward compat ──────────────────────────────────────────────────────
|
||||
|
||||
def test_compact_includes_project_id():
|
||||
"""New session compact dict includes project_id as null."""
|
||||
sids = []
|
||||
try:
|
||||
sid, sess = make_session(sids)
|
||||
# Give it a title so it appears in the list
|
||||
post("/api/session/rename", {"session_id": sid, "title": "Compat Test"})
|
||||
dl, _ = get("/api/sessions")
|
||||
match = [s for s in dl["sessions"] if s["session_id"] == sid]
|
||||
assert len(match) == 1
|
||||
assert "project_id" in match[0]
|
||||
assert match[0]["project_id"] is None
|
||||
finally:
|
||||
for s in sids:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
def test_session_move_requires_session_id():
|
||||
"""Moving without session_id returns 400."""
|
||||
d, status = post("/api/session/move", {"project_id": "abc"})
|
||||
assert status == 400
|
||||
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"
|
||||
Reference in New Issue
Block a user