Compare commits

...

31 Commits

Author SHA1 Message Date
Nathan Esquenazi
a92fff75a3 Merge pull request #29 from nesquena/feat/safe-html-rendering
feat: safe HTML rendering in AI responses, active session gold style, Sprint 16 tests
2026-04-02 17:34:01 -07:00
Hermes
0be7ccde4c feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:

Pre-pass (ui.js):
  Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
  equivalents (**text**, *text*, `text`, newline) before the pipeline
  runs. Code blocks and backtick spans are stashed first so their content
  is never modified.

inlineMd() helper (ui.js):
  New helper for processing inline formatting inside list items,
  blockquotes, and headings. Previously these used esc() directly, which
  escaped <strong>/<code> tags that had already been converted from HTML
  by the pre-pass — causing them to appear as literal &lt;strong&gt; text
  instead of rendering as bold. inlineMd() applies bold/italic/code
  processing and then escapes only unknown tags.

Safety net (ui.js):
  After the full pipeline, any HTML tags NOT emitted by our own renderer
  (i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
  via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
  produces. XSS is fully blocked.

Active session gold style (sessions.js, style.css):
  Active session item now uses gold/amber (#e8a030) instead of blue,
  matching the logo gradient color for better visual hierarchy.
  Project color border-left is skipped when the session is active
  (gold always wins). Session items get border-radius: 0 8px 8px 0
  to complement the left border indicator.

Tests (tests/test_sprint16.py — 74 tests):
  - Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
  - Behavioural: all safe tags render in paragraphs, list items (ul+ol),
    blockquotes, headings (h1/h2/h3)
  - Exact screenshot regression: the 4-item list with <strong> labels
    and <code> values that was showing as literal text
  - XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
    <object>, XSS inside bold, XSS nested inside <strong>)
  - Edge cases: code block protection, double-escaping guards, br tag,
    mixed markdown+HTML, inlineMd called in list/blockquote handlers

Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
Nathan Esquenazi
fcd155be55 Merge pull request #27 from nesquena/docs/update-all-markdown
docs: update all markdown to v0.18 state
2026-04-02 15:15:10 -07:00
Nathan Esquenazi
0c601a9cc8 docs: fix models.py line count (114 -> 132)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:14:42 -07:00
Nathan Esquenazi
4b326dbdf0 docs: update all markdown files to reflect v0.18 state
Brings all documentation up to date after Sprint 16, PRs #18-25:

- CHANGELOG: add v0.18 (Sprint 16), v0.17.3 (bug fixes), update footer
- ROADMAP: Sprint 16 in history, custom model discovery feature, updated
  line counts and architecture table, fix Wave 3 checkboxes
- SPRINTS: bump version to v0.18, update parity percentages, fix
  reasoning display sprint reference
- ARCHITECTURE: update all file line counts to match current source,
  add Session model fields (pinned, archived, project_id, tool_calls),
  document ICONS constant and sessions.js overlay pattern, update
  Phase A/I sections
- BUGS: restructure with open/fixed sections, add v0.17.3 fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:56:06 -07:00
Nathan Esquenazi
fd993c5513 Merge pull request #20 from nesquena/sprint-16-sidebar-polish
Sprint 16: Session sidebar visual polish — overlay actions, SVG icons, project borders
2026-04-02 11:50:28 -07:00
Nathan Esquenazi
d2bcd2b2f7 feat: Sprint 16 — session sidebar visual polish
- Action buttons overlay: wrap pin/move/archive/dup/trash in a
  .session-actions container with position:absolute. Titles now use
  full available width. Actions appear on hover with gradient fade
  from the right edge. Overlay auto-hides during inline rename.

- SVG line icons: replace all emoji HTML entities with monochrome
  SVGs that inherit currentColor. Consistent across all platforms.

- Pin indicator: small gold star rendered inline only when pinned.
  Unpinned sessions get full title width (zero space reservation).

- Project border: sessions assigned to a project show a colored
  left border matching the project color, replacing the old
  always-visible blue folder button.

Fixes both BUGS.md items (title truncation + sticky folder button).
Tests: 214 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:49:24 -07:00
Nathan Esquenazi
5e4645ee05 Merge pull request #25 from nesquena/fix/project-picker-overflow
fix: project picker clipped and width issues
2026-04-02 11:46:56 -07:00
Nathan Esquenazi
ae1faa7252 Merge pull request #24 from nesquena/fix/model-discovery-logger-crash
fix: NameError on logger in custom endpoint model discovery
2026-04-02 11:46:48 -07:00
Nathan Esquenazi
6c7fb4ee44 fix: NameError on logger in custom endpoint model discovery
get_available_models() references 'logger' in the except block of
the custom endpoint fetch (added in PR #18), but 'logger' is never
imported or defined in api/config.py. When the custom endpoint is
unreachable (the normal case -- most users don't have a local LLM),
the except handler raises NameError: name 'logger' is not defined,
which propagates as a 500 on every GET /api/models request.

This broke 7 test_sprint11 tests and caused the model dropdown to
fail for all users regardless of whether they have a custom endpoint.

Fix: replace logger.debug() with a silent pass -- the exception is
expected when no local LLM is configured and needs no logging.

Tests: 237 passed, 0 failed.
2026-04-02 11:36:01 -07:00
Nathan Esquenazi
e59eb8bb5b fix: project picker clipped, full-screen width bug, New Project shortcut
Five fixes to the Sprint 15 Move to Project picker:

1. CRITICAL: Picker was invisible (overflow:hidden clipping)
   Appended to document.body + positioned with fixed/getBoundingClientRect
   instead of inside .session-item (overflow:hidden). Flips above button
   when near bottom of viewport.

2. CRITICAL: Picker stretched full screen width
   position:fixed removed the containing block width constraint. Added
   max-width:220px; width:max-content to .project-picker.

3. UX: No way to create a project from the picker
   Added '+ New project': creates project and moves session in one click.

4. UX: Feature was undiscoverable
   Folder button shows persistently (blue, 60% opacity) when session
   has a project.

5. Minor: Event listener leak
   removeEventListener was missing from picker item onclick handlers.

Tests: 237 passed (7 pre-existing failures from unrelated logger bug).
2026-04-02 18:18:20 +00:00
Nathan Esquenazi
ca06fd5533 Merge pull request #19 from nesquena/fix/add-glm-5.1-model
feat: add GLM-5.1 to Z.AI model list
2026-04-02 11:10:34 -07:00
Nathan Esquenazi
7ddd896b36 feat: add GLM-5.1 to Z.AI model list
Adds the newly available GLM-5.1 model to the hardcoded Z.AI provider
model list so it appears in the model dropdown. Fixes #17.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:02:10 -07:00
Nathan Esquenazi
f109910b58 Merge pull request #18 from nesquena/fix/custom-model-discovery
feat: custom endpoint model discovery for local LLMs (Ollama, LM Studio)
2026-04-02 10:55:35 -07:00
Nathan Esquenazi
b784fff104 refactor: keep only model discovery, drop redundant routing changes
- Revert routes.py and streaming.py to master: resolve_model_provider()
  already handles provider routing and base_url passthrough for all models.
- Fix indentation error in config.py (2-space indent on comment line).
- Fix auto_detected_models scope: initialize before try block.
- Remove unused urllib.parse import.
- Simplify unknown-provider model group logic.
- Remove verbose comments and redundant variable assignments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:15:41 -07:00
Dhaval
d9293c6097 refactor: streamline model provider resolution in chat start handler 2026-04-02 09:14:21 -07:00
Dhaval
669412cbc9 refactor: switch from requests to urllib for custom endpoint model fetching 2026-04-02 09:14:21 -07:00
Dhaval
c1a9324f35 feat: add support for Ollama and LM Studio providers in model fetching 2026-04-02 09:14:21 -07:00
Dhaval
cbf82898cb feat: implement custom endpoint fetching for model lists and update streaming handler 2026-04-02 09:14:21 -07:00
Nathan Esquenazi
fb916d1f7e Merge pull request #16 from nesquena/release/v0.17.1
release: v0.17.1 — changelog + version bump
2026-04-02 01:19:16 -07:00
Nathan Esquenazi
9452f56821 docs: update changelog and version to v0.17.1
Covers PRs #11, #13, #14, #15: Sprint 15 features, security hardening,
OpenRouter routing fix, project picker UX fixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:18:53 -07:00
Nathan Esquenazi
95645d651e Merge pull request #15 from nesquena/fix/project-picker-ux
fix: project picker visibility, width, create-from-picker, button state, listener leak
2026-04-02 01:16:57 -07:00
Nathan Esquenazi
2f281cbbd7 fix: project picker clipping, create-from-picker, button visibility, listener leak
- Picker dropdown: append to document.body with fixed positioning instead
  of inside the session-item (which has overflow:hidden). Flips above
  when near bottom of viewport.
- Add "+ New project" item at bottom of picker so users can create a
  project and assign in one flow.
- Folder button stays visible (blue, 60% opacity) when session belongs
  to a project, instead of only appearing on hover.
- Clean up document click listener in all picker item onclick handlers
  to prevent stale listener accumulation.

Tests: 214 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:12:14 -07:00
Nathan Esquenazi
537337a158 Merge pull request #14 from nesquena/fix/project-validation
fix: OpenRouter model routing regression + project input validation
2026-04-02 01:05:39 -07:00
Nathan Esquenazi
8075442200 fix: OpenRouter model routing regression + project input validation
- resolve_model_provider: fix regression where OpenRouter model IDs like
  openai/gpt-5.4-mini had their prefix stripped, causing AIAgent to look
  for OPENAI_API_KEY (direct API) instead of routing through OpenRouter.
  All chats returned Connection lost for OpenRouter users. Fix: only strip
  prefix and use direct-API when config.provider explicitly matches that
  provider; pass full provider/model string through for openrouter.
- Project name: cap at 128 chars, reject empty after strip on create/rename
- Project color: validate ^#[0-9a-fA-F]{3,8}$ to prevent CSS injection
  via dot.style.background in sessions.js
- Remove 2 redundant sys.path.insert() calls in cron handlers

Tests: 214 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:04:14 -07:00
Nathan Esquenazi
ebdd955578 Merge pull request #11 from nesquena/sprint-15-session-projects
Sprint 15: Session Projects + Code Copy + Tool Card Toggle
2026-04-02 00:12:26 -07:00
Nathan Esquenazi
1a4793848e feat: Sprint 15 — session projects, code copy button, tool card toggle
Session projects: named groups for organizing sessions. Project filter
bar with chips between search and session list. Create/rename/delete
projects, assign sessions via folder icon dropdown. Stored in
projects.json, project_id on Session model. 5 new API endpoints.

Code block copy button: every code block gets a Copy button in the
language header (or top-right for plain blocks). Clipboard API with
"Copied!" feedback.

Tool card expand/collapse: messages with 2+ tool cards get an
"Expand all / Collapse all" toggle above the card group.

13 new tests (237 total), all passing. No regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:11:49 -07:00
Nathan Esquenazi
8ed206657c Merge pull request #13 from nesquena/fix/security-hardening
fix(security): 5 security hardening fixes
2026-04-02 00:05:50 -07:00
Nathan Esquenazi
06e1f11070 fix: revert 3 regressions introduced alongside security fixes
1. Restore resolve_model_provider() in _handle_chat_sync -- removed
   multi-provider model routing, breaking cross-provider selection.

2. Restore new URL(path, location.origin) + credentials:include on
   fetch calls -- reverted reverse-proxy auth fix from v0.16.1.

3. Revert cron import refactor (_cron_module, _real_hermes_home_env)
   back to original from cron.jobs import pattern.

Tests: 201 passed, 23 pre-existing failures, 0 new regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:05:09 -07:00
Hermes
089dd7e3de fix(security): 5 security hardening fixes
1. Path traversal in _serve_static() [CRITICAL]
   Sandbox resolved path to static/ directory using relative_to().
   GET /static/../../../../etc/passwd now returns 404.

2. Skill category path traversal [HIGH]
   Validate category param in skill save: reject values with '/' or '..'.

3. Gate /api/approval/inject_test to loopback only [HIGH]
   Endpoint now returns 404 for any non-127.0.0.1 client,
   preserving test functionality while blocking remote access.

4. Escape captured groups in renderMd() [HIGH]
   All inline markdown regexes (bold, italic, headings, blockquote,
   list items, table cells/headers, link labels) now run captured
   text through esc() before inserting into innerHTML, preventing
   XSS via AI-generated content.

5. SRI hashes for CDN resources + pin Mermaid version [MEDIUM]
   Added integrity= + crossorigin= to all three PrismJS CDN tags.
   Pinned Mermaid from floating @10 to @10.9.3 with SRI hash.

Tests: 224 passed, 0 failed.
2026-04-02 06:46:40 +00:00
Nathan Esquenazi
0875dddbff fix(security): sandbox _serve_static() to prevent path traversal
Resolved path was not checked against the static/ directory, allowing
GET /static/../../../../etc/passwd to serve arbitrary files.

Fix: resolve the path and call relative_to(static_root) before serving.
Returns 404 for any path that escapes the static/ directory.

fix(css): add !important to three dead mobile overrides in @media(640px)

Three @media(max-width:640px) rules added by the mobile responsive PR
were silently overridden by later bare rules in the same stylesheet:
  .composer-wrap padding (overridden by line 347)
  .suggestion-grid max-width (overridden by line 364)
  .tool-card margin-left (overridden by line 460)

Fix: add !important to these three declarations so the mobile overrides
actually fire on narrow screens.

Tests: 224 passed, 0 failed.
2026-04-02 06:39:27 +00:00
14 changed files with 1872 additions and 205 deletions

View File

@@ -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
View 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.

View File

@@ -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*

View File

@@ -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 |

View File

@@ -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)*

View File

@@ -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 = {}

View File

@@ -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')

View File

@@ -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

View File

@@ -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">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</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">

View File

@@ -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?'&#9733;':'&#9734;';
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?'&#9993;':'&#128230;';
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='&#10697;';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='&#128465;';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');
}

View File

@@ -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;}

View File

@@ -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
View 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
View 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 &lt;strong&gt;."""
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 "&lt;strong&gt;" 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 "&lt;strong&gt;" not in out, \
"Literal &lt;strong&gt; found — <strong> is not rendering as bold"
assert "&lt;/strong&gt;" not in out, \
"Literal &lt;/strong&gt; found — closing tag is not rendering"
assert "&lt;code&gt;" not in out, \
"Literal &lt;code&gt; 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 "&lt;strong&gt;" 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 "&lt;strong&gt;" 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 "&lt;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 "&lt;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>&lt;br&gt;</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 "&lt;strong&gt;" 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 "&lt;strong&gt;" 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 "&lt;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 "&lt;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 "&lt;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 "&lt;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 "&lt;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 "&lt;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 "&lt;" 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 "&lt;strong&gt;" not in out
assert "&lt;code&gt;" 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 "&lt;em&gt;" 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 "&lt;b&gt;" 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 "&lt;i&gt;" 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 "&lt;strong&gt;" 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 "&lt;strong&gt;" 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 "&lt;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 "&lt;strong&gt;" 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 "&lt;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 "&lt;strong&gt;" 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 "&lt;code&gt;" 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 &amp; exactly once, not &amp;amp;."""
out = render_md("foo & bar")
assert "&amp;amp;" not in out
assert "&amp;" 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 &lt; exactly once."""
out = render_md("`a < b`")
assert "&lt;lt;" not in out
assert "&lt;" 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;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"